From 2310392d937817a207b6fd1563880f0fc063c3f0 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:40:07 +0530 Subject: [PATCH 001/161] Checkpoint governed App runtime and public ingress foundations --- .github/workflows/ci.yml | 2 + apps/api/src/lib/app-public-service.ts | 319 ++++++++++ apps/api/src/lib/app-public-worker-handler.ts | 94 +++ apps/api/src/lib/app-run-attempt-runner.ts | 272 ++++++++- apps/api/src/lib/app-run-provider-executor.ts | 2 +- apps/api/src/lib/app-run-runtime.ts | 4 + apps/api/src/lib/app-run-secret-repository.ts | 4 +- apps/api/src/lib/app-runtime-authority.ts | 248 ++++++++ apps/api/src/lib/app-runtime-channel.ts | 103 ++++ apps/api/src/lib/app-runtime-contract.ts | 101 ++++ apps/api/src/middleware/app-public-limits.ts | 128 ++++ apps/api/src/routes/app-public.ts | 73 +++ apps/api/src/routes/app-runtime-channel.ts | 115 ++++ apps/api/src/workers/index.ts | 4 + apps/api/test/app-public-limits.test.ts | 161 +++++ apps/api/test/app-public-service-db.test.ts | 306 ++++++++++ apps/api/test/app-run-engine-db.test.ts | 5 +- apps/api/test/app-runtime-channel-db.test.ts | 403 +++++++++++++ apps/api/test/app-runtime-channel.test.ts | 25 + .../test/fixtures/app-runtime-http-host.ts | 25 + packages/db/scripts/apply-extras.ts | 10 + packages/db/src/schema.ts | 307 +++++++++- .../0.3.0-preview.31-app-runtime-channel.sql | 344 +++++++++++ .../0.3.0-preview.32-app-public-claims.sql | 101 ++++ packages/db/upgrades/manifest.ts | 10 + packages/shared/package.json | 2 +- packages/shared/src/app-platform-authority.ts | 60 ++ packages/shared/src/capabilities.ts | 6 +- packages/shared/src/index.ts | 1 + .../test/app-platform-authority.test.ts | 76 +++ scripts/gate-g/README.md | 30 + scripts/gate-g/acceptance-reporter.mjs | 10 + scripts/gate-g/check-evidence.mjs | 70 +++ scripts/gate-g/check-evidence.test.mjs | 58 ++ scripts/gate-g/experiences/README.md | 41 ++ .../experiences/bootstrap-author-worker.js | 79 +++ .../gate-g/experiences/bootstrap-check.mjs | 93 +++ .../gate-g/experiences/bootstrap-host.html | 5 + scripts/gate-g/experiences/bootstrap-host.js | 36 ++ .../gate-g/experiences/bootstrap-server.mjs | 81 +++ .../gate-g/experiences/bootstrap-trusted.js | 28 + scripts/gate-g/experiences/browser-check.mjs | 110 ++++ scripts/gate-g/experiences/host.js | 192 ++++++ scripts/gate-g/experiences/iframe.html | 2 + scripts/gate-g/experiences/iframe.js | 32 + scripts/gate-g/experiences/index.html | 9 + scripts/gate-g/experiences/make-local-cert.py | 38 ++ scripts/gate-g/experiences/server.mjs | 73 +++ scripts/gate-g/experiences/worker.js | 75 +++ scripts/gate-g/public/probe.mjs | 358 +++++++++++ scripts/gate-g/required-tests.json | 565 ++++++++++++++++++ scripts/gate-g/runtime/README.md | 35 ++ scripts/gate-g/runtime/experiment.mjs | 192 ++++++ scripts/gate-g/runtime/provider.mjs | 23 + scripts/gate-g/runtime/state.mjs | 144 +++++ scripts/gate-g/runtime/worker.mjs | 40 ++ scripts/gate-g/verify-upgrade.mjs | 57 ++ 57 files changed, 5757 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/lib/app-public-service.ts create mode 100644 apps/api/src/lib/app-public-worker-handler.ts create mode 100644 apps/api/src/lib/app-runtime-authority.ts create mode 100644 apps/api/src/lib/app-runtime-channel.ts create mode 100644 apps/api/src/lib/app-runtime-contract.ts create mode 100644 apps/api/src/middleware/app-public-limits.ts create mode 100644 apps/api/src/routes/app-public.ts create mode 100644 apps/api/src/routes/app-runtime-channel.ts create mode 100644 apps/api/test/app-public-limits.test.ts create mode 100644 apps/api/test/app-public-service-db.test.ts create mode 100644 apps/api/test/app-runtime-channel-db.test.ts create mode 100644 apps/api/test/app-runtime-channel.test.ts create mode 100644 apps/api/test/fixtures/app-runtime-http-host.ts create mode 100644 packages/db/upgrades/0.3.0-preview.31-app-runtime-channel.sql create mode 100644 packages/db/upgrades/0.3.0-preview.32-app-public-claims.sql create mode 100644 packages/shared/src/app-platform-authority.ts create mode 100644 packages/shared/test/app-platform-authority.test.ts create mode 100644 scripts/gate-g/README.md create mode 100644 scripts/gate-g/acceptance-reporter.mjs create mode 100644 scripts/gate-g/check-evidence.mjs create mode 100644 scripts/gate-g/check-evidence.test.mjs create mode 100644 scripts/gate-g/experiences/README.md create mode 100644 scripts/gate-g/experiences/bootstrap-author-worker.js create mode 100644 scripts/gate-g/experiences/bootstrap-check.mjs create mode 100644 scripts/gate-g/experiences/bootstrap-host.html create mode 100644 scripts/gate-g/experiences/bootstrap-host.js create mode 100644 scripts/gate-g/experiences/bootstrap-server.mjs create mode 100644 scripts/gate-g/experiences/bootstrap-trusted.js create mode 100644 scripts/gate-g/experiences/browser-check.mjs create mode 100644 scripts/gate-g/experiences/host.js create mode 100644 scripts/gate-g/experiences/iframe.html create mode 100644 scripts/gate-g/experiences/iframe.js create mode 100644 scripts/gate-g/experiences/index.html create mode 100644 scripts/gate-g/experiences/make-local-cert.py create mode 100644 scripts/gate-g/experiences/server.mjs create mode 100644 scripts/gate-g/experiences/worker.js create mode 100644 scripts/gate-g/public/probe.mjs create mode 100644 scripts/gate-g/required-tests.json create mode 100644 scripts/gate-g/runtime/README.md create mode 100644 scripts/gate-g/runtime/experiment.mjs create mode 100644 scripts/gate-g/runtime/provider.mjs create mode 100644 scripts/gate-g/runtime/state.mjs create mode 100644 scripts/gate-g/runtime/worker.mjs create mode 100644 scripts/gate-g/verify-upgrade.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69bc7f9c..1ff1f1b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: run: pnpm module:verify - name: Test App Kit authoring contracts run: pnpm --filter @deft/app-kit test + - name: Test shared authority and resource contracts + run: pnpm --filter @deft/shared test - name: Verify release publishing contract run: pnpm test:release-workflow - name: Verify container process supervision diff --git a/apps/api/src/lib/app-public-service.ts b/apps/api/src/lib/app-public-service.ts new file mode 100644 index 00000000..193aa5aa --- /dev/null +++ b/apps/api/src/lib/app-public-service.ts @@ -0,0 +1,319 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { + AppInstallationAuthoritySchema, + AppPublicPrincipalSchema, + ModuleResourceRefV1Schema, + isSameAppInstallationAuthority, + type AppPublicPrincipal, +} from '@deft/shared'; +import { + appCanonicalClaims, + appGrantSnapshots, + appInstallations, + appModuleBindings, + appPublicEndpoints, + appPublicIngress, + appVersions, + jobQueue, + moduleInstallations, + moduleRecords, + moduleVersions, +} from '@deft/db/schema'; +import { db } from './db.js'; +import { enqueue, QUEUE_NAMES } from './queues.js'; + +type PublicTransaction = Parameters[0]>[0]; +type Endpoint = typeof appPublicEndpoints.$inferSelect; +type Ingress = typeof appPublicIngress.$inferSelect; +type AppInstallation = typeof appInstallations.$inferSelect; + +export const PublicClaimInputSchema = z.strictObject({ + resource_ref: ModuleResourceRefV1Schema, + expected_revision: z.number().int().positive().max(2_147_483_647), + idempotency_key: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/), +}); +export type PublicClaimInput = z.infer; + +export type PublicClaimResult = Readonly<{ + claim_id: string; + claim_state: 'confirmed'; + follow_up_state: 'pending' | 'unsupported'; + replayed: boolean; +}>; + +export type AppPublicErrorCode = + | 'PUBLIC_NOT_FOUND' + | 'PUBLIC_INVALID_INPUT' + | 'PUBLIC_PAYLOAD_TOO_LARGE' + | 'PUBLIC_IDEMPOTENCY_CONFLICT' + | 'PUBLIC_CLAIM_CONFLICT' + | 'PUBLIC_UNAVAILABLE'; + +export class AppPublicError extends Error { + constructor(readonly code: AppPublicErrorCode, readonly status: 400 | 404 | 409 | 413 | 503) { + super(code === 'PUBLIC_NOT_FOUND' ? 'Public endpoint not found' + : code === 'PUBLIC_INVALID_INPUT' ? 'Invalid public claim' + : code === 'PUBLIC_PAYLOAD_TOO_LARGE' ? 'Public request is too large' + : code === 'PUBLIC_IDEMPOTENCY_CONFLICT' ? 'Request key belongs to different input' + : code === 'PUBLIC_CLAIM_CONFLICT' ? 'Resource is unavailable' + : 'Public claim is temporarily unavailable'); + this.name = 'AppPublicError'; + } +} + +const MAX_PUBLIC_BODY_BYTES = 8192; +const slugPattern = /^[A-Za-z0-9_-]{32,128}$/; +const hash = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; + +export function publicEndpointReviewDigest(endpoint: Pick): string { + return hash(JSON.stringify({ + review_version: 'deft.app_public_review.v1', + endpoint_id: endpoint.id, + org_id: endpoint.org_id, + slug_digest: endpoint.slug_digest, + app_installation_id: endpoint.app_installation_id, + app_version_id: endpoint.app_version_id, + grant_snapshot_id: endpoint.grant_snapshot_id, + installation_lifecycle_epoch: endpoint.installation_lifecycle_epoch, + installation_grant_epoch: endpoint.installation_grant_epoch, + module_installation_id: endpoint.module_installation_id, + collection_key: endpoint.collection_key, + endpoint_epoch: endpoint.endpoint_epoch, + public_label: endpoint.public_label, + max_body_bytes: endpoint.max_body_bytes, + })); +} + +function parseBody(rawBody: Uint8Array, maxBodyBytes: number): PublicClaimInput { + if (rawBody.byteLength > MAX_PUBLIC_BODY_BYTES || rawBody.byteLength > maxBodyBytes) { + throw new AppPublicError('PUBLIC_PAYLOAD_TOO_LARGE', 413); + } + try { + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(rawBody); + return PublicClaimInputSchema.parse(JSON.parse(decoded)); + } catch { + throw new AppPublicError('PUBLIC_INVALID_INPUT', 400); + } +} + +function inputDigest(input: PublicClaimInput): string { + return hash(JSON.stringify({ resource_ref: input.resource_ref, expected_revision: input.expected_revision })); +} + +function principalFor(endpoint: Endpoint): AppPublicPrincipal { + return AppPublicPrincipalSchema.parse({ + audience: 'app_public', + org_id: endpoint.org_id, + app_installation_id: endpoint.app_installation_id, + app_version_id: endpoint.app_version_id, + lifecycle_epoch: endpoint.installation_lifecycle_epoch, + grant_epoch: endpoint.installation_grant_epoch, + endpoint_id: endpoint.id, + endpoint_epoch: endpoint.endpoint_epoch, + }); +} + +async function resolveEndpoint(tx: PublicTransaction, slug: string): Promise<{ + endpoint: Endpoint; principal: AppPublicPrincipal; app: AppInstallation; +}> { + if (!slugPattern.test(slug)) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + const slugDigest = hash(slug); + // The first lookup is only a locator. Lock the owning App before the + // endpoint, matching lifecycle's App -> Module lock hierarchy. A changed + // mapping is detected under the endpoint lock and denied. + const [locator] = await tx.select({ + id: appPublicEndpoints.id, + org_id: appPublicEndpoints.org_id, + app_installation_id: appPublicEndpoints.app_installation_id, + }).from(appPublicEndpoints).where(eq(appPublicEndpoints.slug_digest, slugDigest)).limit(1); + if (!locator) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + const [app] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, locator.org_id), + eq(appInstallations.id, locator.app_installation_id), + )).limit(1).for('share'); + if (!app) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + const [endpoint] = await tx.select().from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, locator.org_id), + eq(appPublicEndpoints.id, locator.id), + )).limit(1).for('share'); + if (!endpoint || endpoint.slug_digest !== slugDigest + || endpoint.app_installation_id !== app.id || endpoint.state !== 'enabled' + || endpoint.review_digest !== publicEndpointReviewDigest(endpoint)) { + throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + } + return { endpoint, principal: principalFor(endpoint), app }; +} + +async function assertLiveAuthority(tx: PublicTransaction, endpoint: Endpoint, principal: AppPublicPrincipal, app: AppInstallation) { + // Order follows lifecycle and Module mutation: App, endpoint, Module + // installation, then canonical record. The App row lock is already held. + if (app.state !== 'active' || app.active_version_id !== endpoint.app_version_id + || app.active_grant_snapshot_id !== endpoint.grant_snapshot_id + || !isSameAppInstallationAuthority(principal, AppInstallationAuthoritySchema.parse({ + org_id: app.org_id, + app_installation_id: app.id, + app_version_id: app.active_version_id, + lifecycle_epoch: app.lifecycle_epoch, + grant_epoch: app.grant_epoch, + }))) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + + const [version] = await tx.select({ id: appVersions.id }).from(appVersions).where(and( + eq(appVersions.org_id, principal.org_id), + eq(appVersions.installation_id, app.id), + eq(appVersions.id, endpoint.app_version_id), + eq(appVersions.state, 'active'), + )).limit(1); + const [grant] = await tx.select({ id: appGrantSnapshots.id }).from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, principal.org_id), + eq(appGrantSnapshots.app_installation_id, app.id), + eq(appGrantSnapshots.app_version_id, endpoint.app_version_id), + eq(appGrantSnapshots.id, endpoint.grant_snapshot_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).limit(1); + if (!version || !grant) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + + const [moduleInstallation] = await tx.select().from(moduleInstallations).where(and( + eq(moduleInstallations.org_id, principal.org_id), + eq(moduleInstallations.id, endpoint.module_installation_id), + )).limit(1).for('share'); + if (!moduleInstallation || !moduleInstallation.is_enabled || moduleInstallation.is_deleted) { + throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + } + const [binding] = await tx.select({ module_version_id: appModuleBindings.module_version_id }) + .from(appModuleBindings).where(and( + eq(appModuleBindings.org_id, principal.org_id), + eq(appModuleBindings.app_installation_id, app.id), + eq(appModuleBindings.app_version_id, endpoint.app_version_id), + eq(appModuleBindings.module_installation_id, moduleInstallation.id), + eq(appModuleBindings.module_id, moduleInstallation.module_id), + )).limit(1); + if (!binding) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + const [moduleVersion] = await tx.select({ id: moduleVersions.id }).from(moduleVersions).where(and( + eq(moduleVersions.org_id, principal.org_id), + eq(moduleVersions.installation_id, moduleInstallation.id), + eq(moduleVersions.id, binding.module_version_id), + eq(moduleVersions.is_active, true), + )).limit(1); + if (!moduleVersion) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); +} + +async function outcomeFromReceipt(tx: PublicTransaction, receipt: Ingress, inputFingerprint: string): Promise { + if (receipt.input_digest !== inputFingerprint) throw new AppPublicError('PUBLIC_IDEMPOTENCY_CONFLICT', 409); + if (receipt.state === 'conflict') return 'conflict'; + if (receipt.state !== 'confirmed') throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + const [claim] = await tx.select({ id: appCanonicalClaims.id }).from(appCanonicalClaims).where(and( + eq(appCanonicalClaims.org_id, receipt.org_id), + eq(appCanonicalClaims.endpoint_id, receipt.endpoint_id), + eq(appCanonicalClaims.ingress_id, receipt.id), + )).limit(1); + if (!claim) throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + return { claim_id: claim.id, claim_state: 'confirmed', follow_up_state: receipt.follow_up_state, replayed: true }; +} + +async function enqueueIngress(tx: PublicTransaction, orgId: string, endpointId: string, ingressId: string, endpointEpoch: number) { + const payload = { organization_id: orgId, endpoint_id: endpointId, ingress_id: ingressId, endpoint_epoch: endpointEpoch }; + const dedupeKey = `app-public-ingress:${ingressId}`; + await enqueue(QUEUE_NAMES.AGENT_JOBS, 'app-public-ingress', payload, + { executor: tx, orgId, dedupeKey, maxAttempts: 3 }); + // enqueue() deliberately uses ON CONFLICT DO NOTHING. Verify it did not + // silently keep a different job under the same dedupe key. + const [job] = await tx.select({ queue: jobQueue.queue, name: jobQueue.name, data: jobQueue.data }) + .from(jobQueue).where(and(eq(jobQueue.org_id, orgId), eq(jobQueue.dedupe_key, dedupeKey))).limit(1); + const data = job?.data; + const fields = data as Record | undefined; + if (!job || job.queue !== QUEUE_NAMES.AGENT_JOBS || job.name !== 'app-public-ingress' + || !data || typeof data !== 'object' || Array.isArray(data) + || Object.keys(data).length !== 4 + || fields?.organization_id !== payload.organization_id + || fields?.endpoint_id !== payload.endpoint_id + || fields?.ingress_id !== payload.ingress_id + || fields?.endpoint_epoch !== payload.endpoint_epoch) { + throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + } +} + +type Delivery = typeof enqueueIngress; + +/** This service derives the public principal from one reviewed endpoint row. + * Request cookies, Authorization and caller-supplied organization are absent + * from its interface by design. No ModuleActor is constructed or borrowed. */ +export class AppPublicClaimService { + constructor(private readonly options: { enabled?: boolean; deliver?: Delivery } = {}) {} + + isEnabled(): boolean { return this.options.enabled === true; } + + async claim(slug: string, rawBody: Uint8Array): Promise { + if (!this.isEnabled()) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + if (rawBody.byteLength > MAX_PUBLIC_BODY_BYTES) throw new AppPublicError('PUBLIC_PAYLOAD_TOO_LARGE', 413); + let outcome: PublicClaimResult | 'conflict'; + try { + outcome = await db.transaction(async (tx) => { + const { endpoint, principal, app } = await resolveEndpoint(tx, slug); + await assertLiveAuthority(tx, endpoint, principal, app); + const input = parseBody(rawBody, endpoint.max_body_bytes); + const ref = input.resource_ref; + if (ref.provider.provider_instance_id !== endpoint.module_installation_id + || ref.resource_type !== endpoint.collection_key) { + throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + } + const fingerprint = inputDigest(input); + const keyDigest = hash(`${endpoint.id}\0${input.idempotency_key}`); + const ingressId = randomUUID(); + const [inserted] = await tx.insert(appPublicIngress).values({ + id: ingressId, org_id: principal.org_id, endpoint_id: endpoint.id, + endpoint_epoch: endpoint.endpoint_epoch, request_key_digest: keyDigest, + input_digest: fingerprint, state: 'processing', + }).onConflictDoNothing().returning({ id: appPublicIngress.id }); + if (!inserted) { + const [receipt] = await tx.select().from(appPublicIngress).where(and( + eq(appPublicIngress.org_id, principal.org_id), + eq(appPublicIngress.endpoint_id, endpoint.id), + eq(appPublicIngress.endpoint_epoch, endpoint.endpoint_epoch), + eq(appPublicIngress.request_key_digest, keyDigest), + )).limit(1); + if (!receipt) throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + return outcomeFromReceipt(tx, receipt, fingerprint); + } + const [record] = await tx.select({ id: moduleRecords.id, revision: moduleRecords.revision }) + .from(moduleRecords).where(and( + eq(moduleRecords.org_id, principal.org_id), + eq(moduleRecords.installation_id, endpoint.module_installation_id), + eq(moduleRecords.id, ref.resource_id), + eq(moduleRecords.collection_key, endpoint.collection_key), + eq(moduleRecords.is_deleted, false), + )).limit(1).for('share'); + if (!record || record.revision !== input.expected_revision) { + await tx.update(appPublicIngress).set({ state: 'conflict' }).where(eq(appPublicIngress.id, ingressId)); + return 'conflict'; + } + const claimId = randomUUID(); + const [claimed] = await tx.insert(appCanonicalClaims).values({ + id: claimId, org_id: principal.org_id, endpoint_id: endpoint.id, ingress_id: ingressId, + provider_kind: 'module', provider_instance_id: endpoint.module_installation_id, + resource_type: endpoint.collection_key, resource_id: record.id, + claim_kind: 'exclusive', + }).onConflictDoNothing().returning({ id: appCanonicalClaims.id }); + if (!claimed) { + await tx.update(appPublicIngress).set({ state: 'conflict' }).where(eq(appPublicIngress.id, ingressId)); + return 'conflict'; + } + await (this.options.deliver ?? enqueueIngress)(tx, principal.org_id, endpoint.id, ingressId, endpoint.endpoint_epoch); + await tx.update(appPublicIngress).set({ state: 'confirmed' }).where(eq(appPublicIngress.id, ingressId)); + return { claim_id: claimId, claim_state: 'confirmed', follow_up_state: 'pending', replayed: false }; + }); + } catch (error) { + if (error instanceof AppPublicError) throw error; + throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + } + if (outcome === 'conflict') throw new AppPublicError('PUBLIC_CLAIM_CONFLICT', 409); + return outcome; + } +} + +// An explicit host decision is required before any public ingress can be served. +export const appPublicClaimService = new AppPublicClaimService(); diff --git a/apps/api/src/lib/app-public-worker-handler.ts b/apps/api/src/lib/app-public-worker-handler.ts new file mode 100644 index 00000000..c01ee5d4 --- /dev/null +++ b/apps/api/src/lib/app-public-worker-handler.ts @@ -0,0 +1,94 @@ +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { + appCanonicalClaims, appGrantSnapshots, appInstallations, appPublicEndpoints, + appPublicIngress, appVersions, jobQueue, moduleInstallations, +} from '@deft/db/schema'; +import type { JobHandler } from '../workers/types.js'; +import { db } from './db.js'; +import { QUEUE_NAMES } from './queues.js'; +import { publicEndpointReviewDigest } from './app-public-service.js'; + +const PayloadSchema = z.strictObject({ + organization_id: z.string().uuid(), + endpoint_id: z.string().uuid(), + ingress_id: z.string().uuid(), + endpoint_epoch: z.number().int().positive(), +}); + +/** A validated queue handoff has no generic business callback yet. Persist a + * terminal unsupported result so the queue cannot silently imply delivery. */ +export const handleAppPublicIngress: JobHandler = async (job) => { + if (job.name !== 'app-public-ingress' || job.signal?.aborted) throw new Error('Invalid public ingress job'); + const payload = PayloadSchema.parse(job.data); + await db.transaction(async (tx) => { + const [queued] = await tx.select().from(jobQueue).where(eq(jobQueue.id, job.id)).limit(1); + const queuedData = queued?.data; + const fields = queuedData as Record | undefined; + if (!queued || queued.org_id !== payload.organization_id || queued.queue !== QUEUE_NAMES.AGENT_JOBS + || queued.name !== 'app-public-ingress' || queued.dedupe_key !== `app-public-ingress:${payload.ingress_id}` + || !queuedData || typeof queuedData !== 'object' || Array.isArray(queuedData) + || Object.keys(queuedData).length !== 4 + || fields?.organization_id !== payload.organization_id || fields?.endpoint_id !== payload.endpoint_id + || fields?.ingress_id !== payload.ingress_id || fields?.endpoint_epoch !== payload.endpoint_epoch) { + throw new Error('Invalid public ingress queue identity'); + } + // Locator only. Preserve App -> endpoint lock order used by the claim and + // lifecycle paths; no caller supplied principal or cookie enters here. + const [locator] = await tx.select({ app_installation_id: appPublicEndpoints.app_installation_id }) + .from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, payload.organization_id), eq(appPublicEndpoints.id, payload.endpoint_id), + )).limit(1); + if (!locator) throw new Error('Public ingress endpoint is missing'); + const [app] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, payload.organization_id), eq(appInstallations.id, locator.app_installation_id), + )).limit(1).for('share'); + if (!app) throw new Error('Public ingress app is missing'); + const [endpoint] = await tx.select().from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, payload.organization_id), eq(appPublicEndpoints.id, payload.endpoint_id), + )).limit(1).for('share'); + if (!endpoint || endpoint.app_installation_id !== app.id) throw new Error('Public ingress endpoint changed'); + const [version] = await tx.select({ id: appVersions.id }).from(appVersions).where(and( + eq(appVersions.org_id, payload.organization_id), eq(appVersions.id, endpoint.app_version_id), + eq(appVersions.installation_id, app.id), eq(appVersions.state, 'active'), + )).limit(1); + const [grant] = await tx.select({ id: appGrantSnapshots.id }).from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, payload.organization_id), eq(appGrantSnapshots.id, endpoint.grant_snapshot_id), + eq(appGrantSnapshots.app_installation_id, app.id), eq(appGrantSnapshots.app_version_id, endpoint.app_version_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).limit(1); + const [module] = await tx.select().from(moduleInstallations).where(and( + eq(moduleInstallations.org_id, payload.organization_id), eq(moduleInstallations.id, endpoint.module_installation_id), + )).limit(1).for('share'); + const [ingress] = await tx.select().from(appPublicIngress).where(and( + eq(appPublicIngress.org_id, payload.organization_id), eq(appPublicIngress.endpoint_id, endpoint.id), + eq(appPublicIngress.id, payload.ingress_id), + )).limit(1).for('update'); + if (!ingress || ingress.endpoint_epoch !== payload.endpoint_epoch || ingress.state !== 'confirmed') { + throw new Error('Public ingress receipt is not confirmed'); + } + const [claim] = await tx.select().from(appCanonicalClaims).where(and( + eq(appCanonicalClaims.org_id, payload.organization_id), eq(appCanonicalClaims.endpoint_id, endpoint.id), + eq(appCanonicalClaims.ingress_id, ingress.id), + )).limit(1); + if (!claim || claim.provider_kind !== 'module' || claim.provider_instance_id !== endpoint.module_installation_id + || claim.resource_type !== endpoint.collection_key || claim.claim_kind !== 'exclusive') { + throw new Error('Public ingress claim is missing'); + } + if (ingress.follow_up_state === 'unsupported') return; + if (ingress.follow_up_state !== 'pending') throw new Error('Invalid public follow-up state'); + const live = endpoint.state === 'enabled' && endpoint.endpoint_epoch === payload.endpoint_epoch + && endpoint.review_digest === publicEndpointReviewDigest(endpoint) + && app.state === 'active' && app.active_version_id === endpoint.app_version_id + && app.active_grant_snapshot_id === endpoint.grant_snapshot_id + && app.lifecycle_epoch === endpoint.installation_lifecycle_epoch + && app.grant_epoch === endpoint.installation_grant_epoch + && Boolean(version && grant && module?.is_enabled && !module.is_deleted); + if (job.signal?.aborted) throw new Error('Public ingress job aborted'); + await tx.update(appPublicIngress).set({ + follow_up_state: 'unsupported', + follow_up_code: live ? 'APP_HANDLER_UNAVAILABLE' : 'ENDPOINT_REVOKED', + handled_at: new Date(), + }).where(eq(appPublicIngress.id, ingress.id)); + }); +}; diff --git a/apps/api/src/lib/app-run-attempt-runner.ts b/apps/api/src/lib/app-run-attempt-runner.ts index 42b66828..99feb2ba 100644 --- a/apps/api/src/lib/app-run-attempt-runner.ts +++ b/apps/api/src/lib/app-run-attempt-runner.ts @@ -1,12 +1,13 @@ import { createHash } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; -import { appRunAttempts } from '@deft/db/schema'; +import { appRunAttempts, appRuntimeSessions } from '@deft/db/schema'; import { APP_RUN_CONTRACT_VERSIONS, AppRunRetainedProviderResultSchema, AppRunSafeOutcomeSchema, assertAppRunOutputWithinBudget, + canonicalCapabilityJson, classifyAppRunCrashRecovery, type AppRunSafeOutcome, } from '@deft/shared'; @@ -33,6 +34,11 @@ import { } from './app-run-attention.js'; import { AppRunSecretRepository } from './app-run-secret-repository.js'; import type { AppRunSecretService } from './app-run-secrets.js'; +import { loadLiveRuntimeAuthority, runtimeRunMatchesAuthority } from './app-runtime-authority.js'; +import { + APP_RUNTIME_CHANNEL_VERSION, type AppRuntimeClaimEnvelope, + type AppRuntimeResultRequest, type AppRuntimeStartEnvelope, +} from './app-runtime-contract.js'; import { noOpAppRunAttemptQueue, type AppRunAttemptQueue, @@ -108,6 +114,8 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { workerId: string, signal?: AbortSignal, ): Promise { + const current = await this.repository.inspect(orgId, runId); + if (current?.provider_kind === 'app_runtime') return { run: current }; await this.recoverRun(orgId, runId, attemptId); const claimed = await this.#claim(orgId, runId, attemptId, workerId); if (!claimed) { @@ -188,6 +196,177 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { }); } + /** External pull claim uses the existing attempt ledger and its claim event. + * The session token is checked again inside the claim transaction. */ + async claimRuntimeAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; + session_id: string; token_hash: string; + }>): Promise { + await this.recoverRun(input.org_id, input.run_id, input.attempt_id); + const claimed = await this.#claim(input.org_id, input.run_id, input.attempt_id, + `app_runtime:${input.session_id}`, { session_id: input.session_id, token_hash: input.token_hash }); + if (!claimed || !claimed.attempt.claim_token || !claimed.attempt.lease_expires_at + || !claimed.attempt.runtime_sequence || !claimed.attempt.runtime_binding_id + || claimed.attempt.runtime_session_epoch === null || claimed.attempt.runtime_epoch === null) return null; + const authority = await this.repository.transaction((tx) => loadLiveRuntimeAuthority( + tx, input.org_id, input.session_id, input.token_hash, this.now)); + if (!authority) return null; + return Object.freeze({ + schema_version: APP_RUNTIME_CHANNEL_VERSION, + ...authority.pin, + run_id: claimed.run.id, + attempt_id: claimed.attempt.id, + attempt_number: claimed.attempt.attempt_number, + claim_token: claimed.attempt.claim_token, + lease_expires_at: claimed.attempt.lease_expires_at.toISOString(), + sequence: claimed.attempt.runtime_sequence, + initiating_actor_type: claimed.run.initiating_actor_type, + initiating_actor_id: claimed.run.initiating_actor_id, + grant_snapshot_id: authority.grant_snapshot_id, + operation_name: claimed.run.operation_name, + ...(claimed.run.retry_class === 'idempotent_with_key' + ? { provider_idempotency_key: appRunProviderIdempotencyKey(claimed.run.id) } : {}), + }); + } + + async startRuntimeAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; session_id: string; + token_hash: string; claim_token: string; sequence: number; + }>): Promise { + const claimed = await this.#loadRuntimeClaim(input); + if (!claimed) return null; + const boundary = await this.#markProviderCallStarted(claimed, + { session_id: input.session_id, token_hash: input.token_hash }); + if (!boundary) return null; + const exactInput = await this.repository.transaction(async (tx) => { + const authority = await loadLiveRuntimeAuthority(tx, input.org_id, + input.session_id, input.token_hash, this.now); + if (!authority) return null; + const run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.state !== 'running' || run.cancel_requested_at + || !run.execution_released_at || run.input_expires_at <= this.now() + || !await runtimeRunMatchesAuthority(tx, input.org_id, input.run_id, authority)) return null; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${input.attempt_id} FOR SHARE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, input.attempt_id), + eq(appRunAttempts.run_id, input.run_id), + )).limit(1); + if (!attempt || attempt.state !== 'provider_call_started' + || attempt.claim_token !== input.claim_token + || attempt.runtime_session_id !== input.session_id + || attempt.runtime_sequence !== input.sequence + || !attempt.lease_expires_at || attempt.lease_expires_at <= this.now()) return null; + // The authority rows remain locked until the secret read completes. + return this.secretRepository.readInput(input.org_id, input.run_id, tx); + }); + if (exactInput === null) return null; + return Object.freeze({ + schema_version: APP_RUNTIME_CHANNEL_VERSION, + run_id: input.run_id, attempt_id: input.attempt_id, + lease_expires_at: claimed.attempt.lease_expires_at!.toISOString(), + input: exactInput, + ...(claimed.run.retry_class === 'idempotent_with_key' + ? { provider_idempotency_key: appRunProviderIdempotencyKey(input.run_id) } : {}), + }); + } + + async heartbeatRuntimeAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; session_id: string; + token_hash: string; claim_token: string; sequence: number; + }>): Promise { + return this.renewLease(input.org_id, input.attempt_id, input.claim_token, input); + } + + async completeRuntimeAttempt(input: Readonly<{ + org_id: string; token_hash: string; result: AppRuntimeResultRequest; + }>): Promise { + const result = input.result; + const fingerprintValue = `deft.app_runtime.result.v1:${createHash('sha256') + .update(canonicalCapabilityJson(result)).digest('hex')}`; + const digest = this.secrets.fingerprintText('idempotency', fingerprintValue).fingerprint; + const replayDigests = new Set(this.secrets.fingerprintTextCandidates('idempotency', + fingerprintValue).map((candidate) => candidate.fingerprint)); + const now = this.now(); + const completed = await this.repository.transaction(async (tx) => { + const authority = await loadLiveRuntimeAuthority(tx, input.org_id, + result.session_id, input.token_hash, this.now); + if (!authority) return false; + const run = await this.repository.lockRun(tx, input.org_id, result.run_id); + if (!run || run.provider_kind !== 'app_runtime') return false; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${result.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, result.attempt_id), + eq(appRunAttempts.run_id, result.run_id), + )).limit(1); + if (!attempt || attempt.claim_token !== result.claim_token + || attempt.runtime_session_id !== result.session_id + || attempt.runtime_sequence !== result.sequence) return false; + if (!authority || authority.pin.runtime_binding_id !== attempt.runtime_binding_id + || authority.pin.runtime_epoch !== attempt.runtime_epoch + || authority.pin.session_epoch !== attempt.runtime_session_epoch + || !await runtimeRunMatchesAuthority(tx, input.org_id, result.run_id, authority)) return false; + if (attempt.runtime_result_hmac) return replayDigests.has(attempt.runtime_result_hmac); + if (attempt.state !== 'provider_call_started' || !attempt.lease_expires_at + || attempt.lease_expires_at <= this.now()) return false; + if (result.status === 'indeterminate') { + await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); + return true; + } + const outcome = await this.#knownOutcome(run, result); + if (result.status === 'returned' && outcome.result_status === 'retained') { + const envelope = AppRunRetainedProviderResultSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.provider_result, + provider_succeeded: result.provider_succeeded, + output: result.output, + }); + await this.secretRepository.insertOutput(tx, { + org_id: input.org_id, run_id: run.id, attempt_id: attempt.id, + value: envelope, expires_at: run.result_expires_at, + }); + } + await tx.update(appRunAttempts).set({ + provider_call_finished_at: now, safe_outcome: outcome, + runtime_result_hmac: digest, updated_at: now, + }).where(and(eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, attempt.id))); + await this.#finalizeKnownInTransaction(tx, run, { ...attempt, + provider_call_finished_at: now, safe_outcome: outcome }, now); + return true; + }); + if (!completed) return null; + const settled = await this.repository.inspect(input.org_id, result.run_id); + if (settled) await this.#projectState(settled); + return settled; + } + + async #loadRuntimeClaim(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; session_id: string; + token_hash: string; claim_token: string; sequence: number; + }>): Promise { + return this.repository.transaction(async (tx) => { + const authority = await loadLiveRuntimeAuthority(tx, input.org_id, + input.session_id, input.token_hash, this.now); + if (!authority) return null; + const run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.provider_kind !== 'app_runtime') return null; + if (!authority || !await runtimeRunMatchesAuthority(tx, input.org_id, input.run_id, authority)) return null; + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.run_id, input.run_id), + eq(appRunAttempts.id, input.attempt_id), + )).limit(1); + if (!attempt || attempt.claim_token !== input.claim_token + || attempt.runtime_session_id !== input.session_id + || attempt.runtime_binding_id !== authority.pin.runtime_binding_id + || attempt.runtime_epoch !== authority.pin.runtime_epoch + || attempt.runtime_session_epoch !== authority.pin.session_epoch + || attempt.runtime_sequence !== input.sequence + || !attempt.lease_expires_at || attempt.lease_expires_at <= this.now() + || !['claimed', 'provider_call_started'].includes(attempt.state)) return null; + return { run, attempt }; + }); + } + /** Schedule against a Run already locked by the caller. Queue insertion is * in the same transaction as attempt creation/release, closing the crash gap * between durable authority and worker ownership. */ @@ -216,14 +395,22 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { return (await this.#createAttempt(tx, run, now))?.id ?? null; } - async renewLease(orgId: string, attemptId: string, claimToken: string): Promise { + async renewLease(orgId: string, attemptId: string, claimToken: string, + runtime?: Readonly<{ run_id: string; session_id: string; token_hash: string; sequence: number }>, + ): Promise { const now = this.now(); const [identity] = await db.select({ run_id: appRunAttempts.run_id }).from(appRunAttempts).where(and( eq(appRunAttempts.org_id, orgId), eq(appRunAttempts.id, attemptId), )).limit(1); if (!identity) return false; return this.repository.transaction(async (tx) => { - if (!await this.repository.lockRun(tx, orgId, identity.run_id)) return false; + const authority = runtime ? await loadLiveRuntimeAuthority(tx, orgId, + runtime.session_id, runtime.token_hash, this.now) : null; + if (runtime && !authority) return false; + const run = await this.repository.lockRun(tx, orgId, identity.run_id); + if (!run || (runtime ? run.provider_kind !== 'app_runtime' : run.provider_kind !== 'mcp')) return false; + if (runtime && (!authority || runtime.run_id !== run.id + || !await runtimeRunMatchesAuthority(tx, orgId, run.id, authority))) return false; await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${orgId} AND id = ${attemptId} FOR UPDATE`); const [current] = await tx.select().from(appRunAttempts).where(and( @@ -232,12 +419,17 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { if ( !current || current.claim_token !== claimToken + || (runtime && (current.runtime_session_id !== runtime.session_id + || current.runtime_binding_id !== authority!.pin.runtime_binding_id + || current.runtime_epoch !== authority!.pin.runtime_epoch + || current.runtime_session_epoch !== authority!.pin.session_epoch + || current.runtime_sequence !== runtime.sequence)) || !current.lease_expires_at - || current.lease_expires_at <= now + || current.lease_expires_at <= (runtime ? this.now() : now) || (current.state !== 'claimed' && current.state !== 'provider_call_started') ) return false; const [renewed] = await tx.update(appRunAttempts).set({ - lease_expires_at: new Date(now.getTime() + boundedLeaseMs(this.leaseMs)), + lease_expires_at: new Date((runtime ? this.now() : now).getTime() + boundedLeaseMs(this.leaseMs)), updated_at: now, }).where(and( eq(appRunAttempts.org_id, orgId), @@ -327,19 +519,28 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { runId: string, attemptId: string, workerId: string, + runtime?: Readonly<{ session_id: string; token_hash: string }>, ): Promise { const now = this.now(); return this.repository.transaction(async (tx) => { + const runtimeAuthority = runtime ? await loadLiveRuntimeAuthority( + tx, orgId, runtime.session_id, runtime.token_hash, this.now) : null; + if (runtime && !runtimeAuthority) return null; let run = await this.repository.lockRun(tx, orgId, runId); if ( !run + || (runtime ? ( + run.provider_kind !== 'app_runtime' + || !runtimeAuthority + || !await runtimeRunMatchesAuthority(tx, orgId, runId, runtimeAuthority) + ) : run.provider_kind !== 'mcp') || !run.execution_released_at || ['succeeded', 'failed', 'cancelled', 'expired', 'unknown_outcome'].includes(run.state) - || !await this.executionAuthorizer.authorizeExecution({ + || (!runtime && !await this.executionAuthorizer.authorizeExecution({ org_id: orgId, run, tx, stage: 'claim', now, - }) + })) ) return null; - if (run.input_expires_at <= now) { + if (run.input_expires_at <= (runtime ? this.now() : now)) { run = await this.repository.transition(tx, { run, state: 'expired', now, error_code: 'APP_RUN_EXPIRED', safe_outcome: AppRunSafeOutcomeSchema.parse({ @@ -355,13 +556,31 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { eq(appRunAttempts.id, attemptId), )).limit(1); if (!attempt || attempt.state !== 'pending') return null; + let runtimeSequence: number | null = null; + if (runtimeAuthority) { + const [session] = await tx.update(appRuntimeSessions).set({ + next_sequence: sql`${appRuntimeSessions.next_sequence} + 1`, + updated_at: now, + }).where(and(eq(appRuntimeSessions.org_id, orgId), + eq(appRuntimeSessions.id, runtimeAuthority.pin.session_id))) + .returning({ next_sequence: appRuntimeSessions.next_sequence }); + if (!session) return null; + runtimeSequence = session.next_sequence - 1; + } const claimToken = crypto.randomUUID(); const [claimed] = await tx.update(appRunAttempts).set({ state: 'claimed', claim_owner: workerId, claim_token: claimToken, + ...(runtimeAuthority ? { + runtime_binding_id: runtimeAuthority.pin.runtime_binding_id, + runtime_session_id: runtimeAuthority.pin.session_id, + runtime_session_epoch: runtimeAuthority.pin.session_epoch, + runtime_epoch: runtimeAuthority.pin.runtime_epoch, + runtime_sequence: runtimeSequence!, + } : {}), claimed_at: now, - lease_expires_at: new Date(now.getTime() + boundedLeaseMs(this.leaseMs)), + lease_expires_at: new Date((runtime ? this.now() : now).getTime() + boundedLeaseMs(this.leaseMs)), updated_at: now, }).where(and( eq(appRunAttempts.org_id, orgId), @@ -411,24 +630,32 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { return attempt; } - async #markProviderCallStarted(claimed: ClaimedAttempt): Promise): Promise | null> { const now = this.now(); return this.repository.transaction(async (tx) => { + const authority = runtime ? await loadLiveRuntimeAuthority(tx, claimed.run.org_id, + runtime.session_id, runtime.token_hash, this.now) : null; + if (runtime && !authority) return null; let run = await this.repository.lockRun(tx, claimed.run.org_id, claimed.run.id); if ( !run + || (runtime ? (run.provider_kind !== 'app_runtime' || !authority + || !await runtimeRunMatchesAuthority(tx, run.org_id, run.id, authority)) + : run.provider_kind !== 'mcp') || !run.execution_released_at || run.state === 'cancelled' - || !await this.executionAuthorizer.authorizeExecution({ + || (runtime && (run.cancel_requested_at || run.input_expires_at <= this.now())) + || (!runtime && !await this.executionAuthorizer.authorizeExecution({ org_id: run.org_id, run, tx, stage: 'provider_call', now, - }) + })) ) return null; - const dispatchPin = run.origin_kind === 'app' + const dispatchPin = run.origin_kind === 'app' && !runtime ? await this.repository.loadAppProviderDispatchPin(tx, run.org_id, run.id) : undefined; - if (run.origin_kind === 'app' && !dispatchPin) return null; + if (run.origin_kind === 'app' && !runtime && !dispatchPin) return null; await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${claimed.run.org_id} AND id = ${claimed.attempt.id} FOR UPDATE`); const [current] = await tx.select().from(appRunAttempts).where(and( @@ -437,11 +664,18 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { )).limit(1); if ( !current - || current.state !== 'claimed' + || (runtime ? !['claimed', 'provider_call_started'].includes(current.state) + : current.state !== 'claimed') || current.claim_token !== claimed.attempt.claim_token + || (runtime && (current.runtime_session_id !== runtime.session_id + || current.runtime_binding_id !== authority!.pin.runtime_binding_id + || current.runtime_epoch !== authority!.pin.runtime_epoch + || current.runtime_session_epoch !== authority!.pin.session_epoch + || current.runtime_sequence !== claimed.attempt.runtime_sequence)) || !current.lease_expires_at - || current.lease_expires_at <= now + || current.lease_expires_at <= (runtime ? this.now() : now) ) return null; + if (runtime && current.state === 'provider_call_started') return Object.freeze({}); const [attempt] = await tx.update(appRunAttempts).set({ state: 'provider_call_started', provider_call_started_at: now, updated_at: now, }).where(and( @@ -655,6 +889,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { run: AppRunSafeView, attempt: typeof appRunAttempts.$inferSelect, now: Date, + runtimeResultHmac?: string, ): Promise { const decision = classifyAppRunCrashRecovery({ retry_class: run.retry_class, @@ -668,6 +903,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { error_code: terminalAttemptState === 'unknown_outcome' ? 'APP_RUN_UNKNOWN_OUTCOME' : 'APP_RUN_PROVIDER_UNAVAILABLE', + ...(runtimeResultHmac ? { runtime_result_hmac: runtimeResultHmac } : {}), updated_at: now, }).where(and(eq(appRunAttempts.org_id, run.org_id), eq(appRunAttempts.id, attempt.id))); await this.repository.appendEvent(tx, { @@ -675,7 +911,9 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { event_type: 'attempt_terminal', payload: { attempt_id: attempt.id, state: terminalAttemptState }, now, }); - if (decision === 'create_retry_attempt' && attempt.attempt_number < run.attempt_limit && run.input_expires_at > now) { + if (decision === 'create_retry_attempt' + && (run.provider_kind !== 'app_runtime' || attempt.state === 'claimed') + && attempt.attempt_number < run.attempt_limit && run.input_expires_at > now) { await this.#createAttempt(tx, run, now); await this.#writeAttemptReceipt( tx, diff --git a/apps/api/src/lib/app-run-provider-executor.ts b/apps/api/src/lib/app-run-provider-executor.ts index 66aabc61..a3ccb6d2 100644 --- a/apps/api/src/lib/app-run-provider-executor.ts +++ b/apps/api/src/lib/app-run-provider-executor.ts @@ -6,7 +6,7 @@ import type { export type AppRunProviderExecutionRequest = Readonly<{ org_id: string; - provider_kind: 'mcp'; + provider_kind: 'mcp' | 'app_runtime'; provider_instance_id: string; operation_name: string; origin_kind?: 'core' | 'legacy_connector' | 'app'; diff --git a/apps/api/src/lib/app-run-runtime.ts b/apps/api/src/lib/app-run-runtime.ts index 9b306a93..d2a582be 100644 --- a/apps/api/src/lib/app-run-runtime.ts +++ b/apps/api/src/lib/app-run-runtime.ts @@ -17,6 +17,7 @@ import { AppRunSecretRepository } from './app-run-secret-repository.js'; import { AppRunSecretService } from './app-run-secrets.js'; import { AppRunPreparedInputService } from './app-run-prepared-input.js'; import { AppRunService } from './app-run-service.js'; +import { AppRuntimeChannel } from './app-runtime-channel.js'; import { APP_AUTOMATIONS_ENABLED, APP_RUN_APP_ORIGIN_ENABLED, @@ -31,6 +32,7 @@ export type AppRunRuntime = Readonly<{ liveAuthorization: PostgresAppRunLiveAuthorization; service: AppRunService; attemptRunner: AppRunAttemptRunner; + runtimeChannel: AppRuntimeChannel; approvalResolver: PostgresAppRunApprovalResolver; receiptReader: PostgresAppRunReceiptReader; operations: AppRunOperationsService; @@ -79,6 +81,7 @@ async function createAppRunRuntime(): Promise { () => APP_RUN_APP_ORIGIN_ENABLED, () => APP_AUTOMATIONS_ENABLED, ); + const runtimeChannel = new AppRuntimeChannel(attemptRunner); const approvalResolver = new PostgresAppRunApprovalResolver( repository, liveAuthorization, @@ -110,6 +113,7 @@ async function createAppRunRuntime(): Promise { liveAuthorization, service, attemptRunner, + runtimeChannel, approvalResolver, receiptReader, operations, diff --git a/apps/api/src/lib/app-run-secret-repository.ts b/apps/api/src/lib/app-run-secret-repository.ts index b165dbd6..47e6ffa6 100644 --- a/apps/api/src/lib/app-run-secret-repository.ts +++ b/apps/api/src/lib/app-run-secret-repository.ts @@ -90,8 +90,8 @@ export class AppRunSecretRepository { }); } - async readInput(orgId: string, runId: string): Promise { - const [row] = await db.select().from(appRunSecretPayloads).where(and( + async readInput(orgId: string, runId: string, tx: AppRunTransaction | typeof db = db): Promise { + const [row] = await tx.select().from(appRunSecretPayloads).where(and( eq(appRunSecretPayloads.org_id, orgId), eq(appRunSecretPayloads.run_id, runId), eq(appRunSecretPayloads.payload_kind, 'input'), diff --git a/apps/api/src/lib/app-runtime-authority.ts b/apps/api/src/lib/app-runtime-authority.ts new file mode 100644 index 00000000..52a0709f --- /dev/null +++ b/apps/api/src/lib/app-runtime-authority.ts @@ -0,0 +1,248 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import { + appGrantSnapshots, appInstallations, appRuntimeBindings, + appRuntimeRegistrations, appRuntimeSessions, appRuns, appVersions, + capabilityProviderSnapshots, orgMembers, +} from '@deft/db/schema'; +import { AppRunAuthorizationSnapshotSchema, AppRuntimeSessionAuthoritySchema, + canonicalCapabilityJson, type AppRuntimeSessionAuthority } from '@deft/shared'; +import { db } from './db.js'; +import type { AppRunTransaction } from './app-run-repository.js'; +import { APP_RUNTIME_CHANNEL_VERSION, APP_RUNTIME_SESSION_MS } from './app-runtime-contract.js'; + +export function hashAppRuntimeToken(token: string): string { + return `sha256:${createHash('sha256').update('deft.app_runtime.session.v1\0').update(token).digest('hex')}`; +} + +export type LiveRuntimeAuthority = Readonly<{ + pin: AppRuntimeSessionAuthority; + grant_snapshot_id: string; + operator_user_id: string; + provider_instance_id: string; + provider_snapshot_id: string; + operation_name: string; + risk_class: typeof appRuntimeBindings.$inferSelect['risk_class']; + review_requirement: typeof appRuntimeBindings.$inferSelect['review_requirement']; + retry_class: typeof appRuntimeBindings.$inferSelect['retry_class']; + retention_class: typeof appRuntimeBindings.$inferSelect['retention_class']; +}>; + +/** This private candidate slice accepts a human-origin Run fixture only. A + * future app-origin intake must capture the full actor/surface authority + * vector; no existing v0-v2 entrance can assert this binding. */ +export async function runtimeRunMatchesAuthority( + tx: AppRunTransaction, orgId: string, runId: string, authority: LiveRuntimeAuthority, +): Promise { + const [run] = await tx.select().from(appRuns).where(and( + eq(appRuns.org_id, orgId), eq(appRuns.id, runId), + )).limit(1); + if (!run || run.origin_kind !== 'app' || run.provider_kind !== 'app_runtime' + || run.origin_app_installation_id !== authority.pin.app_installation_id + || run.origin_app_version_id !== authority.pin.app_version_id + || run.origin_app_grant_snapshot_id !== authority.grant_snapshot_id + || run.origin_runtime_binding_id !== authority.pin.runtime_binding_id + || run.origin_app_binding_key !== null + || run.provider_instance_id !== authority.provider_instance_id + || run.provider_snapshot_id !== authority.provider_snapshot_id + || run.operation_name !== authority.operation_name + || run.risk_class !== authority.risk_class + || run.review_requirement !== authority.review_requirement + || run.retry_class !== authority.retry_class + || run.retention_class !== authority.retention_class + || run.initiating_actor_type !== 'human' + || run.execution_actor_type !== 'human' + || run.initiating_actor_id !== run.execution_actor_id) return false; + const snapshot = AppRunAuthorizationSnapshotSchema.safeParse(run.authorization_snapshot); + if (!snapshot.success || snapshot.data.authenticated_subject.actor_type !== 'human' + || snapshot.data.authenticated_subject.user_id !== run.initiating_actor_id) return false; + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} + AND user_id = ${run.initiating_actor_id} FOR SHARE`); + const [member] = await tx.select({ + id: orgMembers.id, is_active: orgMembers.is_active, + app_run_authorization_version: orgMembers.app_run_authorization_version, + }).from(orgMembers).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, run.initiating_actor_id), + )).limit(1); + if (!member?.is_active) return false; + const membershipVersion = `sha256:${createHash('sha256') + .update('deft.app_run.authority.v1\0membership\0') + .update(canonicalCapabilityJson({ id: member.id, + authority_version: member.app_run_authorization_version })) + .digest('hex')}`; + return snapshot.data.authority_refs.some((ref) => ref.authority_kind === 'membership' + && ref.authority_id === run.initiating_actor_id && ref.version === membershipVersion); +} + +/** Every channel operation rereads live host authority under row locks. + * Token hash is necessary but never sufficient: revocation, operator membership, + * installation epochs, active version/grant and reviewed binding are all live. */ +export async function loadLiveRuntimeAuthority( + tx: AppRunTransaction, + orgId: string, + sessionId: string, + tokenHash: string, + now: () => Date, +): Promise { + // Locate without trusting the row, then lock in the same order as App + // lifecycle transitions: installation -> registration -> binding -> session. + const [locator] = await tx.select().from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.org_id, orgId), eq(appRuntimeSessions.id, sessionId), + eq(appRuntimeSessions.token_hash, tokenHash), + )).limit(1); + if (!locator) return null; + const [registrationLocator] = await tx.select({ + app_installation_id: appRuntimeRegistrations.app_installation_id, + }).from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, orgId), + eq(appRuntimeRegistrations.id, locator.runtime_registration_id), + )).limit(1); + if (!registrationLocator) return null; + await tx.execute(sql`SELECT id FROM app_installations + WHERE org_id = ${orgId} AND id = ${registrationLocator.app_installation_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations + WHERE org_id = ${orgId} AND id = ${locator.runtime_registration_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_bindings + WHERE org_id = ${orgId} AND id = ${locator.runtime_binding_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_sessions + WHERE org_id = ${orgId} AND id = ${sessionId} FOR UPDATE`); + const [session] = await tx.select().from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.org_id, orgId), eq(appRuntimeSessions.id, sessionId), + eq(appRuntimeSessions.token_hash, tokenHash), + )).limit(1); + const checkedAt = now(); + if (!session || session.audience !== 'app_runtime' || session.revoked_at + || session.expires_at <= checkedAt || session.runtime_registration_id !== locator.runtime_registration_id + || session.runtime_binding_id !== locator.runtime_binding_id) return null; + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, orgId), + eq(appRuntimeRegistrations.id, session.runtime_registration_id), + )).limit(1); + if (!registration || registration.state !== 'active' + || registration.contract_version !== APP_RUNTIME_CHANNEL_VERSION + || registration.runtime_epoch !== session.runtime_epoch + || registration.operator_user_id !== session.operator_user_id) return null; + const [binding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, orgId), eq(appRuntimeBindings.id, session.runtime_binding_id), + )).limit(1); + if (!binding || binding.state !== 'active' + || binding.runtime_registration_id !== registration.id + || binding.app_installation_id !== registration.app_installation_id + || binding.app_version_id !== registration.app_version_id + || binding.grant_snapshot_id !== registration.grant_snapshot_id + || binding.provider_kind !== 'app_runtime') return null; + if (registration.app_installation_id !== registrationLocator.app_installation_id) return null; + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, orgId), eq(appInstallations.id, registration.app_installation_id), + )).limit(1); + if (!installation || installation.state !== 'active' + || installation.active_version_id !== registration.app_version_id + || installation.active_grant_snapshot_id !== registration.grant_snapshot_id + || installation.lifecycle_epoch !== session.lifecycle_epoch + || installation.grant_epoch !== session.grant_epoch) return null; + await tx.execute(sql`SELECT id FROM app_versions WHERE org_id = ${orgId} + AND id = ${registration.app_version_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} + AND user_id = ${session.operator_user_id} FOR SHARE`); + const [version] = await tx.select({ state: appVersions.state }).from(appVersions).where(and( + eq(appVersions.org_id, orgId), eq(appVersions.id, registration.app_version_id), + eq(appVersions.installation_id, registration.app_installation_id), + )).limit(1); + const [grant] = await tx.select({ snapshot_kind: appGrantSnapshots.snapshot_kind }).from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, orgId), eq(appGrantSnapshots.id, registration.grant_snapshot_id), + eq(appGrantSnapshots.app_installation_id, registration.app_installation_id), + eq(appGrantSnapshots.app_version_id, registration.app_version_id), + )).limit(1); + const [member] = await tx.select({ is_active: orgMembers.is_active }).from(orgMembers).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, session.operator_user_id), + )).limit(1); + const [snapshot] = await tx.select({ provider_kind: capabilityProviderSnapshots.provider_kind }).from(capabilityProviderSnapshots).where(and( + eq(capabilityProviderSnapshots.org_id, orgId), eq(capabilityProviderSnapshots.id, binding.provider_snapshot_id), + eq(capabilityProviderSnapshots.provider_instance_id, binding.provider_instance_id), + )).limit(1); + if (version?.state !== 'active' || grant?.snapshot_kind !== 'effective' + || !member?.is_active || snapshot?.provider_kind !== 'app_runtime') return null; + if (session.expires_at <= now()) return null; + return Object.freeze({ + pin: AppRuntimeSessionAuthoritySchema.parse({ + org_id: orgId, app_installation_id: registration.app_installation_id, + app_version_id: registration.app_version_id, + lifecycle_epoch: session.lifecycle_epoch, grant_epoch: session.grant_epoch, + audience: 'app_runtime', runtime_registration_id: registration.id, + runtime_binding_id: binding.id, runtime_epoch: registration.runtime_epoch, + session_id: session.id, session_epoch: session.session_epoch, + }), + grant_snapshot_id: registration.grant_snapshot_id, + operator_user_id: session.operator_user_id, + provider_instance_id: binding.provider_instance_id, + provider_snapshot_id: binding.provider_snapshot_id, + operation_name: binding.operation_name, + risk_class: binding.risk_class, + review_requirement: binding.review_requirement, + retry_class: binding.retry_class, + retention_class: binding.retention_class, + }); +} + +/** Host-only minting seam; caller must already authenticate the human operator. */ +export async function issueAppRuntimeSession(input: Readonly<{ + org_id: string; + runtime_binding_id: string; + operator_user_id: string; + now?: Date; +}>): Promise | null> { + const now = input.now ?? new Date(); + const sessionId = crypto.randomUUID(); + const sessionToken = randomBytes(32).toString('base64url'); + let issued = false; + try { issued = await db.transaction(async (tx) => { + const [binding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, input.org_id), eq(appRuntimeBindings.id, input.runtime_binding_id), + )).limit(1); + if (!binding) return false; + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, input.org_id), eq(appRuntimeRegistrations.id, binding.runtime_registration_id), + )).limit(1); + if (!registration || registration.operator_user_id !== input.operator_user_id) return false; + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${input.org_id} + AND id = ${registration.app_installation_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${input.org_id} + AND id = ${registration.id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_bindings WHERE org_id = ${input.org_id} + AND id = ${binding.id} FOR SHARE`); + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, input.org_id), + eq(appInstallations.id, registration.app_installation_id), + )).limit(1); + if (!installation) return false; + const [lockedRegistration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, input.org_id), eq(appRuntimeRegistrations.id, registration.id), + )).limit(1); + const [lockedBinding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, input.org_id), eq(appRuntimeBindings.id, binding.id), + )).limit(1); + if (!lockedRegistration || !lockedBinding + || lockedRegistration.app_installation_id !== installation.id + || lockedRegistration.operator_user_id !== input.operator_user_id + || lockedBinding.runtime_registration_id !== lockedRegistration.id) return false; + const bootstrapTokenHash = hashAppRuntimeToken(sessionToken); + // The live check runs after insertion in the same transaction, so a stale + // registration, grant, membership or installation cannot mint authority. + await tx.insert(appRuntimeSessions).values({ + id: sessionId, org_id: input.org_id, + runtime_registration_id: registration.id, runtime_binding_id: binding.id, + operator_user_id: input.operator_user_id, token_hash: bootstrapTokenHash, + runtime_epoch: lockedRegistration.runtime_epoch, + lifecycle_epoch: installation.lifecycle_epoch, grant_epoch: installation.grant_epoch, + expires_at: new Date(now.getTime() + APP_RUNTIME_SESSION_MS), + created_at: now, updated_at: now, + }); + if (!await loadLiveRuntimeAuthority(tx, input.org_id, sessionId, bootstrapTokenHash, + () => input.now ?? new Date())) { + throw new Error('APP_RUNTIME_AUTHORITY_STALE'); + } + return true; + }); } catch { return null; } + return issued ? Object.freeze({ session_id: sessionId, session_token: sessionToken, + expires_at: new Date(now.getTime() + APP_RUNTIME_SESSION_MS) }) : null; +} diff --git a/apps/api/src/lib/app-runtime-channel.ts b/apps/api/src/lib/app-runtime-channel.ts new file mode 100644 index 00000000..b396f61d --- /dev/null +++ b/apps/api/src/lib/app-runtime-channel.ts @@ -0,0 +1,103 @@ +import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; +import { appRunAttempts, appRuns, appRuntimeSessions } from '@deft/db/schema'; +import { db } from './db.js'; +import { APP_RUN_APP_ORIGIN_ENABLED, APP_RUNS_ENABLED } from './env.js'; +import type { AppRunAttemptRunner } from './app-run-attempt-runner.js'; +import { hashAppRuntimeToken, issueAppRuntimeSession } from './app-runtime-authority.js'; +import { + AppRuntimeClaimRequestSchema, AppRuntimeHeartbeatRequestSchema, + AppRuntimeStartRequestSchema, parseAppRuntimeResult, +} from './app-runtime-contract.js'; + +/** Deliberately separate from employee/public App audiences. No route or App + * Kit v0-v2 submission can activate it merely by installation. */ +export function appRuntimeChannelEnabled(): boolean { + return APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED + && process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED === 'true'; +} + +export class AppRuntimeChannel { + constructor(private readonly runner: AppRunAttemptRunner) {} + + /** Host-only issuance after the caller authenticates an operator user. + * Registration/binding review remains a separate privileged workflow. */ + async issueSession(input: Parameters[0]) { + if (!appRuntimeChannelEnabled()) return null; + return issueAppRuntimeSession(input); + } + + async claim(value: unknown) { + if (!appRuntimeChannelEnabled()) return null; + const request = AppRuntimeClaimRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return null; + const candidates = await db.select({ + run_id: appRunAttempts.run_id, attempt_id: appRunAttempts.id, + }).from(appRunAttempts).innerJoin(appRuns, and( + eq(appRuns.org_id, appRunAttempts.org_id), eq(appRuns.id, appRunAttempts.run_id), + )).where(and( + eq(appRunAttempts.org_id, identity.org_id), + eq(appRunAttempts.state, 'pending'), + eq(appRuns.origin_kind, 'app'), eq(appRuns.provider_kind, 'app_runtime'), + eq(appRuns.origin_runtime_binding_id, identity.runtime_binding_id), + isNotNull(appRuns.execution_released_at), gt(appRuns.input_expires_at, new Date()), + )).orderBy(asc(appRunAttempts.created_at)).limit(8); + for (const candidate of candidates) { + const claimed = await this.runner.claimRuntimeAttempt({ + org_id: identity.org_id, run_id: candidate.run_id, + attempt_id: candidate.attempt_id, session_id: request.session_id, + token_hash: identity.token_hash, + }); + if (claimed) return claimed; + } + return null; + } + + async start(value: unknown) { + if (!appRuntimeChannelEnabled()) return null; + const request = AppRuntimeStartRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return null; + return this.runner.startRuntimeAttempt({ + org_id: identity.org_id, run_id: request.run_id, attempt_id: request.attempt_id, + session_id: request.session_id, token_hash: identity.token_hash, + claim_token: request.claim_token, sequence: request.sequence, + }); + } + + async heartbeat(value: unknown): Promise { + if (!appRuntimeChannelEnabled()) return false; + const request = AppRuntimeHeartbeatRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return false; + return this.runner.heartbeatRuntimeAttempt({ + org_id: identity.org_id, run_id: request.run_id, attempt_id: request.attempt_id, + session_id: request.session_id, token_hash: identity.token_hash, + claim_token: request.claim_token, sequence: request.sequence, + }); + } + + async complete(value: unknown) { + if (!appRuntimeChannelEnabled()) return null; + const result = parseAppRuntimeResult(value); + const identity = await this.#session(result.session_id, result.session_token); + if (!identity) return null; + return this.runner.completeRuntimeAttempt({ + org_id: identity.org_id, token_hash: identity.token_hash, result, + }); + } + + async #session(sessionId: string, token: string): Promise | null> { + const tokenHash = hashAppRuntimeToken(token); + const [session] = await db.select({ + org_id: appRuntimeSessions.org_id, + runtime_binding_id: appRuntimeSessions.runtime_binding_id, + token_hash: appRuntimeSessions.token_hash, + }).from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.id, sessionId), eq(appRuntimeSessions.token_hash, tokenHash), + )).limit(1); + return session ?? null; + } +} diff --git a/apps/api/src/lib/app-runtime-contract.ts b/apps/api/src/lib/app-runtime-contract.ts new file mode 100644 index 00000000..56b4d2d1 --- /dev/null +++ b/apps/api/src/lib/app-runtime-contract.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { + APP_RUN_CONTRACT_VERSIONS, + AppRunRetainedProviderResultSchema, + assertAppRunOutputWithinBudget, + CapabilityJsonValueSchema, + type CapabilityJsonValue, + type AppRuntimeSessionAuthority, +} from '@deft/shared'; + +/** Candidate channel contract. It is not exported from the public App Kit. */ +export const APP_RUNTIME_CHANNEL_VERSION = 'deft.app_runtime_channel.v1' as const; +export const APP_RUNTIME_AUDIENCE = 'app_runtime' as const; +export const APP_RUNTIME_SESSION_MS = 15 * 60_000; +export const APP_RUNTIME_LEASE_MS = 60_000; + +const identity = z.string().min(1).max(512) + .refine((value) => value === value.trim() && !/[\u0000-\u001f\u007f]/u.test(value)); +const credential = z.string().min(32).max(512).regex(/^[A-Za-z0-9_-]+$/u); +const sequence = z.number().int().positive().max(2_147_483_647); + +export const AppRuntimeSessionCredentialSchema = z.object({ + session_id: identity, + session_token: credential, +}).strict(); +export type AppRuntimeSessionCredential = z.infer; + +export const AppRuntimeClaimRequestSchema = AppRuntimeSessionCredentialSchema.extend({ + schema_version: z.literal(APP_RUNTIME_CHANNEL_VERSION), + max_claims: z.literal(1), +}).strict(); +export type AppRuntimeClaimRequest = z.infer; + +const claimedAttempt = { + schema_version: z.literal(APP_RUNTIME_CHANNEL_VERSION), + session_id: identity, + session_token: credential, + run_id: identity, + attempt_id: identity, + claim_token: identity, + sequence, +}; +export const AppRuntimeStartRequestSchema = z.object(claimedAttempt).strict(); +export const AppRuntimeHeartbeatRequestSchema = z.object(claimedAttempt).strict(); + +export const AppRuntimeResultRequestSchema = z.discriminatedUnion('status', [ + z.object({ + ...claimedAttempt, + status: z.literal('returned'), + provider_succeeded: z.boolean(), + output: CapabilityJsonValueSchema, + }).strict(), + z.object({ + ...claimedAttempt, + status: z.literal('not_attempted'), + error_code: z.enum(['APP_RUN_PROVIDER_UNAVAILABLE', 'APP_RUN_PROVIDER_TIMEOUT']), + }).strict(), + z.object({ + ...claimedAttempt, + status: z.literal('indeterminate'), + }).strict(), +]); +export type AppRuntimeResultRequest = z.infer; + +/** Bound the exact retained result envelope before any completion write. */ +export function parseAppRuntimeResult(value: unknown): AppRuntimeResultRequest { + const parsed = AppRuntimeResultRequestSchema.parse(value); + if (parsed.status === 'returned') { + const envelope = AppRunRetainedProviderResultSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.provider_result, + provider_succeeded: parsed.provider_succeeded, + output: parsed.output, + }); + assertAppRunOutputWithinBudget(envelope); + } + return parsed; +} + +export type AppRuntimeClaimEnvelope = Readonly; + +export type AppRuntimeStartEnvelope = Readonly<{ + schema_version: typeof APP_RUNTIME_CHANNEL_VERSION; + run_id: string; + attempt_id: string; + lease_expires_at: string; + input: CapabilityJsonValue; + provider_idempotency_key?: string; +}>; diff --git a/apps/api/src/middleware/app-public-limits.ts b/apps/api/src/middleware/app-public-limits.ts new file mode 100644 index 00000000..ed43f7a0 --- /dev/null +++ b/apps/api/src/middleware/app-public-limits.ts @@ -0,0 +1,128 @@ +import { isIP } from 'node:net'; +import { getConnInfo } from '@hono/node-server/conninfo'; +import type { Context, MiddlewareHandler } from 'hono'; + +const WINDOW_MS = 60_000; +const UNKNOWN_PEER = 'unknown'; +const OVERFLOW_PEER = 'overflow'; + +type Bucket = { windowStart: number; count: number; inFlight: number }; +export type AppPublicLimitsOptions = Readonly<{ + /** Supply only a socket peer or a value verified by a trusted host proxy. */ + peerAddress?: (c: Context) => string | null | undefined; + now?: () => number; + globalPerMinute?: number; + peerPerMinute?: number; + globalConcurrent?: number; + peerConcurrent?: number; + maxPeerBuckets?: number; +}>; + +function positiveInt(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback; +} + +function socketPeer(c: Context): string | null { + try { + return getConnInfo(c).remote.address ?? null; + } catch { + // Hono's in-memory request adapter has no socket. Share the unknown bucket. + return null; + } +} + +function peerKey(value: string | null | undefined): string { + const candidate = value?.trim(); + return candidate && candidate.length <= 45 && isIP(candidate) !== 0 + ? candidate + : UNKNOWN_PEER; +} + +function tick(bucket: Bucket, now: number): void { + if (now < bucket.windowStart || now - bucket.windowStart >= WINDOW_MS) { + bucket.windowStart = now; + bucket.count = 0; + } +} + +/** + * Process-local admission before public body parsing, authentication lookups, + * or DB work. Forwarding headers are deliberately never inspected here. + * A deployment with a trusted reverse proxy may inject its verified peer. + */ +export function createAppPublicLimits(options: AppPublicLimitsOptions = {}): MiddlewareHandler { + const now = options.now ?? Date.now; + const resolvePeer = options.peerAddress ?? socketPeer; + const globalPerMinute = positiveInt(options.globalPerMinute, 600); + const peerPerMinute = positiveInt(options.peerPerMinute, 30); + const globalConcurrent = positiveInt(options.globalConcurrent, 32); + const peerConcurrent = positiveInt(options.peerConcurrent, 2); + const maxPeerBuckets = positiveInt(options.maxPeerBuckets, 1024); + const peers = new Map(); + const unknown: Bucket = { windowStart: now(), count: 0, inFlight: 0 }; + const overflow: Bucket = { windowStart: now(), count: 0, inFlight: 0 }; + const global: Bucket = { windowStart: now(), count: 0, inFlight: 0 }; + let admissions = 0; + + return async (c, next) => { + const current = now(); + tick(global, current); + // Charge every request, including rejected identities, to the global + // budget so rotating peers cannot bypass the process ceiling. + global.count += 1; + if (global.count > globalPerMinute) { + c.header('Retry-After', '60'); + return c.json({ error: 'Public request rate limit reached', code: 'PUBLIC_RATE_LIMITED' }, 429); + } + + const key = peerKey(resolvePeer(c)); + let peer: Bucket; + if (key === UNKNOWN_PEER) { + peer = unknown; + } else { + peer = peers.get(key)!; + if (!peer) { + admissions += 1; + // Expired idle identities can be removed, but collection happens only + // periodically and is capped by maxPeerBuckets. + if (admissions % 64 === 0 && peers.size >= maxPeerBuckets) { + for (const [id, bucket] of peers) { + if (bucket.inFlight === 0 && current - bucket.windowStart >= WINDOW_MS) peers.delete(id); + } + } + if (peers.size < maxPeerBuckets) { + peer = { windowStart: current, count: 0, inFlight: 0 }; + peers.set(key, peer); + } else { + peer = overflow; + } + } + } + tick(peer, current); + peer.count += 1; + if (peer.count > peerPerMinute) { + c.header('Retry-After', '60'); + return c.json({ error: 'Public request rate limit reached', code: 'PUBLIC_RATE_LIMITED' }, 429); + } + if (c.req.raw.signal.aborted) { + return c.json({ error: 'Public request unavailable', code: 'PUBLIC_UNAVAILABLE' }, 503); + } + if (global.inFlight >= globalConcurrent || peer.inFlight >= peerConcurrent) { + c.header('Retry-After', '1'); + return c.json({ error: 'Public request capacity reached', code: 'PUBLIC_UNAVAILABLE' }, 503); + } + + global.inFlight += 1; + peer.inFlight += 1; + // Aborted requests keep their slot until downstream work actually settles. + // Releasing early would allow more real DB work than the concurrency cap. + try { + await next(); + } finally { + global.inFlight -= 1; + peer.inFlight -= 1; + } + }; +} + +export const appPublicLimits = createAppPublicLimits(); diff --git a/apps/api/src/routes/app-public.ts b/apps/api/src/routes/app-public.ts new file mode 100644 index 00000000..7d4b42cd --- /dev/null +++ b/apps/api/src/routes/app-public.ts @@ -0,0 +1,73 @@ +import { Hono } from 'hono'; +import { AppPublicError, AppPublicClaimService, appPublicClaimService } from '../lib/app-public-service.js'; +import { appPublicLimits } from '../middleware/app-public-limits.js'; + +// Deliberately unmounted until the public gateway review and limits are wired. +// This route never reads workspace cookies, bearer headers or c.get('user'). +const HARD_BODY_LIMIT = 8192; +const READ_DEADLINE_MS = 10_000; + +async function boundedBody(stream: ReadableStream | null): Promise { + if (!stream) return new Uint8Array(); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + void reader.cancel().catch(() => undefined); + }, READ_DEADLINE_MS); + try { + for (;;) { + const { done, value } = await reader.read(); + if (timedOut) throw new AppPublicError('PUBLIC_UNAVAILABLE', 503); + if (done) break; + size += value.byteLength; + if (size > HARD_BODY_LIMIT) { + void reader.cancel().catch(() => undefined); + throw new AppPublicError('PUBLIC_PAYLOAD_TOO_LARGE', 413); + } + chunks.push(value); + } + } finally { + clearTimeout(timer); + reader.releaseLock(); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } + return body; +} + +export function createAppPublicRoutes(service: AppPublicClaimService = appPublicClaimService) { + const routes = new Hono(); + routes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!service.isEnabled()) { + return c.json({ error: 'Public endpoint not found', code: 'PUBLIC_NOT_FOUND' }, 404); + } + await next(); + }); + routes.use('*', appPublicLimits); + routes.post('/:slug/claims', async (c) => { + try { + if (!service.isEnabled()) throw new AppPublicError('PUBLIC_NOT_FOUND', 404); + if (!/^application\/json(?:\s*;|$)/i.test(c.req.header('content-type') ?? '')) { + return c.json({ error: 'JSON body required', code: 'PUBLIC_INVALID_INPUT' }, 400); + } + const rawBody = await boundedBody(c.req.raw.body); + const result = await service.claim(c.req.param('slug'), rawBody); + c.header('Cache-Control', 'no-store'); + return c.json({ result }, result.replayed ? 200 : 201); + } catch (error) { + c.header('Cache-Control', 'no-store'); + if (error instanceof AppPublicError) { + return c.json({ error: error.message, code: error.code }, error.status); + } + return c.json({ error: 'Public claim is temporarily unavailable', code: 'PUBLIC_UNAVAILABLE' }, 503); + } + }); + return routes; +} + +export const appPublicRoutes = createAppPublicRoutes(); diff --git a/apps/api/src/routes/app-runtime-channel.ts b/apps/api/src/routes/app-runtime-channel.ts new file mode 100644 index 00000000..419570cb --- /dev/null +++ b/apps/api/src/routes/app-runtime-channel.ts @@ -0,0 +1,115 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { appRuntimeChannelEnabled } from '../lib/app-runtime-channel.js'; +import { getAppRunRuntime } from '../lib/app-run-runtime.js'; + +const MAX_RUNTIME_BODY_BYTES = 1_100_000; +const RUNTIME_READ_DEADLINE_MS = 15_000; +export const appRuntimeChannelRoutes = new Hono(); + +// Flag gate runs before body consumption or database access. This router must +// be mounted outside human/employee cookie middleware; no cookie is accepted. +appRuntimeChannelRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!appRuntimeChannelEnabled()) { + return c.json({ error: 'Runtime channel unavailable', code: 'APP_RUNTIME_DISABLED' }, 503); + } + if (c.req.header('cookie')) { + return c.json({ error: 'Runtime credential required', code: 'APP_RUNTIME_ACCESS_DENIED' }, 403); + } + await next(); +}); + +async function request(c: Context, maxBytes: number): Promise> { + const authorization = c.req.header('authorization') ?? ''; + const match = /^AppRuntime ([A-Za-z0-9_-]{32,512})$/u.exec(authorization); + if (!match) throw new Error('AUTH'); + if (c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new Error('JSON'); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes) throw new Error('SIZE'); + const reader = c.req.raw.body?.getReader(); + if (!reader) throw new Error('JSON'); + const chunks: Uint8Array[] = []; + let total = 0; + const deadline = Date.now() + RUNTIME_READ_DEADLINE_MS; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('TIMEOUT'); + let timer: ReturnType | undefined; + const next = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('TIMEOUT')), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) throw new Error('SIZE'); + chunks.push(next.value); + } + } catch (error) { + void reader.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + const body: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if (!body || typeof body !== 'object' || Array.isArray(body) + || Object.hasOwn(body, 'session_token')) throw new Error('JSON'); + return { ...body, session_token: match[1] }; +} + +function failure(c: Context, error: unknown) { + if (error instanceof z.ZodError || error instanceof SyntaxError + || error instanceof TypeError || (error instanceof Error && ['JSON', 'SIZE'].includes(error.message))) { + const tooLarge = error instanceof Error && error.message === 'SIZE'; + return c.json({ error: tooLarge ? 'Runtime request too large' : 'Invalid runtime request', + code: 'VALIDATION_ERROR' }, tooLarge ? 413 : 400); + } + if (error instanceof Error && error.message === 'AUTH') { + return c.json({ error: 'Runtime credential required', code: 'APP_RUNTIME_ACCESS_DENIED' }, 403); + } + if (error instanceof Error && error.message === 'TIMEOUT') { + return c.json({ error: 'Runtime request timed out', code: 'APP_RUNTIME_TIMEOUT' }, 408); + } + console.error('[app-runtime] channel request failed'); + return c.json({ error: 'Runtime request failed', code: 'INTERNAL_ERROR' }, 500); +} + +appRuntimeChannelRoutes.post('/claim', async (c) => { + try { + const payload = await request(c, 4096); + const claim = await (await getAppRunRuntime()).runtimeChannel.claim(payload); + return claim ? c.json({ claim }) : c.json({ claim: null }); + } catch (error) { return failure(c, error); } +}); +appRuntimeChannelRoutes.post('/start', async (c) => { + try { + const payload = await request(c, 4096); + const started = await (await getAppRunRuntime()).runtimeChannel.start(payload); + return started ? c.json({ started }) + : c.json({ error: 'Runtime claim unavailable', code: 'APP_RUNTIME_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); +appRuntimeChannelRoutes.post('/heartbeat', async (c) => { + try { + const payload = await request(c, 4096); + const renewed = await (await getAppRunRuntime()).runtimeChannel.heartbeat(payload); + return renewed ? c.json({ renewed: true }) + : c.json({ error: 'Runtime claim unavailable', code: 'APP_RUNTIME_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); +appRuntimeChannelRoutes.post('/result', async (c) => { + try { + const payload = await request(c, MAX_RUNTIME_BODY_BYTES); + const run = await (await getAppRunRuntime()).runtimeChannel.complete(payload); + return run ? c.json({ run }) + : c.json({ error: 'Runtime claim unavailable', code: 'APP_RUNTIME_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); diff --git a/apps/api/src/workers/index.ts b/apps/api/src/workers/index.ts index 3452234a..da2447f6 100644 --- a/apps/api/src/workers/index.ts +++ b/apps/api/src/workers/index.ts @@ -263,6 +263,10 @@ async function getAgentJobHandler(jobName: string): Promise { const mod = await import('../lib/app-run-worker-handler.js'); return mod.handleAppRunAttempt; } + case 'app-public-ingress': { + const mod = await import('../lib/app-public-worker-handler.js'); + return mod.handleAppPublicIngress; + } case 'certification-noop': { // Synthetic 60-person certification intentionally measures queue claim, // completion, and recovery without invoking a product side effect. diff --git a/apps/api/test/app-public-limits.test.ts b/apps/api/test/app-public-limits.test.ts new file mode 100644 index 00000000..c52ea8bb --- /dev/null +++ b/apps/api/test/app-public-limits.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Hono } from 'hono'; +import { createAppPublicLimits } from '../src/middleware/app-public-limits.js'; + +function appWithLimits(options: Parameters[0], handler?: () => Promise) { + const app = new Hono(); + let bodyReads = 0; + app.use('*', createAppPublicLimits(options)); + app.post('/:slug/claims', async (c) => { + bodyReads += 1; + if (handler) await handler(); + return c.json({ ok: true }); + }); + app.onError(() => new Response('internal error', { status: 500 })); + return { app, bodyReads: () => bodyReads }; +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { release = resolve; }); + return { promise, release }; +} + +test('forged forwarding headers cannot multiply socket-peer budget; rejection precedes route', async () => { + const { app, bodyReads } = appWithLimits({ + peerAddress: () => '203.0.113.7', + globalPerMinute: 10, + peerPerMinute: 2, + }); + for (const forged of ['198.51.100.1', '198.51.100.2']) { + const response = await app.request('/phantom/claims', { + method: 'POST', + headers: { 'x-forwarded-for': forged, 'x-real-ip': forged }, + body: '{}', + }); + assert.equal(response.status, 200); + } + const rejected = await app.request('/different-unknown-slug/claims', { + method: 'POST', + headers: { 'x-forwarded-for': '198.51.100.3', 'x-real-ip': '198.51.100.3' }, + body: '{}', + }); + assert.equal(rejected.status, 429); + assert.equal((await rejected.json() as { code: string }).code, 'PUBLIC_RATE_LIMITED'); + assert.equal(bodyReads(), 2); +}); + +test('unknown socket peer shares a fail-closed bucket regardless of forwarding headers', async () => { + const { app } = appWithLimits({ globalPerMinute: 10, peerPerMinute: 1 }); + assert.equal((await app.request('/a/claims', { method: 'POST', headers: { 'x-forwarded-for': '203.0.113.1' } })).status, 200); + assert.equal((await app.request('/b/claims', { method: 'POST', headers: { 'x-forwarded-for': '203.0.113.2' } })).status, 429); +}); + +test('peer identity table is capped and overflow identities share one bucket', async () => { + let peer = '203.0.113.1'; + const { app } = appWithLimits({ + peerAddress: () => peer, + maxPeerBuckets: 1, + globalPerMinute: 10, + peerPerMinute: 1, + }); + assert.equal((await app.request('/a/claims', { method: 'POST' })).status, 200); + peer = '203.0.113.2'; + assert.equal((await app.request('/b/claims', { method: 'POST' })).status, 200); + peer = '203.0.113.3'; + assert.equal((await app.request('/c/claims', { method: 'POST' })).status, 429); + peer = '203.0.113.1'; + assert.equal((await app.request('/d/claims', { method: 'POST' })).status, 429); +}); + +test('global budget holds across peer and slug rotation', async () => { + let peerNumber = 1; + const { app } = appWithLimits({ + peerAddress: () => `203.0.113.${peerNumber++}`, + globalPerMinute: 2, + peerPerMinute: 10, + }); + assert.equal((await app.request('/a/claims', { method: 'POST' })).status, 200); + assert.equal((await app.request('/b/claims', { method: 'POST' })).status, 200); + assert.equal((await app.request('/c/claims', { method: 'POST' })).status, 429); +}); + +test('in-flight slots reject before route and release after success or error', async () => { + const gate = deferred(); + let entered = 0; + const { app } = appWithLimits({ + peerAddress: () => '203.0.113.5', + globalPerMinute: 10, + peerPerMinute: 10, + globalConcurrent: 1, + peerConcurrent: 1, + }, async () => { + entered += 1; + if (entered === 1) await gate.promise; + if (entered === 2) throw new Error('test failure'); + }); + const first = app.request('/a/claims', { method: 'POST' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(entered, 1); + const busy = await app.request('/b/claims', { method: 'POST' }); + assert.equal(busy.status, 503); + assert.equal(entered, 1); + gate.release(); + assert.equal((await first).status, 200); + assert.equal((await app.request('/c/claims', { method: 'POST' })).status, 500); + assert.equal((await app.request('/d/claims', { method: 'POST' })).status, 200); +}); + +test('abort does not release capacity until downstream work settles', async () => { + const gate = deferred(); + let entered = 0; + const { app } = appWithLimits({ + peerAddress: () => '203.0.113.8', + globalPerMinute: 10, + peerPerMinute: 10, + globalConcurrent: 1, + peerConcurrent: 1, + }, async () => { + entered += 1; + if (entered === 1) await gate.promise; + }); + const controller = new AbortController(); + const first = app.request(new Request('http://localhost/a/claims', { + method: 'POST', signal: controller.signal, + })); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(entered, 1); + controller.abort(); + assert.equal((await app.request('/b/claims', { method: 'POST' })).status, 503); + assert.equal(entered, 1, 'aborted client did not release unfinished work'); + gate.release(); + await Promise.allSettled([first]); + assert.equal((await app.request('/c/claims', { method: 'POST' })).status, 200); +}); + + + +test('global concurrency cap spans different verified peers', async () => { + const gate = deferred(); + let peer = '203.0.113.11'; + let entered = 0; + const { app } = appWithLimits({ + peerAddress: () => peer, + globalPerMinute: 10, + peerPerMinute: 10, + globalConcurrent: 1, + peerConcurrent: 2, + }, async () => { + entered += 1; + if (entered === 1) await gate.promise; + }); + const first = app.request('/first/claims', { method: 'POST' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + peer = '203.0.113.12'; + assert.equal((await app.request('/second/claims', { method: 'POST' })).status, 503); + assert.equal(entered, 1); + gate.release(); + assert.equal((await first).status, 200); + assert.equal((await app.request('/third/claims', { method: 'POST' })).status, 200); +}); diff --git a/apps/api/test/app-public-service-db.test.ts b/apps/api/test/app-public-service-db.test.ts new file mode 100644 index 00000000..bf143e8f --- /dev/null +++ b/apps/api/test/app-public-service-db.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import { and, count, eq, sql } from 'drizzle-orm'; +import { SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT } from '@deft/app-kit'; +import { CAPABILITY_CONTRACT_VERSIONS, RESOURCE_CONTRACT_VERSIONS, createCapabilityProviderDiscoverySnapshot } from '@deft/shared'; +import { + appCanonicalClaims, appGrantSnapshots, appInstallations, appModuleBindings, appPublicEndpoints, + appPublicIngress, appVersions, jobQueue, mcpConnections, orgMembers, orgs, users, +} from '@deft/db/schema'; +import { closeDb, db } from '../src/lib/db.js'; +import { activateAppInstallation, stageAppPackage } from '../src/lib/app-service.js'; +import { activateConnectedAppInstallation, prepareConnectedAppReview } from '../src/lib/app-review-service.js'; +import { createModuleRecord, getModuleInstallation, humanModuleActor } from '../src/lib/module-service.js'; +import { AppPublicClaimService, AppPublicError, publicEndpointReviewDigest } from '../src/lib/app-public-service.js'; +import { dequeueJob, enqueue, QUEUE_NAMES } from '../src/lib/queues.js'; +import { _getAgentJobHandlerForTest, _processDequeuedJobForTest } from '../src/workers/index.js'; +import { createAppPublicRoutes } from '../src/routes/app-public.js'; +import { safeTestDatabaseUrl } from './fixtures/safe-test-database.js'; +import { buildPhase5ConnectedAppPackage, buildPhase5DependencyAppPackage } from './fixtures/phase5-connected-app-package.js'; + +const canRun = Boolean(safeTestDatabaseUrl()); +after(async () => closeDb()); +const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; + +async function fixture() { + const suffix = randomUUID(); + const orgId = randomUUID(); + const ownerUserId = randomUUID(); + const connectionId = randomUUID(); + const connectionSlug = `public-mail-${suffix}`; + await db.insert(orgs).values({ id: orgId, name: 'Gate G public test', slug: `public-${suffix}` }); + await db.insert(users).values({ id: ownerUserId, email: `public-${suffix}@example.test`, name: 'Public reviewer' }); + await db.insert(orgMembers).values({ id: randomUUID(), org_id: orgId, user_id: ownerUserId, role: 'owner', is_active: true }); + const owner = humanModuleActor({ orgId, userId: ownerUserId, role: 'owner', source: 'ui' }); + const dependencyPackage = await buildPhase5DependencyAppPackage(); + const dependency = await stageAppPackage(owner, dependencyPackage.json); + await activateAppInstallation(owner, dependency.id, dependency.package_digest); + const connectedPackage = await buildPhase5ConnectedAppPackage(); + const staged = await stageAppPackage(owner, connectedPackage.json); + const [version] = await db.select().from(appVersions).where(eq(appVersions.id, staged.version_id)); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(appGrantSnapshots).where(eq(appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + await db.insert(mcpConnections).values({ + id: connectionId, org_id: orgId, name: 'Synthetic capability', slug: connectionSlug, + server_url: 'https://public-fixture.example.test/mcp', transport: 'streamable-http', auth_type: 'none', + is_active: true, enabled_tools: ['send_email'], created_by: ownerUserId, + }); + const snapshot = await createCapabilityProviderDiscoverySnapshot({ + adapter_contract_version: CAPABILITY_CONTRACT_VERSIONS.mcp_adapter, + provider: { org_id: orgId, provider_kind: 'mcp', provider_instance_id: connectionId }, + captured_at: '2026-08-31T12:00:00.000Z', + operations: [{ + identity: { provider: { org_id: orgId, provider_kind: 'mcp', provider_instance_id: connectionId }, operation_name: 'send_email' }, + title: 'Synthetic send', description: 'No invocation', + input_schema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, + output_schema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema, + }], + }); + const capability = { + async discover() { + return { + provider_kind: 'mcp' as const, + tools: [{ + name: `mcp__${connectionSlug}__send_email`, originalName: 'send_email', description: 'Synthetic send', + inputSchema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, + outputSchema: SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema, + connectionId, connectionSlug, isWrite: true, approvalTier: 'full-review' as const, + rawTool: { name: 'send_email' }, + }], + snapshot, + }; + }, + async invoke(): Promise { throw new Error('fixture must never invoke capability'); }, + }; + const reviewRequest = { + app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch, + connector_selections: [{ connector_requirement_key: 'mail_provider', mcp_connection_id: connectionId }], + }; + const review = await prepareConnectedAppReview(owner, staged.id, reviewRequest, capability); + await activateConnectedAppInstallation(owner, staged.id, { + ...reviewRequest, expected_review_digest: review.review_digest, accept_host_policy: true, + }, capability); + const [app] = await db.select().from(appInstallations).where(and(eq(appInstallations.org_id, orgId), eq(appInstallations.id, staged.id))); + assert.ok(app?.active_version_id && app.active_grant_snapshot_id); + const module = await getModuleInstallation(owner, { moduleId: 'org.deft.reference.resource-campaigns' }); + const [binding] = await db.select().from(appModuleBindings).where(and( + eq(appModuleBindings.org_id, orgId), eq(appModuleBindings.app_installation_id, staged.id), + eq(appModuleBindings.module_installation_id, module.id), + )); + assert.ok(binding); + const slug = randomBytes(32).toString('base64url'); + const endpoint = { + id: randomUUID(), org_id: orgId, slug_digest: digest(slug), app_installation_id: app.id, + app_version_id: app.active_version_id, grant_snapshot_id: app.active_grant_snapshot_id, + installation_lifecycle_epoch: app.lifecycle_epoch, installation_grant_epoch: app.grant_epoch, + module_installation_id: module.id, collection_key: 'campaigns', endpoint_epoch: 1, + public_label: 'Reserve resource', max_body_bytes: 1024, + }; + await db.insert(appPublicEndpoints).values({ + ...endpoint, state: 'disabled', review_digest: publicEndpointReviewDigest(endpoint), + reviewed_by_user_id: ownerUserId, reviewed_at: new Date(), + }); + async function record(name: string) { + const created = await createModuleRecord(owner, { + module_id: module.module_id, collection_key: 'campaigns', + data: { name, subject: `Private ${name}`, body: `Private body ${name}`, status: 'draft' }, + relations: {}, expected_manifest_digest: module.manifest_digest, idempotency_key: `public-${name}-${suffix}`, + }); + assert.ok(created.record); + return created.record; + } + function body(recordId: string, revision: number, key: string) { + return Buffer.from(JSON.stringify({ + resource_ref: { + schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module', provider_instance_id: module.id }, + resource_type: 'campaigns', resource_id: recordId, + }, expected_revision: revision, idempotency_key: key, + })); + } + return { orgId, ownerUserId, slug, endpoint, body, record }; +} + +test('reviewed public claims use canonical rows and one transaction for ingress and queue', { skip: !canRun }, async () => { + const f = await fixture(); + const firstRecord = await f.record('first'); + const disabled = new AppPublicClaimService({ enabled: true }); + await assert.rejects(disabled.claim(f.slug, f.body(firstRecord.id, firstRecord.revision, 'disabled')), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_NOT_FOUND'); + await db.update(appPublicEndpoints).set({ state: 'enabled' }).where(eq(appPublicEndpoints.id, f.endpoint.id)); + const defaultOff = new AppPublicClaimService(); + await assert.rejects(defaultOff.claim(f.slug, f.body(firstRecord.id, firstRecord.revision, 'default-off')), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_NOT_FOUND'); + const input = JSON.parse(f.body(firstRecord.id, firstRecord.revision, 'negative').toString('utf8')) as Record; + const negativeCases: Array<[Buffer, AppPublicError['code']]> = [ + [Buffer.from('{broken'), 'PUBLIC_INVALID_INPUT'], + [Buffer.from(JSON.stringify({ ...input, caller_org_id: f.orgId })), 'PUBLIC_INVALID_INPUT'], + [Buffer.from(JSON.stringify({ ...input, resource_ref: { ...input.resource_ref, + provider: { kind: 'module', provider_instance_id: randomUUID() } } })), 'PUBLIC_NOT_FOUND'], + [Buffer.from(JSON.stringify({ ...input, resource_ref: { ...input.resource_ref, + resource_type: 'contacts' } })), 'PUBLIC_NOT_FOUND'], + [f.body(firstRecord.id, firstRecord.revision + 1, 'wrong-revision'), 'PUBLIC_CLAIM_CONFLICT'], + [Buffer.alloc(8193, 120), 'PUBLIC_PAYLOAD_TOO_LARGE'], + ]; + const [beforeNegativeClaims] = await db.select({ value: count() }).from(appCanonicalClaims).where(eq(appCanonicalClaims.org_id, f.orgId)); + const [beforeNegativeJobs] = await db.select({ value: count() }).from(jobQueue).where(and( + eq(jobQueue.org_id, f.orgId), eq(jobQueue.name, 'app-public-ingress'), + )); + for (const [body, code] of negativeCases) { + await assert.rejects(disabled.claim(f.slug, body), + (error: unknown) => error instanceof AppPublicError && error.code === code); + } + for (const epochField of ['installation_grant_epoch', 'installation_lifecycle_epoch'] as const) { + const staleSlug = randomBytes(32).toString('base64url'); + const staleEndpoint = { + ...f.endpoint, id: randomUUID(), slug_digest: digest(staleSlug), + [epochField]: f.endpoint[epochField] + 1, + }; + await db.insert(appPublicEndpoints).values({ + ...staleEndpoint, state: 'enabled', review_digest: publicEndpointReviewDigest(staleEndpoint), + reviewed_by_user_id: f.ownerUserId, reviewed_at: new Date(), + }); + await assert.rejects(disabled.claim(staleSlug, f.body(firstRecord.id, firstRecord.revision, `stale-${epochField}`)), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_NOT_FOUND'); + } + const [afterNegativeClaims] = await db.select({ value: count() }).from(appCanonicalClaims).where(eq(appCanonicalClaims.org_id, f.orgId)); + const [afterNegativeJobs] = await db.select({ value: count() }).from(jobQueue).where(and( + eq(jobQueue.org_id, f.orgId), eq(jobQueue.name, 'app-public-ingress'), + )); + assert.equal(afterNegativeClaims?.value, beforeNegativeClaims?.value); + assert.equal(afterNegativeJobs?.value, beforeNegativeJobs?.value); + + const attempts = await Promise.allSettled(Array.from({ length: 100 }, (_, i) => + disabled.claim(f.slug, f.body(firstRecord.id, firstRecord.revision, `race-${i}`)))); + const winners = attempts.filter((result) => result.status === 'fulfilled'); + assert.equal(winners.length, 1); + assert.equal(attempts.filter((result) => result.status === 'rejected' + && result.reason instanceof AppPublicError && result.reason.code === 'PUBLIC_CLAIM_CONFLICT').length, 99); + const [firstClaim] = await db.select().from(appCanonicalClaims).where(and( + eq(appCanonicalClaims.org_id, f.orgId), eq(appCanonicalClaims.resource_id, firstRecord.id), + )); + assert.ok(firstClaim); + const dequeued = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { + orgId: f.orgId, jobName: 'app-public-ingress', dataMatch: { key: 'ingress_id', value: firstClaim.ingress_id }, + }); + assert.ok(dequeued); + const handler = await _getAgentJobHandlerForTest('app-public-ingress'); + assert.ok(handler); + await assert.rejects(handler({ + id: dequeued.id, name: dequeued.name, data: { ...dequeued.data, endpoint_id: randomUUID() }, + attempts: dequeued.attempts, + }), /Invalid public ingress queue identity/); + await _processDequeuedJobForTest(QUEUE_NAMES.AGENT_JOBS, dequeued); + const [handled] = await db.select().from(appPublicIngress).where(eq(appPublicIngress.id, firstClaim.ingress_id)); + assert.equal(handled?.follow_up_state, 'unsupported'); + assert.equal(handled?.follow_up_code, 'APP_HANDLER_UNAVAILABLE'); + assert.ok(handled.handled_at); + await handler({ id: dequeued.id, name: dequeued.name, data: dequeued.data, attempts: dequeued.attempts }); + const [handledAgain] = await db.select().from(appPublicIngress).where(eq(appPublicIngress.id, firstClaim.ingress_id)); + assert.equal(handledAgain?.handled_at?.getTime(), handled.handled_at.getTime()); + const winnerIndex = attempts.findIndex((result) => result.status === 'fulfilled'); + const handledReplay = await disabled.claim(f.slug, f.body(firstRecord.id, firstRecord.revision, `race-${winnerIndex}`)); + assert.equal(handledReplay.follow_up_state, 'unsupported'); + assert.equal(handledReplay.replayed, true); + const [firstIngressCount] = await db.select({ value: count() }).from(appPublicIngress).where(eq(appPublicIngress.endpoint_id, f.endpoint.id)); + assert.equal(firstIngressCount?.value, 101, '100 race receipts plus the rejected revision receipt'); + const secondSlug = randomBytes(32).toString('base64url'); + const peerEndpoint = { ...f.endpoint, id: randomUUID(), slug_digest: digest(secondSlug) }; + await db.insert(appPublicEndpoints).values({ + ...peerEndpoint, state: 'enabled', review_digest: publicEndpointReviewDigest(peerEndpoint), + reviewed_by_user_id: f.ownerUserId, reviewed_at: new Date(), + }); + await assert.rejects(disabled.claim(secondSlug, f.body(firstRecord.id, firstRecord.revision, 'other-endpoint')), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_CLAIM_CONFLICT'); + const [peerClaims] = await db.select({ value: count() }).from(appCanonicalClaims).where(eq(appCanonicalClaims.endpoint_id, peerEndpoint.id)); + assert.equal(peerClaims?.value, 0); + + const secondRecord = await f.record('second'); + const sameKey = f.body(secondRecord.id, secondRecord.revision, 'same-key'); + const replays = await Promise.all(Array.from({ length: 20 }, () => disabled.claim(f.slug, sameKey))); + assert.equal(replays.filter((value) => !value.replayed).length, 1); + assert.equal(new Set(replays.map((value) => value.claim_id)).size, 1); + await assert.rejects(disabled.claim(f.slug, f.body(firstRecord.id, firstRecord.revision, 'same-key')), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_IDEMPOTENCY_CONFLICT'); + + const thirdRecord = await f.record('third'); + const rollbackKey = f.body(thirdRecord.id, thirdRecord.revision, 'rollback'); + const broken = new AppPublicClaimService({ enabled: true, deliver: async () => { throw new Error('injected'); } }); + await assert.rejects(broken.claim(f.slug, rollbackKey), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_UNAVAILABLE'); + const [rolledBack] = await db.select({ value: count() }).from(appPublicIngress).where(and( + eq(appPublicIngress.endpoint_id, f.endpoint.id), eq(appPublicIngress.request_key_digest, digest(`${f.endpoint.id}\0rollback`)), + )); + assert.equal(rolledBack?.value, 0); + const afterRetry = await disabled.claim(f.slug, rollbackKey); + assert.equal(afterRetry.replayed, false); + const [claimCount] = await db.select({ value: count() }).from(appCanonicalClaims).where(eq(appCanonicalClaims.org_id, f.orgId)); + const [queueCount] = await db.select({ value: count() }).from(jobQueue).where(and( + eq(jobQueue.org_id, f.orgId), eq(jobQueue.name, 'app-public-ingress'), + )); + assert.equal(claimCount?.value, 3); + assert.equal(queueCount?.value, 3); + + const routes = createAppPublicRoutes(disabled); + const response = await routes.request(`/${f.slug}/claims`, { + method: 'POST', headers: { + 'content-type': 'application/json', cookie: 'session=forged-employee', authorization: 'Bearer forged-employee', + }, body: sameKey, + }); + assert.equal(response.status, 200); + const projected = await response.json() as { result: Record }; + assert.deepEqual(Object.keys(projected.result).sort(), ['claim_id', 'claim_state', 'follow_up_state', 'replayed']); + assert.equal(JSON.stringify(projected).includes('Private'), false); + assert.equal(projected.result.claim_id, replays[0]?.claim_id); + const fourthRecord = await f.record('fourth'); + let enteredDelivery!: () => void; + let releaseDelivery!: () => void; + const inDelivery = new Promise((resolve) => { enteredDelivery = resolve; }); + const deliveryReleased = new Promise((resolve) => { releaseDelivery = resolve; }); + const held = new AppPublicClaimService({ enabled: true, deliver: async (tx, orgId, endpointId, ingressId, epoch) => { + enteredDelivery(); + await deliveryReleased; + await enqueue(QUEUE_NAMES.AGENT_JOBS, 'app-public-ingress', { + organization_id: orgId, endpoint_id: endpointId, ingress_id: ingressId, endpoint_epoch: epoch, + }, { executor: tx, orgId, dedupeKey: `app-public-ingress:${ingressId}`, maxAttempts: 3 }); + } }); + const inFlight = held.claim(f.slug, f.body(fourthRecord.id, fourthRecord.revision, 'disable-race')); + await inDelivery; + try { + await assert.rejects(db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '150ms'`); + await tx.update(appPublicEndpoints).set({ state: 'disabled' }).where(eq(appPublicEndpoints.id, f.endpoint.id)); + }), (error: unknown) => { + const cause = error instanceof Error ? (error as Error & { cause?: unknown }).cause : undefined; + return typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '55P03'; + }); + } finally { + releaseDelivery(); + } + const orderedClaim = await inFlight; + assert.equal(orderedClaim.claim_state, 'confirmed'); + await db.update(appPublicEndpoints).set({ state: 'disabled' }).where(eq(appPublicEndpoints.id, f.endpoint.id)); + const [orderedReceipt] = await db.select().from(appPublicIngress).where(and( + eq(appPublicIngress.endpoint_id, f.endpoint.id), + eq(appPublicIngress.request_key_digest, digest(`${f.endpoint.id}\0disable-race`)), + )); + assert.ok(orderedReceipt); + const revokedJob = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { + orgId: f.orgId, jobName: 'app-public-ingress', dataMatch: { key: 'ingress_id', value: orderedReceipt.id }, + }); + assert.ok(revokedJob); + await _processDequeuedJobForTest(QUEUE_NAMES.AGENT_JOBS, revokedJob); + const [revokedReceipt] = await db.select().from(appPublicIngress).where(eq(appPublicIngress.id, orderedReceipt.id)); + assert.equal(revokedReceipt?.follow_up_state, 'unsupported'); + assert.equal(revokedReceipt?.follow_up_code, 'ENDPOINT_REVOKED'); + await assert.rejects(disabled.claim(f.slug, f.body(thirdRecord.id, thirdRecord.revision, 'after-disable')), + (error: unknown) => error instanceof AppPublicError && error.code === 'PUBLIC_NOT_FOUND'); + const [retained] = await db.select({ value: count() }).from(appCanonicalClaims).where(eq(appCanonicalClaims.org_id, f.orgId)); + assert.equal(retained?.value, 4); +}); diff --git a/apps/api/test/app-run-engine-db.test.ts b/apps/api/test/app-run-engine-db.test.ts index 09a3f56c..ca5679e0 100644 --- a/apps/api/test/app-run-engine-db.test.ts +++ b/apps/api/test/app-run-engine-db.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import test, { after, before } from 'node:test'; import pg from 'pg'; import { @@ -71,7 +71,8 @@ function retainedKeys(ids: ReadonlySet, seed: number, exclude: ReadonlyS // every referenced key ID to remain present. return Object.fromEntries([...ids] .filter((keyId) => !exclude.has(keyId)) - .map((keyId) => [keyId, key(seed)])); + .map((keyId) => [keyId, createHash('sha256') + .update(`app-run-engine-retained:${seed}:${keyId}`).digest('base64')])); } function keyProvider( diff --git a/apps/api/test/app-runtime-channel-db.test.ts b/apps/api/test/app-runtime-channel-db.test.ts new file mode 100644 index 00000000..743fc4a8 --- /dev/null +++ b/apps/api/test/app-runtime-channel-db.test.ts @@ -0,0 +1,403 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const safeDatabase = databaseUrl && databaseUrl === process.env.DATABASE_URL + && /(?:^|[-_])(test|ci|acceptance|phase\d+)(?:$|[-_])/iu.test( + new URL(databaseUrl).pathname.slice(1)); + +test('reviewed Runtime claim, start, known result and replay use the App Run ledger', { + skip: !safeDatabase, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`runtime-db-test:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'runtime-test-enc-v1', keys: { 'runtime-test-enc-v1': key('encryption') } }, + receipt_signing: { current: 'runtime-test-sig-v1', keys: { 'runtime-test-sig-v1': key('signing') } }, + fingerprint: { current: 'runtime-test-fp-v1', keys: { 'runtime-test-fp-v1': key('fingerprint') } }, + }); + const [{ db, closeDb }, schema, shared, appKit, appService, reviewService, + moduleService, packageFixture, providerExecutor, repositoryModule, + secretModule, keyringModule, secretRepositoryModule, receiptModule, runnerModule, + channelModule, authorityModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/shared'), + import('@deft/app-kit'), import('../src/lib/app-service.js'), + import('../src/lib/app-review-service.js'), import('../src/lib/module-service.js'), + import('./fixtures/phase5-connected-app-package.js'), + import('../src/lib/app-run-provider-executor.js'), + import('../src/lib/app-run-repository.js'), + import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-secret-repository.js'), + import('../src/lib/app-run-receipts.js'), + import('../src/lib/app-run-attempt-runner.js'), + import('../src/lib/app-runtime-channel.js'), + import('../src/lib/app-runtime-authority.js'), + ]); + const { and, eq, sql } = await import('drizzle-orm'); + // The HTTP host uses the production global key-retirement guard. Preserve + // unrelated test key IDs while isolating this fixture's actual key material. + const { databaseCompleteAppRunTestKeyringFixture } = await import('./fixtures/app-run-test-keyrings.js'); + const covered = await databaseCompleteAppRunTestKeyringFixture('runtime-db-test'); + covered.keys.destroy(); + const combinedKeyrings = JSON.parse(covered.environment); + const runtimeKeyrings = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + for (const purpose of ['run_encryption', 'receipt_signing', 'fingerprint']) { + combinedKeyrings[purpose].current = runtimeKeyrings[purpose].current; + Object.assign(combinedKeyrings[purpose].keys, runtimeKeyrings[purpose].keys); + } + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify(combinedKeyrings); + const orgId = randomUUID(); + const userId = randomUUID(); + const connectionId = randomUUID(); + const registrationId = randomUUID(); + const bindingId = randomUUID(); + const snapshotId = randomUUID(); + const suffix = randomUUID(); + try { + await db.insert(schema.orgs).values({ id: orgId, name: 'Runtime fixture', slug: `runtime-${suffix}` }); + await db.insert(schema.users).values({ id: userId, + email: `runtime-${suffix}@example.test`, name: 'Runtime owner' }); + const [membership] = await db.insert(schema.orgMembers).values({ + id: randomUUID(), org_id: orgId, user_id: userId, role: 'owner', is_active: true, + }).returning(); + assert.ok(membership); + const owner = moduleService.humanModuleActor({ orgId, userId, role: 'owner', source: 'ui' }); + const dependencyPackage = await packageFixture.buildPhase5DependencyAppPackage(); + const dependency = await appService.stageAppPackage(owner, dependencyPackage.json); + await appService.activateAppInstallation(owner, dependency.id, dependency.package_digest); + const connectedPackage = await packageFixture.buildPhase5ConnectedAppPackage(); + const connected = await appService.stageAppPackage(owner, connectedPackage.json); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, connected.version_id), + )); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id), + )); + assert.ok(requested); + await db.insert(schema.mcpConnections).values({ + id: connectionId, org_id: orgId, name: 'Fixture review connector', + slug: `runtime-review-${suffix}`, server_url: 'https://example.test/mcp', + transport: 'streamable-http', auth_type: 'none', is_active: true, + enabled_tools: ['send_email'], created_by: userId, + }); + const mcpProvider = { org_id: orgId, provider_kind: 'mcp' as const, + provider_instance_id: connectionId }; + const discovery = await shared.createCapabilityProviderDiscoverySnapshot({ + adapter_contract_version: shared.CAPABILITY_CONTRACT_VERSIONS.mcp_adapter, + provider: mcpProvider, captured_at: new Date().toISOString(), + operations: [{ identity: { provider: mcpProvider, operation_name: 'send_email' }, + title: 'Fixture review connector', description: 'Synthetic only', + input_schema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, + output_schema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema }], + }); + const capability = { + async discover() { return { + provider_kind: 'mcp' as const, + tools: [{ name: `mcp__runtime_review_${suffix}__send_email`, originalName: 'send_email', + description: 'Synthetic only', + inputSchema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, + outputSchema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema, + connectionId, connectionSlug: `runtime-review-${suffix}`, isWrite: true, + approvalTier: 'full-review' as const, rawTool: { name: 'send_email' } }], + snapshot: discovery, + }; }, + async invoke() { throw new Error('fixture review connector must never invoke'); }, + }; + const reviewRequest = { + app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: connected.lifecycle_epoch, + expected_grant_epoch: connected.grant_epoch, + connector_selections: [{ connector_requirement_key: 'mail_provider', + mcp_connection_id: connectionId }], + }; + const review = await reviewService.prepareConnectedAppReview( + owner, connected.id, reviewRequest, capability); + await reviewService.activateConnectedAppInstallation(owner, connected.id, { + ...reviewRequest, expected_review_digest: review.review_digest, + accept_host_policy: true, + }, capability); + const [installation] = await db.select().from(schema.appInstallations).where(and( + eq(schema.appInstallations.org_id, orgId), eq(schema.appInstallations.id, connected.id), + )); + assert.ok(installation?.active_grant_snapshot_id); + + // Synthetic host-reviewed Runtime binding. Released v0-v2 package did not + // author this contract, and no public intake accepts Runtime Runs yet. + await db.insert(schema.capabilityProviderSnapshots).values({ + id: snapshotId, org_id: orgId, provider_kind: 'app_runtime', + provider_instance_id: registrationId, + adapter_contract_version: 'deft.app_runtime_channel.v1', + snapshot_digest: `sha256:${'a'.repeat(64)}`, + safe_snapshot: { fixture: true }, captured_at: new Date(), + }); + await db.insert(schema.appRuntimeRegistrations).values({ + id: registrationId, org_id: orgId, app_installation_id: connected.id, + app_version_id: version.id, grant_snapshot_id: installation.active_grant_snapshot_id, + operator_user_id: userId, contract_version: 'deft.app_runtime_channel.v1', + }); + await assert.rejects(db.update(schema.appRuntimeRegistrations).set({ + state: 'active', runtime_epoch: 1, contract_version: 'substituted', + reviewed_by_user_id: userId, reviewed_at: new Date(), + }).where(eq(schema.appRuntimeRegistrations.id, registrationId)), + (error: unknown) => (error as { cause?: { message?: string } }).cause?.message === 'APP_RUNTIME_IMMUTABLE_FIELD'); + await db.update(schema.appRuntimeRegistrations).set({ state: 'active', runtime_epoch: 1, + reviewed_by_user_id: userId, reviewed_at: new Date() }).where(and( + eq(schema.appRuntimeRegistrations.org_id, orgId), + eq(schema.appRuntimeRegistrations.id, registrationId), + )); + await db.insert(schema.appRuntimeBindings).values({ + id: bindingId, org_id: orgId, app_installation_id: connected.id, + app_version_id: version.id, grant_snapshot_id: installation.active_grant_snapshot_id, + runtime_registration_id: registrationId, action_key: 'fixture_action', + interface_identity: `deft.runtime.v1:${orgId.toLowerCase()}:${connected.id.toLowerCase()}:fixture_action`, + provider_instance_id: registrationId, provider_snapshot_id: snapshotId, + operation_name: 'fixture_action', risk_class: 'external_write', + review_requirement: 'always', retry_class: 'unsafe_or_unknown', + retention_class: 'standard', + }); + await assert.rejects(db.update(schema.appRuntimeBindings).set({ + state: 'active', operation_name: 'substituted', + reviewed_by_user_id: userId, reviewed_at: new Date(), + }).where(eq(schema.appRuntimeBindings.id, bindingId)), + (error: unknown) => (error as { cause?: { message?: string } }).cause?.message === 'APP_RUNTIME_IMMUTABLE_FIELD'); + await db.update(schema.appRuntimeBindings).set({ state: 'active', + reviewed_by_user_id: userId, reviewed_at: new Date() }).where(and( + eq(schema.appRuntimeBindings.org_id, orgId), eq(schema.appRuntimeBindings.id, bindingId), + )); + const keys = keyringModule.parseEnvironmentAppRunKeyrings(process.env.DEFT_APP_RUN_KEYRINGS); + const secrets = new secretModule.AppRunSecretService(keys); + const secretRepository = new secretRepositoryModule.AppRunSecretRepository(secrets); + const repository = new repositoryModule.PostgresAppRunRepository(); + const receiptWriter = new receiptModule.PostgresAppRunReceiptWriter(secrets, secretRepository); + const receiptReader = new receiptModule.PostgresAppRunReceiptReader(secrets); + let clockOffsetMs = 0; + const runner = new runnerModule.AppRunAttemptRunner(repository, secretRepository, secrets, + new providerExecutor.PinnedMcpAppRunProviderExecutor(), undefined, + () => new Date(Date.now() + clockOffsetMs), 60_000, 20_000, receiptWriter); + const channel = new channelModule.AppRuntimeChannel(runner); + const session = await channel.issueSession({ org_id: orgId, + runtime_binding_id: bindingId, operator_user_id: userId }); + assert.ok(session); + + async function createSyntheticRun() { + const runId = randomUUID(); + const attemptId = randomUUID(); + const input = { fixture: 'exact input' }; + const membershipVersion = `sha256:${createHash('sha256') + .update('deft.app_run.authority.v1\0membership\0') + .update(shared.canonicalCapabilityJson({ id: membership.id, + authority_version: membership.app_run_authorization_version })).digest('hex')}`; + const inputFingerprint = secrets.fingerprintJson('input', input); + const idemFingerprint = secrets.fingerprintText('idempotency', runId); + await db.insert(schema.appRuns).values({ + id: runId, org_id: orgId, contract_version: shared.APP_RUN_CONTRACT_VERSIONS.run, + origin_kind: 'app', initiating_actor_type: 'human', initiating_actor_id: userId, + execution_actor_type: 'human', execution_actor_id: userId, + provider_kind: 'app_runtime', provider_instance_id: registrationId, + operation_name: 'fixture_action', provider_snapshot_id: snapshotId, + origin_app_installation_id: connected.id, origin_app_version_id: version.id, + origin_app_grant_snapshot_id: installation.active_grant_snapshot_id!, + origin_runtime_binding_id: bindingId, + risk_class: 'external_write', review_requirement: 'always', + review_scope: 'per_invocation', retry_class: 'unsafe_or_unknown', + retention_class: 'standard', idempotency_key_version: idemFingerprint.key_version, + idempotency_fingerprint: idemFingerprint.fingerprint, + input_fingerprint_key_version: inputFingerprint.key_version, + input_fingerprint: inputFingerprint.fingerprint, + authorization_snapshot: { schema_version: shared.APP_RUN_CONTRACT_VERSIONS.run, + authenticated_subject: { actor_type: 'human', user_id: userId }, + authority_refs: [{ authority_kind: 'membership', authority_id: userId, + version: membershipVersion }] }, + safe_preview: { schema_version: shared.APP_RUN_CONTRACT_VERSIONS.run, + title: 'Runtime fixture', resource_refs: [] }, + root_run_id: runId, depth: 0, + input_expires_at: new Date(Date.now() + 300_000), + result_expires_at: new Date(Date.now() + 600_000), + idempotency_expires_at: new Date(Date.now() + 900_000), + attempt_limit: 1, execution_release_kind: 'approved', + execution_released_at: new Date(), + }); + await db.transaction((tx) => secretRepository.insertInput(tx, { + org_id: orgId, run_id: runId, value: input, + expires_at: new Date(Date.now() + 300_000), + })); + await db.insert(schema.appRunAttempts).values({ + id: attemptId, org_id: orgId, run_id: runId, + attempt_number: 1, state: 'pending', + }); + return { runId, attemptId, input }; + } + + const run = await createSyntheticRun(); + const claim = await channel.claim({ schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); + assert.equal(claim?.run_id, run.runId); + assert.equal(claim?.attempt_id, run.attemptId); + const request = { schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, + run_id: run.runId, attempt_id: run.attemptId, + claim_token: claim!.claim_token, sequence: claim!.sequence }; + const foreignSession = await channel.issueSession({ org_id: orgId, + runtime_binding_id: bindingId, operator_user_id: userId }); + assert.ok(foreignSession); + assert.equal(await channel.start({ ...request, + session_id: foreignSession.session_id, session_token: foreignSession.session_token }), null); + const originalReadInput = secretRepository.readInput.bind(secretRepository); + let enteredInput!: () => void; + let releaseInput!: () => void; + const inputEntered = new Promise((resolve) => { enteredInput = resolve; }); + const inputReleased = new Promise((resolve) => { releaseInput = resolve; }); + secretRepository.readInput = async (...args) => { + enteredInput(); + await inputReleased; + return originalReadInput(...args); + }; + const startPending = channel.start(request); + let waitTimer: ReturnType | undefined; + try { + await Promise.race([inputEntered, new Promise((_, reject) => { + waitTimer = setTimeout(() => reject(new Error('Input disclosure boundary was not reached')), 5000); + })]); + for (const revoke of ['binding', 'membership']) { + await assert.rejects(db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`); + if (revoke === 'binding') { + await tx.update(schema.appRuntimeBindings).set({ state: 'revoked' }) + .where(eq(schema.appRuntimeBindings.id, bindingId)); + } else { + await tx.update(schema.orgMembers).set({ is_active: false }) + .where(eq(schema.orgMembers.id, membership.id)); + } + }), (error: unknown) => (error as { cause?: { code?: string } }).cause?.code === '55P03', + 'revocation must wait for the authorized input read'); + } + } finally { + if (waitTimer) clearTimeout(waitTimer); + releaseInput(); + secretRepository.readInput = originalReadInput; + } + const started = await startPending; + assert.deepEqual(started?.input, run.input); + assert.equal(await channel.heartbeat(request), true); + const result = { ...request, status: 'returned' as const, + provider_succeeded: true, output: { provider_receipt: 'fixture-effect-1' } }; + assert.equal((await channel.complete(result))?.state, 'succeeded'); + assert.equal((await channel.complete(result))?.state, 'succeeded'); + const rotatedEnvironment = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + rotatedEnvironment.fingerprint.current = 'runtime-test-fp-v2'; + rotatedEnvironment.fingerprint.keys['runtime-test-fp-v2'] = key('fingerprint-v2'); + const rotatedKeys = keyringModule.parseEnvironmentAppRunKeyrings(JSON.stringify(rotatedEnvironment)); + const rotatedSecrets = new secretModule.AppRunSecretService(rotatedKeys); + const rotatedSecretRepository = new secretRepositoryModule.AppRunSecretRepository(rotatedSecrets); + const rotatedRunner = new runnerModule.AppRunAttemptRunner(repository, + rotatedSecretRepository, rotatedSecrets, + new providerExecutor.PinnedMcpAppRunProviderExecutor(), undefined, + () => new Date(), 60_000, 20_000, + new receiptModule.PostgresAppRunReceiptWriter(rotatedSecrets, rotatedSecretRepository)); + const rotatedChannel = new channelModule.AppRuntimeChannel(rotatedRunner); + assert.equal((await rotatedChannel.complete(result))?.state, 'succeeded', + 'retained fingerprint keys must accept exact replay after rotation'); + rotatedKeys.destroy(); + assert.equal((await receiptReader.readVerified(orgId, run.runId)) + .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + assert.equal(await channel.complete({ ...result, output: { provider_receipt: 'substituted' } }), null); + + // Exercise the actual HTTP router in a separate host process. The client + // has only the session credential and wire requests; it cannot call runner + // methods in the server process. Intake above remains a synthetic fixture. + const httpRun = await createSyntheticRun(); + const child = fork(fileURLToPath(new URL('./fixtures/app-runtime-http-host.ts', import.meta.url)), { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, DEFT_RUNTIME_HTTP_FIXTURE: 'true' }, + }); + let childErrors = ''; + child.stderr?.on('data', (chunk) => { childErrors = (childErrors + String(chunk)).slice(-4000); }); + const closed = new Promise((resolve) => child.once('exit', () => resolve())); + try { + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Runtime fixture startup timed out')), 30_000); + child.once('error', (error) => { clearTimeout(timer); reject(error); }); + child.once('exit', (code) => { clearTimeout(timer); reject(new Error(`Runtime fixture exited ${code}: ${childErrors}`)); }); + child.once('message', (message) => { + clearTimeout(timer); + const value = (message as { port?: number }).port; + if (!Number.isInteger(value) || !value || value < 1 || value > 65535) reject(new Error('Invalid fixture port')); + else resolve(value); + }); + }); + async function post(path: string, body: Record, extraHeaders: Record = {}) { + return fetch(`http://127.0.0.1:${port}/${path}`, { + method: 'POST', headers: { 'content-type': 'application/json', + authorization: `AppRuntime ${session!.session_token}`, ...extraHeaders }, + body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), + }); + } + const wireSession = { schema_version: 'deft.app_runtime_channel.v1', session_id: session.session_id }; + assert.equal((await post('claim', { ...wireSession, max_claims: 1 }, { cookie: 'session=forged' })).status, 403); + assert.equal((await post('claim', { ...wireSession, max_claims: 1, session_token: session.session_token })).status, 400); + assert.equal((await post('claim', { ...wireSession, max_claims: 1, padding: 'x'.repeat(5000) })).status, 413); + const claimedResponse = await post('claim', { ...wireSession, max_claims: 1 }); + assert.equal(claimedResponse.status, 200); + const wireClaim = (await claimedResponse.json() as { claim: { run_id: string; attempt_id: string; claim_token: string; sequence: number } }).claim; + assert.equal(wireClaim.run_id, httpRun.runId); + const wireRequest = { ...wireSession, run_id: wireClaim.run_id, attempt_id: wireClaim.attempt_id, + claim_token: wireClaim.claim_token, sequence: wireClaim.sequence }; + assert.equal((await post('start', { ...wireRequest, sequence: wireClaim.sequence + 1 })).status, 403); + const wireStart = await post('start', wireRequest); + assert.equal(wireStart.status, 200); + assert.deepEqual((await wireStart.json() as { started: { input: unknown } }).started.input, httpRun.input); + assert.equal((await post('heartbeat', wireRequest)).status, 200); + const wireResult = { ...wireRequest, status: 'returned', provider_succeeded: true, output: { provider_receipt: 'http-fixture-effect' } }; + for (let i = 0; i < 2; i++) { + const response = await post('result', wireResult); + assert.equal(response.status, 200); + assert.equal((await response.json() as { run: { state: string } }).run.state, 'succeeded'); + } + assert.equal((await receiptReader.readVerified(orgId, httpRun.runId)) + .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + } finally { + if (child.connected) child.send('stop'); + const timer = setTimeout(() => child.kill(), 5000); + await closed; + clearTimeout(timer); + } + + const unknown = await createSyntheticRun(); + const unknownClaim = await channel.claim({ schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); + assert.equal(unknownClaim?.run_id, unknown.runId); + const unknownRequest = { schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, + run_id: unknown.runId, attempt_id: unknown.attemptId, + claim_token: unknownClaim!.claim_token, sequence: unknownClaim!.sequence }; + assert.ok(await channel.start(unknownRequest)); + await db.update(schema.appRuntimeBindings).set({ state: 'revoked' }).where(and( + eq(schema.appRuntimeBindings.org_id, orgId), eq(schema.appRuntimeBindings.id, bindingId), + )); + assert.equal(await channel.complete({ ...unknownRequest, status: 'returned', + provider_succeeded: true, output: { provider_receipt: 'late' } }), null); + assert.equal(await channel.start(unknownRequest), null); + assert.equal(await channel.heartbeat(unknownRequest), false); + clockOffsetMs = 120_000; + assert.equal(await runner.recoverRun(orgId, unknown.runId, unknown.attemptId), 1); + assert.equal((await repository.inspect(orgId, unknown.runId))?.state, 'unknown_outcome'); + assert.equal((await receiptReader.readVerified(orgId, unknown.runId)) + .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + assert.equal(await authorityModule.issueAppRuntimeSession({ org_id: orgId, + runtime_binding_id: bindingId, operator_user_id: userId }), null); + keys.destroy(); + } finally { + await closeDb(); + } +}); diff --git a/apps/api/test/app-runtime-channel.test.ts b/apps/api/test/app-runtime-channel.test.ts new file mode 100644 index 00000000..4eb1b190 --- /dev/null +++ b/apps/api/test/app-runtime-channel.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { appRuntimeChannelRoutes } from '../src/routes/app-runtime-channel.js'; +import { parseAppRuntimeResult } from '../src/lib/app-runtime-contract.js'; + +test('disabled Runtime transport rejects before parsing a request body', async () => { + assert.notEqual(process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED, 'true'); + const app = new Hono().route('/runtime', appRuntimeChannelRoutes); + const response = await app.request('/runtime/claim', { + method: 'POST', body: '{malformed', + }); + assert.equal(response.status, 503); + assert.equal((await response.json()).code, 'APP_RUNTIME_DISABLED'); +}); + +test('Runtime result refuses an oversized retained output before any DB write', () => { + assert.throws(() => parseAppRuntimeResult({ + schema_version: 'deft.app_runtime_channel.v1', + session_id: 'session', session_token: 'a'.repeat(32), + run_id: 'run', attempt_id: 'attempt', claim_token: 'claim', sequence: 1, + status: 'returned', provider_succeeded: true, + output: { payload: 'x'.repeat(1_048_576) }, + })); +}); diff --git a/apps/api/test/fixtures/app-runtime-http-host.ts b/apps/api/test/fixtures/app-runtime-http-host.ts new file mode 100644 index 00000000..97f091bd --- /dev/null +++ b/apps/api/test/fixtures/app-runtime-http-host.ts @@ -0,0 +1,25 @@ +import { serve } from '@hono/node-server'; +import { closeDb } from '../../src/lib/db.js'; +import { appRuntimeChannelRoutes } from '../../src/routes/app-runtime-channel.js'; + +// Isolated test host only. Production API registration remains a separate gate. +if (process.env.DEFT_RUNTIME_HTTP_FIXTURE !== 'true' || !process.send + || !process.env.DEFT_TEST_DATABASE_URL + || process.env.DEFT_TEST_DATABASE_URL !== process.env.DATABASE_URL) { + throw new Error('Runtime HTTP fixture requires explicit disposable test process'); +} +const server = serve({ fetch: appRuntimeChannelRoutes.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + process.send!({ port: info.port }); +}); +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await closeDb(); + process.exit(0); +} +process.on('message', (message) => { if (message === 'stop') void stop(); }); +process.on('disconnect', () => { void stop(); }); +process.on('SIGTERM', () => { void stop(); }); diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index 5526d15d..81039d7a 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -140,6 +140,16 @@ async function main() { await client.query(readFileSync(resolve(upgradesDir, nativeCreateFile), 'utf8')); console.log(`[apply-extras] reconciled ${nativeCreateFile}`); + // These additive, dormant surfaces must follow the historical App Run + // reconciliations, which restore the predecessor's provider/ancestry checks. + for (const platformFile of [ + '0.3.0-preview.31-app-runtime-channel.sql', + '0.3.0-preview.32-app-public-claims.sql', + ]) { + await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); + console.log(`[apply-extras] reconciled ${platformFile}`); + } + // Expression-based unique indexes can't be declared in schema.ts, so // `drizzle-kit push` silently drops them. Re-create the ones the app // depends on so pushed and migrated databases behave the same. diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ecbcb578..976082f8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -648,7 +648,7 @@ export const favorites = pgTable('favorites', { export const capabilityProviderSnapshots = pgTable('capability_provider_snapshots', { ...id(), org_id: text('org_id').notNull().references(() => orgs.id, { onDelete: 'cascade' }), - provider_kind: text('provider_kind').$type<'mcp'>().notNull(), + provider_kind: text('provider_kind').$type<'mcp' | 'app_runtime'>().notNull(), provider_instance_id: text('provider_instance_id').notNull(), adapter_contract_version: text('adapter_contract_version').notNull(), snapshot_digest: text('snapshot_digest').notNull(), @@ -663,7 +663,7 @@ export const capabilityProviderSnapshots = pgTable('capability_provider_snapshot .on(t.org_id, t.provider_kind, t.provider_instance_id, t.snapshot_digest), index('capability_provider_snapshots_provider_idx') .on(t.org_id, t.provider_kind, t.provider_instance_id, t.captured_at), - check('capability_provider_snapshots_kind_check', sql`${t.provider_kind} IN ('mcp')`), + check('capability_provider_snapshots_kind_check', sql`${t.provider_kind} IN ('mcp', 'app_runtime')`), check('capability_provider_snapshots_digest_check', sql`${t.snapshot_digest} ~ '^sha256:[a-f0-9]{64}$'`), check('capability_provider_snapshots_json_check', sql`jsonb_typeof(${t.safe_snapshot}) = 'object'`), check('capability_provider_snapshots_size_check', sql`octet_length(${t.safe_snapshot}::text) <= 1048576`), @@ -682,13 +682,14 @@ export const appRuns = pgTable('app_runs', { .$type<'human' | 'agent_employee' | 'system' | 'automation'>() .notNull(), execution_actor_id: text('execution_actor_id').notNull(), - provider_kind: text('provider_kind').$type<'mcp'>().notNull(), + provider_kind: text('provider_kind').$type<'mcp' | 'app_runtime'>().notNull(), provider_instance_id: text('provider_instance_id').notNull(), operation_name: text('operation_name').notNull(), provider_snapshot_id: text('provider_snapshot_id').notNull(), origin_app_installation_id: text('origin_app_installation_id'), origin_app_version_id: text('origin_app_version_id'), origin_app_binding_key: text('origin_app_binding_key'), + origin_runtime_binding_id: text('origin_runtime_binding_id'), origin_app_grant_snapshot_id: text('origin_app_grant_snapshot_id'), origin_app_automation_definition_id: text('origin_app_automation_definition_id'), origin_app_automation_fire_id: text('origin_app_automation_fire_id'), @@ -808,6 +809,20 @@ export const appRuns = pgTable('app_runs', { ], name: 'app_runs_app_action_binding_fk', }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.origin_app_installation_id, t.origin_app_version_id, + t.origin_app_grant_snapshot_id, t.origin_runtime_binding_id, t.provider_kind, + t.provider_instance_id, t.operation_name, t.provider_snapshot_id, + t.risk_class, t.review_requirement, t.retry_class, t.retention_class], + foreignColumns: [appRuntimeBindings.org_id, appRuntimeBindings.app_installation_id, + appRuntimeBindings.app_version_id, appRuntimeBindings.grant_snapshot_id, + appRuntimeBindings.id, appRuntimeBindings.provider_kind, + appRuntimeBindings.provider_instance_id, appRuntimeBindings.operation_name, + appRuntimeBindings.provider_snapshot_id, appRuntimeBindings.risk_class, + appRuntimeBindings.review_requirement, appRuntimeBindings.retry_class, + appRuntimeBindings.retention_class], + name: 'app_runs_runtime_binding_fk', + }).onDelete('restrict'), foreignKey({ columns: [t.org_id, t.origin_app_automation_definition_id], foreignColumns: [appAutomationDefinitions.org_id, appAutomationDefinitions.id], @@ -865,7 +880,9 @@ export const appRuns = pgTable('app_runs', { ${t.origin_kind} = 'app' AND ${t.origin_app_installation_id} IS NOT NULL AND ${t.origin_app_version_id} IS NOT NULL + AND ${t.provider_kind} = 'mcp' AND ${t.origin_app_binding_key} IS NOT NULL + AND ${t.origin_runtime_binding_id} IS NULL AND ${t.origin_app_grant_snapshot_id} IS NOT NULL AND ${t.risk_class} = 'external_write' AND ${t.review_requirement} = 'always' @@ -887,11 +904,26 @@ export const appRuns = pgTable('app_runs', { AND ${t.execution_actor_id} = ${t.origin_app_automation_definition_id} ) ) + ) OR ( + ${t.origin_kind} = 'app' + AND ${t.provider_kind} = 'app_runtime' + AND ${t.origin_app_installation_id} IS NOT NULL + AND ${t.origin_app_version_id} IS NOT NULL + AND ${t.origin_app_grant_snapshot_id} IS NOT NULL + AND ${t.origin_app_binding_key} IS NULL + AND ${t.origin_runtime_binding_id} IS NOT NULL + AND ${t.origin_app_automation_definition_id} IS NULL + AND ${t.origin_app_automation_fire_id} IS NULL + AND ${t.initiating_actor_type} <> 'automation' + AND ${t.execution_actor_type} <> 'automation' + AND ${t.review_scope} = 'per_invocation' ) OR ( ${t.origin_kind} <> 'app' + AND ${t.provider_kind} = 'mcp' AND ${t.origin_app_installation_id} IS NULL AND ${t.origin_app_version_id} IS NULL AND ${t.origin_app_binding_key} IS NULL + AND ${t.origin_runtime_binding_id} IS NULL AND ${t.origin_app_grant_snapshot_id} IS NULL AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL @@ -909,7 +941,7 @@ export const appRuns = pgTable('app_runs', { ${t.initiating_actor_type} IN ('human', 'agent_employee', 'system', 'automation') AND ${t.execution_actor_type} IN ('human', 'agent_employee', 'system', 'automation') `), - check('app_runs_provider_kind_check', sql`${t.provider_kind} IN ('mcp')`), + check('app_runs_provider_kind_check', sql`${t.provider_kind} IN ('mcp', 'app_runtime')`), check('app_runs_state_check', sql`${t.state} IN ( 'pending', 'pending_approval', 'running', 'waiting_external', 'succeeded', 'failed', 'cancelled', 'expired', 'unknown_outcome' @@ -995,6 +1027,12 @@ export const appRunAttempts = pgTable('app_run_attempts', { provider_call_finished_at: timestamp('provider_call_finished_at'), provider_idempotency_key_version: text('provider_idempotency_key_version'), provider_idempotency_fingerprint: text('provider_idempotency_fingerprint'), + runtime_binding_id: text('runtime_binding_id'), + runtime_session_id: text('runtime_session_id'), + runtime_session_epoch: integer('runtime_session_epoch'), + runtime_epoch: integer('runtime_epoch'), + runtime_sequence: integer('runtime_sequence'), + runtime_result_hmac: text('runtime_result_hmac'), safe_outcome: jsonb('safe_outcome').$type | null>(), error_code: text('error_code'), ...timestamps(), @@ -1004,6 +1042,14 @@ export const appRunAttempts = pgTable('app_run_attempts', { foreignColumns: [appRuns.org_id, appRuns.id], name: 'app_run_attempts_org_run_fk', }).onDelete('cascade'), + foreignKey({ + columns: [t.org_id, t.runtime_binding_id, t.runtime_session_id, + t.runtime_session_epoch, t.runtime_epoch], + foreignColumns: [appRuntimeSessions.org_id, appRuntimeSessions.runtime_binding_id, + appRuntimeSessions.id, appRuntimeSessions.session_epoch, + appRuntimeSessions.runtime_epoch], + name: 'app_run_attempts_runtime_session_fk', + }).onDelete('restrict'), unique('app_run_attempts_org_run_id_unique').on(t.org_id, t.run_id, t.id), uniqueIndex('app_run_attempts_number_unique').on(t.org_id, t.run_id, t.attempt_number), uniqueIndex('app_run_attempts_one_active_unique') @@ -1035,6 +1081,15 @@ export const appRunAttempts = pgTable('app_run_attempts', { ${t.safe_outcome} IS NULL OR (jsonb_typeof(${t.safe_outcome}) = 'object' AND octet_length(${t.safe_outcome}::text) <= 32768) `), + check('app_run_attempts_runtime_shape_check', sql` + (${t.runtime_binding_id} IS NULL AND ${t.runtime_session_id} IS NULL + AND ${t.runtime_session_epoch} IS NULL AND ${t.runtime_epoch} IS NULL + AND ${t.runtime_sequence} IS NULL AND ${t.runtime_result_hmac} IS NULL) + OR (${t.runtime_binding_id} IS NOT NULL AND ${t.runtime_session_id} IS NOT NULL + AND ${t.runtime_session_epoch} IS NOT NULL AND ${t.runtime_session_epoch} >= 0 + AND ${t.runtime_epoch} IS NOT NULL AND ${t.runtime_epoch} >= 0 + AND ${t.runtime_sequence} IS NOT NULL AND ${t.runtime_sequence} >= 1) + `), ]); export const appRunSecretPayloads = pgTable('app_run_secret_payloads', { @@ -2029,6 +2084,130 @@ export const appActionBindings = pgTable('app_action_bindings', { `), ]); +// Reviewed runtime ownership is separate from released App Kit v0-v2 actions. +// Registrations and bindings are dormant until a host-reviewed contract is +// explicitly activated. These rows are not another Run or scheduling ledger. +export const appRuntimeRegistrations = pgTable('app_runtime_registrations', { + ...id(), + ...orgId(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + grant_snapshot_kind: text('grant_snapshot_kind').$type<'effective'>().default('effective').notNull(), + operator_user_id: text('operator_user_id').notNull(), + contract_version: text('contract_version').notNull(), + state: text('state').$type<'disabled' | 'active' | 'revoked'>().default('disabled').notNull(), + runtime_epoch: integer('runtime_epoch').default(0).notNull(), + reviewed_by_user_id: text('reviewed_by_user_id'), + reviewed_at: timestamp('reviewed_at'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id], + foreignColumns: [appVersions.org_id, appVersions.installation_id, appVersions.id], + name: 'app_runtime_registrations_version_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, + t.grant_snapshot_id, t.grant_snapshot_kind], + foreignColumns: [appGrantSnapshots.org_id, appGrantSnapshots.app_installation_id, + appGrantSnapshots.app_version_id, appGrantSnapshots.id, appGrantSnapshots.snapshot_kind], + name: 'app_runtime_registrations_grant_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.operator_user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_runtime_registrations_operator_fk' }).onDelete('restrict'), + unique('app_runtime_registrations_org_id_id_unique').on(t.org_id, t.id), + unique('app_runtime_registrations_ancestry_unique').on(t.org_id, t.app_installation_id, + t.app_version_id, t.grant_snapshot_id, t.id), + check('app_runtime_registrations_state_check', sql`${t.state} IN ('disabled','active','revoked')`), + check('app_runtime_registrations_kind_check', sql`${t.grant_snapshot_kind} = 'effective'`), + check('app_runtime_registrations_epoch_check', sql`${t.runtime_epoch} >= 0`), + check('app_runtime_registrations_review_check', sql` + (${t.state} = 'disabled' AND ${t.reviewed_at} IS NULL AND ${t.reviewed_by_user_id} IS NULL) + OR (${t.state} <> 'disabled' AND ${t.reviewed_at} IS NOT NULL AND ${t.reviewed_by_user_id} IS NOT NULL) + `), +]); + +export const appRuntimeBindings = pgTable('app_runtime_bindings', { + ...id(), + ...orgId(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + runtime_registration_id: text('runtime_registration_id').notNull(), + action_key: text('action_key').notNull(), + interface_identity: text('interface_identity').notNull(), + provider_kind: text('provider_kind').$type<'app_runtime'>().default('app_runtime').notNull(), + provider_instance_id: text('provider_instance_id').notNull(), + provider_snapshot_id: text('provider_snapshot_id').notNull(), + operation_name: text('operation_name').notNull(), + risk_class: text('risk_class').$type<'read' | 'internal_write' | 'external_write' | 'destructive' | 'privileged'>().notNull(), + review_requirement: text('review_requirement').$type<'policy' | 'always'>().notNull(), + retry_class: text('retry_class').$type<'safe' | 'idempotent_with_key' | 'unsafe_or_unknown'>().notNull(), + retention_class: text('retention_class').$type<'ephemeral' | 'standard' | 'extended'>().notNull(), + state: text('state').$type<'disabled' | 'active' | 'revoked'>().default('disabled').notNull(), + reviewed_by_user_id: text('reviewed_by_user_id'), + reviewed_at: timestamp('reviewed_at'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, + t.grant_snapshot_id, t.runtime_registration_id], + foreignColumns: [appRuntimeRegistrations.org_id, appRuntimeRegistrations.app_installation_id, + appRuntimeRegistrations.app_version_id, appRuntimeRegistrations.grant_snapshot_id, + appRuntimeRegistrations.id], name: 'app_runtime_bindings_registration_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.provider_kind, t.provider_instance_id, t.provider_snapshot_id], + foreignColumns: [capabilityProviderSnapshots.org_id, capabilityProviderSnapshots.provider_kind, + capabilityProviderSnapshots.provider_instance_id, capabilityProviderSnapshots.id], + name: 'app_runtime_bindings_provider_fk' }).onDelete('restrict'), + unique('app_runtime_bindings_org_id_id_unique').on(t.org_id, t.id), + unique('app_runtime_bindings_run_identity_unique').on(t.org_id, t.app_installation_id, + t.app_version_id, t.grant_snapshot_id, t.id, t.provider_kind, + t.provider_instance_id, t.operation_name, t.provider_snapshot_id, + t.risk_class, t.review_requirement, t.retry_class, t.retention_class), + unique('app_runtime_bindings_registration_identity_unique').on(t.org_id, t.runtime_registration_id, t.id), + check('app_runtime_bindings_kind_check', sql`${t.provider_kind} = 'app_runtime'`), + check('app_runtime_bindings_identity_check', sql` + ${t.provider_instance_id} = ${t.runtime_registration_id} + AND ${t.action_key} ~ '^[a-z][a-z0-9_]{0,47}$' + AND ${t.action_key} !~ '^(deft|core|system)(_|$)' + AND ${t.interface_identity} = 'deft.runtime.v1:' || lower(${t.org_id}) || ':' || + lower(${t.app_installation_id}) || ':' || ${t.action_key} + `), + check('app_runtime_bindings_state_check', sql`${t.state} IN ('disabled','active','revoked')`), + check('app_runtime_bindings_review_check', sql` + (${t.state} = 'disabled' AND ${t.reviewed_at} IS NULL AND ${t.reviewed_by_user_id} IS NULL) + OR (${t.state} <> 'disabled' AND ${t.reviewed_at} IS NOT NULL AND ${t.reviewed_by_user_id} IS NOT NULL) + `), +]); + +export const appRuntimeSessions = pgTable('app_runtime_sessions', { + ...id(), + ...orgId(), + runtime_registration_id: text('runtime_registration_id').notNull(), + runtime_binding_id: text('runtime_binding_id').notNull(), + operator_user_id: text('operator_user_id').notNull(), + token_hash: text('token_hash').notNull(), + audience: text('audience').$type<'app_runtime'>().default('app_runtime').notNull(), + session_epoch: integer('session_epoch').default(0).notNull(), + runtime_epoch: integer('runtime_epoch').notNull(), + lifecycle_epoch: integer('lifecycle_epoch').notNull(), + grant_epoch: integer('grant_epoch').notNull(), + next_sequence: integer('next_sequence').default(1).notNull(), + expires_at: timestamp('expires_at').notNull(), + revoked_at: timestamp('revoked_at'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.runtime_registration_id, t.runtime_binding_id], + foreignColumns: [appRuntimeBindings.org_id, appRuntimeBindings.runtime_registration_id, + appRuntimeBindings.id], name: 'app_runtime_sessions_binding_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.operator_user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_runtime_sessions_operator_fk' }).onDelete('restrict'), + unique('app_runtime_sessions_attempt_identity_unique').on(t.org_id, t.runtime_binding_id, + t.id, t.session_epoch, t.runtime_epoch), + unique('app_runtime_sessions_token_hash_unique').on(t.token_hash), + check('app_runtime_sessions_audience_check', sql`${t.audience} = 'app_runtime'`), + check('app_runtime_sessions_epoch_check', sql`${t.session_epoch} >= 0 AND ${t.runtime_epoch} >= 0 AND ${t.lifecycle_epoch} >= 0 AND ${t.grant_epoch} >= 0 AND ${t.next_sequence} >= 1`), + check('app_runtime_sessions_token_check', sql`${t.token_hash} ~ '^sha256:[a-f0-9]{64}$'`), +]); + // ═══ APP AUTOMATION FOUNDATION (DORMANT TRACK A) ═══ // Definitions are host-authored review records, not executable schedules. // Only their lifecycle state and epoch may change after creation. Fires are a @@ -4500,3 +4679,123 @@ export const revokedTokens = pgTable('revoked_tokens', { }, (t) => [ index('revoked_tokens_hash_idx').on(t.token_hash), ]); + +// Gate G public ingress is host-owned. The first claim provider is a canonical +// Module record; App packages cannot create endpoints active or provide SQL. +export const appPublicEndpoints = pgTable('app_public_endpoints', { + ...id(), + ...orgId(), + slug_digest: text('slug_digest').notNull(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + installation_lifecycle_epoch: integer('installation_lifecycle_epoch').notNull(), + installation_grant_epoch: integer('installation_grant_epoch').notNull(), + module_installation_id: text('module_installation_id').notNull(), + collection_key: text('collection_key').notNull(), + state: text('state').$type<'disabled' | 'enabled'>().default('disabled').notNull(), + endpoint_epoch: integer('endpoint_epoch').default(1).notNull(), + review_digest: text('review_digest').notNull(), + reviewed_by_user_id: text('reviewed_by_user_id').notNull(), + reviewed_at: timestamp('reviewed_at').notNull(), + public_label: text('public_label').notNull(), + max_body_bytes: integer('max_body_bytes').default(1024).notNull(), + ...timestamps(), +}, (t) => [ + foreignKey({ + columns: [t.org_id, t.app_installation_id, t.app_version_id], + foreignColumns: [appVersions.org_id, appVersions.installation_id, appVersions.id], + name: 'app_public_endpoints_version_fk', + }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.app_installation_id, t.app_version_id, t.grant_snapshot_id], + foreignColumns: [appGrantSnapshots.org_id, appGrantSnapshots.app_installation_id, + appGrantSnapshots.app_version_id, appGrantSnapshots.id], + name: 'app_public_endpoints_grant_fk', + }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.module_installation_id], + foreignColumns: [moduleInstallations.org_id, moduleInstallations.id], + name: 'app_public_endpoints_module_fk', + }).onDelete('restrict'), + unique('app_public_endpoints_org_id_unique').on(t.org_id, t.id), + uniqueIndex('app_public_endpoints_slug_digest_unique').on(t.slug_digest), + index('app_public_endpoints_org_installation_idx').on(t.org_id, t.app_installation_id, t.state), + check('app_public_endpoints_slug_digest_check', sql`${t.slug_digest} ~ '^sha256:[a-f0-9]{64}$'`), + check('app_public_endpoints_review_digest_check', sql`${t.review_digest} ~ '^sha256:[a-f0-9]{64}$'`), + check('app_public_endpoints_state_check', sql`${t.state} IN ('disabled', 'enabled')`), + check('app_public_endpoints_epoch_check', sql`${t.endpoint_epoch} >= 1 + AND ${t.installation_lifecycle_epoch} >= 0 AND ${t.installation_grant_epoch} >= 1`), + check('app_public_endpoints_collection_check', sql`${t.collection_key} ~ '^[a-z][a-z0-9_]{0,63}$'`), + check('app_public_endpoints_label_check', sql`octet_length(${t.public_label}) BETWEEN 1 AND 200`), + check('app_public_endpoints_body_limit_check', sql`${t.max_body_bytes} BETWEEN 128 AND 8192`), +]); + +// A receipt is retained even for a losing claim. No raw request body, cookie, +// public key or private Module projection is copied into this table. +export const appPublicIngress = pgTable('app_public_ingress', { + ...id(), + ...orgId(), + endpoint_id: text('endpoint_id').notNull(), + endpoint_epoch: integer('endpoint_epoch').notNull(), + request_key_digest: text('request_key_digest').notNull(), + input_digest: text('input_digest').notNull(), + state: text('state').$type<'processing' | 'confirmed' | 'conflict'>().notNull(), + follow_up_state: text('follow_up_state').$type<'pending' | 'unsupported'>().default('pending').notNull(), + follow_up_code: text('follow_up_code').$type<'APP_HANDLER_UNAVAILABLE' | 'ENDPOINT_REVOKED'>(), + handled_at: timestamp('handled_at'), + created_at: timestamp('created_at').defaultNow().notNull(), +}, (t) => [ + foreignKey({ + columns: [t.org_id, t.endpoint_id], + foreignColumns: [appPublicEndpoints.org_id, appPublicEndpoints.id], + name: 'app_public_ingress_endpoint_fk', + }).onDelete('restrict'), + unique('app_public_ingress_org_endpoint_id_unique').on(t.org_id, t.endpoint_id, t.id), + uniqueIndex('app_public_ingress_request_unique').on(t.org_id, t.endpoint_id, t.endpoint_epoch, t.request_key_digest), + index('app_public_ingress_endpoint_created_idx').on(t.org_id, t.endpoint_id, t.created_at), + check('app_public_ingress_epoch_check', sql`${t.endpoint_epoch} >= 1`), + check('app_public_ingress_key_digest_check', sql`${t.request_key_digest} ~ '^sha256:[a-f0-9]{64}$'`), + check('app_public_ingress_input_digest_check', sql`${t.input_digest} ~ '^sha256:[a-f0-9]{64}$'`), + check('app_public_ingress_state_check', sql`${t.state} IN ('processing', 'confirmed', 'conflict')`), + check('app_public_ingress_follow_up_check', sql`(${t.follow_up_state} = 'pending' AND ${t.follow_up_code} IS NULL AND ${t.handled_at} IS NULL) + OR (${t.follow_up_state} = 'unsupported' AND ${t.follow_up_code} IS NOT NULL + AND ${t.follow_up_code} IN ('APP_HANDLER_UNAVAILABLE', 'ENDPOINT_REVOKED') AND ${t.handled_at} IS NOT NULL)`), +]); + +// The uniqueness key omits endpoint identity: two public endpoints cannot +// claim the same canonical resource at once. Released rows remain for audit. +export const appCanonicalClaims = pgTable('app_canonical_claims', { + ...id(), + ...orgId(), + endpoint_id: text('endpoint_id').notNull(), + ingress_id: text('ingress_id').notNull(), + provider_kind: text('provider_kind').$type<'module'>().notNull(), + provider_instance_id: text('provider_instance_id').notNull(), + resource_type: text('resource_type').notNull(), + resource_id: text('resource_id').notNull(), + claim_kind: text('claim_kind').$type<'exclusive'>().notNull(), + released_at: timestamp('released_at'), + created_at: timestamp('created_at').defaultNow().notNull(), +}, (t) => [ + foreignKey({ + columns: [t.org_id, t.endpoint_id, t.ingress_id], + foreignColumns: [appPublicIngress.org_id, appPublicIngress.endpoint_id, appPublicIngress.id], + name: 'app_canonical_claims_ingress_fk', + }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.provider_instance_id, t.resource_id], + foreignColumns: [moduleRecords.org_id, moduleRecords.installation_id, moduleRecords.id], + name: 'app_canonical_claims_module_record_fk', + }).onDelete('restrict'), + unique('app_canonical_claims_org_id_unique').on(t.org_id, t.id), + uniqueIndex('app_canonical_claims_ingress_unique').on(t.org_id, t.ingress_id), + uniqueIndex('app_canonical_claims_active_resource_unique') + .on(t.org_id, t.provider_kind, t.provider_instance_id, t.resource_id, t.claim_kind) + .where(sql`${t.released_at} IS NULL`), + index('app_canonical_claims_endpoint_created_idx').on(t.org_id, t.endpoint_id, t.created_at), + check('app_canonical_claims_provider_check', sql`${t.provider_kind} = 'module'`), + check('app_canonical_claims_resource_type_check', sql`${t.resource_type} ~ '^[a-z][a-z0-9_]{0,63}$'`), + check('app_canonical_claims_kind_check', sql`${t.claim_kind} = 'exclusive'`), + check('app_canonical_claims_release_check', sql`${t.released_at} IS NULL OR ${t.released_at} >= ${t.created_at}`), +]); diff --git a/packages/db/upgrades/0.3.0-preview.31-app-runtime-channel.sql b/packages/db/upgrades/0.3.0-preview.31-app-runtime-channel.sql new file mode 100644 index 00000000..f8d4446c --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.31-app-runtime-channel.sql @@ -0,0 +1,344 @@ +-- Forward-only, default-off App Runtime channel ancestry. This adds no public +-- App Kit contract, execution entrance, scheduler, or provider call path. +ALTER TABLE capability_provider_snapshots + DROP CONSTRAINT IF EXISTS capability_provider_snapshots_kind_check; +ALTER TABLE capability_provider_snapshots + ADD CONSTRAINT capability_provider_snapshots_kind_check + CHECK (provider_kind IN ('mcp', 'app_runtime')); +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_provider_kind_check; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_provider_kind_check + CHECK (provider_kind IN ('mcp', 'app_runtime')); + +CREATE TABLE IF NOT EXISTS app_runtime_registrations ( + id text PRIMARY KEY, + org_id text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + grant_snapshot_kind text NOT NULL DEFAULT 'effective', + operator_user_id text NOT NULL, + contract_version text NOT NULL, + state text NOT NULL DEFAULT 'disabled', + runtime_epoch integer NOT NULL DEFAULT 0, + reviewed_by_user_id text, + reviewed_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_runtime_registrations_version_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id) REFERENCES + app_versions(org_id, installation_id, id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_registrations_grant_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, grant_snapshot_kind) + REFERENCES app_grant_snapshots + (org_id, app_installation_id, app_version_id, id, snapshot_kind) ON DELETE RESTRICT, + CONSTRAINT app_runtime_registrations_operator_fk FOREIGN KEY (org_id, operator_user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_registrations_org_id_id_unique UNIQUE (org_id, id), + CONSTRAINT app_runtime_registrations_ancestry_unique UNIQUE + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id), + CONSTRAINT app_runtime_registrations_kind_check CHECK (grant_snapshot_kind = 'effective'), + CONSTRAINT app_runtime_registrations_state_check CHECK (state IN ('disabled','active','revoked')), + CONSTRAINT app_runtime_registrations_epoch_check CHECK (runtime_epoch >= 0), + CONSTRAINT app_runtime_registrations_review_check CHECK + ((state = 'disabled' AND reviewed_at IS NULL AND reviewed_by_user_id IS NULL) + OR (state <> 'disabled' AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)) +); + +CREATE TABLE IF NOT EXISTS app_runtime_bindings ( + id text PRIMARY KEY, + org_id text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + runtime_registration_id text NOT NULL, + action_key text NOT NULL, + interface_identity text NOT NULL, + provider_kind text NOT NULL DEFAULT 'app_runtime', + provider_instance_id text NOT NULL, + provider_snapshot_id text NOT NULL, + operation_name text NOT NULL, + risk_class text NOT NULL, + review_requirement text NOT NULL, + retry_class text NOT NULL, + retention_class text NOT NULL, + state text NOT NULL DEFAULT 'disabled', + reviewed_by_user_id text, + reviewed_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_runtime_bindings_registration_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, runtime_registration_id) + REFERENCES app_runtime_registrations + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_bindings_provider_fk FOREIGN KEY + (org_id, provider_kind, provider_instance_id, provider_snapshot_id) + REFERENCES capability_provider_snapshots + (org_id, provider_kind, provider_instance_id, id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_bindings_org_id_id_unique UNIQUE (org_id, id), + CONSTRAINT app_runtime_bindings_registration_identity_unique UNIQUE + (org_id, runtime_registration_id, id), + CONSTRAINT app_runtime_bindings_run_identity_unique UNIQUE + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, + provider_kind, provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, retry_class, retention_class), + CONSTRAINT app_runtime_bindings_kind_check CHECK (provider_kind = 'app_runtime'), + CONSTRAINT app_runtime_bindings_identity_check CHECK + (provider_instance_id = runtime_registration_id + AND action_key ~ '^[a-z][a-z0-9_]{0,47}$' + AND action_key !~ '^(deft|core|system)(_|$)' + AND interface_identity = 'deft.runtime.v1:' || lower(org_id) || ':' || + lower(app_installation_id) || ':' || action_key), + CONSTRAINT app_runtime_bindings_state_check CHECK (state IN ('disabled','active','revoked')), + CONSTRAINT app_runtime_bindings_review_check CHECK + ((state = 'disabled' AND reviewed_at IS NULL AND reviewed_by_user_id IS NULL) + OR (state <> 'disabled' AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)) +); + +CREATE TABLE IF NOT EXISTS app_runtime_sessions ( + id text PRIMARY KEY, + org_id text NOT NULL, + runtime_registration_id text NOT NULL, + runtime_binding_id text NOT NULL, + operator_user_id text NOT NULL, + token_hash text NOT NULL, + audience text NOT NULL DEFAULT 'app_runtime', + session_epoch integer NOT NULL DEFAULT 0, + runtime_epoch integer NOT NULL, + lifecycle_epoch integer NOT NULL, + grant_epoch integer NOT NULL, + next_sequence integer NOT NULL DEFAULT 1, + expires_at timestamp NOT NULL, + revoked_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_runtime_sessions_binding_fk FOREIGN KEY + (org_id, runtime_registration_id, runtime_binding_id) + REFERENCES app_runtime_bindings (org_id, runtime_registration_id, id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_sessions_operator_fk FOREIGN KEY (org_id, operator_user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + CONSTRAINT app_runtime_sessions_attempt_identity_unique UNIQUE + (org_id, runtime_binding_id, id, session_epoch, runtime_epoch), + CONSTRAINT app_runtime_sessions_token_hash_unique UNIQUE (token_hash), + CONSTRAINT app_runtime_sessions_audience_check CHECK (audience = 'app_runtime'), + CONSTRAINT app_runtime_sessions_epoch_check CHECK + (session_epoch >= 0 AND runtime_epoch >= 0 AND lifecycle_epoch >= 0 + AND grant_epoch >= 0 AND next_sequence >= 1), + CONSTRAINT app_runtime_sessions_token_check CHECK (token_hash ~ '^sha256:[a-f0-9]{64}$') +); + +ALTER TABLE app_runs ADD COLUMN IF NOT EXISTS origin_runtime_binding_id text; +ALTER TABLE app_run_attempts + ADD COLUMN IF NOT EXISTS runtime_binding_id text, + ADD COLUMN IF NOT EXISTS runtime_session_id text, + ADD COLUMN IF NOT EXISTS runtime_session_epoch integer, + ADD COLUMN IF NOT EXISTS runtime_epoch integer, + ADD COLUMN IF NOT EXISTS runtime_sequence integer, + ADD COLUMN IF NOT EXISTS runtime_result_hmac text; + +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_app_origin_coherence_check; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_app_origin_coherence_check CHECK ( + (origin_kind = 'app' AND provider_kind = 'mcp' + AND origin_app_installation_id IS NOT NULL AND origin_app_version_id IS NOT NULL + AND origin_app_binding_key IS NOT NULL AND origin_runtime_binding_id IS NULL + AND origin_app_grant_snapshot_id IS NOT NULL + AND risk_class = 'external_write' AND review_requirement = 'always' + AND retry_class = 'idempotent_with_key' AND retention_class = 'standard' + AND ((review_scope = 'per_invocation' + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type <> 'automation' AND execution_actor_type <> 'automation') + OR (review_scope = 'approved_automation_definition' + AND origin_app_automation_definition_id IS NOT NULL AND origin_app_automation_fire_id IS NOT NULL + AND initiating_actor_type = 'human' AND execution_actor_type = 'automation' + AND execution_actor_id = origin_app_automation_definition_id))) + OR (origin_kind = 'app' AND provider_kind = 'app_runtime' + AND origin_app_installation_id IS NOT NULL AND origin_app_version_id IS NOT NULL + AND origin_app_grant_snapshot_id IS NOT NULL AND origin_app_binding_key IS NULL + AND origin_runtime_binding_id IS NOT NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type <> 'automation' AND execution_actor_type <> 'automation' + AND review_scope = 'per_invocation') + OR (origin_kind <> 'app' AND provider_kind = 'mcp' + AND origin_app_installation_id IS NULL AND origin_app_version_id IS NULL + AND origin_app_binding_key IS NULL AND origin_runtime_binding_id IS NULL + AND origin_app_grant_snapshot_id IS NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type <> 'automation' AND execution_actor_type <> 'automation') +); + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'app_runs_runtime_binding_fk') THEN + ALTER TABLE app_runs ADD CONSTRAINT app_runs_runtime_binding_fk FOREIGN KEY + (org_id, origin_app_installation_id, origin_app_version_id, + origin_app_grant_snapshot_id, origin_runtime_binding_id, provider_kind, + provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, retry_class, retention_class) + REFERENCES app_runtime_bindings + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, + provider_kind, provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, retry_class, retention_class) ON DELETE RESTRICT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'app_run_attempts_runtime_session_fk') THEN + ALTER TABLE app_run_attempts ADD CONSTRAINT app_run_attempts_runtime_session_fk FOREIGN KEY + (org_id, runtime_binding_id, runtime_session_id, runtime_session_epoch, runtime_epoch) + REFERENCES app_runtime_sessions + (org_id, runtime_binding_id, id, session_epoch, runtime_epoch) ON DELETE RESTRICT; + END IF; +END $$; +ALTER TABLE app_run_attempts DROP CONSTRAINT IF EXISTS app_run_attempts_runtime_shape_check; +ALTER TABLE app_run_attempts ADD CONSTRAINT app_run_attempts_runtime_shape_check CHECK ( + (runtime_binding_id IS NULL AND runtime_session_id IS NULL + AND runtime_session_epoch IS NULL AND runtime_epoch IS NULL + AND runtime_sequence IS NULL AND runtime_result_hmac IS NULL) + OR (runtime_binding_id IS NOT NULL AND runtime_session_id IS NOT NULL + AND runtime_session_epoch IS NOT NULL AND runtime_session_epoch >= 0 + AND runtime_epoch IS NOT NULL AND runtime_epoch >= 0 + AND runtime_sequence IS NOT NULL AND runtime_sequence >= 1) +); + +-- Additive guards preserve the old transition functions byte-for-byte. A +-- claim can pin its session exactly once; a known result can pin its digest +-- exactly once. Revocation/expiry is checked by the API on every call. +CREATE OR REPLACE FUNCTION enforce_app_runtime_run_identity() RETURNS trigger AS $$ +BEGIN + IF NEW.origin_runtime_binding_id IS DISTINCT FROM OLD.origin_runtime_binding_id THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_run_identity_trigger ON app_runs; +CREATE TRIGGER app_runtime_run_identity_trigger BEFORE UPDATE ON app_runs + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_run_identity(); + +CREATE OR REPLACE FUNCTION enforce_app_runtime_attempt_identity() RETURNS trigger AS $$ +BEGIN + IF OLD.runtime_session_id IS NOT NULL AND + (NEW.runtime_binding_id, NEW.runtime_session_id, NEW.runtime_session_epoch, + NEW.runtime_epoch, NEW.runtime_sequence) IS DISTINCT FROM + (OLD.runtime_binding_id, OLD.runtime_session_id, OLD.runtime_session_epoch, + OLD.runtime_epoch, OLD.runtime_sequence) THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + IF OLD.runtime_session_id IS NULL AND NEW.runtime_session_id IS NOT NULL AND + NOT (OLD.state = 'pending' AND NEW.state = 'claimed') THEN + RAISE EXCEPTION 'APP_RUN_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + IF OLD.runtime_result_hmac IS NOT NULL AND NEW.runtime_result_hmac IS DISTINCT FROM OLD.runtime_result_hmac THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + IF OLD.runtime_result_hmac IS NULL AND NEW.runtime_result_hmac IS NOT NULL AND + NOT (OLD.state = 'provider_call_started' AND + (NEW.provider_call_finished_at IS NOT NULL OR NEW.state = 'unknown_outcome')) THEN + RAISE EXCEPTION 'APP_RUN_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_attempt_identity_trigger ON app_run_attempts; +CREATE TRIGGER app_runtime_attempt_identity_trigger BEFORE UPDATE ON app_run_attempts + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_attempt_identity(); + +-- Review transitions are one-way. Live workers still check mutable installation, +-- operator and grant state on every channel call. +CREATE OR REPLACE FUNCTION enforce_app_runtime_registration() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'APP_RUNTIME_APPEND_ONLY' USING ERRCODE = '55000'; + END IF; + IF TG_OP = 'UPDATE' THEN + IF (NEW.org_id, NEW.app_installation_id, NEW.app_version_id, NEW.grant_snapshot_id, + NEW.grant_snapshot_kind, NEW.operator_user_id, NEW.contract_version, + NEW.created_at) IS DISTINCT FROM + (OLD.org_id, OLD.app_installation_id, OLD.app_version_id, OLD.grant_snapshot_id, + OLD.grant_snapshot_kind, OLD.operator_user_id, OLD.contract_version, + OLD.created_at) + THEN RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; END IF; + IF (NEW.reviewed_by_user_id, NEW.reviewed_at) IS DISTINCT FROM + (OLD.reviewed_by_user_id, OLD.reviewed_at) + AND NOT (OLD.state = 'disabled' AND NEW.state = 'active' + AND OLD.reviewed_by_user_id IS NULL AND OLD.reviewed_at IS NULL + AND NEW.reviewed_by_user_id IS NOT NULL AND NEW.reviewed_at IS NOT NULL) + THEN RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; END IF; + IF NOT ((OLD.state = 'disabled' AND NEW.state = 'active' + AND NEW.runtime_epoch = OLD.runtime_epoch + 1) + OR (OLD.state = 'active' AND NEW.state = 'revoked' + AND NEW.runtime_epoch = OLD.runtime_epoch + 1) + OR (OLD.state = NEW.state AND NEW.runtime_epoch = OLD.runtime_epoch)) THEN + RAISE EXCEPTION 'APP_RUNTIME_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + END IF; + IF NEW.state = 'active' AND NOT EXISTS ( + SELECT 1 FROM org_members WHERE org_id = NEW.org_id + AND user_id = NEW.reviewed_by_user_id AND is_active + AND role IN ('owner','admin') + ) THEN RAISE EXCEPTION 'APP_RUNTIME_REVIEW_INVALID' USING ERRCODE = '55000'; END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_registration_guard_trigger ON app_runtime_registrations; +CREATE TRIGGER app_runtime_registration_guard_trigger + BEFORE INSERT OR UPDATE OR DELETE ON app_runtime_registrations + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_registration(); + +CREATE OR REPLACE FUNCTION enforce_app_runtime_binding() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'APP_RUNTIME_APPEND_ONLY' USING ERRCODE = '55000'; + END IF; + IF TG_OP = 'UPDATE' THEN + IF (NEW.org_id, NEW.app_installation_id, NEW.app_version_id, NEW.grant_snapshot_id, + NEW.runtime_registration_id, NEW.action_key, NEW.interface_identity, + NEW.provider_kind, NEW.provider_instance_id, NEW.provider_snapshot_id, + NEW.operation_name, NEW.risk_class, NEW.review_requirement, + NEW.retry_class, NEW.retention_class, NEW.created_at) IS DISTINCT FROM + (OLD.org_id, OLD.app_installation_id, OLD.app_version_id, OLD.grant_snapshot_id, + OLD.runtime_registration_id, OLD.action_key, OLD.interface_identity, + OLD.provider_kind, OLD.provider_instance_id, OLD.provider_snapshot_id, + OLD.operation_name, OLD.risk_class, OLD.review_requirement, + OLD.retry_class, OLD.retention_class, OLD.created_at) + THEN RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; END IF; + IF (NEW.reviewed_by_user_id, NEW.reviewed_at) IS DISTINCT FROM + (OLD.reviewed_by_user_id, OLD.reviewed_at) + AND NOT (OLD.state = 'disabled' AND NEW.state = 'active' + AND OLD.reviewed_by_user_id IS NULL AND OLD.reviewed_at IS NULL + AND NEW.reviewed_by_user_id IS NOT NULL AND NEW.reviewed_at IS NOT NULL) + THEN RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; END IF; + IF NOT ((OLD.state = 'disabled' AND NEW.state = 'active') + OR (OLD.state = 'active' AND NEW.state = 'revoked') + OR OLD.state = NEW.state) THEN + RAISE EXCEPTION 'APP_RUNTIME_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + END IF; + IF NEW.state = 'active' AND NOT EXISTS ( + SELECT 1 FROM org_members WHERE org_id = NEW.org_id + AND user_id = NEW.reviewed_by_user_id AND is_active + AND role IN ('owner','admin') + ) THEN RAISE EXCEPTION 'APP_RUNTIME_REVIEW_INVALID' USING ERRCODE = '55000'; END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_binding_guard_trigger ON app_runtime_bindings; +CREATE TRIGGER app_runtime_binding_guard_trigger + BEFORE INSERT OR UPDATE OR DELETE ON app_runtime_bindings + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_binding(); + +CREATE OR REPLACE FUNCTION enforce_app_runtime_session() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'APP_RUNTIME_APPEND_ONLY' USING ERRCODE = '55000'; + END IF; + IF TG_OP = 'UPDATE' THEN + IF (NEW.org_id, NEW.runtime_registration_id, NEW.runtime_binding_id, + NEW.operator_user_id, NEW.token_hash, NEW.audience, NEW.session_epoch, + NEW.runtime_epoch, NEW.lifecycle_epoch, NEW.grant_epoch, + NEW.expires_at, NEW.created_at) IS DISTINCT FROM + (OLD.org_id, OLD.runtime_registration_id, OLD.runtime_binding_id, + OLD.operator_user_id, OLD.token_hash, OLD.audience, OLD.session_epoch, + OLD.runtime_epoch, OLD.lifecycle_epoch, OLD.grant_epoch, + OLD.expires_at, OLD.created_at) + OR NEW.next_sequence < OLD.next_sequence + OR (OLD.revoked_at IS NOT NULL AND NEW.revoked_at IS DISTINCT FROM OLD.revoked_at) + THEN RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; END IF; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_session_guard_trigger ON app_runtime_sessions; +CREATE TRIGGER app_runtime_session_guard_trigger + BEFORE UPDATE OR DELETE ON app_runtime_sessions + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_session(); diff --git a/packages/db/upgrades/0.3.0-preview.32-app-public-claims.sql b/packages/db/upgrades/0.3.0-preview.32-app-public-claims.sql new file mode 100644 index 00000000..c7ce60ef --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.32-app-public-claims.sql @@ -0,0 +1,101 @@ +-- Gate G public claim foundation. Dormant until an explicitly reviewed endpoint +-- is enabled. Existing claims and ingress receipts survive disable/rollback. +CREATE TABLE IF NOT EXISTS app_public_endpoints ( + id text PRIMARY KEY, + org_id text NOT NULL, + slug_digest text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + installation_lifecycle_epoch integer NOT NULL, + installation_grant_epoch integer NOT NULL, + module_installation_id text NOT NULL, + collection_key text NOT NULL, + state text NOT NULL DEFAULT 'disabled', + endpoint_epoch integer NOT NULL DEFAULT 1, + review_digest text NOT NULL, + reviewed_by_user_id text NOT NULL, + reviewed_at timestamp NOT NULL, + public_label text NOT NULL, + max_body_bytes integer NOT NULL DEFAULT 1024, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_public_endpoints_version_fk FOREIGN KEY (org_id, app_installation_id, app_version_id) + REFERENCES app_versions(org_id, installation_id, id) ON DELETE RESTRICT, + CONSTRAINT app_public_endpoints_grant_fk FOREIGN KEY (org_id, app_installation_id, app_version_id, grant_snapshot_id) + REFERENCES app_grant_snapshots(org_id, app_installation_id, app_version_id, id) ON DELETE RESTRICT, + CONSTRAINT app_public_endpoints_module_fk FOREIGN KEY (org_id, module_installation_id) + REFERENCES module_installations(org_id, id) ON DELETE RESTRICT, + CONSTRAINT app_public_endpoints_org_id_unique UNIQUE (org_id, id), + CONSTRAINT app_public_endpoints_slug_digest_check CHECK (slug_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_public_endpoints_review_digest_check CHECK (review_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_public_endpoints_state_check CHECK (state IN ('disabled', 'enabled')), + CONSTRAINT app_public_endpoints_epoch_check CHECK (endpoint_epoch >= 1 + AND installation_lifecycle_epoch >= 0 AND installation_grant_epoch >= 1), + CONSTRAINT app_public_endpoints_collection_check CHECK (collection_key ~ '^[a-z][a-z0-9_]{0,63}$'), + CONSTRAINT app_public_endpoints_label_check CHECK (octet_length(public_label) BETWEEN 1 AND 200), + CONSTRAINT app_public_endpoints_body_limit_check CHECK (max_body_bytes BETWEEN 128 AND 8192) +); +CREATE UNIQUE INDEX IF NOT EXISTS app_public_endpoints_slug_digest_unique ON app_public_endpoints(slug_digest); +CREATE INDEX IF NOT EXISTS app_public_endpoints_org_installation_idx + ON app_public_endpoints(org_id, app_installation_id, state); + +CREATE TABLE IF NOT EXISTS app_public_ingress ( + id text PRIMARY KEY, + org_id text NOT NULL, + endpoint_id text NOT NULL, + endpoint_epoch integer NOT NULL, + request_key_digest text NOT NULL, + input_digest text NOT NULL, + state text NOT NULL, + follow_up_state text NOT NULL DEFAULT 'pending', + follow_up_code text, + handled_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_public_ingress_endpoint_fk FOREIGN KEY (org_id, endpoint_id) + REFERENCES app_public_endpoints(org_id, id) ON DELETE RESTRICT, + CONSTRAINT app_public_ingress_org_endpoint_id_unique UNIQUE (org_id, endpoint_id, id), + CONSTRAINT app_public_ingress_epoch_check CHECK (endpoint_epoch >= 1), + CONSTRAINT app_public_ingress_key_digest_check CHECK (request_key_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_public_ingress_input_digest_check CHECK (input_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_public_ingress_state_check CHECK (state IN ('processing', 'confirmed', 'conflict')), + CONSTRAINT app_public_ingress_follow_up_check CHECK ( + (follow_up_state = 'pending' AND follow_up_code IS NULL AND handled_at IS NULL) + OR (follow_up_state = 'unsupported' AND follow_up_code IS NOT NULL + AND follow_up_code IN ('APP_HANDLER_UNAVAILABLE', 'ENDPOINT_REVOKED') AND handled_at IS NOT NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS app_public_ingress_request_unique + ON app_public_ingress(org_id, endpoint_id, endpoint_epoch, request_key_digest); +CREATE INDEX IF NOT EXISTS app_public_ingress_endpoint_created_idx + ON app_public_ingress(org_id, endpoint_id, created_at); + +CREATE TABLE IF NOT EXISTS app_canonical_claims ( + id text PRIMARY KEY, + org_id text NOT NULL, + endpoint_id text NOT NULL, + ingress_id text NOT NULL, + provider_kind text NOT NULL, + provider_instance_id text NOT NULL, + resource_type text NOT NULL, + resource_id text NOT NULL, + claim_kind text NOT NULL, + released_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_canonical_claims_ingress_fk FOREIGN KEY (org_id, endpoint_id, ingress_id) + REFERENCES app_public_ingress(org_id, endpoint_id, id) ON DELETE RESTRICT, + CONSTRAINT app_canonical_claims_module_record_fk FOREIGN KEY (org_id, provider_instance_id, resource_id) + REFERENCES module_records(org_id, installation_id, id) ON DELETE RESTRICT, + CONSTRAINT app_canonical_claims_org_id_unique UNIQUE (org_id, id), + CONSTRAINT app_canonical_claims_provider_check CHECK (provider_kind = 'module'), + CONSTRAINT app_canonical_claims_resource_type_check CHECK (resource_type ~ '^[a-z][a-z0-9_]{0,63}$'), + CONSTRAINT app_canonical_claims_kind_check CHECK (claim_kind = 'exclusive'), + CONSTRAINT app_canonical_claims_release_check CHECK (released_at IS NULL OR released_at >= created_at) +); +CREATE UNIQUE INDEX IF NOT EXISTS app_canonical_claims_ingress_unique + ON app_canonical_claims(org_id, ingress_id); +CREATE UNIQUE INDEX IF NOT EXISTS app_canonical_claims_active_resource_unique + ON app_canonical_claims(org_id, provider_kind, provider_instance_id, resource_id, claim_kind) + WHERE released_at IS NULL; +CREATE INDEX IF NOT EXISTS app_canonical_claims_endpoint_created_idx + ON app_canonical_claims(org_id, endpoint_id, created_at); diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index bb3852d7..763dc1c2 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -172,6 +172,16 @@ export const upgradeManifest = { file: '0.3.0-preview.30-module-record-merges.sql', description: 'Preserve tenant-bound original values and link provenance for reviewed Module record merges', }, + { + version: '0.3.0-preview.31', + file: '0.3.0-preview.31-app-runtime-channel.sql', + description: 'Add dormant reviewed Runtime ancestry, sessions and fenced Run attempts', + }, + { + version: '0.3.0-preview.32', + file: '0.3.0-preview.32-app-public-claims.sql', + description: 'Add dormant public endpoints, retained ingress and canonical exclusive claims', + }, ] satisfies UpgradeMigration[], } as const; diff --git a/packages/shared/package.json b/packages/shared/package.json index 81330844..56ac89ce 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,7 +5,7 @@ "license": "AGPL-3.0-only", "type": "module", "scripts": { - "test": "tsx --test test/modules.test.ts test/capabilities.test.ts test/app-runs.test.ts test/resources.test.ts", + "test": "tsx --test test/modules.test.ts test/capabilities.test.ts test/app-runs.test.ts test/resources.test.ts test/app-platform-authority.test.ts", "typecheck": "tsc --noEmit" }, "exports": { diff --git a/packages/shared/src/app-platform-authority.ts b/packages/shared/src/app-platform-authority.ts new file mode 100644 index 00000000..f86fda0b --- /dev/null +++ b/packages/shared/src/app-platform-authority.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { ResourceHostOrganizationIdSchema, ResourceProviderInstanceIdSchema } from './resources'; + +/** Host-created identity pins, not credentials or authorization decisions. + * Never accept these objects from an App to establish its rights. Callers must + * resolve the live principal, active installation and effective grant first. + * These internal contracts do not widen released App Protocol v0-v2 manifests. + */ +const id = ResourceProviderInstanceIdSchema; +const epoch = z.number().int().min(0).max(2_147_483_647); +const installationShape = { + org_id: ResourceHostOrganizationIdSchema, + app_installation_id: id, + app_version_id: id, + lifecycle_epoch: epoch, + grant_epoch: epoch, +}; + +export const AppInstallationAuthoritySchema = z.strictObject(installationShape); +export type AppInstallationAuthority = z.infer; + +export const AppRuntimeSessionAuthoritySchema = z.strictObject({ + ...installationShape, + audience: z.literal('app_runtime'), + runtime_registration_id: id, + runtime_binding_id: id, + runtime_epoch: epoch, + session_id: id, + session_epoch: epoch, +}); +export type AppRuntimeSessionAuthority = z.infer; + +export const AppPublicPrincipalSchema = z.strictObject({ + ...installationShape, + audience: z.literal('app_public'), + endpoint_id: id, + endpoint_epoch: epoch, +}); +export type AppPublicPrincipal = z.infer; + +export const AppExperienceSessionAuthoritySchema = z.strictObject({ + ...installationShape, + audience: z.literal('app_experience'), + user_id: id, + session_id: id, + session_epoch: epoch, +}); +export type AppExperienceSessionAuthority = z.infer; + +/** Equality is necessary but insufficient: lifecycle and membership are live checks. */ +export function isSameAppInstallationAuthority( + expected: AppInstallationAuthority, + current: AppInstallationAuthority, +): boolean { + return expected.org_id === current.org_id && + expected.app_installation_id === current.app_installation_id && + expected.app_version_id === current.app_version_id && + expected.lifecycle_epoch === current.lifecycle_epoch && + expected.grant_epoch === current.grant_epoch; +} diff --git a/packages/shared/src/capabilities.ts b/packages/shared/src/capabilities.ts index 8c72ee55..54ad4304 100644 --- a/packages/shared/src/capabilities.ts +++ b/packages/shared/src/capabilities.ts @@ -96,7 +96,7 @@ const NonEmptyExactStringSchema = z // Phase 2 seam must not reject a value that the legacy path can execute. const ProviderOwnedNameSchema = z.string().min(1); -export const CapabilityProviderKindSchema = z.enum(['mcp']); +export const CapabilityProviderKindSchema = z.enum(['mcp', 'app_runtime']); export type CapabilityProviderKind = z.infer; export const CapabilityProviderIdentitySchema = z.object({ @@ -230,9 +230,9 @@ export const CapabilityInvocationErrorCodeSchema = z.enum([ export type CapabilityInvocationErrorCode = z.infer; export const CapabilityInvocationProviderRefSchema = z.object({ - provider_kind: CapabilityProviderKindSchema, + provider_kind: z.literal('mcp'), requested_provider_key: ProviderOwnedNameSchema, - resolved_provider: CapabilityProviderIdentitySchema.optional(), + resolved_provider: CapabilityProviderIdentitySchema.extend({ provider_kind: z.literal('mcp') }).optional(), }).strict(); export type CapabilityInvocationProviderRef = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a1ab3bfb..ac2f29e5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,3 +6,4 @@ export * from './app-runs'; export * from './capabilities'; export * from './modules'; export * from './resources'; +export * from './app-platform-authority'; diff --git a/packages/shared/test/app-platform-authority.test.ts b/packages/shared/test/app-platform-authority.test.ts new file mode 100644 index 00000000..9e08c25e --- /dev/null +++ b/packages/shared/test/app-platform-authority.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + AppExperienceSessionAuthoritySchema, AppInstallationAuthoritySchema, + AppPublicPrincipalSchema, AppRuntimeSessionAuthoritySchema, isSameAppInstallationAuthority, +} from '../src/app-platform-authority'; +import { + CapabilityInvocationProviderRefSchema, CapabilityInvocationRequestSchema, + CapabilityProviderIdentitySchema, +} from '../src/capabilities'; + +const installation = { + org_id: 'org-1', app_installation_id: 'install-1', app_version_id: 'version-1', + lifecycle_epoch: 3, grant_epoch: 7, +}; +const runtime = { + ...installation, audience: 'app_runtime', runtime_registration_id: 'runtime-1', + runtime_binding_id: 'binding-1', runtime_epoch: 2, session_id: 'runtime-session-1', session_epoch: 1, +}; +const publicPrincipal = { ...installation, audience: 'app_public', endpoint_id: 'endpoint-1', endpoint_epoch: 4 }; +const experience = { ...installation, audience: 'app_experience', user_id: 'user-1', session_id: 'ui-1', session_epoch: 1 }; + +test('audiences are disjoint and carry no employee or caller-supplied grant authority', () => { + const pairs = [ + [AppRuntimeSessionAuthoritySchema, runtime], + [AppPublicPrincipalSchema, publicPrincipal], + [AppExperienceSessionAuthoritySchema, experience], + ] as const; + for (const [schema, value] of pairs) { + assert.equal(schema.safeParse(value).success, true); + for (const [, foreign] of pairs) if (foreign !== value) assert.equal(schema.safeParse(foreign).success, false); + for (const field of ['employee_id', 'effective_grant', 'permissions', 'token', 'unknown']) { + assert.equal(schema.safeParse({ ...value, [field]: 'injected' }).success, false); + } + } +}); + +test('installation pins distinguish organization, installation, version and every epoch', () => { + assert.equal(isSameAppInstallationAuthority(installation, { ...installation }), true); + for (const field of ['org_id', 'app_installation_id', 'app_version_id'] as const) { + assert.equal(isSameAppInstallationAuthority(installation, { ...installation, [field]: 'foreign' }), false); + } + for (const field of ['lifecycle_epoch', 'grant_epoch'] as const) { + assert.equal(isSameAppInstallationAuthority(installation, { ...installation, [field]: installation[field] + 1 }), false); + for (const value of [-1, 0.5, '1', NaN, Infinity, 2_147_483_648]) { + assert.equal(AppInstallationAuthoritySchema.safeParse({ ...installation, [field]: value }).success, false); + } + } +}); + +test('host identity contracts reject missing, padded, oversized and control-character identities', () => { + for (const value of ['', ' org-1', 'org-1 ', 'org\n1', 'x'.repeat(129)]) { + assert.equal(AppInstallationAuthoritySchema.safeParse({ ...installation, org_id: value }).success, false); + } + const { app_version_id: omitted, ...missing } = installation; + assert.equal(AppInstallationAuthoritySchema.safeParse(missing).success, false); + assert.equal(AppInstallationAuthoritySchema.safeParse({ ...installation, ignored: true }).success, false); +}); + +test('Runtime provider identity does not open the legacy capability invocation entrance', () => { + assert.equal(CapabilityProviderIdentitySchema.safeParse({ + org_id: 'org-1', provider_kind: 'app_runtime', provider_instance_id: 'runtime-1', + }).success, true); + const request = { + org_id: 'org-1', actor: { user_id: 'user-1' }, input: {}, + provider: { provider_kind: 'mcp', connection_slug: 'provider-1', operation_name: 'send' }, + }; + assert.equal(CapabilityInvocationRequestSchema.safeParse(request).success, true); + assert.equal(CapabilityInvocationRequestSchema.safeParse({ + ...request, provider: { ...request.provider, provider_kind: 'app_runtime' }, + }).success, false); + assert.equal(CapabilityInvocationProviderRefSchema.safeParse({ + provider_kind: 'mcp', requested_provider_key: 'provider-1', + resolved_provider: { org_id: 'org-1', provider_kind: 'app_runtime', provider_instance_id: 'runtime-1' }, + }).success, false); +}); diff --git a/scripts/gate-g/README.md b/scripts/gate-g/README.md new file mode 100644 index 00000000..58f71ec0 --- /dev/null +++ b/scripts/gate-g/README.md @@ -0,0 +1,30 @@ +# Gate G experiment and acceptance tools + +These fixtures exercise proposed boundaries. They do not implement or certify the production App platform. Internal decisions and execution reports live outside this repository. + +- `experiences/`: loopback browser egress and bounded interaction experiment. +- `runtime/`: separate-process recovery experiment with independent synthetic host/provider ledgers. +- `public/`: transaction/claim experiment on an explicitly assigned disposable PostgreSQL database. +- `required-tests.json`: reviewed inventory of 63 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, and eleven Runtime/public foundation cases. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. +- `verify-upgrade.mjs`: read-only retained-data fingerprints and schema snapshots for the assigned disposable PostgreSQL cluster. Capture a tracked predecessor before candidate upgrades, compare retained columns afterward, and compare candidate fresh/upgrade schemas separately. + +## Capture and check test execution + +From the relevant package, pass reporter flags **before** test file arguments: + +```text +pnpm exec tsx --test --test-concurrency=1 --test-reporter=spec --test-reporter-destination=stdout --test-reporter=../../scripts/gate-g/acceptance-reporter.mjs --test-reporter-destination= test/*.test.ts +``` + +App Kit must first be built. Its packed consumer tests require `npm_execpath` to name the matching pinned pnpm CLI and the pinned dependencies to be available in that CLI's offline cache. A bare `pnpm exec` does not establish `npm_execpath` on every host. The ordinary `pnpm --filter @deft/app-kit test` remains the standard suite command, but appending reporter flags after its test glob does not capture reporter output on the tested tsx version. Do not replace a missing capture with a green console summary. + +From the repository root: + +```text +node scripts/gate-g/check-evidence.mjs scripts/gate-g/required-tests.json app-kit +node --test scripts/gate-g/check-evidence.test.mjs +``` + +The checker requires every inventoried case exactly once, exact source paths rooted in the current checkout, no failures/skips/todos/cancellations, matching execution counts, and a final runner summary. Failed or truncated output fails closed. Additional executed tests must also pass. The JSONL reporter omits test stdout and error details; retain a separate console log for diagnosis. + +This checks execution completeness, not authenticity: JSONL is not a signed attestation. Record the source revision, fixture hashes/diff, runtime versions, command, environment profile and reviewer alongside evidence. A rerun or source change requires a new evidence record. Required cases must never be removed merely to make a gate green. diff --git a/scripts/gate-g/acceptance-reporter.mjs b/scripts/gate-g/acceptance-reporter.mjs new file mode 100644 index 00000000..bac0ad45 --- /dev/null +++ b/scripts/gate-g/acceptance-reporter.mjs @@ -0,0 +1,10 @@ +// Run with node --test --test-reporter= --test-reporter-destination=. +// Only metadata is retained; test stdout can contain sensitive fixture values. +export default async function* acceptanceReporter(events) { + for await (const { type, data } of events) { + if (!['test:pass', 'test:fail', 'test:summary'].includes(type)) continue; + const { name, file, nesting, skip, todo, success, counts, details } = data; + yield `${JSON.stringify({ type, name, file, nesting, skip, todo, success, counts, + testType: details?.type, durationMs: details?.duration_ms })}\n`; + } +} diff --git a/scripts/gate-g/check-evidence.mjs b/scripts/gate-g/check-evidence.mjs new file mode 100644 index 00000000..e16bede3 --- /dev/null +++ b/scripts/gate-g/check-evidence.mjs @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const normalize = (value) => value.replaceAll('\\', '/'); +const caseKey = ({ file, name }) => `${normalize(file)}::${name}`; + +// A green process exit is insufficient: every frozen case must execute and pass. +// This is an execution check, not an attestation or a Gate G certification. +export function checkEvidence(profile, events, { root = process.cwd() } = {}) { + if (!Array.isArray(profile.cases) || profile.cases.length === 0) { + throw new Error('A nonempty, reviewed required-case inventory is mandatory'); + } + if (profile.cases.some((entry) => !entry || typeof entry.file !== 'string' || !entry.file || + typeof entry.name !== 'string' || !entry.name)) throw new Error('Malformed required case'); + const expected = new Set(profile.cases.map((entry) => caseKey({ ...entry, file: resolve(root, entry.file) }))); + if (expected.size !== profile.cases.length) throw new Error('Duplicate required case'); + const errors = []; + const completed = events.filter(({ type, testType }) => + ['test:pass', 'test:fail'].includes(type) && testType !== 'suite'); + for (const event of events) { + if (event.type === 'test:fail') errors.push(`Failed: ${event.name}`); + if (event.skip || event.todo) errors.push(`Not executed: ${event.name}`); + } + const summaries = events.filter(({ type, file, nesting }) => + type === 'test:summary' && !file && (nesting === undefined || nesting === 0)); + if (summaries.length !== 1 || summaries[0].success !== true) { + errors.push('Missing or unsuccessful final runner summary'); + } + if (summaries[0] !== events.at(-1)) errors.push('Runner summary must terminate the evidence stream'); + const counts = summaries[0]?.counts; + const countFields = ['tests', 'passed', 'failed', 'skipped', 'todo', 'cancelled']; + if (!counts || countFields.some((key) => !Number.isSafeInteger(counts[key]) || counts[key] < 0)) { + errors.push('Missing or malformed runner counts'); + } else if (counts.failed || counts.skipped || counts.todo || counts.cancelled) { + errors.push('Runner reports failed, skipped, todo, or cancelled cases'); + } else if (counts.tests !== completed.length || counts.passed !== completed.length) { + errors.push('Runner counts do not match captured test executions'); + } + for (const required of profile.cases) { + const matches = completed.filter((event) => typeof event.file === 'string' && + normalize(resolve(event.file)) === normalize(resolve(root, required.file)) && + event.name === required.name); + if (matches.length !== 1) errors.push(`Expected exactly one execution: ${caseKey(required)} (found ${matches.length})`); + else if (matches[0].type !== 'test:pass' || matches[0].skip || matches[0].todo) { + errors.push(`Required case did not pass: ${caseKey(required)}`); + } + } + return { profile: profile.id, required: expected.size, observed: completed.length, + passed: errors.length === 0, errors }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const [, , manifestPath, profileId, evidencePath] = process.argv; + if (!manifestPath || !profileId || !evidencePath) { + throw new Error('Usage: node check-evidence.mjs manifest.json profile-id results.jsonl'); + } + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const profile = manifest.profiles.find(({ id }) => id === profileId); + if (!profile) throw new Error(`Unknown profile: ${profileId}`); + const events = readFileSync(evidencePath, 'utf8').split(/\r?\n/).filter(Boolean).map(JSON.parse); + const result = checkEvidence(profile, events); + console.log(JSON.stringify(result, null, 2)); + if (!result.passed) process.exitCode = 1; + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/gate-g/check-evidence.test.mjs b/scripts/gate-g/check-evidence.test.mjs new file mode 100644 index 00000000..eabda884 --- /dev/null +++ b/scripts/gate-g/check-evidence.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { checkEvidence } from './check-evidence.mjs'; + +const requiredFile = join(process.cwd(), 'test', 'boundary.test.ts'); +const profile = { id: 'required', cases: [{ file: requiredFile, name: 'denies stale authority' }] }; +const passed = { type: 'test:pass', file: requiredFile, name: 'denies stale authority', testType: 'test' }; +const summary = { type: 'test:summary', nesting: 0, success: true, counts: { tests: 1, passed: 1, failed: 0, skipped: 0, todo: 0, cancelled: 0 } }; + +test('requires every case, final completion and no skipped work', () => { + assert.equal(checkEvidence(profile, [passed, summary]).passed, true); + for (const events of [ + [summary], + [passed], + [summary, passed], + [{ ...passed, skip: 'flag disabled' }, summary], + [{ ...passed, todo: true }, summary], + [{ ...passed, type: 'test:fail' }, summary], + [passed, passed, summary], + [passed, { ...summary, success: false }], + [passed, { ...summary, counts: { cancelled: 1 } }], + [passed, { ...summary, counts: undefined }], + [passed, { ...summary, counts: { ...summary.counts, passed: 100 } }], + [passed, { ...summary, counts: { ...summary.counts, passed: 0 } }], + [{ ...passed, file: join(process.cwd(), 'foreign', 'test', 'boundary.test.ts') }, summary], + [{ ...passed, file: 'wrong/test/boundary.test.ts.evil' }, summary], + ]) assert.equal(checkEvidence(profile, events).passed, false); +}); + +test('rejects empty or duplicated inventories and failures outside required cases', () => { + assert.throws(() => checkEvidence({ cases: [] }, []), /inventory/); + assert.throws(() => checkEvidence({ cases: [profile.cases[0], profile.cases[0]] }, []), /Duplicate/); + assert.equal(checkEvidence(profile, [passed, { type: 'test:fail', name: 'unexpected failure' }, summary]).passed, false); +}); + +test('checks actual Node reporter output, including a green runner with a skipped requirement', () => { + const directory = mkdtempSync(join(tmpdir(), 'deft-evidence-check-')); + try { + const fixture = join(directory, 'probe.test.mjs'); + const reporter = new URL('./acceptance-reporter.mjs', import.meta.url).href; + const inventory = { id: 'probe', cases: [{ file: fixture, name: 'required probe' }] }; + for (const skipped of [false, true]) { + writeFileSync(fixture, `import test from 'node:test'; test('required probe', { skip: ${skipped} }, () => {});`); + const childEnv = { ...process.env }; + delete childEnv.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ['--test', `--test-reporter=${reporter}`, fixture], { + encoding: 'utf8', env: childEnv, timeout: 30_000, + }); + assert.equal(result.status, 0, result.stderr); + const events = result.stdout.trim().split(/\r?\n/).map(JSON.parse); + assert.equal(checkEvidence(inventory, events).passed, !skipped); + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/scripts/gate-g/experiences/README.md b/scripts/gate-g/experiences/README.md new file mode 100644 index 00000000..00e8683a --- /dev/null +++ b/scripts/gate-g/experiences/README.md @@ -0,0 +1,41 @@ +# Gate G DEC-01 first-wave browser experiment + +This is a synthetic localhost fixture, not a Deft Experience Host or public SDK. It never loads a workspace session, credential, external URL, or user data. + +Run from the repository root with Node 22+: + +```powershell +node scripts/gate-g/experiences/browser-check.mjs +``` + +The browser check starts `server.mjs` on `127.0.0.1:4311`, uses the locally installed Playwright package (or the Codex desktop bundle) and Chrome, and writes `browser-results.json` plus desktop, 390 px, and 320 px screenshots to `GATE_G_EVIDENCE_DIR` or `~/Documents/Codex/2026-09-24/deft-gate-g/experiences`. Override `GATE_G_EXPERIENCE_PORT` with a free port in 4311–4319. It exits nonzero if the observed security and interaction assertions fail. + +For manual inspection, run `node scripts/gate-g/experiences/server.mjs` and open `http://127.0.0.1:4311`. The server only binds loopback. `/sink` records synthetic channel markers in memory; `/observations` shows them. `POST /reset` clears them. The iframe uses `sandbox="allow-scripts"`, an opaque origin, a nonce script, and restrictive response CSP. The Worker has a separate response CSP and returns structured view data for host rendering. + +`browser-results.json` distinguishes API-call return values from actual sink hits. A returned WebSocket or EventSource object is not proof that a request escaped. The Worker test proves only the inspected Chrome/localhost channels; it is not a browser-wide no-egress, credential-isolation, deployment, or production certificate. + +## Checkpoint 02: credential and sibling isolation + +`bootstrap-check.mjs` starts a host on `127.0.0.1:4313` and a trusted bootstrap on `localhost:4314`. The bootstrap is inside an opaque `sandbox="allow-scripts"` iframe. It creates a Blob Worker from synthetic author bytes; those bytes never run in its document. The check runs installed Playwright Chromium, Firefox and WebKit, adds a synthetic host-only cookie, and records actual host/app requests, Worker channel probes, sibling BroadcastChannel messages, IndexedDB/CacheStorage writes and reads, and screenshots. + +```powershell +node scripts/gate-g/experiences/bootstrap-check.mjs +$env:GATE_G_BOOTSTRAP_MODE='same-origin' +node scripts/gate-g/experiences/bootstrap-check.mjs +``` + +The second command is a **control** that adds `allow-same-origin` to both frames. It demonstrates which browser state becomes shared. Clear `GATE_G_BOOTSTRAP_MODE` before the opaque run. Results default to the external `~/Documents/Codex/2026-09-24/deft-gate-g/experiences/checkpoint-02/{http-opaque,http-same-origin}` directories. Set `GATE_G_BROWSER` to one of `chromium`, `firefox` or `webkit` to repeat only that browser, and `GATE_G_CHECKPOINT_02_DIR` to preserve a distinct retry. + +For local HTTPS, generate a short-lived, self-signed localhost certificate outside the repository and set these environment variables before running the same check: + +```powershell +python scripts/gate-g/experiences/make-local-cert.py 'C:\temp\gate-g-local-cert' +$env:GATE_G_BOOTSTRAP_TLS='1' +$env:GATE_G_BOOTSTRAP_HOST_PORT='4315' +$env:GATE_G_BOOTSTRAP_APP_PORT='4316' +$env:GATE_G_BOOTSTRAP_CERT='C:\temp\gate-g-local-cert\localhost-cert.pem' +$env:GATE_G_BOOTSTRAP_KEY='C:\temp\gate-g-local-cert\localhost-key.pem' +node scripts/gate-g/experiences/bootstrap-check.mjs +``` + +Playwright ignores this test certificate's trust error. This exercises HTTPS browser behavior, not a real reverse proxy or operator TLS configuration. The synthetic author bundle is embedded without production digest validation. The trusted bootstrap's simple port filter is not a production bridge. diff --git a/scripts/gate-g/experiences/bootstrap-author-worker.js b/scripts/gate-g/experiences/bootstrap-author-worker.js new file mode 100644 index 00000000..eb42a284 --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-author-worker.js @@ -0,0 +1,79 @@ +let siblingChannel = null; +let instanceId = ''; +let appOriginForStorage = ''; +const openDb = () => new Promise((resolve, reject) => { + const request = indexedDB.open('gate-g-probe', 1); + request.onupgradeneeded = () => request.result.createObjectStore('markers'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); +}); +const txDone = transaction => new Promise((resolve, reject) => { + transaction.oncomplete = resolve; + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); +}); +const attempt = async (name, fn, results) => { + try { results[name] = String(await fn()); } + catch (error) { results[name] = `blocked:${error?.name || 'Error'}`; } +}; +async function run(data) { + instanceId = data.id; + appOriginForStorage = data.appOrigin; + const results = { locationOrigin: location.origin, selfOrigin: self.origin, secureContext: isSecureContext }; + await attempt('document', () => document.cookie, results); + await attempt('localStorage', () => localStorage.length, results); + await attempt('indexedDB', async () => { + const db = await openDb(); + const transaction = db.transaction('markers', 'readwrite'); + transaction.objectStore('markers').put(instanceId, 'shared'); + await txDone(transaction); + db.close(); + return 'written'; + }, results); + await attempt('cacheStorage', async () => { const cache = await caches.open('gate-g-probe'); await cache.put(`${data.appOrigin}/marker`, new Response(instanceId)); return 'written'; }, results); + await attempt('broadcastChannel', () => { + siblingChannel = new BroadcastChannel('gate-g-sibling-probe'); + siblingChannel.onmessage = event => postMessage({ kind: 'broadcast-received', from: event.data?.marker }); + return 'opened'; + }, results); + await attempt('fetch-host', () => fetch(`${data.hostOrigin}/sink?channel=bootstrap-fetch-host`), results); + await attempt('fetch-app', () => fetch(`${data.appOrigin}/sink?channel=bootstrap-fetch-app`), results); + await attempt('websocket-app', () => new WebSocket(`${new URL(data.appOrigin).protocol === 'https:' ? 'wss:' : 'ws:'}//${new URL(data.appOrigin).host}/sink?channel=bootstrap-websocket`), results); + await attempt('eventsource-app', () => new EventSource(`${data.appOrigin}/sink?channel=bootstrap-eventsource`), results); + await attempt('importScripts-app', () => importScripts(`${data.appOrigin}/sink?channel=bootstrap-importscripts`), results); + await attempt('nested-worker-url', () => new Worker(`${data.appOrigin}/sink?channel=bootstrap-nested-worker`), results); + await attempt('nested-blob-worker', () => new Promise((resolve, reject) => { + const source = `onmessage=async event=>{const out={};try{await fetch(event.data.fetchUrl);out.fetch='allowed'}catch(error){out.fetch='blocked:'+error.name}try{importScripts(event.data.scriptUrl);out.importScripts='allowed'}catch(error){out.importScripts='blocked:'+error.name}postMessage(out)}`; + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); + const child = new Worker(url); + URL.revokeObjectURL(url); + const timeout = setTimeout(() => { child.terminate(); reject(new Error('nested timeout')); }, 2000); + child.onmessage = event => { clearTimeout(timeout); child.terminate(); resolve(JSON.stringify(event.data)); }; + child.onerror = error => { clearTimeout(timeout); child.terminate(); reject(error); }; + child.postMessage({ fetchUrl: `${data.appOrigin}/sink?channel=bootstrap-nested-blob-fetch`, scriptUrl: `${data.appOrigin}/sink?channel=bootstrap-nested-blob-importscripts` }); + }), results); + await attempt('navigation', () => { location.href = `${data.appOrigin}/sink?channel=bootstrap-navigation`; return location.href; }, results); + postMessage({ kind: 'probe', id: instanceId, results }); +} +onmessage = event => { + if (event.data?.kind === 'start') { run(event.data); return; } + if (event.data?.kind === 'broadcast') { siblingChannel?.postMessage({ marker: event.data.marker }); return; } + if (event.data?.kind === 'storage-read') { + (async () => { + const result = { kind: 'storage-read', id: instanceId }; + try { + const cache = await caches.open('gate-g-probe'); + const response = await cache.match(`${appOriginForStorage}/marker`); + result.cacheValue = response ? await response.text() : null; + } catch (error) { result.cacheError = error?.name || 'Error'; } + try { + const db = await openDb(); + const transaction = db.transaction('markers', 'readonly'); + const request = transaction.objectStore('markers').get('shared'); + result.indexedDbValue = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result ?? null); request.onerror = () => reject(request.error); }); + db.close(); + } catch (error) { result.indexedDbError = error?.name || 'Error'; } + postMessage(result); + })(); + } +}; diff --git a/scripts/gate-g/experiences/bootstrap-check.mjs b/scripts/gate-g/experiences/bootstrap-check.mjs new file mode 100644 index 00000000..d9b2b883 --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-check.mjs @@ -0,0 +1,93 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +let playwright; +try { playwright = require('playwright'); } +catch { playwright = require(join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); } +const hostPort = Number(process.env.GATE_G_BOOTSTRAP_HOST_PORT || 4313); +const appPort = Number(process.env.GATE_G_BOOTSTRAP_APP_PORT || 4314); +const useHttps = process.env.GATE_G_BOOTSTRAP_TLS === '1'; +const mode = process.env.GATE_G_BOOTSTRAP_MODE === 'same-origin' ? 'same-origin' : 'opaque'; +const scheme = useHttps ? 'https' : 'http'; +const hostOrigin = `${scheme}://127.0.0.1:${hostPort}`; +const appOrigin = `${scheme}://localhost:${appPort}`; +const evidence = process.env.GATE_G_CHECKPOINT_02_DIR || join(homedir(), 'Documents/Codex/2026-09-24/deft-gate-g/experiences/checkpoint-02', `${scheme}-${mode}`); +await mkdir(evidence, { recursive: true }); +const server = spawn(process.execPath, [fileURLToPath(new URL('./bootstrap-server.mjs', import.meta.url))], { + env: { ...process.env, GATE_G_BOOTSTRAP_HOST_PORT: String(hostPort), GATE_G_BOOTSTRAP_APP_PORT: String(appPort) }, + stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, +}); +let log = ''; +server.stdout.on('data', chunk => log += chunk); +server.stderr.on('data', chunk => log += chunk); +const api = await playwright.request.newContext({ ignoreHTTPSErrors: true }); +async function ready(url) { + for (let i = 0; i < 60; i++) { + try { if ((await api.get(url)).status() < 500) return; } catch {} + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`server not ready: ${url}`); +} +const report = { started: new Date().toISOString(), hostOrigin, appOrigin, mode, browsers: [], serverLog: '', pass: false }; +try { + await Promise.all([ready(hostOrigin), ready(`${appOrigin}/bootstrap`)]); + const names = process.env.GATE_G_BROWSER ? [process.env.GATE_G_BROWSER] : ['chromium', 'firefox', 'webkit']; + for (const name of names) { + await api.post(`${hostOrigin}/reset`); + const sample = { name, version: null, results: null, hits: [], errors: [], screenshot: null }; + let browser; + try { + browser = await playwright[name].launch({ headless: true }); + sample.version = browser.version(); + const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, ignoreHTTPSErrors: true }); + await context.addCookies([{ name: 'synthetic_host_session', value: 'host-cookie-marker', url: hostOrigin, httpOnly: true, sameSite: 'Lax' }]); + const page = await context.newPage(); + page.on('console', message => { if (message.type() === 'error') sample.errors.push(message.text()); }); + page.on('pageerror', error => sample.errors.push(error.message)); + await page.goto(mode === 'same-origin' ? `${hostOrigin}/?mode=same-origin` : hostOrigin, { waitUntil: 'domcontentloaded' }); + await page.click('#start'); + try { await page.waitForFunction(() => (window.__bootstrapProbe.a && window.__bootstrapProbe.b) || window.__bootstrapProbe.errors.length > 0, { timeout: 10000 }); } + catch { sample.errors.push('Timed out waiting for both author Workers'); } + if (await page.evaluate(() => Boolean(window.__bootstrapProbe.a && window.__bootstrapProbe.b))) { + await page.click('#broadcast'); + await page.waitForTimeout(500); + await page.click('#storage-read'); + try { await page.waitForFunction(() => window.__bootstrapProbe.storageReads.length >= 2, { timeout: 5000 }); } + catch { sample.errors.push('Timed out waiting for sibling storage reads'); } + } + sample.results = await page.evaluate(() => window.__bootstrapProbe); + sample.hits = await (await api.get(`${hostOrigin}/observations`)).json(); + sample.screenshot = join(evidence, `${name}.png`); + await page.screenshot({ path: sample.screenshot, fullPage: true }); + sample.hostCookieSeen = sample.hits.some(hit => hit.side === 'host' && hit.path === '/' && hit.cookiePresent); + sample.appCookieSeen = sample.hits.some(hit => hit.side === 'app' && hit.path === '/bootstrap' && hit.cookiePresent); + sample.authorEgress = sample.hits.filter(hit => hit.channel?.startsWith('bootstrap-')); + const reads = sample.results?.storageReads || []; + sample.siblingIsolated = sample.results?.received?.length === 0 && reads.length === 2 && reads.every(read => read.indexedDbError === 'SecurityError') && + (reads.every(read => read.cacheError) || (reads.some(read => read.id === 'a' && read.cacheValue === 'a') && reads.some(read => read.id === 'b' && read.cacheValue === 'b'))); + sample.sharedControl = sample.results?.received?.some(item => item.id === 'a' && item.from === 'from-b') && + sample.results?.received?.some(item => item.id === 'b' && item.from === 'from-a') && reads.length === 2 && + reads.every(read => read.indexedDbValue && read.indexedDbValue === reads[0].indexedDbValue && read.cacheValue && read.cacheValue === reads[0].cacheValue); + await page.click('#revoke'); + sample.revokedFrames = await page.locator('iframe').count(); + await context.close(); + } catch (error) { sample.failure = error.stack || String(error); } + finally { if (browser) await browser.close(); } + report.browsers.push(sample); + } + report.pass = report.browsers.every(sample => sample.results?.a && sample.results?.b && sample.hostCookieSeen && !sample.appCookieSeen && sample.authorEgress.length === 0 && sample.revokedFrames === 0 && (mode === 'opaque' ? sample.siblingIsolated : sample.sharedControl)); + if (!report.pass) process.exitCode = 1; +} catch (error) { report.error = error.stack || String(error); process.exitCode = 1; } +finally { + server.kill(); + await api.dispose(); + report.finished = new Date().toISOString(); + report.serverLog = log; + await writeFile(join(evidence, 'bootstrap-results.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ pass: report.pass, mode, browsers: report.browsers.map(x => ({ name: x.name, version: x.version, workerStarted: Boolean(x.results?.a && x.results?.b), hostCookieSeen: x.hostCookieSeen, appCookieSeen: x.appCookieSeen, authorEgress: x.authorEgress?.map(y => y.channel), siblingIsolated: x.siblingIsolated, sharedControl: x.sharedControl, received: x.results?.received, storageReads: x.results?.storageReads, errors: x.errors.slice(0, 5), failure: x.failure })), error: report.error }, null, 2)); +} diff --git a/scripts/gate-g/experiences/bootstrap-host.html b/scripts/gate-g/experiences/bootstrap-host.html new file mode 100644 index 00000000..f9aad13e --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-host.html @@ -0,0 +1,5 @@ +Gate G bootstrap comparison + +

Credential-isolated Worker candidate

Host at 127.0.0.1; trusted bootstrap at localhost in opaque sandbox; author Worker created from a synthetic embedded blob. Two sibling frames test shared state and messages.

+ +

Instance A

Instance B

Network ledger

diff --git a/scripts/gate-g/experiences/bootstrap-host.js b/scripts/gate-g/experiences/bootstrap-host.js new file mode 100644 index 00000000..49b42706 --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-host.js @@ -0,0 +1,36 @@ +const state = window.__bootstrapProbe = { a: null, b: null, received: [], storageReads: [], errors: [] }; +const appOrigin = '__APP_ORIGIN__'; +const sameOriginControl = new URLSearchParams(location.search).get('mode') === 'same-origin'; +const ports = new Map(); +const $ = id => document.getElementById(id); +const show = () => { + $('result-a').textContent = JSON.stringify({ probe: state.a, received: state.received.filter(x => x.id === 'a'), storage: state.storageReads.filter(x => x.id === 'a') }, null, 2); + $('result-b').textContent = JSON.stringify({ probe: state.b, received: state.received.filter(x => x.id === 'b'), storage: state.storageReads.filter(x => x.id === 'b') }, null, 2); +}; +async function hits() { $('hits').textContent = JSON.stringify(await (await fetch('/observations')).json(), null, 2); } +$('refresh').onclick = hits; +function frame(id) { + const iframe = document.createElement('iframe'); + iframe.sandbox = sameOriginControl ? 'allow-scripts allow-same-origin' : 'allow-scripts'; + iframe.title = `Trusted bootstrap ${id}`; + iframe.referrerPolicy = 'no-referrer'; + iframe.onload = () => { + iframe.onload = null; + const channel = new MessageChannel(); + ports.set(id, channel.port1); + channel.port1.onmessage = message => { + if (message.data?.kind === 'probe') state[id] = message.data.results; + else if (message.data?.kind === 'broadcast-received') state.received.push({ id, from: message.data.from }); + else if (message.data?.kind === 'storage-read') state.storageReads.push({ id, cacheValue: message.data.cacheValue, cacheError: message.data.cacheError, indexedDbValue: message.data.indexedDbValue, indexedDbError: message.data.indexedDbError }); + else if (message.data?.kind === 'bootstrap-error') state.errors.push({ id, reason: message.data.reason }); + show(); hits(); + }; + iframe.contentWindow.postMessage({ kind: 'start', id }, '*', [channel.port2]); + }; + iframe.src = `${appOrigin}/bootstrap?instance=${id}`; + $(`frame-${id}`).append(iframe); +} +$('start').onclick = () => { $('start').disabled = true; frame('a'); frame('b'); $('broadcast').disabled = false; $('storage-read').disabled = false; $('revoke').disabled = false; }; +$('broadcast').onclick = () => { ports.get('a')?.postMessage({ kind: 'broadcast', marker: 'from-a' }); ports.get('b')?.postMessage({ kind: 'broadcast', marker: 'from-b' }); }; +$('storage-read').onclick = () => { ports.get('a')?.postMessage({ kind: 'storage-read' }); ports.get('b')?.postMessage({ kind: 'storage-read' }); }; +$('revoke').onclick = () => { for (const port of ports.values()) port.postMessage({ kind: 'revoke' }); ports.clear(); $('frame-a').replaceChildren(); $('frame-b').replaceChildren(); $('broadcast').disabled = true; $('storage-read').disabled = true; $('revoke').disabled = true; }; diff --git a/scripts/gate-g/experiences/bootstrap-server.mjs b/scripts/gate-g/experiences/bootstrap-server.mjs new file mode 100644 index 00000000..203d084c --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-server.mjs @@ -0,0 +1,81 @@ +import { createServer as createHttpServer } from 'node:http'; +import { createServer as createHttpsServer } from 'node:https'; +import { readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const hostPort = Number(process.env.GATE_G_BOOTSTRAP_HOST_PORT || 4313); +const appPort = Number(process.env.GATE_G_BOOTSTRAP_APP_PORT || 4314); +const useHttps = process.env.GATE_G_BOOTSTRAP_TLS === '1'; +const scheme = useHttps ? 'https' : 'http'; +const hostOrigin = `${scheme}://127.0.0.1:${hostPort}`; +const appOrigin = `${scheme}://localhost:${appPort}`; +const createServer = useHttps + ? handler => createHttpsServer({ key: readFileSync(process.env.GATE_G_BOOTSTRAP_KEY), cert: readFileSync(process.env.GATE_G_BOOTSTRAP_CERT) }, handler) + : createHttpServer; +const hits = []; +const record = (side, req, url, method = req.method) => { + const hit = { side, path: url.pathname, channel: url.searchParams.get('channel'), method, cookiePresent: Boolean(req.headers.cookie), at: new Date().toISOString() }; + hits.push(hit); + console.log(JSON.stringify({ event: 'bootstrap-request', ...hit })); +}; +const common = res => { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'); +}; +const sink = (side, req, res, url) => { + record(side, req, url); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('synthetic sink'); +}; +const host = createServer(async (req, res) => { + common(res); + const url = new URL(req.url || '/', hostOrigin); + if (url.pathname === '/observations') { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(hits)); return; } + if (url.pathname === '/reset' && req.method === 'POST') { hits.length = 0; res.end('ok'); return; } + if (url.pathname === '/sink') { sink('host', req, res, url); return; } + if (url.pathname === '/' || url.pathname === '/bootstrap-host.js') { + record('host', req, url); + res.setHeader('Content-Type', url.pathname === '/' ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8'); + const filename = url.pathname === '/' ? 'bootstrap-host.html' : 'bootstrap-host.js'; + res.end((await readFile(join(here, filename), 'utf8')).replaceAll('__APP_ORIGIN__', appOrigin)); + return; + } + res.writeHead(404); res.end('not found'); +}); +host.on('upgrade', (req, socket) => { + const url = new URL(req.url || '/', hostOrigin); + if (url.pathname === '/sink') record('host', req, url, 'UPGRADE'); + socket.destroy(); +}); + +const app = createServer(async (req, res) => { + common(res); + const url = new URL(req.url || '/', appOrigin); + if (url.pathname === '/sink') { sink('app', req, res, url); return; } + if (url.pathname !== '/bootstrap') { res.writeHead(404); res.end('not found'); return; } + record('app', req, url); + const nonce = randomBytes(18).toString('base64'); + const csp = `default-src 'none'; script-src 'nonce-${nonce}'; worker-src blob:; connect-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors ${hostOrigin}`; + res.setHeader('Content-Security-Policy', csp); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + const authorBase64 = (await readFile(join(here, 'bootstrap-author-worker.js'))).toString('base64'); + const bootstrap = await readFile(join(here, 'bootstrap-trusted.js'), 'utf8'); + res.end(`Trusted app bootstrap

Trusted bootstrap only

`); +}); +app.on('upgrade', (req, socket) => { + const url = new URL(req.url || '/', appOrigin); + if (url.pathname === '/sink') record('app', req, url, 'UPGRADE'); + socket.destroy(); +}); + +await Promise.all([ + new Promise(resolve => host.listen(hostPort, '127.0.0.1', resolve)), + new Promise(resolve => app.listen(appPort, 'localhost', resolve)), +]); +console.log(`Gate G bootstrap host ${hostOrigin} app ${appOrigin}`); diff --git a/scripts/gate-g/experiences/bootstrap-trusted.js b/scripts/gate-g/experiences/bootstrap-trusted.js new file mode 100644 index 00000000..b4a9b239 --- /dev/null +++ b/scripts/gate-g/experiences/bootstrap-trusted.js @@ -0,0 +1,28 @@ +// This script is host-owned. The author bytes execute only inside the Worker. +addEventListener('message', event => { + if (event.source !== parent || event.origin !== HOST_ORIGIN || event.data?.kind !== 'start' || !event.ports[0]) return; + const port = event.ports[0]; + let worker; + try { + const bytes = Uint8Array.from(atob(AUTHOR_BASE64), char => char.charCodeAt(0)); + const blob = new Blob([bytes], { type: 'text/javascript' }); + const url = URL.createObjectURL(blob); + worker = new Worker(url); + URL.revokeObjectURL(url); + } catch (error) { + port.postMessage({ kind: 'bootstrap-error', reason: `${error.name}: ${error.message}` }); + return; + } + let count = 0; + worker.onmessage = message => { + if (++count > 100 || !['probe', 'broadcast-received', 'storage-read'].includes(message.data?.kind)) return; + port.postMessage(message.data); + }; + worker.onerror = error => port.postMessage({ kind: 'bootstrap-error', reason: error.message }); + port.onmessage = command => { + if (command.data?.kind === 'broadcast') worker.postMessage({ kind: 'broadcast', marker: String(command.data.marker).slice(0, 40) }); + if (command.data?.kind === 'storage-read') worker.postMessage({ kind: 'storage-read' }); + if (command.data?.kind === 'revoke') { worker.terminate(); port.close(); } + }; + worker.postMessage({ kind: 'start', id: String(event.data.id).slice(0, 10), hostOrigin: HOST_ORIGIN, appOrigin: location.origin }); +}, { once: true }); diff --git a/scripts/gate-g/experiences/browser-check.mjs b/scripts/gate-g/experiences/browser-check.mjs new file mode 100644 index 00000000..15655769 --- /dev/null +++ b/scripts/gate-g/experiences/browser-check.mjs @@ -0,0 +1,110 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const require = createRequire(import.meta.url); +let playwright; +try { playwright = require('playwright'); } +catch { + playwright = require(join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); +} +const port = Number(process.env.GATE_G_EXPERIENCE_PORT || 4311); +const origin = `http://127.0.0.1:${port}`; +const evidence = process.env.GATE_G_EVIDENCE_DIR || join(homedir(), 'Documents/Codex/2026-09-24/deft-gate-g/experiences'); +await mkdir(evidence, { recursive: true }); +const server = spawn(process.execPath, [new URL('./server.mjs', import.meta.url).pathname.replace(/^\/(?=[A-Za-z]:)/, '')], { + env: { ...process.env, GATE_G_EXPERIENCE_PORT: String(port) }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, +}); +let serverLog = ''; +server.stdout.on('data', chunk => serverLog += chunk); +server.stderr.on('data', chunk => serverLog += chunk); +async function ready() { + for (let i = 0; i < 60; i++) { + try { if ((await fetch(origin)).ok) return; } catch {} + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('fixture server did not start'); +} +const assert = (condition, message) => { if (!condition) throw new Error(message); }; +let browser; +const report = { started: new Date().toISOString(), origin, browser: null, samples: [], serverLog: '', pass: false }; +try { + await ready(); + browser = await playwright.chromium.launch({ channel: 'chrome', headless: true }); + report.browser = browser.version(); + for (const [label, width, height] of [['desktop', 1280, 900], ['mobile390', 390, 844], ['mobile320', 320, 720]]) { + await fetch(`${origin}/reset`, { method: 'POST' }); + const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: false }); + const page = await context.newPage(); + const consoleErrors = []; + page.on('console', item => { if (item.type() === 'error') consoleErrors.push(item.text()); }); + await page.goto(origin, { waitUntil: 'domcontentloaded' }); + await page.click('#run-iframe'); + await page.waitForFunction(() => window.__gateG.iframe !== null, { timeout: 10000 }); + await page.click('#run-worker'); + await page.waitForFunction(() => window.__gateG.worker !== null, { timeout: 10000 }); + await page.waitForTimeout(800); + const before = await page.evaluate(() => window.__gateG); + const hits = await (await fetch(`${origin}/observations`)).json(); + assert(hits.some(hit => hit.channel?.startsWith('iframe-')), `${label}: no iframe egress reached sink`); + assert(hits.some(hit => hit.channel === 'iframe-self-navigation'), `${label}: isolated iframe self-navigation did not reach sink`); + assert(!hits.some(hit => hit.channel?.startsWith('worker-')), `${label}: Worker channel reached sink`); + const updateStart = Date.now(); + await page.getByRole('button', { name: 'Add record' }).click(); + await page.getByRole('button', { name: 'New 4' }).click(); + await page.getByRole('heading', { name: 'New 4' }).waitFor({ state: 'visible' }); + const updateLatencyMs = Date.now() - updateStart; + await page.getByRole('textbox', { name: 'Text editor' }).fill('Edited synthetic draft'); + await page.getByRole('textbox', { name: 'Text editor' }).press('Tab'); + await page.locator('[data-focus-key="grid-1-1"]').focus(); + await page.keyboard.press('ArrowRight'); + const gridFocus = await page.evaluate(() => document.activeElement?.dataset?.focusKey); + assert(gridFocus === 'grid-1-2', `${label}: grid keyboard navigation failed`); + await page.locator('[data-focus-key="grid-1-1"]').fill('41'); + await page.locator('[data-focus-key="grid-1-1"]').press('Tab'); + await page.waitForFunction(() => window.__gateG.view?.grid?.[1]?.[1] === '41'); + await page.locator('[data-focus-key="grid-2-0"]').evaluate(node => { + const data = new DataTransfer(); data.setData('text/plain', 'Pasted\t7\nNext\t9'); + node.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data })); + }); + await page.waitForFunction(() => window.__gateG.view?.grid?.[2]?.[0] === 'Pasted' && window.__gateG.view?.grid?.[3]?.[1] === '9'); + const canvasBox = await page.locator('canvas').boundingBox(); + await page.mouse.move(canvasBox.x + 20, canvasBox.y + 20); + await page.mouse.down(); await page.mouse.move(canvasBox.x + Math.min(180, canvasBox.width - 20), canvasBox.y + 75, { steps: 10 }); await page.mouse.up(); + await page.waitForFunction(() => window.__gateG.view?.strokes?.length > 1); + assert(await page.evaluate(() => window.__gateG.view?.draft === 'Edited synthetic draft'), `${label}: editor value did not persist in Worker`); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); + const screenshot = join(evidence, `${label}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + const acceptedBeforeSpoof = await page.evaluate(() => window.__gateG.messages); + await page.evaluate(() => window.postMessage({ kind: 'view', view: { rows: [{ id: 'spoof', name: 'Spoofed' }] } }, '*')); + assert(await page.evaluate(() => window.__gateG.messages) === acceptedBeforeSpoof, `${label}: window message reached Worker receiver`); + await page.click('#flood-worker'); + await page.waitForFunction(() => window.__gateG.rejected > 0); + const rejectedFlood = await page.evaluate(() => window.__gateG.rejected); + await page.click('#late-worker'); + await page.click('#revoke-worker'); + assert(await page.locator('#worker-ui').textContent() === '', `${label}: revocation did not remove UI`); + const after = await page.evaluate(() => window.__gateG); + await page.click('#run-worker'); + await page.waitForFunction(() => window.__gateG.view?.rows?.length === 3); + await page.waitForTimeout(450); + assert(!(await page.locator('#worker-ui').textContent()).includes('Late stale view'), `${label}: old Worker populated replacement session`); + const afterRestart = await page.evaluate(() => ({ session: window.__gateG.session, rejected: window.__gateG.rejected, rows: window.__gateG.view?.rows?.length })); + report.samples.push({ label, viewport: { width, height }, before, after, afterRestart, hits, gridFocus, overflow, updateLatencyMs, rejectedFlood, consoleErrors, screenshot }); + await context.close(); + } + report.pass = true; +} catch (error) { + report.error = error.stack || String(error); + process.exitCode = 1; +} finally { + if (browser) await browser.close(); + server.kill(); + report.finished = new Date().toISOString(); + report.serverLog = serverLog; + await writeFile(join(evidence, 'browser-results.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ pass: report.pass, browser: report.browser, samples: report.samples.map(x => ({ label: x.label, hits: x.hits.map(y => y.channel), overflow: x.overflow, messages: x.before.messages })), error: report.error }, null, 2)); +} diff --git a/scripts/gate-g/experiences/host.js b/scripts/gate-g/experiences/host.js new file mode 100644 index 00000000..3dc09cb2 --- /dev/null +++ b/scripts/gate-g/experiences/host.js @@ -0,0 +1,192 @@ +const $ = id => document.getElementById(id); +const telemetry = window.__gateG = { iframe: null, worker: null, rejected: 0, messages: 0, session: 0 }; +let activeWorker = null; +let currentView = null; +let rateWindow = performance.now(); +let rateCount = 0; + +const write = (id, value) => { $(id).textContent = JSON.stringify(value, null, 2); }; +async function refreshHits() { write('hits', await (await fetch('/observations')).json()); } +$('refresh-hits').onclick = refreshHits; + +$('run-iframe').onclick = () => { + $('run-iframe').disabled = true; + $('iframe-status').textContent = 'Running browser probes…'; + const frame = document.createElement('iframe'); + frame.title = 'Sandboxed synthetic author HTML'; + frame.sandbox = 'allow-scripts'; + frame.referrerPolicy = 'no-referrer'; + frame.style.cssText = 'width:100%;height:70px;border:1px solid #6582a6;background:white'; + frame.onload = () => { + frame.onload = null; + const channel = new MessageChannel(); + channel.port1.onmessage = event => { + if (event.data?.kind !== 'results') return; + telemetry.iframe = event.data.results; + write('iframe-results', event.data.results); + $('iframe-status').textContent = 'Probe responses received; inspect sink for actual requests.'; + setTimeout(refreshHits, 350); + }; + frame.contentWindow.postMessage({ kind: 'start' }, '*', [channel.port2]); + }; + frame.src = '/iframe'; + $('iframe-container').append(frame); + const navigationFrame = document.createElement('iframe'); + navigationFrame.title = 'Navigation-only synthetic author HTML'; + navigationFrame.sandbox = 'allow-scripts'; + navigationFrame.referrerPolicy = 'no-referrer'; + navigationFrame.style.cssText = 'width:100%;height:45px;border:1px solid #6582a6;background:white'; + navigationFrame.src = '/iframe?mode=navigation'; + $('iframe-container').append(navigationFrame); +}; + +function send(action, extra = {}) { + if (activeWorker) activeWorker.postMessage({ kind: 'event', action, ...extra }); +} +const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = String(text).slice(0, 2000); + return node; +}; +function draw(canvas, strokes) { + const context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + context.strokeStyle = '#1756a9'; + context.lineWidth = 3; + context.beginPath(); + strokes.forEach((point, index) => { + const x = point.x * canvas.width; + const y = point.y * canvas.height; + if (index) context.lineTo(x, y); else context.moveTo(x, y); + }); + context.stroke(); + for (const point of strokes) { + context.beginPath(); + context.arc(point.x * canvas.width, point.y * canvas.height, 3, 0, Math.PI * 2); + context.fillStyle = '#1756a9'; + context.fill(); + } +} +function validView(view) { + const short = (value, max) => typeof value === 'string' && value.length <= max; + const validStrokes = strokes => Array.isArray(strokes) && strokes.length <= 200 && + strokes.every(point => point && Number.isFinite(point.x) && point.x >= 0 && point.x <= 1 && Number.isFinite(point.y) && point.y >= 0 && point.y <= 1); + return view && typeof view === 'object' && + Array.isArray(view.rows) && view.rows.length <= 100 && + view.rows.every(row => row && short(row.id, 80) && short(row.name, 200)) && + short(view.selected, 80) && + (view.detail === null || (view.detail && short(view.detail.name, 200) && short(view.detail.note, 2000))) && + Array.isArray(view.grid) && view.grid.length <= 30 && + view.grid.every(row => Array.isArray(row) && row.length === 3 && row.every(cell => short(cell, 100))) && + short(view.draft, 2000) && + validStrokes(view.strokes); +} +function render(view) { + if (!validView(view)) { telemetry.rejected++; return; } + const root = $('worker-ui'); + const active = document.activeElement; + const focusKey = active?.dataset?.focusKey; + const selectionStart = active instanceof HTMLTextAreaElement ? active.selectionStart : null; + root.replaceChildren(); + const shell = element('div', 'app-shell'); + const list = element('div', 'list'); + const add = element('button', '', 'Add record'); add.onclick = () => send('add'); list.append(add); + for (const row of view.rows) { + const button = element('button', '', row.name); + button.dataset.focusKey = `row-${row.id}`; + button.setAttribute('aria-current', String(row.id === view.selected)); + button.onclick = () => send('select', { id: row.id }); + list.append(button); + } + const detail = element('div', 'detail'); + detail.append(element('h3', '', view.detail?.name || 'No selection'), element('p', '', view.detail?.note || '')); + const editor = element('textarea', 'field'); + editor.setAttribute('aria-label', 'Text editor'); editor.dataset.focusKey = 'editor'; + editor.rows = 4; editor.value = view.draft; + editor.oninput = () => send('draft', { value: editor.value }); + detail.append(editor); + shell.append(list, detail); root.append(shell); + root.append(element('h3', '', 'Editable grid')); + const grid = element('div', 'grid'); grid.setAttribute('role', 'grid'); + view.grid.forEach((row, r) => row.forEach((cell, c) => { + const input = element('input', 'field'); input.value = String(cell).slice(0, 100); + input.setAttribute('aria-label', `Grid row ${r + 1} column ${c + 1}`); + input.dataset.focusKey = `grid-${r}-${c}`; + input.onchange = () => send('grid', { row: r, column: c, value: input.value }); + input.onkeydown = event => { + const offsets = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }; + if (!offsets[event.key]) return; + event.preventDefault(); + const [dr, dc] = offsets[event.key]; + grid.querySelector(`[data-focus-key="grid-${Math.max(0, Math.min(view.grid.length - 1, r + dr))}-${Math.max(0, Math.min(2, c + dc))}"]`)?.focus(); + }; + input.onpaste = event => { + const cells = event.clipboardData.getData('text/plain').split(/\r?\n/).slice(0, 20).map(x => x.split('\t').slice(0, 3)); + if (cells.length < 2 && cells[0].length < 2) return; + event.preventDefault(); + send('grid-paste', { row: r, column: c, values: cells.map(line => line.map(value => value.slice(0, 100))) }); + }; + grid.append(input); + })); + root.append(grid, element('h3', '', 'Canvas-like pointer input')); + const canvas = element('canvas', 'canvas'); canvas.width = 620; canvas.height = 150; + canvas.setAttribute('aria-label', 'Pointer drawing surface'); + canvas.onpointerdown = event => { canvas.setPointerCapture(event.pointerId); canvas.onpointermove(event); }; + canvas.onpointermove = event => { + if (event.buttons !== 1) return; + const box = canvas.getBoundingClientRect(); + send('draw', { x: (event.clientX - box.left) / box.width, y: (event.clientY - box.top) / box.height }); + }; + root.append(canvas); + draw(canvas, view.strokes); + if (focusKey) { + const restored = [...root.querySelectorAll('[data-focus-key]')].find(node => node.dataset.focusKey === focusKey); + restored?.focus(); + if (selectionStart !== null && restored instanceof HTMLTextAreaElement) restored.setSelectionRange(selectionStart, selectionStart); + } +} + +$('run-worker').onclick = () => { + if (activeWorker) return; + const session = ++telemetry.session; + rateWindow = performance.now(); rateCount = 0; + const worker = new Worker('/worker.js'); + activeWorker = worker; + $('run-worker').disabled = true; $('revoke-worker').disabled = false; + $('flood-worker').disabled = false; $('late-worker').disabled = false; + $('worker-status').textContent = `Session ${session} active`; + worker.onmessage = event => { + if (activeWorker !== worker || telemetry.session !== session) { telemetry.rejected++; return; } + const now = performance.now(); + if (now - rateWindow > 1000) { rateWindow = now; rateCount = 0; } + if (++rateCount > 100) { telemetry.rejected++; return; } + let length; + try { length = JSON.stringify(event.data).length; } + catch { telemetry.rejected++; return; } + if (length > 65536) { telemetry.rejected++; return; } + telemetry.messages++; + if (event.data?.kind === 'probe') { telemetry.worker = event.data.results; write('worker-results', event.data.results); refreshHits(); } + if (event.data?.kind === 'strokes' && Array.isArray(event.data.strokes) && event.data.strokes.length <= 200 && event.data.strokes.every(point => point && Number.isFinite(point.x) && point.x >= 0 && point.x <= 1 && Number.isFinite(point.y) && point.y >= 0 && point.y <= 1)) { + if (currentView) { currentView.strokes = event.data.strokes; telemetry.view = currentView; } + const canvas = $('worker-ui').querySelector('canvas'); + if (canvas) draw(canvas, event.data.strokes); + } + if (event.data?.kind === 'view') { + if (!validView(event.data.view)) { telemetry.rejected++; return; } + currentView = event.data.view; telemetry.view = currentView; render(currentView); + } + }; + worker.postMessage({ kind: 'start' }); +}; +$('flood-worker').onclick = () => activeWorker?.postMessage({ kind: 'flood' }); +$('late-worker').onclick = () => activeWorker?.postMessage({ kind: 'late' }); +$('revoke-worker').onclick = () => { + if (!activeWorker) return; + activeWorker.terminate(); activeWorker = null; telemetry.session++; + currentView = null; $('worker-ui').replaceChildren(); + telemetry.view = null; + $('worker-status').textContent = 'Session revoked; author logic terminated'; + $('run-worker').disabled = false; $('revoke-worker').disabled = true; + $('flood-worker').disabled = true; $('late-worker').disabled = true; +}; diff --git a/scripts/gate-g/experiences/iframe.html b/scripts/gate-g/experiences/iframe.html new file mode 100644 index 00000000..098faf18 --- /dev/null +++ b/scripts/gate-g/experiences/iframe.html @@ -0,0 +1,2 @@ +Sandboxed author HTML +

Opaque-origin author HTML fixture

diff --git a/scripts/gate-g/experiences/iframe.js b/scripts/gate-g/experiences/iframe.js new file mode 100644 index 00000000..b17c595b --- /dev/null +++ b/scripts/gate-g/experiences/iframe.js @@ -0,0 +1,32 @@ +const sink = channel => `/sink?channel=iframe-${channel}`; +addEventListener('message', async event => { + if (event.data?.kind !== 'start' || !event.ports[0]) return; + const port = event.ports[0]; + const results = {}; + const attempt = async (name, fn) => { + try { results[name] = String(await fn()); } + catch (error) { results[name] = `blocked:${error.name}`; } + }; + await attempt('parent-dom', () => parent.document.body.textContent); + await attempt('sibling-dom', () => parent.frames[1].document.body.textContent); + await attempt('cookie-api', () => document.cookie); + await attempt('local-storage', () => localStorage.length); + await attempt('service-worker', () => navigator.serviceWorker.register('/iframe.js')); + await attempt('fetch', () => fetch(sink('fetch'))); + await attempt('websocket', () => new WebSocket('ws://' + location.host + sink('websocket'))); + await attempt('eventsource', () => new EventSource(sink('eventsource'))); + await attempt('image', () => { const x = new Image(); x.src = sink('image'); document.body.append(x); return 'created'; }); + await attempt('css', () => { const x = document.createElement('link'); x.rel = 'stylesheet'; x.href = sink('css'); document.head.append(x); return 'created'; }); + await attempt('font', () => { const x = new FontFace('Probe', `url(${sink('font')})`); return x.load(); }); + await attempt('media', () => { const x = document.createElement('video'); x.src = sink('media'); x.preload = 'auto'; document.body.append(x); return 'created'; }); + await attempt('preload', () => { const x = document.createElement('link'); x.rel = 'preload'; x.as = 'script'; x.href = sink('preload'); document.head.append(x); return 'created'; }); + await attempt('ping', () => { const x = document.createElement('a'); x.href = '#'; x.ping = sink('ping'); x.click(); return 'clicked'; }); + await attempt('form', () => { const x = document.createElement('form'); x.method = 'POST'; x.action = sink('form'); document.body.append(x); x.submit(); return 'submitted'; }); + await attempt('download', () => { const x = document.createElement('a'); x.href = sink('download'); x.download = 'marker'; x.click(); return 'clicked'; }); + await attempt('popup', () => { const x = open(sink('popup')); if (!x) throw new Error('denied'); return 'opened'; }); + await attempt('top-navigation', () => { top.location.href = sink('top-navigation'); return 'assigned'; }); + await attempt('nested-frame', () => { const x = document.createElement('iframe'); x.src = sink('nested-frame'); document.body.append(x); return 'created'; }); + await attempt('nested-worker', () => new Worker(sink('nested-worker'))); + port.postMessage({ kind: 'results', results }); + setTimeout(() => { location.href = sink('self-navigation'); }, 100); +}, { once: true }); diff --git a/scripts/gate-g/experiences/index.html b/scripts/gate-g/experiences/index.html new file mode 100644 index 00000000..a4f37530 --- /dev/null +++ b/scripts/gate-g/experiences/index.html @@ -0,0 +1,9 @@ +Gate G · experience isolation + +

Gate G experience boundary probe

Synthetic localhost data only. Compare sandboxed author HTML with Worker logic and host-rendered controls. The sink log records actual browser requests; UI labels alone do not prove a block.

+

Sandboxed HTML

Opaque origin, scripts, restrictive CSP. The author still controls its document and frame.

Ready

+

Worker + host rendering

Author logic returns bounded view data; trusted host creates controls and handles events.

Ready

+

Structured interaction fixture

Master/detail updates, keyboard grid selection and paste, text editing, and pointer drawing. This proves only these bounded interactions.

+

Observed localhost requests

diff --git a/scripts/gate-g/experiences/make-local-cert.py b/scripts/gate-g/experiences/make-local-cert.py new file mode 100644 index 00000000..59420197 --- /dev/null +++ b/scripts/gate-g/experiences/make-local-cert.py @@ -0,0 +1,38 @@ +"""Generate an ephemeral localhost/127.0.0.1 certificate for this synthetic HTTPS probe.""" +import ipaddress +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + + +target = Path(sys.argv[1]).resolve() +target.mkdir(parents=True, exist_ok=True) +key = ec.generate_private_key(ec.SECP256R1()) +now = datetime.now(timezone.utc) +name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Gate G local test only")]) +cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=2)) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + critical=False, + ) + .sign(key, hashes.SHA256()) +) +(target / "localhost-key.pem").write_bytes( + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) +) +(target / "localhost-cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) +print(target / "localhost-cert.pem") diff --git a/scripts/gate-g/experiences/server.mjs b/scripts/gate-g/experiences/server.mjs new file mode 100644 index 00000000..87f39272 --- /dev/null +++ b/scripts/gate-g/experiences/server.mjs @@ -0,0 +1,73 @@ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); +const port = Number(process.env.GATE_G_EXPERIENCE_PORT || 4311); +const origin = `http://127.0.0.1:${port}`; +const hits = []; +const nonce = randomBytes(18).toString('base64'); +const iframeCsp = `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'none'; img-src 'none'; font-src 'none'; media-src 'none'; form-action 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'none'; navigate-to 'none'`; +const workerCsp = "default-src 'none'; script-src 'none'; connect-src 'none'; worker-src 'none'; object-src 'none'"; +const files = new Map([ + ['/', ['index.html', 'text/html; charset=utf-8']], + ['/host.js', ['host.js', 'text/javascript; charset=utf-8']], + ['/iframe', ['iframe.html', 'text/html; charset=utf-8']], + ['/iframe.js', ['iframe.js', 'text/javascript; charset=utf-8']], + ['/worker.js', ['worker.js', 'text/javascript; charset=utf-8']], +]); + +const server = createServer(async (req, res) => { + const url = new URL(req.url || '/', origin); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=()'); + if (url.pathname === '/sink') { + const hit = { channel: url.searchParams.get('channel'), at: new Date().toISOString(), method: req.method }; + hits.push(hit); + console.log(JSON.stringify({ event: 'synthetic-egress', ...hit })); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end('Local synthetic sinkMarker received'); + return; + } + if (url.pathname === '/observations') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(hits)); + return; + } + if (url.pathname === '/reset' && req.method === 'POST') { + hits.length = 0; + res.end('ok'); + return; + } + const file = files.get(url.pathname); + if (!file) { res.writeHead(404); res.end('not found'); return; } + if (url.pathname === '/iframe' || url.pathname === '/iframe.js') res.setHeader('Content-Security-Policy', iframeCsp); + if (url.pathname === '/worker.js') res.setHeader('Content-Security-Policy', workerCsp); + res.setHeader('Content-Type', file[1]); + try { + if (url.pathname === '/iframe') { + if (url.searchParams.get('mode') === 'navigation') { + res.end(`Navigation-only fixture`); + } else { + const page = await readFile(join(here, file[0]), 'utf8'); + const script = await readFile(join(here, 'iframe.js'), 'utf8'); + res.end(page.replace('', ``)); + } + } else res.end(await readFile(join(here, file[0]))); + } + catch (error) { console.error(error); res.writeHead(500); res.end('fixture read failed'); } +}); +server.on('upgrade', (req, socket) => { + const url = new URL(req.url || '/', origin); + if (url.pathname === '/sink') { + const hit = { channel: url.searchParams.get('channel'), at: new Date().toISOString(), method: 'UPGRADE' }; + hits.push(hit); + console.log(JSON.stringify({ event: 'synthetic-egress', ...hit })); + } + socket.destroy(); +}); +server.listen(port, '127.0.0.1', () => console.log(`Gate G experiences: ${origin}`)); diff --git a/scripts/gate-g/experiences/worker.js b/scripts/gate-g/experiences/worker.js new file mode 100644 index 00000000..567221b2 --- /dev/null +++ b/scripts/gate-g/experiences/worker.js @@ -0,0 +1,75 @@ +const sink = channel => `/sink?channel=worker-${channel}`; +let rows = [ + { id: 'a', name: 'Aster', note: 'First synthetic record' }, + { id: 'b', name: 'Birch', note: 'Second synthetic record' }, + { id: 'c', name: 'Cedar', note: 'Third synthetic record' }, +]; +let selected = 'a'; +let draft = 'A small editable text surface.\nNo workspace content is loaded.'; +let strokes = []; +let grid = [['Item', 'Count', 'State'], ['Alpha', '12', 'Open'], ['Beta', '8', 'Done'], ['Gamma', '3', 'Open']]; +let revision = 0; + +function render() { + const detail = rows.find(row => row.id === selected); + postMessage({ kind: 'view', revision: ++revision, view: { + rows: rows.map(({ id, name }) => ({ id, name })), + selected, + detail: detail ? { name: detail.name, note: detail.note } : null, + grid, + draft, + strokes, + }}); +} + +async function probe() { + const results = {}; + const attempt = async (name, fn) => { + try { results[name] = String(await fn()); } + catch (error) { results[name] = `blocked:${error.name}`; } + }; + await attempt('document', () => document.body.textContent); + await attempt('parent', () => parent.document.body.textContent); + await attempt('cookie-api', () => document.cookie); + await attempt('local-storage', () => localStorage.length); + await attempt('service-worker', () => navigator.serviceWorker.register('/worker.js')); + await attempt('fetch', () => fetch(sink('fetch'))); + await attempt('xhr', () => new Promise((resolve, reject) => { const x = new XMLHttpRequest(); x.onload = resolve; x.onerror = reject; x.open('GET', sink('xhr')); x.send(); })); + await attempt('websocket', () => new WebSocket(`ws://${location.host}${sink('websocket')}`)); + await attempt('eventsource', () => new EventSource(sink('eventsource'))); + await attempt('import-scripts', () => importScripts(sink('import-scripts'))); + await attempt('nested-worker', () => new Worker(sink('nested-worker'))); + await attempt('navigation', () => { location.href = sink('navigation'); return location.href; }); + await attempt('open', () => open(sink('popup'))); + await attempt('send-beacon', () => navigator.sendBeacon(sink('beacon'), 'x')); + postMessage({ kind: 'probe', results }); +} + +onmessage = event => { + const data = event.data; + if (!data || typeof data !== 'object') return; + if (data.kind === 'start') { probe(); render(); return; } + if (data.kind === 'flood') { for (let i = 0; i < 125; i++) postMessage({ kind: 'noop', i }); postMessage({ kind: 'noop', padding: 'x'.repeat(66000) }); return; } + if (data.kind === 'late') { setTimeout(() => postMessage({ kind: 'view', revision: 99999, view: { rows: [{ id: 'late', name: 'Late stale view' }], selected: 'late', grid: [], strokes: [], draft: 'stale' } }), 300); return; } + if (data.kind !== 'event') return; + if (data.action === 'select' && rows.some(row => row.id === data.id)) selected = data.id; + else if (data.action === 'add') rows = [...rows, { id: String(rows.length + 1), name: `New ${rows.length + 1}`, note: 'Incrementally added' }]; + else if (data.action === 'draft' && typeof data.value === 'string' && data.value.length <= 2000) draft = data.value; + else if (data.action === 'grid' && Number.isInteger(data.row) && Number.isInteger(data.column) && data.row >= 0 && data.row < 4 && data.column >= 0 && data.column < 3 && typeof data.value === 'string' && data.value.length <= 100) { + grid = grid.map((row, r) => row.map((value, c) => r === data.row && c === data.column ? data.value : value)); + } + else if (data.action === 'grid-paste' && Number.isInteger(data.row) && Number.isInteger(data.column) && Array.isArray(data.values) && data.values.length <= 4) { + const next = grid.map(row => [...row]); + data.values.forEach((line, ri) => { + if (!Array.isArray(line) || line.length > 3) return; + line.forEach((value, ci) => { if (typeof value === 'string' && value.length <= 100 && next[data.row + ri]?.[data.column + ci] !== undefined) next[data.row + ri][data.column + ci] = value; }); + }); + grid = next; + } + else if (data.action === 'draw' && Number.isFinite(data.x) && Number.isFinite(data.y)) { + strokes = [...strokes.slice(-199), { x: Math.max(0, Math.min(1, data.x)), y: Math.max(0, Math.min(1, data.y)) }]; + postMessage({ kind: 'strokes', strokes }); + return; + } else return; + render(); +}; diff --git a/scripts/gate-g/public/probe.mjs b/scripts/gate-g/public/probe.mjs new file mode 100644 index 00000000..c3622d2d --- /dev/null +++ b/scripts/gate-g/public/probe.mjs @@ -0,0 +1,358 @@ +/** + * Gate G D0 experiment only. Run against a disposable PostgreSQL database. + * No Deft routes, migrations, or production contracts are changed here. + */ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; +import { spawn } from 'node:child_process'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(scriptPath, '../../../..'); +const requireFromApi = createRequire(resolve(repoRoot, 'apps/api/package.json')); +const { Pool } = requireFromApi('pg'); +const databaseUrl = process.env.GATE_G_PUBLIC_DATABASE_URL; +let databaseBinding; +try { databaseBinding = new URL(databaseUrl); } catch { /* fixed error below */ } +if (!databaseBinding || !['postgres:', 'postgresql:'].includes(databaseBinding.protocol) + || databaseBinding.hostname !== '127.0.0.1' + || databaseBinding.port !== '55434' + || databaseBinding.pathname !== '/gate_g_public' + || databaseBinding.username !== 'gate_g_probe' + || databaseBinding.search || databaseBinding.hash) { + throw new Error('GATE_G_PUBLIC_DATABASE_URL must exactly match the assigned loopback disposable DB binding'); +} +const pool = new Pool({ connectionString: databaseUrl, max: 24, connectionTimeoutMillis: 10000 }); +const schema = 'gate_g_public_probe'; +const table = (name) => `${schema}.${name}`; +const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +const signal = (stage) => { if (process.send) process.send({ stage }); }; +const park = () => new Promise(() => {}); + +async function setup() { + await pool.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + await pool.query(`CREATE TABLE IF NOT EXISTS ${table('endpoints')} ( + id uuid PRIMARY KEY, org_id uuid NOT NULL, slug text NOT NULL UNIQUE, + public_label text NOT NULL, enabled boolean NOT NULL DEFAULT false, + UNIQUE (org_id, id) + )`); + await pool.query(`CREATE TABLE IF NOT EXISTS ${table('slots')} ( + id uuid PRIMARY KEY, org_id uuid NOT NULL, endpoint_id uuid NOT NULL, + starts_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, + private_note text NOT NULL, + FOREIGN KEY (org_id, endpoint_id) REFERENCES ${table('endpoints')}(org_id, id), + UNIQUE (org_id, endpoint_id, id) + )`); + await pool.query(`CREATE TABLE IF NOT EXISTS ${table('ingress')} ( + id uuid PRIMARY KEY, org_id uuid NOT NULL, endpoint_id uuid NOT NULL, + request_key text NOT NULL, input_digest text NOT NULL, + state text NOT NULL CHECK (state IN ('processing','confirmed','conflict')), + reservation_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (org_id, endpoint_id, request_key), + FOREIGN KEY (org_id, endpoint_id) REFERENCES ${table('endpoints')}(org_id, id) + )`); + await pool.query(`CREATE TABLE IF NOT EXISTS ${table('reservations')} ( + id uuid PRIMARY KEY, org_id uuid NOT NULL, endpoint_id uuid NOT NULL, + slot_id uuid NOT NULL, ingress_id uuid NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (org_id, slot_id), + FOREIGN KEY (org_id, endpoint_id, slot_id) REFERENCES ${table('slots')}(org_id, endpoint_id, id), + FOREIGN KEY (ingress_id) REFERENCES ${table('ingress')}(id) + )`); + await pool.query(`CREATE TABLE IF NOT EXISTS ${table('outbox')} ( + id uuid PRIMARY KEY, org_id uuid NOT NULL, reservation_id uuid NOT NULL UNIQUE, + event_kind text NOT NULL, payload jsonb NOT NULL, + state text NOT NULL CHECK (state IN ('pending','delivered','failed')), + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (reservation_id) REFERENCES ${table('reservations')}(id) + )`); +} + +async function fixture(label = 'Synthetic Booking') { + const orgId = randomUUID(), endpointId = randomUUID(), slotId = randomUUID(); + const slug = randomUUID().replaceAll('-', ''); + await pool.query(`INSERT INTO ${table('endpoints')}(id,org_id,slug,public_label,enabled) VALUES($1,$2,$3,$4,true)`, + [endpointId, orgId, slug, label]); + await pool.query(`INSERT INTO ${table('slots')}(id,org_id,endpoint_id,starts_at,expires_at,private_note) + VALUES($1,$2,$3,now()+interval '1 day',now()+interval '2 days','PRIVATE EMPLOYEE NOTE')`, + [slotId, orgId, endpointId]); + return { orgId, endpointId, slotId, slug, label }; +} + +// Only the server-owned opaque endpoint mapping establishes org and principal. +// Cookie and Authorization are never read by this function or the route. +async function resolvePublicPrincipal(client, slug) { + const { rows } = await client.query(`SELECT id,org_id,public_label FROM ${table('endpoints')} + WHERE slug=$1 AND enabled=true FOR SHARE`, [slug]); + return rows[0] ? { kind: 'public', endpointId: rows[0].id, orgId: rows[0].org_id, label: rows[0].public_label } : null; +} + +async function reserve({ slug, slotId, requestKey, faultAt, onStage }) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const principal = await resolvePublicPrincipal(client, slug); + if (!principal) { await client.query('ROLLBACK'); return { status: 404, body: { code: 'NOT_FOUND' } }; } + if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(requestKey) || !/^[0-9a-f-]{36}$/.test(slotId)) { + await client.query('ROLLBACK'); return { status: 400, body: { code: 'INVALID_INPUT' } }; + } + const inputDigest = digest({ slotId }); + const ingressId = randomUUID(); + const inserted = await client.query(`INSERT INTO ${table('ingress')} + (id,org_id,endpoint_id,request_key,input_digest,state) + VALUES($1,$2,$3,$4,$5,'processing') + ON CONFLICT (org_id,endpoint_id,request_key) DO NOTHING RETURNING id`, + [ingressId, principal.orgId, principal.endpointId, requestKey, inputDigest]); + if (inserted.rowCount === 0) { + const replay = await client.query(`SELECT input_digest,state,reservation_id FROM ${table('ingress')} + WHERE org_id=$1 AND endpoint_id=$2 AND request_key=$3`, + [principal.orgId, principal.endpointId, requestKey]); + await client.query('COMMIT'); + const row = replay.rows[0]; + if (!row || row.input_digest !== inputDigest) return { status: 409, body: { code: 'IDEMPOTENCY_CONFLICT' } }; + return row.state === 'confirmed' + ? { status: 200, body: { state: 'confirmed', reservation_id: row.reservation_id, calendar_state: 'pending', replay: true } } + : { status: 409, body: { code: 'SLOT_CONFLICT', replay: true } }; + } + await onStage?.('after_ingress'); + if (faultAt === 'after_ingress') throw new Error('fault:after_ingress'); + const reservationId = randomUUID(); + const claimed = await client.query(`INSERT INTO ${table('reservations')} + (id,org_id,endpoint_id,slot_id,ingress_id) + SELECT $1,s.org_id,s.endpoint_id,s.id,$2 FROM ${table('slots')} s + WHERE s.id=$3 AND s.org_id=$4 AND s.endpoint_id=$5 + AND s.expires_at>now() + ON CONFLICT (org_id,slot_id) DO NOTHING RETURNING id`, + [reservationId, ingressId, slotId, principal.orgId, principal.endpointId]); + if (claimed.rowCount === 0) { + await client.query(`UPDATE ${table('ingress')} SET state='conflict' WHERE id=$1`, [ingressId]); + await client.query('COMMIT'); + return { status: 409, body: { code: 'SLOT_CONFLICT' } }; + } + await onStage?.('after_reservation'); + if (faultAt === 'after_reservation') throw new Error('fault:after_reservation'); + await client.query(`INSERT INTO ${table('outbox')} + (id,org_id,reservation_id,event_kind,payload,state) + VALUES($1,$2,$3,'reservation.committed',$4::jsonb,'pending')`, + [randomUUID(), principal.orgId, reservationId, JSON.stringify({ endpoint_id: principal.endpointId, reservation_id: reservationId })]); + await onStage?.('after_outbox'); + if (faultAt === 'after_outbox') throw new Error('fault:after_outbox'); + await client.query(`UPDATE ${table('ingress')} SET state='confirmed',reservation_id=$2 WHERE id=$1`, [ingressId, reservationId]); + await onStage?.('before_commit'); + if (faultAt === 'before_commit') throw new Error('fault:before_commit'); + await client.query('COMMIT'); + await onStage?.('after_commit'); + if (faultAt === 'after_commit') throw new Error('fault:after_commit'); + return { status: 201, body: { state: 'confirmed', reservation_id: reservationId, calendar_state: 'pending', replay: false } }; + } catch (error) { + try { await client.query('ROLLBACK'); } catch { /* connection may have been killed */ } + throw error; + } finally { client.release(); } +} + +async function counts({ orgId, endpointId, slotId }) { + const [i,r,o] = await Promise.all([ + pool.query(`SELECT count(*)::int AS n FROM ${table('ingress')} WHERE org_id=$1 AND endpoint_id=$2`, [orgId, endpointId]), + pool.query(`SELECT count(*)::int AS n FROM ${table('reservations')} WHERE org_id=$1 AND slot_id=$2`, [orgId, slotId]), + pool.query(`SELECT count(*)::int AS n FROM ${table('outbox')} WHERE org_id=$1 AND reservation_id IN + (SELECT id FROM ${table('reservations')} WHERE org_id=$1 AND slot_id=$2)`, [orgId, slotId]), + ]); + return { ingress: i.rows[0].n, reservations: r.rows[0].n, outbox: o.rows[0].n }; +} + +function server() { + return createServer(async (req,res) => { + const path = new URL(req.url, 'http://localhost').pathname; + const slug = /^\/p\/([a-f0-9]{32})$/.exec(path)?.[1]; + if (!slug) { res.writeHead(404).end(); return; } + try { + if (req.method === 'GET') { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const principal = await resolvePublicPrincipal(client, slug); + if (!principal) { await client.query('ROLLBACK'); res.writeHead(404).end(JSON.stringify({ code: 'NOT_FOUND' })); return; } + const slots = await client.query(`SELECT s.id,s.starts_at FROM ${table('slots')} s + WHERE s.org_id=$1 AND s.endpoint_id=$2 AND s.expires_at>now() + AND NOT EXISTS (SELECT 1 FROM ${table('reservations')} r WHERE r.org_id=s.org_id AND r.slot_id=s.id)`, + [principal.orgId,principal.endpointId]); + await client.query('COMMIT'); + res.writeHead(200,{ 'content-type':'application/json','cache-control':'no-store' }) + .end(JSON.stringify({ label: principal.label, slots: slots.rows })); + return; + } finally { client.release(); } + } + if (req.method !== 'POST') { res.writeHead(405).end(); return; } + const chunks = []; let bytes = 0; + for await (const chunk of req) { + bytes += chunk.length; + if (bytes > 1024) { res.writeHead(413).end(JSON.stringify({ code:'PAYLOAD_TOO_LARGE' })); return; } + chunks.push(chunk); + } + let body; + try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { res.writeHead(400).end(JSON.stringify({ code:'INVALID_INPUT' })); return; } + if (!body || Object.keys(body).length !== 2 || typeof body.slot_id !== 'string' || typeof body.request_key !== 'string') { + res.writeHead(400).end(JSON.stringify({ code:'INVALID_INPUT' })); return; + } + const result = await reserve({ slug, slotId:body.slot_id, requestKey:body.request_key }); + res.writeHead(result.status,{ 'content-type':'application/json','cache-control':'no-store' }).end(JSON.stringify(result.body)); + } catch { res.writeHead(500,{ 'content-type':'application/json' }).end(JSON.stringify({ code:'INTERNAL_ERROR' })); } + }); +} + +async function request(base, f, key, extraHeaders = {}) { + const response = await fetch(`${base}/p/${f.slug}`, { method:'POST', headers:{ 'content-type':'application/json', ...extraHeaders }, + body: JSON.stringify({ slot_id:f.slotId, request_key:key }) }); + return { status:response.status, body:await response.json() }; +} + +async function crashChild(f, key, stage) { + const child = spawn(process.execPath, [scriptPath, '--child', JSON.stringify(f), key, stage], + { env:process.env, stdio:['ignore','ignore','pipe','ipc'] }); + const stderr=[]; child.stderr.on('data',(x)=>stderr.push(x)); + const closed = new Promise((resolveClose)=>child.once('close',resolveClose)); + let timeout; + try { + const message = await new Promise((resolveMessage,rejectMessage) => { + const cleanup=()=>{ + clearTimeout(timeout); + child.off('message',onMessage); + child.off('close',onClose); + child.off('error',onError); + }; + const onMessage=(value)=>{ cleanup(); resolveMessage(value); }; + const onClose=()=>{ cleanup(); rejectMessage(new Error(`child exited before ${stage}: ${Buffer.concat(stderr).toString()}`)); }; + const onError=(error)=>{ cleanup(); rejectMessage(error); }; + child.once('message',onMessage); + child.once('close',onClose); + child.once('error',onError); + timeout=setTimeout(()=>{ cleanup(); rejectMessage(new Error(`child timeout at ${stage}`)); },10000); + }); + assert.equal(message.stage,stage); + } finally { + clearTimeout(timeout); + if (child.exitCode===null && child.signalCode===null) child.kill('SIGKILL'); + await closed; + } +} + +async function run() { + await setup(); + const output = { revision:'1427f66f84d5cb5f5430c566897a17dda77fe5fc', database:'gate_g_public', schema, cases:{} }; + const http = server(); http.listen(4331,'127.0.0.1'); await once(http,'listening'); + const base='http://127.0.0.1:4331'; + try { + const f=await fixture(); + const fakeHeaders={ cookie:'deft_session=forged-owner; employee_id=forged-employee', authorization:'Bearer forged-employee-token' }; + const [plain,forged]=await Promise.all([ + fetch(`${base}/p/${f.slug}`).then((r)=>r.json()), + fetch(`${base}/p/${f.slug}`,{headers:fakeHeaders}).then((r)=>r.json()), + ]); + assert.deepEqual(forged,plain); + assert.deepEqual(Object.keys(plain).sort(),['label','slots']); + assert.equal(JSON.stringify(plain).includes('PRIVATE EMPLOYEE NOTE'),false); + assert.equal(JSON.stringify(plain).includes(f.orgId),false); + const absent=await fetch(`${base}/p/${randomUUID().replaceAll('-','')}`,{headers:fakeHeaders}); + assert.equal(absent.status,404); + output.cases.public_principal={ forged_cookie_and_bearer_ignored:true, unknown_endpoint:404, projection_keys:Object.keys(plain).sort() }; + + const claims=await Promise.all(Array.from({length:100},(_,n)=>request(base,f,`claim-${n}`,n===0?fakeHeaders:{}))); + const wins=claims.filter((x)=>x.status===201), conflicts=claims.filter((x)=>x.status===409); + assert.equal(wins.length,1); assert.equal(conflicts.length,99); + assert.deepEqual(await counts(f),{ingress:100,reservations:1,outbox:1}); + assert.equal(wins[0].body.calendar_state,'pending'); + const winnerKey=`claim-${claims.findIndex((x)=>x.status===201)}`; + const replay=await request(base,f,winnerKey,fakeHeaders); + assert.equal(replay.status,200); assert.equal(replay.body.reservation_id,wins[0].body.reservation_id); + assert.equal(replay.body.replay,true); + const loserKey=`claim-${claims.findIndex((x)=>x.status===409)}`; + const loserReplay=await request(base,f,loserKey); + assert.equal(loserReplay.status,409); assert.equal(loserReplay.body.replay,true); + const altered=await reserve({slug:f.slug,slotId:randomUUID(),requestKey:winnerKey}); + assert.equal(altered.status,409); assert.equal(altered.body.code,'IDEMPOTENCY_CONFLICT'); + assert.deepEqual(await counts(f),{ingress:100,reservations:1,outbox:1}); + output.cases.concurrency={requests:100,winners:wins.length,conflicts:conflicts.length,counts:await counts(f),winner_replay:replay.status,loser_replay:loserReplay.status,altered_replay:altered.body.code}; + + const same=await fixture(); + const sameKeyClaims=await Promise.all(Array.from({length:20},()=>request(base,same,'same-key'))); + assert.equal(sameKeyClaims.filter((x)=>x.status===201).length,1); + assert.equal(sameKeyClaims.filter((x)=>x.status===200 && x.body.replay).length,19); + assert.equal(new Set(sameKeyClaims.map((x)=>x.body.reservation_id)).size,1); + assert.deepEqual(await counts(same),{ingress:1,reservations:1,outbox:1}); + output.cases.concurrent_replay={requests:20,created:1,replays:19,counts:await counts(same)}; + + const disabled=await fixture(); + await pool.query(`UPDATE ${table('endpoints')} SET enabled=false WHERE id=$1 AND org_id=$2`,[disabled.endpointId,disabled.orgId]); + const denied=await request(base,disabled,'disabled-key',fakeHeaders); + assert.equal(denied.status,404); + assert.deepEqual(await counts(disabled),{ingress:0,reservations:0,outbox:0}); + output.cases.disabled_endpoint={request_status:denied.status,counts:await counts(disabled)}; + + const revocation=await fixture(); + let releaseClaim, reachedCommit; + const claimGate=new Promise((release)=>{ releaseClaim=release; }); + const atCommit=new Promise((reached)=>{ reachedCommit=reached; }); + const claimPromise=reserve({slug:revocation.slug,slotId:revocation.slotId,requestKey:'in-flight', + onStage:(stage)=>stage==='before_commit' ? (reachedCommit(),claimGate) : undefined}); + await atCommit; + const disableClient=await pool.connect(); + try { + await disableClient.query('BEGIN'); + await disableClient.query("SET LOCAL lock_timeout='150ms'"); + await assert.rejects( + disableClient.query(`UPDATE ${table('endpoints')} SET enabled=false WHERE id=$1 AND org_id=$2`, + [revocation.endpointId,revocation.orgId]), + (error)=>error.code==='55P03', + ); + await disableClient.query('ROLLBACK'); + } finally { disableClient.release(); releaseClaim(); } + assert.equal((await claimPromise).status,201); + await pool.query(`UPDATE ${table('endpoints')} SET enabled=false WHERE id=$1 AND org_id=$2`, + [revocation.endpointId,revocation.orgId]); + assert.equal((await request(base,revocation,'late-claim')).status,404); + assert.deepEqual(await counts(revocation),{ingress:1,reservations:1,outbox:1}); + output.cases.disable_claim_ordering={disable_blocked_by_inflight_claim:true,inflight_status:201,after_disable_status:404, + counts:await counts(revocation)}; + + for (const stage of ['after_ingress','after_reservation','after_outbox','before_commit']) { + const faultFixture=await fixture(); + await assert.rejects(reserve({slug:faultFixture.slug,slotId:faultFixture.slotId,requestKey:`fault-${stage}`,faultAt:stage}),/fault:/); + assert.deepEqual(await counts(faultFixture),{ingress:0,reservations:0,outbox:0}); + const recovered=await request(base,faultFixture,`fault-${stage}`); + assert.equal(recovered.status,201); + assert.deepEqual(await counts(faultFixture),{ingress:1,reservations:1,outbox:1}); + output.cases[stage]={after_fault:{ingress:0,reservations:0,outbox:0},retry_status:201}; + } + const before=await fixture(); + await crashChild(before,'crash-before','before_commit'); + assert.deepEqual(await counts(before),{ingress:0,reservations:0,outbox:0}); + assert.equal((await request(base,before,'crash-before')).status,201); + output.cases.crash_before_commit={after_kill:{ingress:0,reservations:0,outbox:0},retry_status:201}; + const after=await fixture(); + await crashChild(after,'crash-after','after_commit'); + assert.deepEqual(await counts(after),{ingress:1,reservations:1,outbox:1}); + const afterReplay=await request(base,after,'crash-after'); + assert.equal(afterReplay.status,200); assert.equal(afterReplay.body.replay,true); + output.cases.crash_after_commit={after_kill:await counts(after),replay_status:200}; + const other=await fixture(); + const wrongTenant=await reserve({slug:other.slug,slotId:f.slotId,requestKey:'foreign-slot'}); + assert.equal(wrongTenant.status,409); + assert.deepEqual(await counts(other),{ingress:1,reservations:0,outbox:0}); + output.cases.tenant_isolation={cross_org_slot_status:wrongTenant.status,counts:await counts(other)}; + console.log(JSON.stringify(output,null,2)); + } finally { http.close(); await once(http,'close'); await pool.end(); } +} + +if (process.argv[2]==='--child') { + const f=JSON.parse(process.argv[3]), key=process.argv[4], stage=process.argv[5]; + reserve({slug:f.slug,slotId:f.slotId,requestKey:key,onStage:(at)=>{ + if (at===stage) { signal(at); return park(); } + }}).catch((error)=>{ process.stderr.write(error.stack); process.exitCode=1; }); +} else { + run().catch((error)=>{ console.error(error); process.exitCode=1; pool.end().catch(()=>{}); }); +} diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json new file mode 100644 index 00000000..956d46b8 --- /dev/null +++ b/scripts/gate-g/required-tests.json @@ -0,0 +1,565 @@ +{ + "baseline": "1427f66f84d5cb5f5430c566897a17dda77fe5fc", + "scope": "Package/unit baselines and explicitly selected database ancestry and legacy cutover-on profiles. This inventory is not the complete Gate G acceptance matrix.", + "profiles": [ + { + "description": "All 63 current App Kit cases. Packed offline consumer tests require registry cache primed for the pinned Zod version.", + "cases": [ + { + "name": "builds and verifies byte-identical deterministic packages", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "exports a machine-readable strict authoring schema", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects capability planes reserved for later protocol versions", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects unsafe, ambiguous, and reserved artifact paths", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects duplicate module identities, paths, and navigation keys", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects navigation views missing from or belonging to another collection", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects unsupported protocols, media types, malformed JSON, and integrity drift", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "preserves direct v0 rejection issues instead of wrapping them in a v1 union error", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "rejects module identity drift and orphan artifacts", + "file": "packages/app-kit/test/app-kit.test.ts" + }, + { + "name": "external authoring loop initializes and builds deterministically without credentials", + "file": "packages/app-kit/test/cli.test.ts" + }, + { + "name": "connected template emits a Module v2 dependency App and requested authority without a grant", + "file": "packages/app-kit/test/cli.test.ts" + }, + { + "name": "external authoring loop checks and builds Protocol v1 deterministically without authority", + "file": "packages/app-kit/test/cli.test.ts" + }, + { + "name": "Protocol v2 check, build, requested-authority, and doctor paths stay stage-only", + "file": "packages/app-kit/test/cli.test.ts" + }, + { + "name": "connected-automation template emits deterministic v2 lock, diff, and simulation", + "file": "packages/app-kit/test/cli.test.ts" + }, + { + "name": "freezes one additive App Kit and protocol-flow compatibility contract", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "candidate Kit refuses a host advertising only the preceding authoring contracts", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "checks a connected package against only the public host and provider contracts", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "projects v0 as explicitly empty requested authority", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "projects connected requested authority without host identity or effective grants", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "publishes provider-independent sandbox email conformance vectors", + "file": "packages/app-kit/test/developer-contract.test.ts" + }, + { + "name": "accepts the unrelated equipment fixture in Kit and host validation", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects bad relation target with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects invalid latest-related rule with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects invalid board summary reference with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects invalid field default with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects invalid search fields with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects invalid navigation target with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "rejects unsupported schema version with host parity and actionable location", + "file": "packages/app-kit/test/module-semantic-validation.test.ts" + }, + { + "name": "packed App Kit builds Contacts, connected Campaigns, and scheduled Campaigns externally", + "file": "packages/app-kit/test/packed-external.test.ts" + }, + { + "name": "the actual packed Kit validates Modules in a clean external directory", + "file": "packages/app-kit/test/packed-module-validation.test.ts" + }, + { + "name": "builds and verifies deterministic connected packages", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "exports a strict schema and a single code-owned support registry", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "derives private interface identity only from host-owned lineage inputs", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "freezes the private sandbox email schema and host-owned policy floor", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "rejects policy injection, executable planes, arbitrary mapping, and ambiguous references", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "rejects included resource drift and false relation targets at package verification", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "an included direct relation uses the same canonical Module without an artificial App dependency", + "file": "packages/app-kit/test/protocol-v1.test.ts" + }, + { + "name": "builds, verifies, and exports strict v2 packages through explicit dispatch", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "accepts only one bounded daily trigger declaration over a resolved action", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "rejects unresolved user input only when the automation request references that action", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "keeps requests non-executable, provider-free, and outside effective authority", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "publishes one frozen code-owned policy without changing the base interface policy", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "registers a complete v2 support matrix without widening v1 authoring", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "preserves frozen v0 and v1 package and requested-authority bytes", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "builds the independent scheduled Campaign upgrade and reports only its requested widening", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "uses exact public schedule and input contracts in the non-executable simulator", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "finds the next bounded real UTC occurrence without duplicating DST rules", + "file": "packages/app-kit/test/protocol-v2.test.ts" + }, + { + "name": "hello-workspace-app: unknown keys preserve rejection issues at root", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "hello-workspace-app: unknown keys preserve rejection issues at modules.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "hello-workspace-app: unknown keys preserve rejection issues at navigation.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "connected-resource-campaigns-app: unknown keys preserve rejection issues at root", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "connected-resource-campaigns-app: unknown keys preserve rejection issues at modules.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "connected-resource-campaigns-app: unknown keys preserve rejection issues at navigation.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "scheduled-connected-resource-campaigns-app: unknown keys preserve rejection issues at root", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "scheduled-connected-resource-campaigns-app: unknown keys preserve rejection issues at modules.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "scheduled-connected-resource-campaigns-app: unknown keys preserve rejection issues at navigation.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 1: unknown keys retain host/Kit parity at root", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 1: unknown keys retain host/Kit parity at collections.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 1: unknown keys retain host/Kit parity at collections.0.fields.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 2: unknown keys retain host/Kit parity at root", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 2: unknown keys retain host/Kit parity at collections.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "Module 2: unknown keys retain host/Kit parity at collections.0.fields.0", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "name": "developer compatibility keeps unknown-key diagnostics without accepting duplicate versions", + "file": "packages/app-kit/test/validation-compatibility.test.ts" + } + ], + "id": "app-kit" + }, + { + "description": "51 focused unit/service contract cases; DATABASE_URL points to unreachable 127.0.0.1:1 so this is not DB acceptance.", + "cases": [ + { + "name": "automation review input is closed, bounded, and has no caller authority vector", + "file": "apps/api/test/app-automation-definition-service.test.ts" + }, + { + "name": "automation policy digest comes from the one code-owned policy descriptor", + "file": "apps/api/test/app-automation-definition-service.test.ts" + }, + { + "name": "fire identity is canonical, deterministic, and epoch-bound", + "file": "apps/api/test/app-automation-definition-service.test.ts" + }, + { + "name": "disabled automation delivery defers without reading authority", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "pending delivery claims with the queue lease and invokes the exact fire", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "a live prior claim defers, while a run-created replay is a no-op", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "a pause or kill after preflight terminalizes only the exact claimed fire", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "a transient invocation failure releases the exact claim for bounded recovery", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "late first delivery becomes a misfire before preflight or claim", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "stale preflight terminalizes pending authority without consuming an attempt", + "file": "apps/api/test/app-automation-dispatch.test.ts" + }, + { + "name": "management pagination reaches an old active schedule with equal timestamps and no duplicates", + "file": "apps/api/test/app-automation-management-pagination.test.ts" + }, + { + "name": "management distinguishes known expiry and revocation from the delivery-time stale check", + "file": "apps/api/test/app-automation-management-pagination.test.ts" + }, + { + "name": "management cursor rejects malformed and cross-installation pages", + "file": "apps/api/test/app-automation-management-pagination.test.ts" + }, + { + "name": "scanner persists old misfires before enqueuing the one catch-up occurrence", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "resume boundary excludes the paused occurrence", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "eligible definitions page without starving later tenants", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "scanner recovers an expired domain claim even when its old queue row is gone", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "expired claims are reconciled even when their definition is no longer eligible", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "delivery delegates terminal queue recovery through one atomic port operation", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "daily wall-clock resolution is exact in a fixed-offset zone", + "file": "apps/api/test/app-automation-schedule.test.ts" + }, + { + "name": "DST fold resolves once at the earlier matching UTC instant", + "file": "apps/api/test/app-automation-schedule.test.ts" + }, + { + "name": "DST gap becomes one explicit skipped logical occurrence", + "file": "apps/api/test/app-automation-schedule.test.ts" + }, + { + "name": "catch-up is bounded and resume excludes the current logical occurrence", + "file": "apps/api/test/app-automation-schedule.test.ts" + }, + { + "name": "scanner date reconciliation is local, inclusive, and bounded", + "file": "apps/api/test/app-automation-schedule.test.ts" + }, + { + "name": "only the App Run secret boundary handles ciphertext and signing material", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "App Run engine flag and key material stay confined to environment and Run composition", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "legacy MCP cutover flag has only the two intake-boundary consumers", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "App-origin Run intake flag defaults in env and is consumed only by Run composition", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "C5 selects one composed worker-owned attempt entrance through Capability Service", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "only the MCP adapter calls the low-level client and governed execution is pinned by provider id", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "Run submission has one repository writer behind the advisory-lock service", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "the App Run approval bridge has one safe compatibility writer and no executor", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "Run operations can repair projections but cannot call a provider", + "file": "apps/api/test/app-run-architecture.test.ts" + }, + { + "name": "provider idempotency keys remain stable and satisfy the frozen interface", + "file": "apps/api/test/app-run-attempt-runner.test.ts" + }, + { + "name": "App Run rollout accepts the three safe engine and legacy MCP intake states", + "file": "apps/api/test/app-run-rollout-config.test.ts" + }, + { + "name": "App automation intake defaults off and requires the complete connected Run stack", + "file": "apps/api/test/app-run-rollout-config.test.ts" + }, + { + "name": "legacy MCP intake values other than exact lowercase true fail closed", + "file": "apps/api/test/app-run-rollout-config.test.ts" + }, + { + "name": "API startup rejects legacy MCP intake while the App Run engine is disabled", + "file": "apps/api/test/app-run-rollout-config.test.ts" + }, + { + "name": "App-origin intake defaults off and cannot start without Apps and Runs", + "file": "apps/api/test/app-run-rollout-config.test.ts" + }, + { + "name": "closed provider slots route host-bound context and have no dynamic register surface", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "malformed and unsupported refs fail before any adapter call", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "missing adapters and invalid host context fail closed", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "adapter denial and unexpected failures disclose no projection or raw provider error", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "safe projection validation rejects extra fields and ref substitution", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "constructor rejects mismatched or dynamically named adapter slots", + "file": "apps/api/test/resource-authorization.test.ts" + }, + { + "name": "Module adapter preserves owner identity and returns only the safe projection", + "file": "apps/api/test/resource-provider-adapters.test.ts" + }, + { + "name": "Module adapter rejects host/actor mismatch and substituted provider identity", + "file": "apps/api/test/resource-provider-adapters.test.ts" + }, + { + "name": "Module owner denials and lifecycle errors map without leaking owner messages", + "file": "apps/api/test/resource-provider-adapters.test.ts" + }, + { + "name": "Task adapter delegates human visibility and emits a bounded safe projection", + "file": "apps/api/test/resource-provider-adapters.test.ts" + }, + { + "name": "Task adapter re-reads employee project scope and fails closed", + "file": "apps/api/test/resource-provider-adapters.test.ts" + }, + { + "name": "Module v1 relations project as ResourceRefs without changing source groups", + "file": "apps/api/test/resource-provider-adapters.test.ts" + } + ], + "id": "platform-unit" + }, + { + "cases": [ + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on immediate safe MCP calls preserve exact output with one governed attempt and no budget debit" + }, + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on post-effect output outside the retained contract is returned once without unsafe persistence or recall" + }, + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on result delivery rechecks live membership after the provider effect" + }, + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on approved MCP action retries converge on one Run, one attempt, one call, and one budget debit" + }, + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on reviewed MCP approval preserves legacy receipts and replays without another provider call" + }, + { + "file": "apps/api/test/capability-immediate-execution-db.test.ts", + "name": "flag-on ambiguous MCP transport outcome becomes inspectable unknown and is never retried" + } + ], + "id": "legacy-mcp-cutover-on", + "description": "Engine and legacy MCP cutover enabled with synthetic isolated keyrings. Exact ^flag-on name selection excludes ten opposite-profile cases; all six selected cases must execute." + }, + { + "cases": [ + { + "file": "apps/api/test/app-origin-run-cutover-db.test.ts", + "name": "App-origin rows require exact tenant-bound installation, version, effective grant, and binding ancestry" + } + ], + "id": "app-origin-ancestry", + "description": "Real disposable phase5-named PostgreSQL database; formerly name-gated ancestry case must execute." + }, + { + "id": "platform-channel-foundation", + "description": "Eleven mandatory Runtime/public DB, HTTP, default-off, result-bound, and ingress admission cases. Use an explicit disposable DB; the Runtime test launches its own loopback HTTP host process. Synthetic host-reviewed intake remains a limitation.", + "cases": [ + { + "file": "apps/api/test/app-runtime-channel-db.test.ts", + "name": "reviewed Runtime claim, start, known result and replay use the App Run ledger" + }, + { + "file": "apps/api/test/app-runtime-channel.test.ts", + "name": "disabled Runtime transport rejects before parsing a request body" + }, + { + "file": "apps/api/test/app-runtime-channel.test.ts", + "name": "Runtime result refuses an oversized retained output before any DB write" + }, + { + "file": "apps/api/test/app-public-service-db.test.ts", + "name": "reviewed public claims use canonical rows and one transaction for ingress and queue" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "forged forwarding headers cannot multiply socket-peer budget; rejection precedes route" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "unknown socket peer shares a fail-closed bucket regardless of forwarding headers" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "peer identity table is capped and overflow identities share one bucket" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "global budget holds across peer and slug rotation" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "in-flight slots reject before route and release after success or error" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "abort does not release capacity until downstream work settles" + }, + { + "file": "apps/api/test/app-public-limits.test.ts", + "name": "global concurrency cap spans different verified peers" + } + ] + } + ], + "schema_version": "deft.gate_g.test_inventory.v1" +} diff --git a/scripts/gate-g/runtime/README.md b/scripts/gate-g/runtime/README.md new file mode 100644 index 00000000..4dbc19b7 --- /dev/null +++ b/scripts/gate-g/runtime/README.md @@ -0,0 +1,35 @@ +# Gate G external runtime crash experiment + +This is a **simulation of a proposed external Runtime claim boundary**, not an +adapter to the current production App Run engine. Its control-plane state is in +`simulated-host.sqlite`. A different OS process writes the durable synthetic +provider effect ledger in `external-provider-effects.sqlite`. Neither file is a +Deft database, migration, scheduler, credential store or production receipt. + +Run on Node 24 with a new output directory. The runner rejects an existing +directory so evidence is never mixed across runs: + +```powershell +node scripts/gate-g/runtime/experiment.mjs --out 'C:/tmp/my-unique-gate-g-runtime-run' +``` + +The runner forks a runtime worker, which claims and heartbeats an attempt, +marks the provider call boundary, and invokes a separate provider process. The +coordinator kills the worker at two controlled points, waits for the lease to +expire, and reconciles the host state against the independent effect ledger. +Assertions cover double claim, stale heartbeat/completion, idempotent replay, +unsupported reconciliation, cancellation, and exact org/actor/install/version/ +grant/epoch substitution. `results.json` records the observed state and effect +counts. A failed assertion exits nonzero; keep the failed output for comparison. + +For a cleanup fault check, add `--fail-after-first-pause` with a fresh output +directory. It intentionally exits nonzero after writing `injected-worker-pid.txt`; +the coordinator's `finally` must terminate and await that paused worker. + +The fixture deliberately models only the claimed recovery semantics. The +current App Run engine already owns Run, attempt, approval, result and receipt +state; a production Runtime channel must extend those services. No code here +implements registration, authentication, runtime sessions, public endpoints, +live authorization, secret delivery, App-private capability policy, bounded +output or real provider credentials. Passing this experiment is not Gate G +acceptance for R03/R05 or any other production row. diff --git a/scripts/gate-g/runtime/experiment.mjs b/scripts/gate-g/runtime/experiment.mjs new file mode 100644 index 00000000..e78497d1 --- /dev/null +++ b/scripts/gate-g/runtime/experiment.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { execFileSync, fork } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { + attempts, cancel, claim, complete, heartbeat, identity, openHost, recover, run, seed, +} from './state.mjs'; + +const outIndex = process.argv.indexOf('--out'); +const outputDir = outIndex >= 0 ? process.argv[outIndex + 1] : mkdtempSync(join(tmpdir(), 'deft-gate-g-runtime-')); +if (!outputDir) throw new Error('--out requires a directory'); +if (outIndex >= 0) { + if (existsSync(outputDir)) throw new Error(`output directory must be fresh: ${outputDir}`); + mkdirSync(outputDir, { recursive: true }); +} +const hostPath = join(outputDir, 'simulated-host.sqlite'); +const providerPath = join(outputDir, 'external-provider-effects.sqlite'); +const workerPath = fileURLToPath(new URL('./worker.mjs', import.meta.url)); +const providerPathScript = fileURLToPath(new URL('./provider.mjs', import.meta.url)); +const db = openHost(hostPath); +const observations = []; +const activeWorkers = new Set(); + +function provider(command, key = 'unused') { + return Number(execFileSync(process.execPath, + [providerPathScript, command, providerPath, key], + { encoding: 'utf8', timeout: 12_000, maxBuffer: 64 * 1024 }).trim()); +} +function replayProviderEffect(runId) { + execFileSync(process.execPath, [providerPathScript, 'effect', providerPath, + `idempotent:${runId}`], { encoding: 'utf8', timeout: 12_000, maxBuffer: 64 * 1024 }); +} +function lookup(runId) { return provider('lookup', `idempotent:${runId}`) === 1; } +function record(caseName, runId, recovery, extra = {}) { + observations.push({ case: caseName, recovery, run: run(db, runId).state, + attempts: attempts(db, runId).map(({ number, state }) => ({ number, state })), + provider_effect_count: provider('count'), ...extra }); +} +function worker(runId, mode, retryClass) { + const child = fork(workerPath, [hostPath, providerPath, runId, mode, retryClass], + { silent: true }); + activeWorkers.add(child); + child.once('exit', () => activeWorkers.delete(child)); + child.once('error', () => activeWorkers.delete(child)); + child.stderr?.on('data', (chunk) => process.stderr.write(chunk)); + return child; +} +function message(child, event, timeoutMs = 6_000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); reject(new Error(`timeout waiting for ${event}`)); + }, timeoutMs); + const onMessage = (value) => { + if (value.event !== event) return; + cleanup(); resolve(value); + }; + const onExit = (code) => { cleanup(); reject(new Error(`worker exited ${code} before ${event}`)); }; + const onError = (error) => { cleanup(); reject(error); }; + function cleanup() { + clearTimeout(timeout); child.off('message', onMessage); child.off('exit', onExit); + child.off('error', onError); + } + child.on('message', onMessage); child.on('exit', onExit); child.on('error', onError); + }); +} +async function kill(child) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + const exit = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`worker ${child.pid} did not exit`)), 5_000); + child.once('exit', () => { clearTimeout(timeout); resolve(); }); + }); + child.kill('SIGKILL'); + await exit; +} +async function pauseAt(runId, mode, retryClass) { + const child = worker(runId, mode, retryClass); + const first = await message(child, 'claim'); + assert.equal(first.accepted, true); + await message(child, 'heartbeat'); + await message(child, mode); + return { child, claim: first.claim, process_id: first.process_id }; +} +async function finish(runId, retryClass) { + const child = worker(runId, 'complete', retryClass); + const first = await message(child, 'claim'); + assert.equal(first.accepted, true); + const completed = await message(child, 'completion'); + assert.equal(completed.accepted, true); + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`worker ${child.pid} did not exit`)), 5_000); + child.once('exit', () => { clearTimeout(timeout); resolve(); }); + }); + } +} +async function leaseExpire() { await delay(1_700); } + +try { + // All three processes are real OS processes: this coordinator, a runtime + // worker, and a provider CLI process with its own persistent effect file. + seed(db, 'before', 'idempotent_with_key'); + const before = await pauseAt('before', 'before_effect', 'idempotent_with_key'); + if (process.argv.includes('--fail-after-first-pause')) { + writeFileSync(join(outputDir, 'injected-worker-pid.txt'), String(before.process_id)); + throw new Error('injected failure after first worker pause'); + } + assert.equal(claim(db, 'before'), null, 'double claim must be denied'); + assert.equal(provider('count'), 0); + await kill(before.child); + await leaseExpire(); + assert.equal(recover(db, 'before', lookup), 'retry_prepared'); + assert.equal(heartbeat(db, before.claim.id, before.claim.token, 2), false); + assert.equal(complete(db, before.claim.id, before.claim.token), false); + await finish('before', 'idempotent_with_key'); + assert.equal(provider('count'), 1); + record('kill_before_effect', 'before', 'retry_prepared', { + killed_worker_process_id: before.process_id, + stale_heartbeat_denied: true, stale_completion_denied: true, double_claim_denied: true, + }); + + seed(db, 'after', 'idempotent_with_key'); + const after = await pauseAt('after', 'after_effect', 'idempotent_with_key'); + assert.equal(provider('count'), 2); + await kill(after.child); + await leaseExpire(); + assert.equal(recover(db, 'after', lookup), 'reconciled_success'); + assert.equal(complete(db, after.claim.id, after.claim.token), false); + assert.equal(claim(db, 'after'), null); + replayProviderEffect('after'); + assert.equal(provider('count'), 2); + record('kill_after_effect_before_ack', 'after', 'reconciled_success', { + killed_worker_process_id: after.process_id, + stale_completion_denied: true, provider_replay_deduplicated: true, no_second_effect: true, + }); + + seed(db, 'unsupported', 'unsafe_or_unknown'); + const unsupported = await pauseAt('unsupported', 'after_effect', 'unsafe_or_unknown'); + assert.equal(provider('count'), 3); + await kill(unsupported.child); + await leaseExpire(); + assert.equal(recover(db, 'unsupported', lookup), 'unknown_outcome'); + assert.equal(claim(db, 'unsupported'), null); + assert.equal(provider('count'), 3); + record('unsupported_reconciliation', 'unsupported', 'unknown_outcome', { + killed_worker_process_id: unsupported.process_id, + no_unsafe_retry: true, + }); + + seed(db, 'cancel', 'idempotent_with_key'); + const canceled = await pauseAt('cancel', 'before_effect', 'idempotent_with_key'); + cancel(db, 'cancel'); + assert.equal(heartbeat(db, canceled.claim.id, canceled.claim.token, 2), false); + assert.equal(complete(db, canceled.claim.id, canceled.claim.token), false); + await kill(canceled.child); + await leaseExpire(); + assert.equal(recover(db, 'cancel', lookup), 'no_work'); + assert.equal(claim(db, 'cancel'), null); + assert.equal(provider('count'), 3); + record('cancellation_before_effect', 'cancel', 'no_work', { callback_denied: true }); + + seed(db, 'identity', 'idempotent_with_key'); + for (const field of ['org_id', 'actor_id', 'installation_id', 'version_id', 'grant_id', 'epoch']) { + assert.equal(claim(db, 'identity', { ...identity, [field]: field === 'epoch' ? 8 : 'foreign' }), null, + `${field} substitution must be denied`); + } + assert.equal(attempts(db, 'identity')[0].state, 'pending'); + record('identity_substitution', 'identity', 'not_claimed', { substitutions_denied: 6 }); + + const result = { + kind: 'simulated_external_runtime_experiment', + source: 'scripts/gate-g/runtime', + generated_at: new Date().toISOString(), + node: process.version, + coordinator_process_id: process.pid, + processes: 'coordinator, forked runtime worker, separately spawned durable provider CLI', + host_ledger: hostPath, + provider_ledger: providerPath, + observations, + caveat: 'This fixture models a proposed external claim protocol. It does not call production App Run services and cannot certify R03/R05.', + }; + writeFileSync(join(outputDir, 'results.json'), JSON.stringify(result, null, 2)); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} finally { + const cleanup = await Promise.allSettled([...activeWorkers].map(kill)); + db.close(); + const failures = cleanup.filter((result) => result.status === 'rejected'); + if (failures.length > 0) throw new AggregateError( + failures.map((result) => result.reason), 'failed to stop runtime workers'); +} diff --git a/scripts/gate-g/runtime/provider.mjs b/scripts/gate-g/runtime/provider.mjs new file mode 100644 index 00000000..18bc118b --- /dev/null +++ b/scripts/gate-g/runtime/provider.mjs @@ -0,0 +1,23 @@ +import { DatabaseSync } from 'node:sqlite'; + +const [command, path, key] = process.argv.slice(2); +if (!command || !path || !key) throw new Error('provider command, ledger path and key required'); +const db = new DatabaseSync(path); +db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; + CREATE TABLE IF NOT EXISTS effects ( + id INTEGER PRIMARY KEY, effect_key TEXT NOT NULL, created_at TEXT NOT NULL, + process_id INTEGER NOT NULL); + CREATE UNIQUE INDEX IF NOT EXISTS effects_idempotent ON effects(effect_key) + WHERE effect_key LIKE 'idempotent:%';`); +if (command === 'effect') { + db.prepare('INSERT OR IGNORE INTO effects(effect_key,created_at,process_id) VALUES (?,?,?)') + .run(key, new Date().toISOString(), process.pid); + process.stdout.write('recorded\n'); +} else if (command === 'lookup') { + const found = db.prepare('SELECT COUNT(*) AS n FROM effects WHERE effect_key=?').get(key).n; + process.stdout.write(`${found}\n`); +} else if (command === 'count') { + const found = db.prepare('SELECT COUNT(*) AS n FROM effects').get().n; + process.stdout.write(`${found}\n`); +} else throw new Error('unknown command'); +db.close(); diff --git a/scripts/gate-g/runtime/state.mjs b/scripts/gate-g/runtime/state.mjs new file mode 100644 index 00000000..7924a491 --- /dev/null +++ b/scripts/gate-g/runtime/state.mjs @@ -0,0 +1,144 @@ +import { DatabaseSync } from 'node:sqlite'; +import { randomUUID } from 'node:crypto'; + +const LEASE_MS = 1_500; + +export function openHost(path) { + const db = new DatabaseSync(path); + db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000; + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, org_id TEXT NOT NULL, actor_id TEXT NOT NULL, + installation_id TEXT NOT NULL, version_id TEXT NOT NULL, grant_id TEXT NOT NULL, + epoch INTEGER NOT NULL, retry_class TEXT NOT NULL, state TEXT NOT NULL, + cancelled INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS attempts ( + id TEXT PRIMARY KEY, run_id TEXT NOT NULL, number INTEGER NOT NULL, + state TEXT NOT NULL, claim_token TEXT, lease_until INTEGER, + sequence INTEGER NOT NULL DEFAULT 0, UNIQUE(run_id, number));`); + return db; +} + +export function seed(db, id, retryClass) { + db.prepare(`INSERT INTO runs VALUES (?, 'org-A', 'actor-A', 'installation-A', + 'version-A', 'grant-A', 7, ?, 'pending', 0)`).run(id, retryClass); + db.prepare(`INSERT INTO attempts(id,run_id,number,state) VALUES (?,?,1,'pending')`) + .run(`${id}:1`, id); +} + +export function run(db, id) { + return db.prepare('SELECT * FROM runs WHERE id=?').get(id); +} + +export function attempts(db, id) { + return db.prepare('SELECT * FROM attempts WHERE run_id=? ORDER BY number').all(id); +} + +function identityMatches(row, expected) { + return row && row.org_id === expected.org_id && row.actor_id === expected.actor_id + && row.installation_id === expected.installation_id + && row.version_id === expected.version_id && row.grant_id === expected.grant_id + && row.epoch === expected.epoch; +} + +export const identity = Object.freeze({ + org_id: 'org-A', actor_id: 'actor-A', installation_id: 'installation-A', + version_id: 'version-A', grant_id: 'grant-A', epoch: 7, +}); + +export function claim(db, runId, expected = identity) { + db.exec('BEGIN IMMEDIATE'); + try { + const row = run(db, runId); + if (!identityMatches(row, expected) || row.cancelled || row.state !== 'pending') { + db.exec('ROLLBACK'); + return null; + } + const attempt = db.prepare(`SELECT * FROM attempts WHERE run_id=? AND state='pending' + ORDER BY number LIMIT 1`).get(runId); + if (!attempt) { db.exec('ROLLBACK'); return null; } + const token = randomUUID(); + const lease = Date.now() + LEASE_MS; + const changed = db.prepare(`UPDATE attempts SET state='claimed',claim_token=?,lease_until=? + WHERE id=? AND state='pending'`).run(token, lease, attempt.id); + db.exec('COMMIT'); + return changed.changes === 1 ? { id: attempt.id, token, lease } : null; + } catch (error) { db.exec('ROLLBACK'); throw error; } +} + +export function heartbeat(db, attemptId, token, sequence) { + const changed = db.prepare(`UPDATE attempts SET lease_until=?,sequence=? + WHERE id=? AND claim_token=? AND sequence=? AND lease_until>? + AND state IN ('claimed','provider_call_started') + AND EXISTS (SELECT 1 FROM runs WHERE runs.id=attempts.run_id AND cancelled=0)`) + .run(Date.now() + LEASE_MS, sequence, attemptId, token, sequence - 1, Date.now()); + return changed.changes === 1; +} + +export function startCall(db, attemptId, token) { + const changed = db.prepare(`UPDATE attempts SET state='provider_call_started' + WHERE id=? AND claim_token=? AND state='claimed' AND lease_until>? + AND EXISTS (SELECT 1 FROM runs WHERE runs.id=attempts.run_id AND cancelled=0)`) + .run(attemptId, token, Date.now()); + return changed.changes === 1; +} + +export function complete(db, attemptId, token, expected = identity) { + db.exec('BEGIN IMMEDIATE'); + try { + const attempt = db.prepare('SELECT * FROM attempts WHERE id=?').get(attemptId); + const row = attempt && run(db, attempt.run_id); + if (!identityMatches(row, expected) || row.cancelled || row.state !== 'pending' + || attempt.state !== 'provider_call_started' || attempt.claim_token !== token + || attempt.lease_until <= Date.now()) { + db.exec('ROLLBACK'); return false; + } + db.prepare("UPDATE attempts SET state='succeeded' WHERE id=?").run(attemptId); + db.prepare("UPDATE runs SET state='succeeded' WHERE id=?").run(attempt.run_id); + db.exec('COMMIT'); return true; + } catch (error) { db.exec('ROLLBACK'); throw error; } +} + +export function cancel(db, runId) { + db.exec('BEGIN IMMEDIATE'); + try { + db.prepare("UPDATE runs SET cancelled=1,state='cancelled' WHERE id=?").run(runId); + db.prepare("UPDATE attempts SET state='cancelled' WHERE run_id=? AND state IN ('pending','claimed','provider_call_started')") + .run(runId); + db.exec('COMMIT'); + } catch (error) { db.exec('ROLLBACK'); throw error; } +} + +export function recover(db, runId, providerLookup) { + const current = run(db, runId); + if (!current || current.cancelled || current.state !== 'pending') return 'no_work'; + const attempt = db.prepare(`SELECT * FROM attempts WHERE run_id=? + AND state IN ('claimed','provider_call_started') ORDER BY number DESC LIMIT 1`).get(runId); + if (!attempt || attempt.lease_until > Date.now()) return 'lease_live'; + // The provider lookup is outside the host transaction. Only an idempotent, + // durable lookup by the stable Run key is allowed to resolve an ambiguous call. + const observed = attempt.state === 'provider_call_started' + && current.retry_class === 'idempotent_with_key' ? providerLookup(runId) : false; + db.exec('BEGIN IMMEDIATE'); + try { + const latest = db.prepare('SELECT * FROM attempts WHERE id=?').get(attempt.id); + if (latest.state !== attempt.state || latest.claim_token !== attempt.claim_token + || latest.lease_until > Date.now() || run(db, runId).state !== 'pending') { + db.exec('ROLLBACK'); return 'changed'; + } + if (observed) { + db.prepare("UPDATE attempts SET state='succeeded' WHERE id=?").run(attempt.id); + db.prepare("UPDATE runs SET state='succeeded' WHERE id=?").run(runId); + db.exec('COMMIT'); return 'reconciled_success'; + } + const ambiguous = attempt.state === 'provider_call_started'; + db.prepare('UPDATE attempts SET state=? WHERE id=?') + .run(ambiguous ? 'unknown_outcome' : 'failed', attempt.id); + if (ambiguous && current.retry_class !== 'idempotent_with_key') { + db.prepare("UPDATE runs SET state='unknown_outcome' WHERE id=?").run(runId); + db.exec('COMMIT'); return 'unknown_outcome'; + } + db.prepare("INSERT INTO attempts(id,run_id,number,state) VALUES (?,?,?,'pending')") + .run(`${runId}:${attempt.number + 1}`, runId, attempt.number + 1); + db.exec('COMMIT'); return 'retry_prepared'; + } catch (error) { db.exec('ROLLBACK'); throw error; } +} diff --git a/scripts/gate-g/runtime/worker.mjs b/scripts/gate-g/runtime/worker.mjs new file mode 100644 index 00000000..b8e74845 --- /dev/null +++ b/scripts/gate-g/runtime/worker.mjs @@ -0,0 +1,40 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { claim, complete, heartbeat, openHost, startCall } from './state.mjs'; + +const [hostPath, providerPath, runId, mode, retryClass] = process.argv.slice(2); +const db = openHost(hostPath); +const claimed = claim(db, runId); +process.send?.({ event: 'claim', accepted: Boolean(claimed), claim: claimed, process_id: process.pid }); +if (!claimed) process.exit(0); +if (!heartbeat(db, claimed.id, claimed.token, 1)) throw new Error('heartbeat rejected'); +process.send?.({ event: 'heartbeat' }); +let sequence = 1; +const heartbeatTimer = setInterval(() => { + sequence += 1; + if (!heartbeat(db, claimed.id, claimed.token, sequence)) clearInterval(heartbeatTimer); +}, 200); +if (!startCall(db, claimed.id, claimed.token)) throw new Error('start rejected'); +if (mode === 'before_effect') { + process.send?.({ event: 'before_effect' }); + await new Promise(() => setInterval(() => {}, 1_000)); +} +const providerScript = fileURLToPath(new URL('./provider.mjs', import.meta.url)); +const key = retryClass === 'idempotent_with_key' + ? `idempotent:${runId}` : `unsafe:${runId}:${claimed.id}`; +const effect = await new Promise((resolve, reject) => { + const processHandle = spawn(process.execPath, [providerScript, 'effect', providerPath, key], + { timeout: 12_000, killSignal: 'SIGKILL' }); + let stderr = ''; + processHandle.stderr.on('data', (chunk) => { stderr += chunk; }); + processHandle.on('error', reject); + processHandle.on('exit', (code) => resolve({ code, stderr })); +}); +if (effect.code !== 0) throw new Error(effect.stderr); +if (mode === 'after_effect') { + process.send?.({ event: 'after_effect' }); + await new Promise(() => setInterval(() => {}, 1_000)); +} +process.send?.({ event: 'completion', accepted: complete(db, claimed.id, claimed.token) }); +clearInterval(heartbeatTimer); +db.close(); diff --git a/scripts/gate-g/verify-upgrade.mjs b/scripts/gate-g/verify-upgrade.mjs new file mode 100644 index 00000000..66e92b27 --- /dev/null +++ b/scripts/gate-g/verify-upgrade.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const require = createRequire(new URL('../../packages/db/package.json', import.meta.url)); +const { Client } = require('pg'); +const [mode, path] = process.argv.slice(2); +if (!['snapshot', 'compare', 'schema'].includes(mode) || !path) throw new Error('Usage: verify-upgrade.mjs snapshot|compare|schema evidence.json'); +const url = new URL(process.env.DEFT_TEST_DATABASE_URL ?? 'invalid:'); +if (!['postgres:', 'postgresql:'].includes(url.protocol) || url.hostname !== '127.0.0.1' || + url.port !== '55435' || !/^\/gate_g_[a-z0-9_]+$/.test(url.pathname) || url.search || url.hash) { + throw new Error('Only the assigned disposable Gate G database cluster is supported'); +} +const client = new Client({ connectionString: url.href }); +const tables = ['app_installations', 'app_versions', 'app_grant_snapshots', 'app_runs', + 'app_run_attempts', 'app_run_receipts', 'module_records']; +const quote = (value) => { + if (!/^[a-z][a-z0-9_]*$/.test(value)) throw new Error('Unexpected database identifier'); + return `"${value}"`; +}; +function stable(value) { + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); + return value; +} +await client.connect(); +try { + if (mode === 'schema') { + const selected = ['app_runs', 'app_run_attempts', 'capability_provider_snapshots']; + const discovered = await client.query("SELECT tablename FROM pg_tables WHERE schemaname='public' AND (tablename LIKE 'app_runtime_%' OR tablename LIKE 'app_public_%' OR tablename='app_canonical_claims') ORDER BY tablename"); + selected.push(...discovered.rows.map((row) => row.tablename)); + const columns = await client.query("SELECT table_name,column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' AND table_name=ANY($1) ORDER BY table_name,column_name", [selected]); + const constraints = await client.query("SELECT c.relname AS table_name,k.conname AS name,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=ANY($1) ORDER BY c.relname,k.conname", [selected]); + const indexes = await client.query("SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=ANY($1) ORDER BY tablename,indexname", [selected]); + const triggers = await client.query("SELECT c.relname AS table_name,t.tgname AS name,pg_get_triggerdef(t.oid) AS definition,pg_get_functiondef(t.tgfoid) AS function FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=ANY($1) AND NOT t.tgisinternal ORDER BY c.relname,t.tgname", [selected]); + writeFileSync(path, JSON.stringify({ columns: columns.rows, constraints: constraints.rows, indexes: indexes.rows, triggers: triggers.rows }, null, 2)); + console.log(`Captured ${selected.length} table definitions`); + } else { + const previous = mode === 'compare' ? JSON.parse(readFileSync(path, 'utf8')) : null; + const result = {}; + for (const table of tables) { + const columns = previous?.[table]?.columns ?? (await client.query("SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=$1 ORDER BY column_name", [table])).rows.map((row) => row.column_name); + if (!columns.length) throw new Error(`Missing table ${table}`); + const rows = (await client.query(`SELECT ${columns.map(quote).join(',')} FROM ${quote(table)} ORDER BY id`)).rows; + result[table] = { columns, count: rows.length, sha256: createHash('sha256').update(JSON.stringify(stable(rows))).digest('hex') }; + } + if (previous) { + assert.deepEqual(result, previous, 'Upgrade changed retained data'); + console.log(JSON.stringify({ retained_data_unchanged: true, tables: Object.fromEntries(Object.entries(result).map(([name, value]) => [name, value.count])) }, null, 2)); + } else { + writeFileSync(path, JSON.stringify(result, null, 2)); + console.log('Retained-data snapshot written; no record contents exported'); + } + } +} finally { await client.end(); } From 8bce1b46974155c583bfef0351f433346d32330a Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:38:54 +0530 Subject: [PATCH 002/161] Add reviewed Runtime App authoring and human execution --- apps/api/src/index.ts | 12 + apps/api/src/lib/app-grant-service.ts | 2 +- apps/api/src/lib/app-review-service.ts | 6 +- apps/api/src/lib/app-run-attempt-runner.ts | 76 +- .../api/src/lib/app-run-live-authorization.ts | 206 ++++++ apps/api/src/lib/app-run-repository.ts | 52 ++ apps/api/src/lib/app-run-service.ts | 312 +++++++- .../api/src/lib/app-runtime-action-service.ts | 47 ++ apps/api/src/lib/app-runtime-authority.ts | 75 +- apps/api/src/lib/app-runtime-channel.ts | 5 +- apps/api/src/lib/app-runtime-management.ts | 376 ++++++++++ apps/api/src/lib/app-runtime-review.ts | 149 ++++ apps/api/src/lib/app-service.ts | 6 +- apps/api/src/lib/env.ts | 6 + apps/api/src/routes/app-runtime-actions.ts | 88 +++ apps/api/src/routes/app-runtime-management.ts | 111 +++ apps/api/src/routes/app-runtime-review.ts | 36 + apps/api/test/app-runtime-actions-db.test.ts | 139 ++++ .../test/app-runtime-actions-http-db.test.ts | 232 ++++++ apps/api/test/app-runtime-channel-db.test.ts | 682 ++++++++++-------- apps/api/test/app-runtime-review-db.test.ts | 134 ++++ .../fixtures/app-runtime-provider-child.ts | 85 +++ .../test/fixtures/app-runtime-review-lock.ts | 71 ++ apps/api/test/fixtures/runtime-v3-package.ts | 25 + apps/web/src/components/agent-action-card.tsx | 37 +- .../components/runtime-app-input-review.tsx | 89 +++ .../src/lib/agent-action-presentation.test.ts | 17 + apps/web/src/lib/agent-action-presentation.ts | 23 +- .../web/src/lib/app-experience-bridge.test.ts | 141 ++++ apps/web/src/lib/app-experience-bridge.ts | 247 +++++++ apps/web/src/lib/app-experience-renderer.ts | 192 +++++ apps/web/src/lib/runtime-app-review.test.ts | 28 + apps/web/src/lib/runtime-app-review.ts | 53 ++ package.json | 1 + packages/app-kit/package.json | 2 +- packages/app-kit/src/cli.ts | 25 +- packages/app-kit/src/experience-sdk.ts | 55 ++ packages/app-kit/src/experience.ts | 108 +++ packages/app-kit/src/index.ts | 77 +- packages/app-kit/src/runtime-authoring.ts | 85 +++ packages/app-kit/src/runtime-client.ts | 141 ++++ packages/app-kit/test/cli.test.ts | 12 +- .../app-kit/test/developer-contract.test.ts | 7 +- packages/app-kit/test/experience-sdk.test.ts | 38 + packages/app-kit/test/experience.test.ts | 46 ++ packages/app-kit/test/packed-external.test.ts | 2 +- packages/app-kit/test/protocol-v2.test.ts | 2 +- .../app-kit/test/runtime-authoring.test.ts | 53 ++ packages/app-kit/test/runtime-client.test.ts | 56 ++ packages/db/scripts/apply-extras.ts | 1 + packages/db/src/schema.ts | 2 +- ...0.3.0-preview.33-app-runtime-authoring.sql | 103 +++ packages/db/upgrades/manifest.ts | 5 + packages/shared/src/app-runs.ts | 11 +- packages/shared/test/app-runs.test.ts | 10 + scripts/gate-g/README.md | 2 +- .../gate-g/experiences/author-rich-worker.js | 68 ++ .../gate-g/experiences/rich-browser-check.mjs | 102 +++ scripts/gate-g/experiences/rich-host.html | 72 ++ scripts/gate-g/experiences/rich-server.mjs | 79 ++ .../runtime-approval-card-browser-entry.tsx | 23 + .../runtime-review-browser-entry.tsx | 21 + .../experiences/runtime-review-browser.mjs | 121 ++++ scripts/gate-g/required-tests.json | 66 +- scripts/gate-g/verify-upgrade.mjs | 5 +- scripts/runtime-packed-author.test.mts | 39 + 66 files changed, 4765 insertions(+), 435 deletions(-) create mode 100644 apps/api/src/lib/app-runtime-action-service.ts create mode 100644 apps/api/src/lib/app-runtime-management.ts create mode 100644 apps/api/src/lib/app-runtime-review.ts create mode 100644 apps/api/src/routes/app-runtime-actions.ts create mode 100644 apps/api/src/routes/app-runtime-management.ts create mode 100644 apps/api/src/routes/app-runtime-review.ts create mode 100644 apps/api/test/app-runtime-actions-db.test.ts create mode 100644 apps/api/test/app-runtime-actions-http-db.test.ts create mode 100644 apps/api/test/app-runtime-review-db.test.ts create mode 100644 apps/api/test/fixtures/app-runtime-provider-child.ts create mode 100644 apps/api/test/fixtures/app-runtime-review-lock.ts create mode 100644 apps/api/test/fixtures/runtime-v3-package.ts create mode 100644 apps/web/src/components/runtime-app-input-review.tsx create mode 100644 apps/web/src/lib/app-experience-bridge.test.ts create mode 100644 apps/web/src/lib/app-experience-bridge.ts create mode 100644 apps/web/src/lib/app-experience-renderer.ts create mode 100644 apps/web/src/lib/runtime-app-review.test.ts create mode 100644 apps/web/src/lib/runtime-app-review.ts create mode 100644 packages/app-kit/src/experience-sdk.ts create mode 100644 packages/app-kit/src/experience.ts create mode 100644 packages/app-kit/src/runtime-authoring.ts create mode 100644 packages/app-kit/src/runtime-client.ts create mode 100644 packages/app-kit/test/experience-sdk.test.ts create mode 100644 packages/app-kit/test/experience.test.ts create mode 100644 packages/app-kit/test/runtime-authoring.test.ts create mode 100644 packages/app-kit/test/runtime-client.test.ts create mode 100644 packages/db/upgrades/0.3.0-preview.33-app-runtime-authoring.sql create mode 100644 scripts/gate-g/experiences/author-rich-worker.js create mode 100644 scripts/gate-g/experiences/rich-browser-check.mjs create mode 100644 scripts/gate-g/experiences/rich-host.html create mode 100644 scripts/gate-g/experiences/rich-server.mjs create mode 100644 scripts/gate-g/experiences/runtime-approval-card-browser-entry.tsx create mode 100644 scripts/gate-g/experiences/runtime-review-browser-entry.tsx create mode 100644 scripts/gate-g/experiences/runtime-review-browser.mjs create mode 100644 scripts/runtime-packed-author.test.mts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 17e69a3a..eb08fb67 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -73,6 +73,10 @@ import { appRoutes } from './routes/apps.js'; import { appActionRoutes } from './routes/app-actions.js'; import { appRunRoutes } from './routes/app-runs.js'; import { appDeveloperRoutes } from './routes/app-developer.js'; +import { appRuntimeChannelRoutes } from './routes/app-runtime-channel.js'; +import { appRuntimeManagementRoutes } from './routes/app-runtime-management.js'; +import { appRuntimeReviewRoutes } from './routes/app-runtime-review.js'; +import { appRuntimeActionRoutes } from './routes/app-runtime-actions.js'; import { APPS_ENABLED, APP_DEVELOPER_PAIRING_ENABLED } from './lib/env.js'; import { moduleTaskLinkRoutes } from './routes/module-task-links.js'; import { authMiddleware } from './middleware/auth.js'; @@ -184,6 +188,11 @@ if (APP_DEVELOPER_PAIRING_ENABLED) { app.use('/api/app-developer/*', authLimiter); app.route('/api/app-developer', appDeveloperRoutes); } +// Runtime credentials have a separate audience; never accept browser cookies. +if (APPS_ENABLED) { + app.use('/api/app-runtime/channel/*', authLimiter); + app.route('/api/app-runtime/channel', appRuntimeChannelRoutes); +} app.use('/api/*', authMiddleware); app.use('/api/*', defaultLimiter); app.use('/api/agent/*', agentLimiter); @@ -245,6 +254,9 @@ app.route('/api/task-templates', taskTemplateRoutes); app.route('/api/work-intents', workIntentRoutes); app.route('/api/modules', moduleRoutes); if (APPS_ENABLED) { + app.route('/api/apps/runtime', appRuntimeManagementRoutes); + app.route('/api/app-runtime-review', appRuntimeReviewRoutes); + app.route('/api/app-runtime-actions', appRuntimeActionRoutes); app.route('/api/apps', appRoutes); app.route('/api/app-actions', appActionRoutes); app.route('/api/app-runs', appRunRoutes); diff --git a/apps/api/src/lib/app-grant-service.ts b/apps/api/src/lib/app-grant-service.ts index 0962b389..ffe7ae74 100644 --- a/apps/api/src/lib/app-grant-service.ts +++ b/apps/api/src/lib/app-grant-service.ts @@ -59,7 +59,7 @@ export function buildRequestedAppGrantProjection(input: { const protocol = input.manifest.compatibility.app_protocol; const portable = projectDeftAppRequestedAuthority(input.manifest); const requirements = portable.requirements; - const resourceRights = portable.resource_rights; + const resourceRights = 'resource_rights' in portable ? portable.resource_rights : []; const classification = portable.classification; const canonicalSnapshot = canonicalizeAppGrantValue({ snapshot_version: APP_GRANT_SNAPSHOT_VERSION, diff --git a/apps/api/src/lib/app-review-service.ts b/apps/api/src/lib/app-review-service.ts index 3a2d0bd3..2e033714 100644 --- a/apps/api/src/lib/app-review-service.ts +++ b/apps/api/src/lib/app-review-service.ts @@ -1477,7 +1477,8 @@ export async function getConnectedAppGrantManagement( }, })); - const dependencyRequirements = requestedAuthority?.requirements.dependencies ?? []; + const dependencyRequirements = requestedAuthority && 'dependencies' in requestedAuthority.requirements + ? requestedAuthority.requirements.dependencies : []; const dependencyInstallations = dependencyRequirements.length === 0 ? [] : await db.select().from(appInstallations).where(and( @@ -1514,7 +1515,8 @@ export async function getConnectedAppGrantManagement( }; }); - const connectorRequirements = requestedAuthority?.requirements.connectors ?? []; + const connectorRequirements = requestedAuthority && 'connectors' in requestedAuthority.requirements + ? requestedAuthority.requirements.connectors : []; const connections = connectorRequirements.length === 0 ? [] : await db.select({ diff --git a/apps/api/src/lib/app-run-attempt-runner.ts b/apps/api/src/lib/app-run-attempt-runner.ts index 99feb2ba..86325c13 100644 --- a/apps/api/src/lib/app-run-attempt-runner.ts +++ b/apps/api/src/lib/app-run-attempt-runner.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; import { appRunAttempts, appRuntimeSessions } from '@deft/db/schema'; +import { parseRuntimeObjectInput } from '@deft/app-kit'; import { APP_RUN_CONTRACT_VERSIONS, AppRunRetainedProviderResultSchema, @@ -209,7 +210,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { || !claimed.attempt.runtime_sequence || !claimed.attempt.runtime_binding_id || claimed.attempt.runtime_session_epoch === null || claimed.attempt.runtime_epoch === null) return null; const authority = await this.repository.transaction((tx) => loadLiveRuntimeAuthority( - tx, input.org_id, input.session_id, input.token_hash, this.now)); + tx, input.org_id, input.session_id, input.token_hash, this.now, input.run_id)); if (!authority) return null; return Object.freeze({ schema_version: APP_RUNTIME_CHANNEL_VERSION, @@ -239,11 +240,12 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { { session_id: input.session_id, token_hash: input.token_hash }); if (!boundary) return null; const exactInput = await this.repository.transaction(async (tx) => { + const run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.provider_kind !== 'app_runtime') return null; const authority = await loadLiveRuntimeAuthority(tx, input.org_id, - input.session_id, input.token_hash, this.now); + input.session_id, input.token_hash, this.now, input.run_id); if (!authority) return null; - const run = await this.repository.lockRun(tx, input.org_id, input.run_id); - if (!run || run.state !== 'running' || run.cancel_requested_at + if (run.state !== 'running' || run.cancel_requested_at || !run.execution_released_at || run.input_expires_at <= this.now() || !await runtimeRunMatchesAuthority(tx, input.org_id, input.run_id, authority)) return null; await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} @@ -289,11 +291,11 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { fingerprintValue).map((candidate) => candidate.fingerprint)); const now = this.now(); const completed = await this.repository.transaction(async (tx) => { - const authority = await loadLiveRuntimeAuthority(tx, input.org_id, - result.session_id, input.token_hash, this.now); - if (!authority) return false; const run = await this.repository.lockRun(tx, input.org_id, result.run_id); if (!run || run.provider_kind !== 'app_runtime') return false; + const authority = await loadLiveRuntimeAuthority(tx, input.org_id, + result.session_id, input.token_hash, this.now, result.run_id); + if (!authority) return false; await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} AND id = ${result.attempt_id} FOR UPDATE`); const [attempt] = await tx.select().from(appRunAttempts).where(and( @@ -305,11 +307,21 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { || attempt.runtime_sequence !== result.sequence) return false; if (!authority || authority.pin.runtime_binding_id !== attempt.runtime_binding_id || authority.pin.runtime_epoch !== attempt.runtime_epoch - || authority.pin.session_epoch !== attempt.runtime_session_epoch - || !await runtimeRunMatchesAuthority(tx, input.org_id, result.run_id, authority)) return false; + || authority.pin.session_epoch !== attempt.runtime_session_epoch) return false; + const reviewedAction = await runtimeRunMatchesAuthority(tx, input.org_id, result.run_id, authority); + if (!reviewedAction) return false; if (attempt.runtime_result_hmac) return replayDigests.has(attempt.runtime_result_hmac); if (attempt.state !== 'provider_call_started' || !attempt.lease_expires_at || attempt.lease_expires_at <= this.now()) return false; + if (result.status === 'returned') { + try { parseRuntimeObjectInput(reviewedAction.output_schema, result.output); } + catch { + // The provider may already have made the external effect. Invalid + // output cannot be signed as success and must not trigger a retry. + await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); + return true; + } + } if (result.status === 'indeterminate') { await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); return true; @@ -345,12 +357,12 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { token_hash: string; claim_token: string; sequence: number; }>): Promise { return this.repository.transaction(async (tx) => { - const authority = await loadLiveRuntimeAuthority(tx, input.org_id, - input.session_id, input.token_hash, this.now); - if (!authority) return null; const run = await this.repository.lockRun(tx, input.org_id, input.run_id); if (!run || run.provider_kind !== 'app_runtime') return null; - if (!authority || !await runtimeRunMatchesAuthority(tx, input.org_id, input.run_id, authority)) return null; + const authority = await loadLiveRuntimeAuthority(tx, input.org_id, + input.session_id, input.token_hash, this.now, input.run_id); + if (!authority) return null; + if (!await runtimeRunMatchesAuthority(tx, input.org_id, input.run_id, authority)) return null; const [attempt] = await tx.select().from(appRunAttempts).where(and( eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.run_id, input.run_id), eq(appRunAttempts.id, input.attempt_id), @@ -404,11 +416,11 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { )).limit(1); if (!identity) return false; return this.repository.transaction(async (tx) => { - const authority = runtime ? await loadLiveRuntimeAuthority(tx, orgId, - runtime.session_id, runtime.token_hash, this.now) : null; - if (runtime && !authority) return false; const run = await this.repository.lockRun(tx, orgId, identity.run_id); if (!run || (runtime ? run.provider_kind !== 'app_runtime' : run.provider_kind !== 'mcp')) return false; + const authority = runtime ? await loadLiveRuntimeAuthority(tx, orgId, + runtime.session_id, runtime.token_hash, this.now, identity.run_id) : null; + if (runtime && !authority) return false; if (runtime && (!authority || runtime.run_id !== run.id || !await runtimeRunMatchesAuthority(tx, orgId, run.id, authority))) return false; await tx.execute(sql`SELECT id FROM app_run_attempts @@ -523,18 +535,15 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { ): Promise { const now = this.now(); return this.repository.transaction(async (tx) => { - const runtimeAuthority = runtime ? await loadLiveRuntimeAuthority( - tx, orgId, runtime.session_id, runtime.token_hash, this.now) : null; - if (runtime && !runtimeAuthority) return null; let run = await this.repository.lockRun(tx, orgId, runId); + if (!run || (runtime ? run.provider_kind !== 'app_runtime' + : run.provider_kind !== 'mcp')) return null; + const runtimeAuthority = runtime ? await loadLiveRuntimeAuthority( + tx, orgId, runtime.session_id, runtime.token_hash, this.now, runId) : null; + if (runtime && (!runtimeAuthority + || !await runtimeRunMatchesAuthority(tx, orgId, runId, runtimeAuthority))) return null; if ( - !run - || (runtime ? ( - run.provider_kind !== 'app_runtime' - || !runtimeAuthority - || !await runtimeRunMatchesAuthority(tx, orgId, runId, runtimeAuthority) - ) : run.provider_kind !== 'mcp') - || !run.execution_released_at + !run.execution_released_at || ['succeeded', 'failed', 'cancelled', 'expired', 'unknown_outcome'].includes(run.state) || (!runtime && !await this.executionAuthorizer.authorizeExecution({ org_id: orgId, run, tx, stage: 'claim', now, @@ -636,16 +645,15 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { }> | null> { const now = this.now(); return this.repository.transaction(async (tx) => { - const authority = runtime ? await loadLiveRuntimeAuthority(tx, claimed.run.org_id, - runtime.session_id, runtime.token_hash, this.now) : null; - if (runtime && !authority) return null; let run = await this.repository.lockRun(tx, claimed.run.org_id, claimed.run.id); + if (!run || (runtime ? run.provider_kind !== 'app_runtime' + : run.provider_kind !== 'mcp')) return null; + const authority = runtime ? await loadLiveRuntimeAuthority(tx, claimed.run.org_id, + runtime.session_id, runtime.token_hash, this.now, claimed.run.id) : null; + if (runtime && (!authority + || !await runtimeRunMatchesAuthority(tx, run.org_id, run.id, authority))) return null; if ( - !run - || (runtime ? (run.provider_kind !== 'app_runtime' || !authority - || !await runtimeRunMatchesAuthority(tx, run.org_id, run.id, authority)) - : run.provider_kind !== 'mcp') - || !run.execution_released_at + !run.execution_released_at || run.state === 'cancelled' || (runtime && (run.cancel_requested_at || run.input_expires_at <= this.now())) || (!runtime && !await this.executionAuthorizer.authorizeExecution({ diff --git a/apps/api/src/lib/app-run-live-authorization.ts b/apps/api/src/lib/app-run-live-authorization.ts index e7b6c466..c4f2b301 100644 --- a/apps/api/src/lib/app-run-live-authorization.ts +++ b/apps/api/src/lib/app-run-live-authorization.ts @@ -29,6 +29,8 @@ import { appGrantSnapshots, appInstallations, appModuleBindings, + appRuntimeBindings, + appRuntimeRegistrations, appRuns, appVersions, capabilityProviderSnapshots, @@ -68,6 +70,8 @@ import { type AppRunPreparedAuthorityVectorV2, } from './app-run-prepared-input.js'; import { APP_AUTOMATION_POLICY_DIGEST, digestAppAutomationFireIdentity } from './app-automation-definition-service.js'; +import { loadReviewedRuntimeAction } from './app-runtime-review.js'; +import { APP_RUNTIME_CHANNEL_VERSION } from './app-runtime-contract.js'; const HOST_POLICY_VERSION = 'deft.app_run.host_policy.v1'; const APP_MCP_INVOKE_SCOPES = Object.freeze(['read:modules', 'invoke:apps'] as const); @@ -113,6 +117,7 @@ type InternalRunAuthorization = Readonly<{ origin_app_installation_id: string | null; origin_app_version_id: string | null; origin_app_binding_key: string | null; + origin_runtime_binding_id: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id: string | null; origin_app_automation_fire_id: string | null; @@ -263,6 +268,176 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize }); } + /** Host-only v3 Runtime capture. The public App Kit cannot assert these + * pins: a reviewed binding and the live effective grant are required. */ + async captureReviewedRuntimeForPreparation(input: Readonly<{ + org_id: string; user_id: string; runtime_binding_id: string; + }>) { + return db.transaction((tx) => this.captureReviewedRuntimeInTransaction(tx, input)); + } + + async captureReviewedRuntimeInTransaction(tx: AppRunTransaction, input: Readonly<{ + org_id: string; user_id: string; runtime_binding_id: string; + }>) { + const [locator] = await tx.select({ + app_installation_id: appRuntimeBindings.app_installation_id, + app_version_id: appRuntimeBindings.app_version_id, + grant_snapshot_id: appRuntimeBindings.grant_snapshot_id, + runtime_registration_id: appRuntimeBindings.runtime_registration_id, + action_key: appRuntimeBindings.action_key, + }).from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, input.org_id), + eq(appRuntimeBindings.id, input.runtime_binding_id), + )).limit(1); + if (!locator) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const [registrationLocator] = await tx.select({ + operator_user_id: appRuntimeRegistrations.operator_user_id, + }).from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, input.org_id), + eq(appRuntimeRegistrations.id, locator.runtime_registration_id), + )).limit(1); + if (!registrationLocator) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + for (const userId of [...new Set([input.user_id, + registrationLocator.operator_user_id])].sort()) { + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${input.org_id} + AND user_id = ${userId} FOR SHARE`); + } + const memberRef = await this.#membership(tx, input.org_id, input.user_id); + const [member] = await tx.select({ role: orgMembers.role, + is_active: orgMembers.is_active }).from(orgMembers).where(and( + eq(orgMembers.org_id, input.org_id), eq(orgMembers.user_id, input.user_id), + )).limit(1); + if (!member?.is_active || member.role === 'guest') throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const [operator] = await tx.select({ is_active: orgMembers.is_active, + role: orgMembers.role }).from(orgMembers).where(and( + eq(orgMembers.org_id, input.org_id), + eq(orgMembers.user_id, registrationLocator.operator_user_id), + )).limit(1); + if (!operator?.is_active || operator.role === 'guest') throw new Error('APP_RUN_AUTHORIZATION_STALE'); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${input.org_id} + AND id = ${locator.app_installation_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_versions WHERE org_id = ${input.org_id} + AND id = ${locator.app_version_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_grant_snapshots WHERE org_id = ${input.org_id} + AND id = ${locator.grant_snapshot_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${input.org_id} + AND id = ${locator.runtime_registration_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_bindings WHERE org_id = ${input.org_id} + AND id = ${input.runtime_binding_id} FOR SHARE`); + const reviewed = await loadReviewedRuntimeAction(tx, input.org_id, + locator.app_installation_id, locator.action_key); + const { installation, version, grant, action } = reviewed; + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, input.org_id), + eq(appRuntimeRegistrations.id, locator.runtime_registration_id), + )).limit(1); + const [binding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, input.org_id), + eq(appRuntimeBindings.id, input.runtime_binding_id), + )).limit(1); + if (!registration || !binding || registration.state !== 'active' + || registration.contract_version !== APP_RUNTIME_CHANNEL_VERSION + || binding.state !== 'active' + || installation.state !== 'active' || version.state !== 'active' + || grant.snapshot_kind !== 'effective' + || installation.active_version_id !== version.id + || installation.active_grant_snapshot_id !== grant.id + || binding.app_installation_id !== installation.id + || binding.app_version_id !== version.id + || binding.grant_snapshot_id !== grant.id + || binding.runtime_registration_id !== registration.id + || registration.app_installation_id !== installation.id + || registration.app_version_id !== version.id + || registration.grant_snapshot_id !== grant.id + || registration.operator_user_id !== registrationLocator.operator_user_id + || binding.action_key !== action.action_key + || binding.operation_name !== action.operation_name + || binding.provider_kind !== 'app_runtime' + || binding.provider_instance_id !== registration.id + || binding.interface_identity !== `deft.runtime.v1:${input.org_id.toLowerCase()}:${installation.id.toLowerCase()}:${action.action_key}` + || binding.risk_class !== action.host_policy.risk_class + || binding.review_requirement !== action.host_policy.review_requirement + || binding.retry_class !== action.host_policy.retry_class + || binding.retention_class !== action.host_policy.retention_class + || action.host_policy.review_scope !== 'per_invocation' + || grant.snapshot_digest !== digestAppGrantValue(grant.canonical_snapshot)) { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } + const [providerRow] = await tx.select().from(capabilityProviderSnapshots).where(and( + eq(capabilityProviderSnapshots.org_id, input.org_id), + eq(capabilityProviderSnapshots.id, binding.provider_snapshot_id), + eq(capabilityProviderSnapshots.provider_kind, 'app_runtime'), + eq(capabilityProviderSnapshots.provider_instance_id, registration.id), + )).limit(1); + if (!providerRow || providerRow.adapter_contract_version !== APP_RUNTIME_CHANNEL_VERSION) { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } + const provider = CapabilityProviderDiscoverySnapshotSchema.parse(providerRow.safe_snapshot); + const operation = provider.operations.find((item) => item.identity.operation_name === binding.operation_name); + if (provider.snapshot_digest !== providerRow.snapshot_digest + || provider.provider.org_id !== input.org_id + || provider.provider.provider_kind !== 'app_runtime' + || provider.provider.provider_instance_id !== registration.id + || provider.operations.length !== 1 || !operation + || digestAppGrantValue(operation.input_schema) !== digestAppGrantValue(action.input_schema) + || digestAppGrantValue(operation.output_schema) !== digestAppGrantValue(action.output_schema)) { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } + const refs: AuthorityRef[] = [ + memberRef, + { authority_kind: 'app_surface', authority_id: 'human:ui', + version: authorityVersion('app_surface', { surface: 'human:ui', provider_kind: 'app_runtime' }) }, + { authority_kind: 'app_installation', authority_id: installation.id, + version: authorityVersion('app_installation', { + lifecycle_epoch: installation.lifecycle_epoch, grant_epoch: installation.grant_epoch, + }) }, + { authority_kind: 'app_version', authority_id: version.id, + version: authorityVersion('app_version', { + manifest_digest: version.manifest_digest, package_digest: version.package_digest, + }) }, + { authority_kind: 'app_grant', authority_id: grant.id, + version: authorityVersion('app_grant', { snapshot_digest: grant.snapshot_digest }) }, + { authority_kind: 'app_runtime_registration', authority_id: registration.id, + version: authorityVersion('app_runtime_registration', { + app_version_id: registration.app_version_id, + grant_snapshot_id: registration.grant_snapshot_id, + contract_version: registration.contract_version, + operator_user_id: registration.operator_user_id, + runtime_epoch: registration.runtime_epoch, + state: registration.state, + }) }, + { authority_kind: 'app_runtime_binding', authority_id: binding.id, + version: authorityVersion('app_runtime_binding', { + registration_id: registration.id, + action_key: binding.action_key, + contract_digest: action.contract_digest, + provider_snapshot_id: binding.provider_snapshot_id, + state: binding.state, + }) }, + { authority_kind: 'provider_schema', authority_id: `${registration.id}:${binding.operation_name}`, + version: authorityVersion('provider_schema', { + snapshot_id: providerRow.id, snapshot_digest: providerRow.snapshot_digest, + schema_digest: operation.schema_digest, + }) }, + { authority_kind: 'policy', authority_id: `${registration.id}:${binding.operation_name}`, + version: authorityVersion('policy', { + host_policy_version: 'deft.app_runtime.host_policy.v1', + policy: action.host_policy, + }) }, + ]; + const authorization_snapshot = AppRunAuthorizationSnapshotSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + authenticated_subject: { actor_type: 'human', user_id: input.user_id }, + authority_refs: refs.sort((a, b) => `${a.authority_kind}\0${a.authority_id}` + .localeCompare(`${b.authority_kind}\0${b.authority_id}`)), + }); + return Object.freeze({ authorization_snapshot, binding, registration, action, + provider_snapshot_digest: providerRow.snapshot_digest, + review_contract_digest: action.contract_digest, + installation_lifecycle_epoch: installation.lifecycle_epoch, + installation_grant_epoch: installation.grant_epoch }); + } + /** Revalidate an exact MCP/OAuth token and its current scopes for actor- * scoped Run reads. This does not create or mutate Run authority. */ async assertTokenScopes(input: AppRunTokenScopeAuthorization): Promise { @@ -285,6 +460,7 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize if ( (prepared.schema_version !== 'deft.app_action_authority.v1' && !isAutomation) || submission.origin.origin_kind !== 'app' + || !('binding_key' in submission.origin) || submission.org_id !== submission.operation.provider.org_id || submission.origin.installation_id !== prepared.installation.id || submission.origin.app_version_id !== prepared.app_version.id @@ -484,6 +660,7 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize origin_app_installation_id: appRuns.origin_app_installation_id, origin_app_version_id: appRuns.origin_app_version_id, origin_app_binding_key: appRuns.origin_app_binding_key, + origin_runtime_binding_id: appRuns.origin_runtime_binding_id, origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, @@ -500,6 +677,35 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize run: AppRunSafeView, internal: InternalRunAuthorization, ): Promise { + if (run.provider_kind === 'app_runtime') { + try { + if (run.origin_kind !== 'app' || run.initiating_actor_type !== 'human' + || run.execution_actor_type !== 'human' + || run.initiating_actor_id !== run.execution_actor_id + || !internal.origin_runtime_binding_id + || internal.origin_app_binding_key !== null + || internal.origin_app_automation_definition_id !== null + || internal.origin_app_automation_fire_id !== null) return false; + const current = await this.captureReviewedRuntimeInTransaction(tx, { + org_id: run.org_id, user_id: run.initiating_actor_id, + runtime_binding_id: internal.origin_runtime_binding_id, + }); + return current.binding.provider_instance_id === run.provider_instance_id + && current.binding.provider_snapshot_id === internal.provider_snapshot_id + && current.binding.operation_name === run.operation_name + && current.binding.risk_class === run.risk_class + && current.binding.review_requirement === run.review_requirement + && current.binding.retry_class === run.retry_class + && current.binding.retention_class === run.retention_class + && current.action.host_policy.review_scope === run.review_scope + && internal.origin_app_installation_id === current.binding.app_installation_id + && internal.origin_app_version_id === current.binding.app_version_id + && internal.origin_app_grant_snapshot_id === current.binding.grant_snapshot_id + && sameAuthorityRefs(AppRunAuthorizationSnapshotSchema.parse( + internal.authorization_snapshot).authority_refs, + current.authorization_snapshot.authority_refs); + } catch { return false; } + } const stored = AppRunAuthorizationSnapshotSchema.parse(internal.authorization_snapshot); if ( stored.authenticated_subject.actor_type !== run.initiating_actor_type diff --git a/apps/api/src/lib/app-run-repository.ts b/apps/api/src/lib/app-run-repository.ts index ee4a9dfc..f1cd5bd5 100644 --- a/apps/api/src/lib/app-run-repository.ts +++ b/apps/api/src/lib/app-run-repository.ts @@ -1,5 +1,6 @@ import { and, asc, eq, gt, inArray, isNull, or, sql } from 'drizzle-orm'; import { + agentActions, appActionBindings, appRunAttempts, appRunEvents, @@ -123,6 +124,49 @@ export class PostgresAppRunRepository { return snapshot ?? null; } + /** Unlocked locator only: callers acquire current membership/App/binding + * locks before taking the Run row lock for transient input review. */ + async findRuntimeReviewPin(tx: AppRunTransaction, orgId: string, runId: string) { + const [row] = await tx.select({ + id: appRuns.id, + org_id: appRuns.org_id, + state: appRuns.state, + origin_kind: appRuns.origin_kind, + initiating_actor_type: appRuns.initiating_actor_type, + initiating_actor_id: appRuns.initiating_actor_id, + execution_actor_type: appRuns.execution_actor_type, + execution_actor_id: appRuns.execution_actor_id, + provider_kind: appRuns.provider_kind, + provider_instance_id: appRuns.provider_instance_id, + provider_snapshot_id: appRuns.provider_snapshot_id, + operation_name: appRuns.operation_name, + origin_app_installation_id: appRuns.origin_app_installation_id, + origin_app_version_id: appRuns.origin_app_version_id, + origin_app_binding_key: appRuns.origin_app_binding_key, + origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, + origin_runtime_binding_id: appRuns.origin_runtime_binding_id, + origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, + origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, + risk_class: appRuns.risk_class, + review_requirement: appRuns.review_requirement, + review_scope: appRuns.review_scope, + retry_class: appRuns.retry_class, + retention_class: appRuns.retention_class, + authorization_snapshot: appRuns.authorization_snapshot, + input_expires_at: appRuns.input_expires_at, + }).from(appRuns).where(and(eq(appRuns.org_id, orgId), eq(appRuns.id, runId))).limit(1); + return row ?? null; + } + + async hasPendingRuntimeApproval(tx: AppRunTransaction, orgId: string, runId: string, userId: string) { + const [row] = await tx.select({ id: agentActions.id }).from(agentActions).where(and( + eq(agentActions.org_id, orgId), eq(agentActions.app_run_id, runId), + eq(agentActions.user_id, userId), eq(agentActions.source, 'app_run'), + eq(agentActions.action, 'app_run_invoke'), eq(agentActions.approval_status, 'pending'), + )).limit(1); + return Boolean(row); + } + async findReplay( tx: AppRunTransaction, submission: AppRunSubmission, @@ -135,6 +179,7 @@ export class PostgresAppRunRepository { origin_app_installation_id: string | null; origin_app_version_id: string | null; origin_app_binding_key: string | null; + origin_runtime_binding_id: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id: string | null; origin_app_automation_fire_id: string | null; @@ -152,6 +197,7 @@ export class PostgresAppRunRepository { origin_app_installation_id: appRuns.origin_app_installation_id, origin_app_version_id: appRuns.origin_app_version_id, origin_app_binding_key: appRuns.origin_app_binding_key, + origin_runtime_binding_id: appRuns.origin_runtime_binding_id, origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, @@ -294,8 +340,14 @@ export class PostgresAppRunRepository { ? input.submission.origin.app_version_id : null, origin_app_binding_key: input.submission.origin.origin_kind === 'app' + && 'binding_key' in input.submission.origin ? input.submission.origin.binding_key : null, + origin_runtime_binding_id: input.submission.origin.origin_kind === 'app' + && 'runtime_binding_id' in input.submission.origin + && typeof input.submission.origin.runtime_binding_id === 'string' + ? input.submission.origin.runtime_binding_id + : null, origin_app_grant_snapshot_id: input.submission.origin.origin_kind === 'app' ? input.submission.origin.grant_snapshot_id : null, diff --git a/apps/api/src/lib/app-run-service.ts b/apps/api/src/lib/app-run-service.ts index 8acb160f..2decf55f 100644 --- a/apps/api/src/lib/app-run-service.ts +++ b/apps/api/src/lib/app-run-service.ts @@ -1,8 +1,10 @@ import { createHash } from 'node:crypto'; import { APP_RUN_DEFAULT_ATTEMPT_LIMIT, + APP_RUN_CONTRACT_VERSIONS, APP_RUN_LIMITS, AppRunAuthorizationSnapshotSchema, + AppRunSafePreviewSchema, AppRunSafeOutcomeSchema, canonicalCapabilityJson, idempotencyDeadline, @@ -16,7 +18,7 @@ import { type AppRunRiskClass, type AppRunSubmission, } from '@deft/shared'; -import { APP_AUTOMATION_POLICY_V1 } from '@deft/app-kit'; +import { APP_AUTOMATION_POLICY_V1, RuntimeObjectSchema, parseRuntimeObjectInput } from '@deft/app-kit'; import { assertAppRunReferencedKeysAvailable, type AppRunKeyProvider, @@ -62,6 +64,39 @@ import type { AppRunPreparedAppVerification, } from './app-run-live-authorization.js'; import { bindAppAutomationFireRunWithExecutor } from './app-automation-repository.js'; +import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; +import type { ReviewedRuntimeInvoke, ReviewedRuntimeCaller } from './app-runtime-action-service.js'; +import type { AppRunTransaction } from './app-run-repository.js'; + +export type ReviewedRuntimeCapture = Readonly<{ + authorization_snapshot: AppRunAuthorizationSnapshot; + binding: Readonly<{ + id: string; + org_id: string; + app_installation_id: string; + app_version_id: string; + grant_snapshot_id: string; + action_key: string; + provider_kind: 'app_runtime'; + provider_instance_id: string; + provider_snapshot_id: string; + operation_name: string; + risk_class: AppRunRiskClass; + review_requirement: 'policy' | 'always'; + retry_class: AppRunRetryClass; + retention_class: AppRunRetentionClass; + }>; + action: Readonly<{ + action_key: string; + contract_digest: string; + input_schema: unknown; + host_policy: Readonly<{ review_scope: 'per_invocation' }>; + }>; + provider_snapshot_digest: string; + review_contract_digest: string; + installation_lifecycle_epoch: number; + installation_grant_epoch: number; +}>; export type AppRunTrustedContext = Readonly<{ org_id: string; @@ -84,6 +119,12 @@ export interface AppRunPreparedAppAuthorizer { org_id: string; run: AppRunSafeView; }>): Promise; + captureReviewedRuntimeForPreparation?(input: Readonly<{ + org_id: string; user_id: string; runtime_binding_id: string; + }>): Promise; + captureReviewedRuntimeInTransaction?(tx: AppRunTransaction, input: Readonly<{ + org_id: string; user_id: string; runtime_binding_id: string; + }>): Promise; } function sameActor(left: AppRunActor, right: AppRunActor): boolean { @@ -101,6 +142,32 @@ function canonicalAuthorization(value: AppRunAuthorizationSnapshot): string { }); } +function runtimeCaptureIdentity(capture: ReviewedRuntimeCapture): string { + const binding = capture.binding; + return canonicalCapabilityJson({ + binding: { + id: binding.id, org_id: binding.org_id, + app_installation_id: binding.app_installation_id, + app_version_id: binding.app_version_id, + grant_snapshot_id: binding.grant_snapshot_id, + action_key: binding.action_key, + provider_kind: binding.provider_kind, + provider_instance_id: binding.provider_instance_id, + provider_snapshot_id: binding.provider_snapshot_id, + operation_name: binding.operation_name, + risk_class: binding.risk_class, + review_requirement: binding.review_requirement, + retry_class: binding.retry_class, + retention_class: binding.retention_class, + }, + action: capture.action, + provider_snapshot_digest: capture.provider_snapshot_digest, + review_contract_digest: capture.review_contract_digest, + installation_lifecycle_epoch: capture.installation_lifecycle_epoch, + installation_grant_epoch: capture.installation_grant_epoch, + }); +} + const APP_AUTHORITY_KINDS = new Set(APP_RUN_APP_AUTHORITY_KINDS); function derivedSubmissionLock(submission: AppRunSubmission, parentRunId: string | null): string { @@ -214,6 +281,7 @@ export function appRunReplayAuthorityMatches( origin_app_installation_id: string | null; origin_app_version_id: string | null; origin_app_binding_key: string | null; + origin_runtime_binding_id?: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id?: string | null; origin_app_automation_fire_id?: string | null; @@ -223,10 +291,17 @@ export function appRunReplayAuthorityMatches( trustedAppVector?: AppRunPreparedAppVerification['authority_vector'], ): boolean { if (submission.origin.origin_kind !== 'app') return true; + const runtimeBindingId = 'runtime_binding_id' in submission.origin + ? submission.origin.runtime_binding_id + : null; + const actionBindingKey = 'binding_key' in submission.origin + ? submission.origin.binding_key + : null; if ( replay.origin_app_installation_id !== submission.origin.installation_id || replay.origin_app_version_id !== submission.origin.app_version_id - || replay.origin_app_binding_key !== submission.origin.binding_key + || replay.origin_app_binding_key !== actionBindingKey + || (replay.origin_runtime_binding_id ?? null) !== runtimeBindingId || replay.origin_app_grant_snapshot_id !== submission.origin.grant_snapshot_id ) return false; if (trustedAppVector?.schema_version === 'deft.app_action_authority.v2') { @@ -348,6 +423,179 @@ export class AppRunService { }, null, vector, context.automation_claim_token); } + /** Host-only human intake for one reviewed Runtime binding. The request + * supplies no provider, policy, origin ancestry, approval or actor facts. */ + async submitReviewedRuntime( + caller: ReviewedRuntimeCaller, + request: ReviewedRuntimeInvoke, + ): Promise { + if (!this.appOriginEnabled() || !appRuntimeChannelEnabled() + || !this.appLiveAuthorization?.captureReviewedRuntimeForPreparation + || !this.appLiveAuthorization.captureReviewedRuntimeInTransaction) { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + let capture: ReviewedRuntimeCapture; + try { + capture = await this.appLiveAuthorization.captureReviewedRuntimeForPreparation({ + org_id: caller.org_id, user_id: caller.user_id, + runtime_binding_id: request.runtime_binding_id, + }); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + const binding = capture.binding; + if (binding.id !== request.runtime_binding_id || binding.org_id !== caller.org_id + || binding.provider_kind !== 'app_runtime' + || binding.action_key !== capture.action.action_key + || binding.operation_name !== capture.action.action_key + || binding.risk_class !== 'external_write' + || binding.review_requirement !== 'always' + || binding.retry_class !== 'unsafe_or_unknown' + || binding.retention_class !== 'standard') { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let input: Record; + try { + input = parseRuntimeObjectInput(RuntimeObjectSchema.parse(capture.action.input_schema), request.input); + } catch { + throw new AppRunError('APP_RUN_INPUT_INVALID'); + } + const actor: AppRunActor = { actor_type: 'human', user_id: caller.user_id }; + const submission = { + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + org_id: caller.org_id, + initiating_actor: actor, + execution_actor: actor, + origin: { + origin_kind: 'app' as const, + installation_id: binding.app_installation_id, + app_version_id: binding.app_version_id, + grant_snapshot_id: binding.grant_snapshot_id, + runtime_binding_id: binding.id, + }, + operation: { + provider: { + org_id: caller.org_id, + provider_kind: 'app_runtime' as const, + provider_instance_id: binding.provider_instance_id, + }, + operation_name: binding.operation_name, + }, + provider_snapshot_digest: capture.provider_snapshot_digest, + policy: { + risk_class: binding.risk_class, + review_requirement: binding.review_requirement, + review_scope: 'per_invocation' as const, + retry_class: binding.retry_class, + }, + retention_class: binding.retention_class, + idempotency_key: request.idempotency_key, + input, + authorization_snapshot: capture.authorization_snapshot, + safe_preview: AppRunSafePreviewSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + title: binding.action_key, + summary: 'One reviewed Runtime action requiring human approval.', + resource_refs: [], + fields: { app_installation_id: binding.app_installation_id, + action_key: binding.action_key, runtime_binding_id: binding.id, + provider_kind: 'app_runtime' }, + }), + }; + return this.#submit({ org_id: caller.org_id, initiating_actor: actor, + execution_actor: actor }, submission, null, undefined, undefined, capture); + } + + /** Transient approval review: disclose exact retained input only to the + * initiating human while the reviewed binding and pending approval remain + * live. Nothing plaintext is copied into a card, event, or receipt. */ + async reviewRuntimeInput(caller: ReviewedRuntimeCaller, runId: string) { + if (!this.appOriginEnabled() || !appRuntimeChannelEnabled() + || !this.appLiveAuthorization?.captureReviewedRuntimeInTransaction) { + throw new AppRunError('APP_RUNS_DISABLED'); + } + return this.repository.transaction(async (tx) => { + const locator = await this.repository.findRuntimeReviewPin(tx, caller.org_id, runId); + if (!locator || locator.initiating_actor_type !== 'human' + || locator.initiating_actor_id !== caller.user_id + || locator.org_id !== caller.org_id + || locator.origin_kind !== 'app' || locator.provider_kind !== 'app_runtime' + || !locator.origin_runtime_binding_id) { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + // Match approval's Run-before-Runtime-authority order. A completed Run + // stops here so a channel call cannot hold authority while waiting on + // this review's Run lock. Manager revocation never takes the Run lock. + const locked = await this.repository.lockRun(tx, caller.org_id, runId); + const pin = await this.repository.findRuntimeReviewPin(tx, caller.org_id, runId); + if (!locked || !pin || pin.state !== 'pending_approval' + || pin.initiating_actor_type !== 'human' + || pin.initiating_actor_id !== caller.user_id + || pin.origin_runtime_binding_id !== locator.origin_runtime_binding_id + || pin.input_expires_at <= this.now()) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let current: ReviewedRuntimeCapture; + try { + current = await this.appLiveAuthorization!.captureReviewedRuntimeInTransaction!(tx, { + org_id: caller.org_id, user_id: caller.user_id, + runtime_binding_id: locator.origin_runtime_binding_id, + }); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let authorityMatches = false; + try { + authorityMatches = pin !== null && canonicalAuthorization( + AppRunAuthorizationSnapshotSchema.parse(pin.authorization_snapshot), + ) === canonicalAuthorization(current.authorization_snapshot); + } catch { /* Corrupt or obsolete authority never discloses input. */ } + if (!locked || !pin || pin.state !== 'pending_approval' + || pin.input_expires_at <= this.now() + || pin.origin_kind !== 'app' || pin.provider_kind !== 'app_runtime' + || pin.initiating_actor_type !== 'human' || pin.initiating_actor_id !== caller.user_id + || pin.execution_actor_type !== 'human' || pin.execution_actor_id !== caller.user_id + || pin.origin_runtime_binding_id !== locator.origin_runtime_binding_id + || pin.origin_app_installation_id !== current.binding.app_installation_id + || pin.origin_app_version_id !== current.binding.app_version_id + || pin.origin_app_grant_snapshot_id !== current.binding.grant_snapshot_id + || pin.origin_app_binding_key !== null + || pin.origin_app_automation_definition_id !== null + || pin.origin_app_automation_fire_id !== null + || pin.provider_instance_id !== current.binding.provider_instance_id + || pin.provider_snapshot_id !== current.binding.provider_snapshot_id + || pin.operation_name !== current.binding.operation_name + || pin.risk_class !== current.binding.risk_class + || pin.review_requirement !== current.binding.review_requirement + || pin.review_scope !== current.action.host_policy.review_scope + || pin.retry_class !== current.binding.retry_class + || pin.retention_class !== current.binding.retention_class + || !authorityMatches + || !await this.repository.hasPendingRuntimeApproval(tx, caller.org_id, runId, caller.user_id)) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let input: Record; + try { + const retained = await this.secretRepository.readInput(caller.org_id, runId, tx); + input = parseRuntimeObjectInput(RuntimeObjectSchema.parse(current.action.input_schema), retained); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + return Object.freeze({ run_id: runId, + action_key: current.action.action_key, + app_installation_id: current.binding.app_installation_id, + app_version_id: current.binding.app_version_id, + grant_snapshot_id: current.binding.grant_snapshot_id, + runtime_binding_id: current.binding.id, + contract_digest: current.action.contract_digest, + policy: Object.freeze({ risk_class: current.binding.risk_class, + review_requirement: current.binding.review_requirement, + review_scope: current.action.host_policy.review_scope, + retry_class: current.binding.retry_class }), + input }); + }); + } + async submitChild( context: AppRunTrustedContext, parentRunId: string, @@ -365,6 +613,7 @@ export class AppRunService { parentRunId: string | null, trustedAppVector?: AppRunPreparedAppVerification['authority_vector'], automationClaimToken?: string, + trustedRuntimeCapture?: ReviewedRuntimeCapture, ): Promise { let submission: AppRunSubmission; try { @@ -377,9 +626,15 @@ export class AppRunService { || !sameActor(context.initiating_actor, submission.initiating_actor) || !sameActor(context.execution_actor, submission.execution_actor) || !sameActor(context.initiating_actor, submission.authorization_snapshot.authenticated_subject) - || (submission.origin.origin_kind === 'app' && !trustedAppVector) - || (submission.origin.origin_kind !== 'app' && trustedAppVector !== undefined) - || (!trustedAppVector && submission.authorization_snapshot.authority_refs.some( + || (submission.origin.origin_kind === 'app' && !trustedAppVector && !trustedRuntimeCapture) + || (submission.origin.origin_kind !== 'app' + && (trustedAppVector !== undefined || trustedRuntimeCapture !== undefined)) + || (trustedAppVector !== undefined && trustedRuntimeCapture !== undefined) + || (submission.origin.origin_kind === 'app' && 'runtime_binding_id' in submission.origin + && !trustedRuntimeCapture) + || (submission.origin.origin_kind === 'app' && 'binding_key' in submission.origin + && !trustedAppVector) + || (!trustedAppVector && !trustedRuntimeCapture && submission.authorization_snapshot.authority_refs.some( (ref) => APP_AUTHORITY_KINDS.has(ref.authority_kind), )) || (submission.origin.origin_kind === 'legacy_connector' @@ -417,6 +672,52 @@ export class AppRunService { canonicalAuthorization(live) !== canonicalAuthorization(submission.authorization_snapshot) ) throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); } + if (trustedRuntimeCapture) { + if (!this.appLiveAuthorization?.captureReviewedRuntimeInTransaction + || submission.origin.origin_kind !== 'app' + || !('runtime_binding_id' in submission.origin) + || submission.initiating_actor.actor_type !== 'human' + || submission.execution_actor.actor_type !== 'human') { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + let live: ReviewedRuntimeCapture; + try { + live = await this.appLiveAuthorization.captureReviewedRuntimeInTransaction(tx, { + org_id: submission.org_id, + user_id: submission.initiating_actor.user_id, + runtime_binding_id: submission.origin.runtime_binding_id, + }); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + const binding = live.binding; + let inputMatches = false; + try { + inputMatches = canonicalCapabilityJson(parseRuntimeObjectInput( + RuntimeObjectSchema.parse(live.action.input_schema), submission.input, + )) === canonicalCapabilityJson(submission.input); + } catch { /* A reviewed contract changed or the input is no longer valid. */ } + if (runtimeCaptureIdentity(live) !== runtimeCaptureIdentity(trustedRuntimeCapture) + || canonicalAuthorization(live.authorization_snapshot) + !== canonicalAuthorization(submission.authorization_snapshot) + || binding.id !== submission.origin.runtime_binding_id + || binding.org_id !== submission.org_id + || binding.app_installation_id !== submission.origin.installation_id + || binding.app_version_id !== submission.origin.app_version_id + || binding.grant_snapshot_id !== submission.origin.grant_snapshot_id + || binding.provider_kind !== submission.operation.provider.provider_kind + || binding.provider_instance_id !== submission.operation.provider.provider_instance_id + || binding.operation_name !== submission.operation.operation_name + || live.provider_snapshot_digest !== submission.provider_snapshot_digest + || binding.risk_class !== submission.policy.risk_class + || binding.review_requirement !== submission.policy.review_requirement + || live.action.host_policy.review_scope !== submission.policy.review_scope + || binding.retry_class !== submission.policy.retry_class + || binding.retention_class !== submission.retention_class + || !inputMatches) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + } const replay = await this.repository.findReplay( tx, submission, @@ -441,6 +742,7 @@ export class AppRunService { origin_app_installation_id: _installation, origin_app_version_id: _appVersion, origin_app_binding_key: _binding, + origin_runtime_binding_id: _runtimeBinding, origin_app_grant_snapshot_id: _grant, origin_app_automation_definition_id: _automationDefinition, origin_app_automation_fire_id: _automationFire, diff --git a/apps/api/src/lib/app-runtime-action-service.ts b/apps/api/src/lib/app-runtime-action-service.ts new file mode 100644 index 00000000..9b2cada5 --- /dev/null +++ b/apps/api/src/lib/app-runtime-action-service.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; +import type { AppRunSafeView } from './app-run-repository.js'; +import { getAppRunRuntime } from './app-run-runtime.js'; +import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; +import { AppRunError } from './app-run-errors.js'; + +export const ReviewedRuntimeInvokeSchema = z.strictObject({ + runtime_binding_id: z.string().uuid(), + idempotency_key: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/), + input: z.unknown(), +}); +export type ReviewedRuntimeInvoke = z.infer; +export type ReviewedRuntimeCaller = Readonly<{ org_id: string; user_id: string }>; + +export interface ReviewedRuntimeRunPort { + submitReviewedRuntime(caller: ReviewedRuntimeCaller, request: ReviewedRuntimeInvoke): Promise; + reviewRuntimeInput(caller: ReviewedRuntimeCaller, runId: string): Promise; +} + +const lazyRuns: ReviewedRuntimeRunPort = { + async submitReviewedRuntime(caller, request) { + return (await getAppRunRuntime()).service.submitReviewedRuntime(caller, request); + }, + async reviewRuntimeInput(caller, runId) { + return (await getAppRunRuntime()).service.reviewRuntimeInput(caller, runId); + }, +}; + +/** An authenticated human requests one reviewed Runtime action. Every binding, + * policy and provider fact is rederived by AppRunService; none comes from the + * request except the opaque binding ID, bounded input and idempotency key. */ +export class AppRuntimeActionService { + constructor(private readonly runs: ReviewedRuntimeRunPort = lazyRuns) {} + + invoke(caller: ReviewedRuntimeCaller, raw: unknown): Promise { + if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); + const request = ReviewedRuntimeInvokeSchema.parse(raw); + return this.runs.submitReviewedRuntime(caller, request); + } + + review(caller: ReviewedRuntimeCaller, runId: string): Promise { + if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); + return this.runs.reviewRuntimeInput(caller, z.string().uuid().parse(runId)); + } +} + +export const appRuntimeActionService = new AppRuntimeActionService(); diff --git a/apps/api/src/lib/app-runtime-authority.ts b/apps/api/src/lib/app-runtime-authority.ts index 52a0709f..82c4e97c 100644 --- a/apps/api/src/lib/app-runtime-authority.ts +++ b/apps/api/src/lib/app-runtime-authority.ts @@ -10,6 +10,10 @@ import { AppRunAuthorizationSnapshotSchema, AppRuntimeSessionAuthoritySchema, import { db } from './db.js'; import type { AppRunTransaction } from './app-run-repository.js'; import { APP_RUNTIME_CHANNEL_VERSION, APP_RUNTIME_SESSION_MS } from './app-runtime-contract.js'; +import { PostgresAppRunLiveAuthorization } from './app-run-live-authorization.js'; +import type { ReviewedRuntimeAction } from './app-runtime-review.js'; + +const runtimeLiveAuthorizer = new PostgresAppRunLiveAuthorization(); export function hashAppRuntimeToken(token: string): string { return `sha256:${createHash('sha256').update('deft.app_runtime.session.v1\0').update(token).digest('hex')}`; @@ -26,6 +30,7 @@ export type LiveRuntimeAuthority = Readonly<{ review_requirement: typeof appRuntimeBindings.$inferSelect['review_requirement']; retry_class: typeof appRuntimeBindings.$inferSelect['retry_class']; retention_class: typeof appRuntimeBindings.$inferSelect['retention_class']; + prelocked_run_actor_id?: string; }>; /** This private candidate slice accepts a human-origin Run fixture only. A @@ -33,11 +38,13 @@ export type LiveRuntimeAuthority = Readonly<{ * vector; no existing v0-v2 entrance can assert this binding. */ export async function runtimeRunMatchesAuthority( tx: AppRunTransaction, orgId: string, runId: string, authority: LiveRuntimeAuthority, -): Promise { +): Promise { const [run] = await tx.select().from(appRuns).where(and( eq(appRuns.org_id, orgId), eq(appRuns.id, runId), )).limit(1); if (!run || run.origin_kind !== 'app' || run.provider_kind !== 'app_runtime' + || (authority.prelocked_run_actor_id !== undefined + && run.initiating_actor_id !== authority.prelocked_run_actor_id) || run.origin_app_installation_id !== authority.pin.app_installation_id || run.origin_app_version_id !== authority.pin.app_version_id || run.origin_app_grant_snapshot_id !== authority.grant_snapshot_id @@ -52,26 +59,29 @@ export async function runtimeRunMatchesAuthority( || run.retention_class !== authority.retention_class || run.initiating_actor_type !== 'human' || run.execution_actor_type !== 'human' - || run.initiating_actor_id !== run.execution_actor_id) return false; + || run.initiating_actor_id !== run.execution_actor_id) return null; const snapshot = AppRunAuthorizationSnapshotSchema.safeParse(run.authorization_snapshot); if (!snapshot.success || snapshot.data.authenticated_subject.actor_type !== 'human' - || snapshot.data.authenticated_subject.user_id !== run.initiating_actor_id) return false; - await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} - AND user_id = ${run.initiating_actor_id} FOR SHARE`); - const [member] = await tx.select({ - id: orgMembers.id, is_active: orgMembers.is_active, - app_run_authorization_version: orgMembers.app_run_authorization_version, - }).from(orgMembers).where(and( - eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, run.initiating_actor_id), - )).limit(1); - if (!member?.is_active) return false; - const membershipVersion = `sha256:${createHash('sha256') - .update('deft.app_run.authority.v1\0membership\0') - .update(canonicalCapabilityJson({ id: member.id, - authority_version: member.app_run_authorization_version })) - .digest('hex')}`; - return snapshot.data.authority_refs.some((ref) => ref.authority_kind === 'membership' - && ref.authority_id === run.initiating_actor_id && ref.version === membershipVersion); + || snapshot.data.authenticated_subject.user_id !== run.initiating_actor_id) return null; + try { + const current = await runtimeLiveAuthorizer.captureReviewedRuntimeInTransaction(tx, { + org_id: orgId, user_id: run.initiating_actor_id, + runtime_binding_id: authority.pin.runtime_binding_id, + }); + const matches = current.registration.id === authority.pin.runtime_registration_id + && current.registration.runtime_epoch === authority.pin.runtime_epoch + && current.binding.provider_instance_id === run.provider_instance_id + && current.binding.provider_snapshot_id === run.provider_snapshot_id + && current.binding.operation_name === run.operation_name + && current.binding.risk_class === run.risk_class + && current.binding.review_requirement === run.review_requirement + && current.binding.retry_class === run.retry_class + && current.binding.retention_class === run.retention_class + && current.action.host_policy.review_scope === run.review_scope + && canonicalCapabilityJson(snapshot.data.authority_refs) + === canonicalCapabilityJson(current.authorization_snapshot.authority_refs); + return matches ? current.action : null; + } catch { return null; } } /** Every channel operation rereads live host authority under row locks. @@ -83,6 +93,7 @@ export async function loadLiveRuntimeAuthority( sessionId: string, tokenHash: string, now: () => Date, + runId?: string, ): Promise { // Locate without trusting the row, then lock in the same order as App // lifecycle transitions: installation -> registration -> binding -> session. @@ -91,6 +102,24 @@ export async function loadLiveRuntimeAuthority( eq(appRuntimeSessions.token_hash, tokenHash), )).limit(1); if (!locator) return null; + // Match App lifecycle's membership -> installation lock order. A Run + // locator is untrusted; its exact actor identity is reread at the boundary. + const memberIds: string[] = []; + let prelockedRunActorId: string | undefined; + if (runId) { + const [runLocator] = await tx.select({ actor_type: appRuns.initiating_actor_type, + actor_id: appRuns.initiating_actor_id }).from(appRuns).where(and( + eq(appRuns.org_id, orgId), eq(appRuns.id, runId), + )).limit(1); + if (!runLocator || runLocator.actor_type !== 'human') return null; + prelockedRunActorId = runLocator.actor_id; + memberIds.push(runLocator.actor_id); + } + memberIds.push(locator.operator_user_id); + for (const userId of [...new Set(memberIds)].sort()) { + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} + AND user_id = ${userId} FOR SHARE`); + } const [registrationLocator] = await tx.select({ app_installation_id: appRuntimeRegistrations.app_installation_id, }).from(appRuntimeRegistrations).where(and( @@ -113,7 +142,8 @@ export async function loadLiveRuntimeAuthority( const checkedAt = now(); if (!session || session.audience !== 'app_runtime' || session.revoked_at || session.expires_at <= checkedAt || session.runtime_registration_id !== locator.runtime_registration_id - || session.runtime_binding_id !== locator.runtime_binding_id) return null; + || session.runtime_binding_id !== locator.runtime_binding_id + || session.operator_user_id !== locator.operator_user_id) return null; const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( eq(appRuntimeRegistrations.org_id, orgId), eq(appRuntimeRegistrations.id, session.runtime_registration_id), @@ -142,8 +172,6 @@ export async function loadLiveRuntimeAuthority( || installation.grant_epoch !== session.grant_epoch) return null; await tx.execute(sql`SELECT id FROM app_versions WHERE org_id = ${orgId} AND id = ${registration.app_version_id} FOR SHARE`); - await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} - AND user_id = ${session.operator_user_id} FOR SHARE`); const [version] = await tx.select({ state: appVersions.state }).from(appVersions).where(and( eq(appVersions.org_id, orgId), eq(appVersions.id, registration.app_version_id), eq(appVersions.installation_id, registration.app_installation_id), @@ -181,6 +209,7 @@ export async function loadLiveRuntimeAuthority( review_requirement: binding.review_requirement, retry_class: binding.retry_class, retention_class: binding.retention_class, + ...(prelockedRunActorId ? { prelocked_run_actor_id: prelockedRunActorId } : {}), }); } @@ -204,6 +233,8 @@ export async function issueAppRuntimeSession(input: Readonly<{ eq(appRuntimeRegistrations.org_id, input.org_id), eq(appRuntimeRegistrations.id, binding.runtime_registration_id), )).limit(1); if (!registration || registration.operator_user_id !== input.operator_user_id) return false; + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${input.org_id} + AND user_id = ${input.operator_user_id} FOR SHARE`); await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${input.org_id} AND id = ${registration.app_installation_id} FOR SHARE`); await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${input.org_id} diff --git a/apps/api/src/lib/app-runtime-channel.ts b/apps/api/src/lib/app-runtime-channel.ts index b396f61d..5cc7536b 100644 --- a/apps/api/src/lib/app-runtime-channel.ts +++ b/apps/api/src/lib/app-runtime-channel.ts @@ -1,7 +1,7 @@ import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; import { appRunAttempts, appRuns, appRuntimeSessions } from '@deft/db/schema'; import { db } from './db.js'; -import { APP_RUN_APP_ORIGIN_ENABLED, APP_RUNS_ENABLED } from './env.js'; +import { isAppRuntimeChannelEnabled } from './env.js'; import type { AppRunAttemptRunner } from './app-run-attempt-runner.js'; import { hashAppRuntimeToken, issueAppRuntimeSession } from './app-runtime-authority.js'; import { @@ -12,8 +12,7 @@ import { /** Deliberately separate from employee/public App audiences. No route or App * Kit v0-v2 submission can activate it merely by installation. */ export function appRuntimeChannelEnabled(): boolean { - return APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED - && process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED === 'true'; + return isAppRuntimeChannelEnabled(); } export class AppRuntimeChannel { diff --git a/apps/api/src/lib/app-runtime-management.ts b/apps/api/src/lib/app-runtime-management.ts new file mode 100644 index 00000000..464f8ddf --- /dev/null +++ b/apps/api/src/lib/app-runtime-management.ts @@ -0,0 +1,376 @@ +import { randomUUID } from 'node:crypto'; +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { + appRuns, + appRuntimeBindings, appRuntimeRegistrations, appRuntimeSessions, + auditLog, orgMembers, +} from '@deft/db/schema'; +import { createCapabilityProviderDiscoverySnapshot } from '@deft/shared'; +import type { ModuleActor } from '@deft/shared/modules'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { isModuleError } from './module-errors.js'; +import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; +import { persistCapabilityProviderSnapshotWithExecutor } from './capability-provider-snapshot-repository.js'; +import { digestAppGrantValue } from './app-grant-service.js'; +import { loadReviewedRuntimeAction } from './app-runtime-review.js'; +import { APP_RUNTIME_CHANNEL_VERSION } from './app-runtime-contract.js'; +import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; +import { issueAppRuntimeSession } from './app-runtime-authority.js'; + +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/); +const Digest = z.string().regex(/^sha256:[a-f0-9]{64}$/); +const ActionKey = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/) + .refine((value) => !/^(deft|core|system)(_|$)/.test(value)); + +export const RuntimeReviewInputSchema = z.strictObject({ + installation_id: Id, + action_key: ActionKey, + operator_user_id: Id, + expected_app_version_id: Id, + expected_package_digest: Digest, + expected_grant_snapshot_digest: Digest, + expected_lifecycle_epoch: z.number().int().nonnegative(), + expected_grant_epoch: z.number().int().nonnegative(), +}); +export const RuntimeActivateInputSchema = RuntimeReviewInputSchema.extend({ + expected_review_digest: Digest, + accept_host_policy: z.literal(true), +}); +export type RuntimeReviewInput = z.infer; +export type RuntimeActivateInput = z.infer; + +type Manager = Extract; +function manager(actor: ModuleActor): asserts actor is Manager { + if (actor.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin') + || (actor.source !== 'ui' && actor.source !== 'rest')) { + throw new AppError('Only interactive workspace owners and admins can review App runtimes', + 'APP_ACCESS_DENIED', 403); + } +} +function stale(): never { + throw new AppError('Reviewed App Runtime authority changed', 'APP_STALE', 409); +} +function denied(): never { + throw new AppError('App Runtime authority unavailable', 'APP_ACCESS_DENIED', 403); +} + +async function assertManager(tx: Parameters[0]>[0], actor: Manager) { + try { await assertCurrentModuleManagerWithExecutor(tx, actor); } + catch (error) { + if (isModuleError(error)) denied(); + throw error; + } +} + +async function reviewContext(tx: Parameters[0]>[0], + actor: Manager, input: RuntimeReviewInput) { + // All Runtime management paths lock member rows before App. Sorting these + // two identities also avoids owner A / operator B review racing with owner + // B / operator A review in the opposite row order. + for (const userId of [...new Set([actor.actor_id, input.operator_user_id])].sort()) { + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${actor.org_id} + AND user_id = ${userId} FOR UPDATE`); + } + await assertManager(tx, actor); + const [operator] = await tx.select({ is_active: orgMembers.is_active, + role: orgMembers.role }).from(orgMembers).where(and( + eq(orgMembers.org_id, actor.org_id), eq(orgMembers.user_id, input.operator_user_id), + )).limit(1); + if (!operator?.is_active || operator.role === 'guest') denied(); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${input.installation_id} FOR UPDATE`); + const reviewed = await loadReviewedRuntimeAction(tx, actor.org_id, + input.installation_id, input.action_key); + const { installation, version, grant, action } = reviewed; + if (installation.state !== 'active' + || installation.active_version_id !== version.id + || installation.active_grant_snapshot_id !== grant.id + || version.state !== 'active' + || grant.snapshot_kind !== 'effective' + || version.id !== input.expected_app_version_id + || version.package_digest !== input.expected_package_digest + || grant.snapshot_digest !== input.expected_grant_snapshot_digest + || installation.lifecycle_epoch !== input.expected_lifecycle_epoch + || installation.grant_epoch !== input.expected_grant_epoch + || action.action_key !== input.action_key + || action.host_policy.risk_class !== 'external_write' + || action.host_policy.review_requirement !== 'always' + || action.host_policy.review_scope !== 'per_invocation' + || action.host_policy.retry_class !== 'unsafe_or_unknown' + || action.host_policy.retention_class !== 'standard') stale(); + const review = Object.freeze({ + schema_version: 'deft.app_runtime_management_review.v1' as const, + org_id: actor.org_id, + installation_id: installation.id, + app_version_id: version.id, + grant_snapshot_id: grant.id, + grant_snapshot_digest: grant.snapshot_digest, + package_digest: version.package_digest, + lifecycle_epoch: installation.lifecycle_epoch, + grant_epoch: installation.grant_epoch, + operator_user_id: input.operator_user_id, + action_key: action.action_key, + operation_name: action.operation_name, + contract_digest: action.contract_digest, + host_policy: action.host_policy, + }); + return { ...reviewed, review: { ...review, review_digest: digestAppGrantValue(review) } }; +} + +export async function prepareRuntimeBindingReview(actor: ModuleActor, value: unknown) { + manager(actor); + const input = RuntimeReviewInputSchema.parse(value); + return db.transaction(async (tx) => { + const { review } = await reviewContext(tx, actor, input); + return review; + }); +} + +export async function activateRuntimeBinding(actor: ModuleActor, value: unknown) { + manager(actor); + const input = RuntimeActivateInputSchema.parse(value); + return db.transaction(async (tx) => { + const { installation, version, grant, action, review } = await reviewContext(tx, actor, input); + if (review.review_digest !== input.expected_review_digest) stale(); + // One live binding for an action/version/grant. Historical revoked rows + // remain immutable and may coexist with a later reviewed registration. + const [existing] = await tx.select({ id: appRuntimeBindings.id }).from(appRuntimeBindings) + .where(and(eq(appRuntimeBindings.org_id, actor.org_id), + eq(appRuntimeBindings.app_installation_id, installation.id), + eq(appRuntimeBindings.app_version_id, version.id), + eq(appRuntimeBindings.grant_snapshot_id, grant.id), + eq(appRuntimeBindings.action_key, action.action_key), + inArray(appRuntimeBindings.state, ['disabled', 'active']))).limit(1); + if (existing) throw new AppError('App Runtime action is already registered', 'APP_STATE_CONFLICT', 409); + const now = new Date(); + const registrationId = randomUUID(); + const bindingId = randomUUID(); + await tx.insert(appRuntimeRegistrations).values({ + id: registrationId, org_id: actor.org_id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, operator_user_id: input.operator_user_id, + contract_version: APP_RUNTIME_CHANNEL_VERSION, state: 'disabled', + created_at: now, updated_at: now, + }); + const provider = { org_id: actor.org_id, provider_kind: 'app_runtime' as const, + provider_instance_id: registrationId }; + const providerSnapshot = await createCapabilityProviderDiscoverySnapshot({ + adapter_contract_version: APP_RUNTIME_CHANNEL_VERSION, + provider, captured_at: now.toISOString(), + operations: [{ + identity: { provider, operation_name: action.operation_name }, + title: action.action_key, description: '', + input_schema: action.input_schema, output_schema: action.output_schema, + }], + }); + const providerSnapshotId = await persistCapabilityProviderSnapshotWithExecutor(tx, providerSnapshot); + await tx.insert(appRuntimeBindings).values({ + id: bindingId, org_id: actor.org_id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, runtime_registration_id: registrationId, + action_key: action.action_key, + interface_identity: `deft.runtime.v1:${actor.org_id.toLowerCase()}:${installation.id.toLowerCase()}:${action.action_key}`, + provider_kind: 'app_runtime', provider_instance_id: registrationId, + provider_snapshot_id: providerSnapshotId, operation_name: action.operation_name, + risk_class: action.host_policy.risk_class, + review_requirement: action.host_policy.review_requirement, + retry_class: action.host_policy.retry_class, + retention_class: action.host_policy.retention_class, + state: 'disabled', created_at: now, updated_at: now, + }); + await tx.update(appRuntimeRegistrations).set({ state: 'active', runtime_epoch: 1, + reviewed_by_user_id: actor.actor_id, reviewed_at: now, updated_at: now }) + .where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, registrationId))); + await tx.update(appRuntimeBindings).set({ state: 'active', + reviewed_by_user_id: actor.actor_id, reviewed_at: now, updated_at: now }) + .where(and(eq(appRuntimeBindings.org_id, actor.org_id), eq(appRuntimeBindings.id, bindingId))); + await tx.insert(auditLog).values({ + org_id: actor.org_id, actor_type: 'human', actor_id: actor.actor_id, + action: 'app.runtime_review_activate', entity_type: 'app_runtime_binding', entity_id: bindingId, + before_state: null, + after_state: { registration_id: registrationId, binding_id: bindingId, + installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, action_key: action.action_key, + contract_digest: action.contract_digest, review_digest: review.review_digest }, + metadata: { source: actor.source }, + }); + return Object.freeze({ registration_id: registrationId, binding_id: bindingId, + app_version_id: version.id, grant_snapshot_id: grant.id, + action_key: action.action_key, review_digest: review.review_digest }); + }); +} + +export async function issueRuntimeOperatorSession(actor: ModuleActor, bindingId: string) { + if (actor.kind !== 'human' || (actor.source !== 'ui' && actor.source !== 'rest')) denied(); + if (!appRuntimeChannelEnabled()) { + throw new AppError('App Runtime channel is disabled', 'APP_FEATURE_DISABLED', 503); + } + const issued = await issueAppRuntimeSession({ org_id: actor.org_id, + runtime_binding_id: Id.parse(bindingId), operator_user_id: actor.actor_id }); + if (!issued) denied(); + return issued; +} + +export async function revokeRuntimeBinding(actor: ModuleActor, bindingId: string) { + manager(actor); + return db.transaction(async (tx) => { + await assertManager(tx, actor); + const [locator] = await tx.select({ installation_id: appRuntimeBindings.app_installation_id, + registration_id: appRuntimeBindings.runtime_registration_id }) + .from(appRuntimeBindings).where(and(eq(appRuntimeBindings.org_id, actor.org_id), + eq(appRuntimeBindings.id, Id.parse(bindingId)))).limit(1); + if (!locator) throw new AppError('App Runtime binding not found', 'APP_NOT_FOUND', 404); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${locator.installation_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${locator.registration_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_runtime_bindings WHERE org_id = ${actor.org_id} + AND id = ${bindingId} FOR UPDATE`); + const [binding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, actor.org_id), eq(appRuntimeBindings.id, bindingId))).limit(1); + if (!binding || binding.runtime_registration_id !== locator.registration_id + || binding.app_installation_id !== locator.installation_id) stale(); + if (binding.state !== 'active') return { revoked: binding.state === 'revoked' }; + const now = new Date(); + await tx.update(appRuntimeBindings).set({ state: 'revoked', updated_at: now }).where(and( + eq(appRuntimeBindings.org_id, actor.org_id), eq(appRuntimeBindings.id, bindingId))); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.runtime_binding_id, bindingId), + sql`${appRuntimeSessions.revoked_at} IS NULL`)); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.runtime_binding_revoke', + entity_type: 'app_runtime_binding', entity_id: bindingId, + before_state: { state: binding.state }, after_state: { state: 'revoked' }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); +} + +export async function revokeRuntimeRegistration(actor: ModuleActor, registrationId: string) { + manager(actor); + return db.transaction(async (tx) => { + await assertManager(tx, actor); + const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id }) + .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, Id.parse(registrationId)))).limit(1); + if (!locator) throw new AppError('App Runtime registration not found', 'APP_NOT_FOUND', 404); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${locator.installation_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${registrationId} FOR UPDATE`); + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, registrationId))).limit(1); + if (!registration || registration.app_installation_id !== locator.installation_id) stale(); + if (registration.state !== 'active') return { revoked: registration.state === 'revoked' }; + const now = new Date(); + await tx.update(appRuntimeRegistrations).set({ state: 'revoked', + runtime_epoch: registration.runtime_epoch + 1, updated_at: now }).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, registrationId))); + await tx.update(appRuntimeBindings).set({ state: 'revoked', updated_at: now }).where(and( + eq(appRuntimeBindings.org_id, actor.org_id), eq(appRuntimeBindings.runtime_registration_id, registrationId), + eq(appRuntimeBindings.state, 'active'))); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.runtime_registration_id, registrationId), + sql`${appRuntimeSessions.revoked_at} IS NULL`)); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.runtime_registration_revoke', + entity_type: 'app_runtime_registration', entity_id: registrationId, + before_state: { state: registration.state, runtime_epoch: registration.runtime_epoch }, + after_state: { state: 'revoked', runtime_epoch: registration.runtime_epoch + 1 }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); +} + +export async function revokeRuntimeSession(actor: ModuleActor, sessionId: string) { + if (actor.kind !== 'human') denied(); + return db.transaction(async (tx) => { + const [locator] = await tx.select({ operator_user_id: appRuntimeSessions.operator_user_id, + registration_id: appRuntimeSessions.runtime_registration_id, + binding_id: appRuntimeSessions.runtime_binding_id }) + .from(appRuntimeSessions).where(and(eq(appRuntimeSessions.org_id, actor.org_id), + eq(appRuntimeSessions.id, Id.parse(sessionId)))).limit(1); + if (!locator) throw new AppError('App Runtime session not found', 'APP_NOT_FOUND', 404); + if (locator.operator_user_id !== actor.actor_id) await assertManager(tx, actor); + else await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${actor.org_id} + AND user_id = ${actor.actor_id} FOR UPDATE`); + const [registration] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id }) + .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, locator.registration_id))).limit(1); + if (!registration) stale(); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${registration.installation_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${locator.registration_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_bindings WHERE org_id = ${actor.org_id} + AND id = ${locator.binding_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_sessions WHERE org_id = ${actor.org_id} + AND id = ${sessionId} FOR UPDATE`); + const [session] = await tx.select().from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, sessionId))).limit(1); + if (!session || session.operator_user_id !== locator.operator_user_id + || session.runtime_binding_id !== locator.binding_id) stale(); + if (session.revoked_at) return { revoked: true }; + const now = new Date(); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, sessionId))); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.runtime_session_revoke', + entity_type: 'app_runtime_session', entity_id: sessionId, + before_state: { revoked: false }, after_state: { revoked: true }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); +} + +/** Safe operator dashboard: no token hashes, input, output or claim tokens. */ +export async function inspectRuntimeBinding(actor: ModuleActor, bindingId: string) { + if (actor.kind !== 'human') denied(); + return db.transaction(async (tx) => { + const [binding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, actor.org_id), eq(appRuntimeBindings.id, Id.parse(bindingId)))).limit(1); + if (!binding) throw new AppError('App Runtime binding not found', 'APP_NOT_FOUND', 404); + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, binding.runtime_registration_id))).limit(1); + if (!registration) stale(); + if (registration.operator_user_id !== actor.actor_id) await assertManager(tx, actor); + else { + const [member] = await tx.select({ is_active: orgMembers.is_active }).from(orgMembers).where(and( + eq(orgMembers.org_id, actor.org_id), eq(orgMembers.user_id, actor.actor_id))).limit(1); + if (!member?.is_active) denied(); + } + const sessions = await tx.select({ id: appRuntimeSessions.id, + expires_at: appRuntimeSessions.expires_at, revoked_at: appRuntimeSessions.revoked_at }) + .from(appRuntimeSessions).where(and(eq(appRuntimeSessions.org_id, actor.org_id), + eq(appRuntimeSessions.runtime_binding_id, binding.id))) + .orderBy(desc(appRuntimeSessions.created_at)).limit(20); + const runs = await tx.select({ id: appRuns.id, state: appRuns.state, + created_at: appRuns.created_at, updated_at: appRuns.updated_at }) + .from(appRuns).where(and(eq(appRuns.org_id, actor.org_id), + eq(appRuns.origin_runtime_binding_id, binding.id))) + .orderBy(desc(appRuns.created_at)).limit(50); + const runCounts = await tx.select({ state: appRuns.state, + count: sql`count(*)::int` }).from(appRuns).where(and( + eq(appRuns.org_id, actor.org_id), + eq(appRuns.origin_runtime_binding_id, binding.id), + )).groupBy(appRuns.state); + const outstanding = runCounts.filter((row) => ['pending', 'pending_approval', + 'running', 'waiting_external', 'unknown_outcome'].includes(row.state)) + .reduce((sum, row) => sum + row.count, 0); + return { binding: { id: binding.id, registration_id: registration.id, + installation_id: binding.app_installation_id, + app_version_id: binding.app_version_id, grant_snapshot_id: binding.grant_snapshot_id, + action_key: binding.action_key, state: binding.state, + registration_state: registration.state, runtime_epoch: registration.runtime_epoch, + operator_user_id: registration.operator_user_id }, + sessions: sessions.map((session) => ({ id: session.id, + expires_at: session.expires_at.toISOString(), revoked: session.revoked_at !== null })), + drain: { drained: outstanding === 0, outstanding, + run_state_counts: Object.fromEntries(runCounts.map((row) => [row.state, row.count])) }, + runs: runs.map((run) => ({ ...run, created_at: run.created_at.toISOString(), + updated_at: run.updated_at.toISOString() })) }; + }); +} diff --git a/apps/api/src/lib/app-runtime-review.ts b/apps/api/src/lib/app-runtime-review.ts new file mode 100644 index 00000000..52eba5ee --- /dev/null +++ b/apps/api/src/lib/app-runtime-review.ts @@ -0,0 +1,149 @@ +import { randomUUID } from 'node:crypto'; +import { and, desc, eq, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { appInstallations, appVersions, appGrantSnapshots, auditLog } from '@deft/db/schema'; +import { DeftAppManifestV3Schema, RUNTIME_ACTION_HOST_POLICY, AppDigestSchema, type DeftAppManifestV3 } from '@deft/app-kit'; +import type { ModuleActor } from '@deft/shared/modules'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; +import { APP_GRANT_SNAPSHOT_VERSION, buildRequestedAppGrantProjection, digestAppGrantValue } from './app-grant-service.js'; + +type Executor = Pick; +const stale = () => new AppError('Runtime App authority changed or is unavailable', 'APP_STALE', 409); +export const RuntimeAppReviewRequestSchema = z.strictObject({ + app_version_id: z.string().min(1).max(128), + expected_package_digest: AppDigestSchema, + expected_requested_snapshot_digest: AppDigestSchema, + expected_lifecycle_epoch: z.number().int().nonnegative(), + expected_grant_epoch: z.number().int().nonnegative(), +}); +export const RuntimeAppActivateRequestSchema = RuntimeAppReviewRequestSchema.extend({ + expected_review_digest: AppDigestSchema, accept_host_policy: z.literal(true), +}); + +export function runtimeActionDescriptors(manifest: DeftAppManifestV3) { + return manifest.runtime_actions.map((action) => { + const capability = manifest.private_capabilities.find((item) => item.key === action.capability_key)!; + const identity = { namespace: 'app_lineage' as const, key: capability.key, version: capability.version }; + return { action_key: action.key, runtime_requirement_key: action.runtime_requirement_key, + interface: identity, operation_name: action.key, + input_schema: capability.input_schema, output_schema: capability.output_schema, + contract_digest: digestAppGrantValue({ interface: identity, input_schema: capability.input_schema, output_schema: capability.output_schema }), + host_policy: RUNTIME_ACTION_HOST_POLICY }; + }); +} +export type ReviewedRuntimeAction = ReturnType[number]; + +async function reviewContext(tx: Executor, actor: ModuleActor, installationId: string, + request: z.infer) { + await assertCurrentModuleManagerWithExecutor(tx, actor); + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, actor.org_id), eq(appInstallations.id, installationId), + )).limit(1).for('update'); + if (!installation || !['staged', 'disabled'].includes(installation.state) + || installation.lifecycle_epoch !== request.expected_lifecycle_epoch + || installation.grant_epoch !== request.expected_grant_epoch) throw stale(); + const [version] = await tx.select().from(appVersions).where(and( + eq(appVersions.org_id, actor.org_id), eq(appVersions.installation_id, installationId), + eq(appVersions.id, request.app_version_id), + )).limit(1).for('share'); + if (!version || version.protocol_version !== '3' || !['staged', 'active'].includes(version.state) + || version.package_digest !== request.expected_package_digest + || (installation.active_version_id && installation.active_version_id !== version.id)) throw stale(); + const manifest = DeftAppManifestV3Schema.parse(version.manifest); + const [requested] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), + eq(appGrantSnapshots.app_version_id, version.id), eq(appGrantSnapshots.id, version.requested_grant_snapshot_id ?? ''), + eq(appGrantSnapshots.snapshot_kind, 'requested'), + )).limit(1); + const expected = buildRequestedAppGrantProjection({ organization_id: actor.org_id, + app_installation_id: installationId, app_version_id: version.id, manifest, + manifest_digest: version.manifest_digest, package_digest: version.package_digest }); + if (!requested || requested.snapshot_digest !== request.expected_requested_snapshot_digest + || requested.snapshot_digest !== expected.snapshot_digest + || digestAppGrantValue(requested.canonical_snapshot) !== expected.snapshot_digest) throw stale(); + const authority = { schema: 'deft.app_runtime_grant.v1' as const, lineage_key: installation.lineage_key, + package_digest: version.package_digest, manifest_digest: version.manifest_digest, + runtime_actions: runtimeActionDescriptors(manifest) }; + const review = { ...request, installation_id: installationId, organization_id: actor.org_id, + authority, requested_snapshot_id: requested.id }; + return { installation, version, requested, authority, review: { ...review, review_digest: digestAppGrantValue(review) } }; +} + +export async function prepareRuntimeAppReview(actor: ModuleActor, installationId: string, raw: unknown) { + const request = RuntimeAppReviewRequestSchema.parse(raw); + return db.transaction(async (tx) => (await reviewContext(tx, actor, installationId, request)).review); +} + +export async function activateRuntimeApp(actor: ModuleActor, installationId: string, raw: unknown) { + const { expected_review_digest, accept_host_policy: _accept, ...request } = RuntimeAppActivateRequestSchema.parse(raw); + return db.transaction(async (tx) => { + const context = await reviewContext(tx, actor, installationId, request); + if (context.review.review_digest !== expected_review_digest) throw stale(); + const [prior] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).orderBy(desc(appGrantSnapshots.created_at), desc(appGrantSnapshots.id)).limit(1); + const effectiveId = randomUUID(); + const now = new Date(); + const classification = { authority_state: 'effective', executable: false, provider_access: false, + runtime_binding_review_required: true }; + const canonical = { ...context.authority, organization_id: actor.org_id, + app_installation_id: installationId, app_version_id: context.version.id, + requested_snapshot_id: context.requested.id, requested_snapshot_digest: context.requested.snapshot_digest, + classification, review_digest: expected_review_digest }; + await tx.insert(appGrantSnapshots).values({ id: effectiveId, org_id: actor.org_id, + app_installation_id: installationId, app_version_id: context.version.id, + app_id: context.installation.app_id, app_version: context.version.version, + manifest_digest: context.version.manifest_digest, package_digest: context.version.package_digest, + snapshot_kind: 'effective', snapshot_version: APP_GRANT_SNAPSHOT_VERSION, + requested_snapshot_id: context.requested.id, supersedes_snapshot_id: prior?.id ?? null, + resource_rights: [], classification, canonical_snapshot: canonical, + snapshot_digest: digestAppGrantValue(canonical), reviewed_by_actor_type: 'human', + reviewed_by_actor_id: actor.actor_id, reviewed_at: now }); + if (context.version.state === 'staged') { + await tx.update(appVersions).set({ state: 'active', activated_at: now }).where(and( + eq(appVersions.org_id, actor.org_id), eq(appVersions.id, context.version.id), + eq(appVersions.state, 'staged'))); + } + const [installation] = await tx.update(appInstallations).set({ state: 'active', + active_version_id: context.version.id, active_grant_snapshot_id: effectiveId, active_grant_snapshot_kind: 'effective', + lifecycle_epoch: sql`${appInstallations.lifecycle_epoch} + 1`, grant_epoch: sql`${appInstallations.grant_epoch} + 1`, + disabled_at: null, updated_by_actor_type: 'human', updated_by_actor_id: actor.actor_id, + }).where(and(eq(appInstallations.org_id, actor.org_id), eq(appInstallations.id, installationId))).returning(); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', actor_id: actor.actor_id, + action: 'app.runtime.review_activate', entity_type: 'app_installation', entity_id: installationId, + before_state: { state: context.installation.state }, + after_state: { state: 'active', grant_snapshot_id: effectiveId, review_digest: expected_review_digest }, + metadata: { source: actor.source } }); + return { installation, grant_snapshot_id: effectiveId }; + }); +} + +/** Callers lock membership first. This reader locks the installation/version and + * reconstructs the reviewed descriptor rather than trusting a JSON grant alone. */ +export async function loadReviewedRuntimeAction(tx: Executor, orgId: string, installationId: string, actionKey: string) { + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, orgId), eq(appInstallations.id, installationId), + )).limit(1).for('share'); + if (!installation || installation.state !== 'active' || !installation.active_version_id + || !installation.active_grant_snapshot_id || installation.active_grant_snapshot_kind !== 'effective') throw stale(); + const [version] = await tx.select().from(appVersions).where(and(eq(appVersions.org_id, orgId), + eq(appVersions.installation_id, installationId), eq(appVersions.id, installation.active_version_id), + eq(appVersions.state, 'active'), eq(appVersions.protocol_version, '3'))).limit(1).for('share'); + const [grant] = await tx.select().from(appGrantSnapshots).where(and(eq(appGrantSnapshots.org_id, orgId), + eq(appGrantSnapshots.app_installation_id, installationId), eq(appGrantSnapshots.id, installation.active_grant_snapshot_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'))).limit(1); + if (!version || !grant || grant.app_version_id !== version.id + || digestAppGrantValue(grant.canonical_snapshot) !== grant.snapshot_digest) throw stale(); + const manifest = DeftAppManifestV3Schema.parse(version.manifest); + const expected = runtimeActionDescriptors(manifest); + const stored = grant.canonical_snapshot; + if (stored.schema !== 'deft.app_runtime_grant.v1' || stored.lineage_key !== installation.lineage_key + || stored.package_digest !== version.package_digest || stored.manifest_digest !== version.manifest_digest + || digestAppGrantValue(stored.runtime_actions) !== digestAppGrantValue(expected)) throw stale(); + const action = expected.find((item) => item.action_key === actionKey); + if (!action) throw stale(); + return { installation, version, grant, action }; +} diff --git a/apps/api/src/lib/app-service.ts b/apps/api/src/lib/app-service.ts index 52605906..5322bba0 100644 --- a/apps/api/src/lib/app-service.ts +++ b/apps/api/src/lib/app-service.ts @@ -489,7 +489,7 @@ export async function disableAppInstallation( const [updated] = await tx.update(appInstallations).set({ state: 'disabled', lifecycle_epoch: sql`${appInstallations.lifecycle_epoch} + 1`, - ...(isConnectedAppProtocolVersion(version.protocol_version) ? { + ...(version.protocol_version !== '0' ? { active_grant_snapshot_id: null, active_grant_snapshot_kind: null, grant_epoch: sql`${appInstallations.grant_epoch} + 1`, @@ -503,7 +503,7 @@ export async function disableAppInstallation( state: 'disabled', lifecycle_epoch: updated.lifecycle_epoch, grant_epoch: updated.grant_epoch, - grant_revoked: isConnectedAppProtocolVersion(version.protocol_version), + grant_revoked: version.protocol_version !== '0', data_preserved: true, }); return { installation: updated, version, bindings }; @@ -540,7 +540,7 @@ export async function enableAppInstallation( eq(appVersions.org_id, actor.org_id), eq(appVersions.id, installation.active_version_id), )).limit(1); if (!version) throw new Error('App enable active version returned no row'); - if (isConnectedAppProtocolVersion(version.protocol_version)) { + if (version.protocol_version !== '0') { throw new AppError( 'Connected Apps require a fresh review before re-enabling', 'APP_REVIEW_REQUIRED', diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index e523ad45..443a0c78 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -122,6 +122,12 @@ export const APP_RUN_LEGACY_MCP_CUTOVER_ENABLED = process.env.DEFT_APP_RUN_LEGACY_MCP_CUTOVER_ENABLED === 'true'; export const APP_RUN_APP_ORIGIN_ENABLED = process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED === 'true'; +// Keep the channel's combined rollout decision beside the Run flags. The +// independent channel switch may be disabled without reopening Run composition. +export function isAppRuntimeChannelEnabled(): boolean { + return APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED + && process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED === 'true'; +} // Track A automation is an independent, deny-by-default privileged plane. export const APP_AUTOMATIONS_ENABLED = process.env.DEFT_APP_AUTOMATIONS_ENABLED === 'true'; diff --git a/apps/api/src/routes/app-runtime-actions.ts b/apps/api/src/routes/app-runtime-actions.ts new file mode 100644 index 00000000..c71d8d0b --- /dev/null +++ b/apps/api/src/routes/app-runtime-actions.ts @@ -0,0 +1,88 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../middleware/auth.js'; +import { AppRuntimeActionService, appRuntimeActionService } from '../lib/app-runtime-action-service.js'; +import { AppRunError } from '../lib/app-run-errors.js'; +import { appRuntimeChannelEnabled } from '../lib/app-runtime-channel.js'; +import { appHttpFailure } from './app-http-errors.js'; + +const MAX_REQUEST_BYTES = 65_536; +const READ_DEADLINE_MS = 10_000; + +async function boundedJson(stream: ReadableStream | null): Promise { + if (!stream) throw new AppRunError('APP_RUN_INPUT_INVALID'); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new AppRunError('APP_RUN_INPUT_INVALID'); + let timer: ReturnType | undefined; + const { done, value } = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new AppRunError('APP_RUN_INPUT_INVALID')), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (done) break; + size += value.byteLength; + if (size > MAX_REQUEST_BYTES) { + throw new AppRunError('APP_RUN_INPUT_TOO_LARGE'); + } + chunks.push(value); + } + } catch (error) { + void reader.cancel().catch(() => undefined); + throw error; + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } + try { + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); + } catch { + throw new AppRunError('APP_RUN_INPUT_INVALID'); + } +} + +/** Mount behind the existing authenticated API group only after 03a review. */ +export function createAppRuntimeActionRoutes(service: AppRuntimeActionService = appRuntimeActionService) { + const routes = new Hono(); + routes.get('/:runId/review', async (c) => { + c.header('Cache-Control', 'no-store'); + try { + if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); + const user = c.get('user') as AuthUser | undefined; + if (!user?.id || !user.org_id) throw new AppRunError('APP_RUN_ACCESS_DENIED'); + return c.json({ review: await service.review({ org_id: user.org_id, user_id: user.id }, + c.req.param('runId')) }); + } catch (error) { + return appHttpFailure(c, error, 'App Run', 'app-runs'); + } + }); + routes.post('/invoke', async (c) => { + c.header('Cache-Control', 'no-store'); + try { + if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); + const user = c.get('user') as AuthUser | undefined; + if (!user?.id || !user.org_id) throw new AppRunError('APP_RUN_ACCESS_DENIED'); + if (!/^application\/json(?:\s*;|$)/i.test(c.req.header('content-type') ?? '')) { + throw new AppRunError('APP_RUN_INPUT_INVALID'); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_REQUEST_BYTES) { + throw new AppRunError('APP_RUN_INPUT_TOO_LARGE'); + } + const raw = await boundedJson(c.req.raw.body); + return c.json({ run: await service.invoke({ org_id: user.org_id, user_id: user.id }, raw) }); + } catch (error) { + return appHttpFailure(c, error, 'App Run', 'app-runs'); + } + }); + return routes; +} + +export const appRuntimeActionRoutes = createAppRuntimeActionRoutes(); diff --git a/apps/api/src/routes/app-runtime-management.ts b/apps/api/src/routes/app-runtime-management.ts new file mode 100644 index 00000000..d8d915be --- /dev/null +++ b/apps/api/src/routes/app-runtime-management.ts @@ -0,0 +1,111 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import type { AuthUser } from '../middleware/auth.js'; +import { humanModuleActor } from '../lib/module-service.js'; +import { AppError } from '../lib/app-errors.js'; +import { + activateRuntimeBinding, inspectRuntimeBinding, issueRuntimeOperatorSession, + prepareRuntimeBindingReview, revokeRuntimeBinding, + revokeRuntimeRegistration, revokeRuntimeSession, +} from '../lib/app-runtime-management.js'; + +export const appRuntimeManagementRoutes = new Hono(); +const MAX_MANAGEMENT_BODY_BYTES = 16_384; +const READ_DEADLINE_MS = 15_000; +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/); + +function actor(c: Context) { + const user = c.get('user') as AuthUser | undefined; + if (!user?.id || !user.org_id) { + throw new AppError('Authentication required', 'APP_ACCESS_DENIED', 403); + } + return humanModuleActor({ orgId: user.org_id, userId: user.id, + role: user.role ?? 'member', source: 'rest' }); +} + +async function body(c: Context): Promise { + if (c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_MANAGEMENT_BODY_BYTES) { + throw new AppError('App Runtime request too large', 'APP_ACTION_INVALID', 413); + } + const reader = c.req.raw.body?.getReader(); + if (!reader) throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + const chunks: Uint8Array[] = []; + let size = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new AppError('App Runtime request timed out', 'APP_ACTION_INVALID', 400); + let timer: ReturnType | undefined; + const next = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new AppError('App Runtime request timed out', 'APP_ACTION_INVALID', 400)), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_MANAGEMENT_BODY_BYTES) { + throw new AppError('App Runtime request too large', 'APP_ACTION_INVALID', 413); + } + chunks.push(next.value); + } + } catch (error) { + void reader.cancel().catch(() => {}); + throw error; + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; +} + +function failure(c: Context, error: unknown) { + if (error instanceof AppError) { + return c.json({ error: error.message, code: error.code }, error.status); + } + if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError) { + return c.json({ error: 'Invalid App Runtime request', code: 'VALIDATION_ERROR' }, 400); + } + console.error('[app-runtime-management] request failed'); + return c.json({ error: 'App Runtime request failed', code: 'INTERNAL_ERROR' }, 500); +} + +appRuntimeManagementRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + await next(); +}); + +appRuntimeManagementRoutes.post('/reviews/prepare', async (c) => { + try { return c.json({ review: await prepareRuntimeBindingReview(actor(c), await body(c)) }); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.post('/bindings/activate', async (c) => { + try { return c.json({ binding: await activateRuntimeBinding(actor(c), await body(c)) }, 201); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.post('/bindings/:bindingId/sessions', async (c) => { + try { return c.json({ session: await issueRuntimeOperatorSession(actor(c), + Id.parse(c.req.param('bindingId'))) }, 201); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.get('/bindings/:bindingId', async (c) => { + try { return c.json(await inspectRuntimeBinding(actor(c), Id.parse(c.req.param('bindingId')))); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.post('/bindings/:bindingId/revoke', async (c) => { + try { return c.json(await revokeRuntimeBinding(actor(c), Id.parse(c.req.param('bindingId')))); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.post('/registrations/:registrationId/revoke', async (c) => { + try { return c.json(await revokeRuntimeRegistration(actor(c), Id.parse(c.req.param('registrationId')))); } + catch (error) { return failure(c, error); } +}); +appRuntimeManagementRoutes.post('/sessions/:sessionId/revoke', async (c) => { + try { return c.json(await revokeRuntimeSession(actor(c), Id.parse(c.req.param('sessionId')))); } + catch (error) { return failure(c, error); } +}); diff --git a/apps/api/src/routes/app-runtime-review.ts b/apps/api/src/routes/app-runtime-review.ts new file mode 100644 index 00000000..4084312d --- /dev/null +++ b/apps/api/src/routes/app-runtime-review.ts @@ -0,0 +1,36 @@ +import { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { z } from 'zod'; +import type { AuthUser } from '../middleware/auth.js'; +import { humanModuleActor } from '../lib/module-service.js'; +import { appRuntimeChannelEnabled } from '../lib/app-runtime-channel.js'; +import { activateRuntimeApp, prepareRuntimeAppReview } from '../lib/app-runtime-review.js'; +import { isAppError } from '../lib/app-errors.js'; +import { isModuleError } from '../lib/module-errors.js'; + +/** Mounted behind the normal authenticated human API middleware. */ +export const appRuntimeReviewRoutes = new Hono(); +appRuntimeReviewRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!appRuntimeChannelEnabled()) return c.json({ error: 'Runtime unavailable', code: 'APP_RUNTIME_DISABLED' }, 503); + await next(); +}); +appRuntimeReviewRoutes.use('*', bodyLimit({ maxSize: 8192 })); +for (const operation of ['review', 'activate'] as const) { + appRuntimeReviewRoutes.post(`/:installationId/${operation}`, async (c) => { + const user = c.get('user') as AuthUser | undefined; + if (!user) return c.json({ error: 'Authentication required', code: 'APP_ACCESS_DENIED' }, 401); + try { + const actor = humanModuleActor({ orgId: user.org_id, userId: user.id, role: user.role ?? 'member', source: 'rest' }); + const id = z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/).parse(c.req.param('installationId')); + const body: unknown = await c.req.json(); + const result = operation === 'review' ? await prepareRuntimeAppReview(actor, id, body) + : await activateRuntimeApp(actor, id, body); + return c.json(result); + } catch (error) { + if (isAppError(error) || isModuleError(error)) return c.json({ error: error.message, code: error.code }, error.status); + if (error instanceof z.ZodError || error instanceof SyntaxError) return c.json({ error: 'Invalid review request', code: 'VALIDATION_ERROR' }, 400); + return c.json({ error: 'Runtime review failed', code: 'INTERNAL_ERROR' }, 500); + } + }); +} diff --git a/apps/api/test/app-runtime-actions-db.test.ts b/apps/api/test/app-runtime-actions-db.test.ts new file mode 100644 index 00000000..486c34e2 --- /dev/null +++ b/apps/api/test/app-runtime-actions-db.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { runtimeV3PackageJson } from './fixtures/runtime-v3-package.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('packed Runtime Kit follows reviewed human Run, approval, claim, result, and signed receipt', { + skip: !safe, +}, async (t) => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256') + .update(`runtime-action-journey:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('encryption') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('signing') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fingerprint') } }, + }); + const [{ db, closeDb }, schema, appService, appReview, management, moduleService, + actionModule, runModule, keyringFixture, lockHelper] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/app-runtime-management.js'), import('../src/lib/module-service.js'), + import('../src/lib/app-runtime-action-service.js'), import('../src/lib/app-run-runtime.js'), + import('./fixtures/app-run-test-keyrings.js'), + import('./fixtures/app-runtime-review-lock.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + let runtime: Awaited> | undefined; + try { + const ring = await keyringFixture.databaseCompleteAppRunTestKeyringFixture('runtime-action-journey'); + process.env.DEFT_APP_RUN_KEYRINGS = ring.environment; + ring.keys.destroy(); + const suffix = randomUUID(); + const orgId = randomUUID(); + const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'Runtime action journey', slug: `runtime-action-${suffix}` }); + await db.insert(schema.users).values({ id: ownerId, email: `runtime-action-${suffix}@example.test`, name: 'Runtime owner' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'ui' }); + const packageJson = await runtimeV3PackageJson(); + const staged = await appService.stageAppPackage(owner, packageJson); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const reviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const review = await appReview.prepareRuntimeAppReview(owner, staged.id, reviewInput); + const active = await appReview.activateRuntimeApp(owner, staged.id, { + ...reviewInput, expected_review_digest: review.review_digest, accept_host_policy: true, + }); + const [grant] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, active.grant_snapshot_id))); + assert.ok(grant); + const bindInput = { installation_id: staged.id, action_key: 'create_shipping_label', + operator_user_id: ownerId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: active.installation.lifecycle_epoch, + expected_grant_epoch: active.installation.grant_epoch }; + const bindingReview = await management.prepareRuntimeBindingReview(owner, bindInput); + const binding = await management.activateRuntimeBinding(owner, { + ...bindInput, expected_review_digest: bindingReview.review_digest, accept_host_policy: true, + }); + runtime = await runModule.getAppRunRuntime(); + const actions = new actionModule.AppRuntimeActionService(runtime.service); + const caller = { org_id: orgId, user_id: ownerId }; + const request = { runtime_binding_id: binding.binding_id, + idempotency_key: `shipping:${suffix}`, input: { shipment_id: 'synthetic-shipment-1' } }; + await assert.rejects(actions.invoke(caller, { ...request, input: { shipment_id: 'x', admin: true } })); + assert.throws(() => actions.invoke(caller, { ...request, policy: { review_requirement: 'never' } })); + assert.throws(() => actions.invoke(caller, { ...request, initiating_actor: { actor_type: 'human', user_id: ownerId } })); + await assert.rejects(actions.invoke({ org_id: randomUUID(), user_id: ownerId }, request)); + const [before] = await db.select().from(schema.appRuns).where(eq(schema.appRuns.org_id, orgId)); + assert.equal(before, undefined, 'invalid and foreign requests never write a Run'); + const run = await actions.invoke(caller, request); + assert.equal(run.state, 'pending_approval'); + assert.equal(run.provider_kind, 'app_runtime'); + await t.test('pending input review does not hold App lock while waiting on approval-held Run', + async () => lockHelper.assertRuntimeInputReviewLockOrder({ org_id: orgId, run_id: run.id, + installation_id: staged.id, review: () => runtime!.service.reviewRuntimeInput(caller, run.id) })); + assert.equal((await actions.invoke(caller, request)).id, run.id); + await assert.rejects(actions.invoke(caller, { ...request, input: { shipment_id: 'different' } }), + (error: unknown) => (error as { code?: string }).code === 'APP_RUN_IDEMPOTENCY_CONFLICT'); + const [approval] = await db.select().from(schema.agentActions).where(and( + eq(schema.agentActions.org_id, orgId), eq(schema.agentActions.app_run_id, run.id))); + assert.ok(approval); + assert.equal(approval.approval_status, 'pending'); + assert.equal((await runtime.approvalResolver.approve(approval.id, ownerId)).status, 'approved'); + const session = await management.issueRuntimeOperatorSession(owner, binding.binding_id); + const claimed = await runtime.runtimeChannel.claim({ schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); + assert.ok(claimed); + assert.equal(claimed.run_id, run.id); + const channelRequest = { schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, + run_id: run.id, attempt_id: claimed.attempt_id, + claim_token: claimed.claim_token, sequence: claimed.sequence }; + const started = await runtime.runtimeChannel.start(channelRequest); + assert.ok(started); + assert.deepEqual(started.input, { shipment_id: 'synthetic-shipment-1' }); + const resultRequest = { ...channelRequest, status: 'returned', + provider_succeeded: true, output: { label_id: 'synthetic-label-1' } }; + const result = await runtime.runtimeChannel.complete(resultRequest); + assert.equal(result?.state, 'succeeded'); + assert.equal((await runtime.runtimeChannel.complete(resultRequest))?.id, run.id); + const receipts = await runtime.receiptReader.readVerified(orgId, run.id); + assert.ok(receipts.some((receipt) => receipt.receipt_kind === 'attempt_terminal' && receipt.verified)); + assert.deepEqual((await runtime.service.result(orgId, run.id, + { actor_type: 'human', user_id: ownerId }, null)).value, + { schema_version: 'deft.app_run_provider_result.v1', provider_succeeded: true, + output: { label_id: 'synthetic-label-1' } }); + await management.revokeRuntimeBinding(owner, binding.binding_id); + await assert.rejects(actions.invoke(caller, { ...request, idempotency_key: `after-revoke:${suffix}` }), + (error: unknown) => (error as { code?: string }).code === 'APP_RUN_AUTHORIZATION_STALE'); + const rows = await db.select({ id: schema.appRuns.id }).from(schema.appRuns).where( + eq(schema.appRuns.org_id, orgId)); + assert.deepEqual(rows.map((row) => row.id), [run.id], 'revoked binding adds no Run'); + } finally { + await runModule.shutdownAppRunRuntime(); + await closeDb(); + } +}); diff --git a/apps/api/test/app-runtime-actions-http-db.test.ts b/apps/api/test/app-runtime-actions-http-db.test.ts new file mode 100644 index 00000000..ea7b97b5 --- /dev/null +++ b/apps/api/test/app-runtime-actions-http-db.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { fork, type ChildProcess } from 'node:child_process'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { runtimeV3PackageJson } from './fixtures/runtime-v3-package.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('authenticated HTTP pairing, packed install, reviews, action and Runtime receipt', { skip: !safe }, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_DEVELOPER_PAIRING_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`runtime-http:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fp') } }, + }); + const [{ app }, { db, closeDb }, schema, sessionModule, runModule, keyringFixture, serverModule] = await Promise.all([ + import('../src/index.js'), import('../src/lib/db.js'), import('@deft/db/schema'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-runtime.js'), + import('./fixtures/app-run-test-keyrings.js'), import('@hono/node-server'), + ]); + const { and, eq } = await import('drizzle-orm'); + const base = 'http://127.0.0.1:4337'; + let server: ReturnType | undefined; + let child: ChildProcess | undefined; + try { + await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 4337 }, () => resolve()); + }); + const ring = await keyringFixture.databaseCompleteAppRunTestKeyringFixture('runtime-http'); + process.env.DEFT_APP_RUN_KEYRINGS = ring.environment; + ring.keys.destroy(); + const suffix = randomUUID(); + const orgId = randomUUID(); + const ownerId = randomUUID(); + const otherId = randomUUID(); + const foreignOrgId = randomUUID(); + const email = `runtime-http-${suffix}@example.test`; + await db.insert(schema.orgs).values({ id: orgId, name: 'Runtime HTTP journey', slug: `runtime-http-${suffix}` }); + await db.insert(schema.orgs).values({ id: foreignOrgId, name: 'Foreign Runtime org', + slug: `runtime-foreign-${suffix}` }); + await db.insert(schema.users).values([{ id: ownerId, email, name: 'Runtime owner' }, + { id: otherId, email: `runtime-other-${suffix}@example.test`, name: 'Other member' }]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: otherId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: foreignOrgId, user_id: otherId, role: 'owner', is_active: true }, + ]); + const web = await sessionModule.createWebSession({ id: ownerId, email, org_id: orgId }); + const otherWeb = await sessionModule.createWebSession({ id: otherId, + email: `runtime-other-${suffix}@example.test`, org_id: orgId }); + const foreignWeb = await sessionModule.createWebSession({ id: otherId, + email: `runtime-other-${suffix}@example.test`, org_id: foreignOrgId }); + const auth = { Authorization: `Bearer ${web.accessToken}` }; + async function call(path: string, method = 'GET', body?: unknown, token = auth.Authorization) { + const response = await fetch(new Request(`${base}${path}`, { + method, headers: { Authorization: token, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + })); + const value = await response.json() as any; + assert.ok(response.ok, `${method} ${path}: ${response.status} ${JSON.stringify(value)}`); + return value; + } + const pairing = (await call('/api/apps/pairings', 'POST')).pairing; + const exchanged = await call('/api/app-developer/pair/exchange', 'POST', { code: pairing.code }, ''); + assert.equal(exchanged.audience, 'app-developer'); + const packageJson = await runtimeV3PackageJson(); + const installResponse = await fetch(new Request(`${base}/api/app-developer/install`, { + method: 'POST', headers: { Authorization: `Bearer ${exchanged.token}`, + 'Content-Type': 'application/json' }, body: packageJson, + })); + const installed = await installResponse.json() as any; + assert.equal(installResponse.status, 201, JSON.stringify(installed)); + const staged = installed.app; + assert.equal(staged.state, 'staged'); + const grants = (await call(`/api/apps/${staged.id}/grants`)).grants; + const version = grants.versions.find((row: any) => row.id === staged.version_id); + const requested = grants.snapshots.find((row: any) => row.id === version.requested_grant_snapshot_id); + assert.ok(requested); + const reviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: grants.installation.lifecycle_epoch, + expected_grant_epoch: grants.installation.grant_epoch }; + const review = await call(`/api/app-runtime-review/${staged.id}/review`, 'POST', reviewInput); + const activated = await call(`/api/app-runtime-review/${staged.id}/activate`, 'POST', { + ...reviewInput, expected_review_digest: review.review_digest, accept_host_policy: true, + }); + const effective = (await call(`/api/apps/${staged.id}/grants`)).grants; + const grant = effective.snapshots.find((row: any) => row.id === activated.grant_snapshot_id); + assert.ok(grant); + const bindingInput = { installation_id: staged.id, action_key: 'create_shipping_label', + operator_user_id: ownerId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: effective.installation.lifecycle_epoch, + expected_grant_epoch: effective.installation.grant_epoch }; + const bindingReview = (await call('/api/apps/runtime/reviews/prepare', 'POST', bindingInput)).review; + const binding = (await call('/api/apps/runtime/bindings/activate', 'POST', { + ...bindingInput, expected_review_digest: bindingReview.review_digest, accept_host_policy: true, + })).binding; + const invoke = { runtime_binding_id: binding.binding_id, idempotency_key: `http-shipping:${suffix}`, + input: { shipment_id: 'synthetic-http-shipment' } }; + const denied = await fetch(new Request(`${base}/api/app-runtime-actions/invoke`, { + method: 'POST', headers: { Authorization: `Bearer ${exchanged.token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(invoke), + })); + assert.equal(denied.status, 401, 'developer audience cannot act as a human'); + for (const malformed of [{ ...invoke, policy: { review_requirement: 'never' } }, + { ...invoke, initiating_actor: { actor_type: 'human', user_id: ownerId } }, + { ...invoke, padding: 'x'.repeat(70_000) }]) { + const rejected = await fetch(new Request(`${base}/api/app-runtime-actions/invoke`, { + method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify(malformed), + })); + assert.equal(rejected.status, 400); + } + assert.deepEqual(await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, orgId)), []); + const { run } = await call('/api/app-runtime-actions/invoke', 'POST', invoke); + assert.equal(run.state, 'pending_approval'); + assert.equal((await call('/api/app-runtime-actions/invoke', 'POST', invoke)).run.id, run.id); + const reviewPath = `/api/app-runtime-actions/${run.id}/review`; + const reviewed = await fetch(`${base}${reviewPath}`, { headers: auth }); + assert.equal(reviewed.status, 200); + assert.equal(reviewed.headers.get('cache-control'), 'no-store'); + const reviewValue = (await reviewed.json() as any).review; + assert.deepEqual(reviewValue.input, { shipment_id: 'synthetic-http-shipment' }); + assert.equal(reviewValue.run_id, run.id); + assert.equal(reviewValue.runtime_binding_id, binding.binding_id); + assert.deepEqual(reviewValue.policy, { risk_class: 'external_write', + review_requirement: 'always', review_scope: 'per_invocation', retry_class: 'unsafe_or_unknown' }); + for (const token of [otherWeb.accessToken, foreignWeb.accessToken]) { + const foreign = await fetch(`${base}${reviewPath}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(foreign.status, 403); + assert.equal((await foreign.json() as any).code, 'APP_RUN_ACCESS_DENIED'); + } + const [approval] = await db.select().from(schema.agentActions).where(and( + eq(schema.agentActions.org_id, orgId), eq(schema.agentActions.app_run_id, run.id))); + assert.ok(approval); + const [storedRun] = await db.select({ safe_preview: schema.appRuns.safe_preview }).from(schema.appRuns) + .where(and(eq(schema.appRuns.org_id, orgId), eq(schema.appRuns.id, run.id))); + assert.ok(storedRun); + assert.equal(JSON.stringify({ safe_preview: storedRun.safe_preview, approval: approval.params }) + .includes('synthetic-http-shipment'), false, 'approval projections contain no private input'); + const runtime = await runModule.getAppRunRuntime(); + assert.equal((await runtime.approvalResolver.approve(approval.id, ownerId)).status, 'approved'); + const session = (await call(`/api/apps/runtime/bindings/${binding.binding_id}/sessions`, 'POST')).session; + const ledgerDir = await mkdtemp(join(tmpdir(), 'deft-runtime-http-')); + const ledgerPath = join(ledgerDir, 'synthetic-carrier.jsonl'); + child = fork(fileURLToPath(new URL('./fixtures/app-runtime-provider-child.ts', import.meta.url)), [], { + execArgv: ['--import', 'tsx'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, DEFT_RUNTIME_PROVIDER_FIXTURE: 'true' }, + }); + const events: Array> = []; + const completed = new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Runtime child timed out: ${JSON.stringify(events)}`)), 30_000); + child!.on('message', (message) => { + if (!message || typeof message !== 'object') return; + const event = message as Record; + events.push(event); + if (event.type === 'result' || event.type === 'error') { + clearTimeout(timer); + if (event.type === 'error') reject(new Error(`Runtime child: ${JSON.stringify(event)}`)); + else resolve(event); + } + }); + child!.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Runtime child exited ${code}: ${JSON.stringify(events)}`)); + }); + }); + child.send({ type: 'start', channel_url: `${base}/api/app-runtime/channel`, + session_id: session.session_id, session_token: session.session_token, + ledger_path: ledgerPath, mode: 'normal' }); + const completedEvent = await completed; + assert.equal(completedEvent.type, 'result'); + assert.ok(events.some((event) => event.type === 'effect_committed')); + const ledger = (await readFile(ledgerPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + assert.deepEqual(ledger, [{ run_id: run.id, item_id: 'synthetic-http-shipment', + effect: 'synthetic_carrier_label' }]); + const outcome = await runtime.repository.inspect(orgId, run.id); + assert.equal(outcome?.state, 'succeeded'); + assert.ok((await runtime.receiptReader.readVerified(orgId, run.id)) + .some((receipt) => receipt.receipt_kind === 'attempt_terminal' && receipt.verified)); + const [eventRows, receiptRows] = await Promise.all([ + db.select({ payload: schema.appRunEvents.payload }).from(schema.appRunEvents) + .where(and(eq(schema.appRunEvents.org_id, orgId), eq(schema.appRunEvents.run_id, run.id))), + db.select({ envelope: schema.appRunReceipts.envelope }).from(schema.appRunReceipts) + .where(and(eq(schema.appRunReceipts.org_id, orgId), eq(schema.appRunReceipts.run_id, run.id))), + ]); + assert.equal(JSON.stringify({ events: eventRows, receipts: receiptRows }) + .includes('synthetic-http-shipment'), false, + 'event and signed receipt projections contain no private input'); + const closedReview = await fetch(`${base}${reviewPath}`, { headers: auth }); + assert.equal(closedReview.status, 409); + assert.equal((await closedReview.json() as any).code, 'APP_RUN_AUTHORIZATION_STALE'); + const pendingAfter = (await call('/api/app-runtime-actions/invoke', 'POST', { + ...invoke, idempotency_key: `review-revoke:${suffix}`, + })).run; + assert.equal(pendingAfter.state, 'pending_approval'); + await call(`/api/apps/runtime/bindings/${binding.binding_id}/revoke`, 'POST'); + const revokedReview = await fetch(`${base}/api/app-runtime-actions/${pendingAfter.id}/review`, { + headers: auth, + }); + assert.equal(revokedReview.status, 409); + assert.equal((await revokedReview.json() as any).code, 'APP_RUN_AUTHORIZATION_STALE'); + } finally { + if (child && child.exitCode === null) child.kill(); + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server!.close(() => resolve())); + } + await runModule.shutdownAppRunRuntime(); + await closeDb(); + } +}); diff --git a/apps/api/test/app-runtime-channel-db.test.ts b/apps/api/test/app-runtime-channel-db.test.ts index 743fc4a8..4967b3a0 100644 --- a/apps/api/test/app-runtime-channel-db.test.ts +++ b/apps/api/test/app-runtime-channel-db.test.ts @@ -1,403 +1,447 @@ import assert from 'node:assert/strict'; import { createHash, randomUUID } from 'node:crypto'; -import test from 'node:test'; import { fork } from 'node:child_process'; +import { mkdtemp, readFile, rmdir, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import test from 'node:test'; const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; const safeDatabase = databaseUrl && databaseUrl === process.env.DATABASE_URL && /(?:^|[-_])(test|ci|acceptance|phase\d+)(?:$|[-_])/iu.test( new URL(databaseUrl).pathname.slice(1)); -test('reviewed Runtime claim, start, known result and replay use the App Run ledger', { +test('reviewed v3 Runtime channel preserves fencing, replay, revocation and unknown outcomes', { skip: !safeDatabase, }, async () => { process.env.DEFT_APPS_ENABLED = 'true'; process.env.DEFT_APP_RUNS_ENABLED = 'true'; process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; - const key = (purpose: string) => createHash('sha256').update(`runtime-db-test:${purpose}`).digest('base64'); + const key = (purpose: string) => createHash('sha256') + .update(`runtime-channel-v3:${purpose}`).digest('base64'); process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', - run_encryption: { current: 'runtime-test-enc-v1', keys: { 'runtime-test-enc-v1': key('encryption') } }, - receipt_signing: { current: 'runtime-test-sig-v1', keys: { 'runtime-test-sig-v1': key('signing') } }, - fingerprint: { current: 'runtime-test-fp-v1', keys: { 'runtime-test-fp-v1': key('fingerprint') } }, + run_encryption: { current: 'runtime-channel-enc-v1', + keys: { 'runtime-channel-enc-v1': key('encryption') } }, + receipt_signing: { current: 'runtime-channel-sig-v1', + keys: { 'runtime-channel-sig-v1': key('signing') } }, + fingerprint: { current: 'runtime-channel-fp-v1', + keys: { 'runtime-channel-fp-v1': key('fingerprint') } }, }); - const [{ db, closeDb }, schema, shared, appKit, appService, reviewService, - moduleService, packageFixture, providerExecutor, repositoryModule, - secretModule, keyringModule, secretRepositoryModule, receiptModule, runnerModule, - channelModule, authorityModule] = await Promise.all([ - import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/shared'), - import('@deft/app-kit'), import('../src/lib/app-service.js'), - import('../src/lib/app-review-service.js'), import('../src/lib/module-service.js'), - import('./fixtures/phase5-connected-app-package.js'), - import('../src/lib/app-run-provider-executor.js'), - import('../src/lib/app-run-repository.js'), - import('../src/lib/app-run-secrets.js'), - import('../src/lib/app-run-keyrings.js'), - import('../src/lib/app-run-secret-repository.js'), - import('../src/lib/app-run-receipts.js'), - import('../src/lib/app-run-attempt-runner.js'), - import('../src/lib/app-runtime-channel.js'), - import('../src/lib/app-runtime-authority.js'), + const [{ db, closeDb }, schema, kit, appService, appReview, management, + moduleService, actionsModule, runtimeModule, keyFixture, secretModule, + keyringModule, runnerModule, providerModule, receiptModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/app-runtime-management.js'), import('../src/lib/module-service.js'), + import('../src/lib/app-runtime-action-service.js'), import('../src/lib/app-run-runtime.js'), + import('./fixtures/app-run-test-keyrings.js'), import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-keyrings.js'), import('../src/lib/app-run-attempt-runner.js'), + import('../src/lib/app-run-provider-executor.js'), import('../src/lib/app-run-receipts.js'), ]); const { and, eq, sql } = await import('drizzle-orm'); - // The HTTP host uses the production global key-retirement guard. Preserve - // unrelated test key IDs while isolating this fixture's actual key material. - const { databaseCompleteAppRunTestKeyringFixture } = await import('./fixtures/app-run-test-keyrings.js'); - const covered = await databaseCompleteAppRunTestKeyringFixture('runtime-db-test'); - covered.keys.destroy(); - const combinedKeyrings = JSON.parse(covered.environment); - const runtimeKeyrings = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + const ring = await keyFixture.databaseCompleteAppRunTestKeyringFixture('runtime-channel-v3'); + const combined = JSON.parse(ring.environment); + const own = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); for (const purpose of ['run_encryption', 'receipt_signing', 'fingerprint']) { - combinedKeyrings[purpose].current = runtimeKeyrings[purpose].current; - Object.assign(combinedKeyrings[purpose].keys, runtimeKeyrings[purpose].keys); + combined[purpose].current = own[purpose].current; + Object.assign(combined[purpose].keys, own[purpose].keys); } - process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify(combinedKeyrings); - const orgId = randomUUID(); - const userId = randomUUID(); - const connectionId = randomUUID(); - const registrationId = randomUUID(); - const bindingId = randomUUID(); - const snapshotId = randomUUID(); - const suffix = randomUUID(); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify(combined); + ring.keys.destroy(); + let runtime: Awaited> | undefined; try { - await db.insert(schema.orgs).values({ id: orgId, name: 'Runtime fixture', slug: `runtime-${suffix}` }); + const suffix = randomUUID(); + const orgId = randomUUID(); + const userId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'Runtime channel v3', + slug: `runtime-channel-${suffix}` }); await db.insert(schema.users).values({ id: userId, - email: `runtime-${suffix}@example.test`, name: 'Runtime owner' }); - const [membership] = await db.insert(schema.orgMembers).values({ - id: randomUUID(), org_id: orgId, user_id: userId, role: 'owner', is_active: true, - }).returning(); - assert.ok(membership); + email: `runtime-channel-${suffix}@example.test`, name: 'Runtime owner' }); + const [member] = await db.insert(schema.orgMembers).values({ id: randomUUID(), + org_id: orgId, user_id: userId, role: 'owner', is_active: true }).returning(); + assert.ok(member); const owner = moduleService.humanModuleActor({ orgId, userId, role: 'owner', source: 'ui' }); - const dependencyPackage = await packageFixture.buildPhase5DependencyAppPackage(); - const dependency = await appService.stageAppPackage(owner, dependencyPackage.json); - await appService.activateAppInstallation(owner, dependency.id, dependency.package_digest); - const connectedPackage = await packageFixture.buildPhase5ConnectedAppPackage(); - const connected = await appService.stageAppPackage(owner, connectedPackage.json); + const packageJson = (await kit.buildDeftAppPackage({ manifest: { + schema_version: '3', id: `community.example.runtime.app${suffix.replace(/-/g, '')}`, + version: '1.0.0', name: 'Runtime channel', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '3' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'provider', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'deliver', version: '1', + input_schema: { type: 'object', properties: { item_id: { type: 'string', maxLength: 120 } }, + required: ['item_id'], additionalProperties: false }, + output_schema: { type: 'object', properties: { receipt_id: { type: 'string', maxLength: 120 } }, + required: ['receipt_id'], additionalProperties: false } }], + runtime_actions: [{ key: 'deliver', label: 'Deliver', capability_key: 'deliver', + runtime_requirement_key: 'provider' }], + }, artifacts: [] })).json; + const staged = await appService.stageAppPackage(owner, packageJson); const [version] = await db.select().from(schema.appVersions).where(and( - eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, connected.version_id), - )); + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); assert.ok(version?.requested_grant_snapshot_id); const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( eq(schema.appGrantSnapshots.org_id, orgId), - eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id), - )); + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); assert.ok(requested); - await db.insert(schema.mcpConnections).values({ - id: connectionId, org_id: orgId, name: 'Fixture review connector', - slug: `runtime-review-${suffix}`, server_url: 'https://example.test/mcp', - transport: 'streamable-http', auth_type: 'none', is_active: true, - enabled_tools: ['send_email'], created_by: userId, - }); - const mcpProvider = { org_id: orgId, provider_kind: 'mcp' as const, - provider_instance_id: connectionId }; - const discovery = await shared.createCapabilityProviderDiscoverySnapshot({ - adapter_contract_version: shared.CAPABILITY_CONTRACT_VERSIONS.mcp_adapter, - provider: mcpProvider, captured_at: new Date().toISOString(), - operations: [{ identity: { provider: mcpProvider, operation_name: 'send_email' }, - title: 'Fixture review connector', description: 'Synthetic only', - input_schema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, - output_schema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema }], - }); - const capability = { - async discover() { return { - provider_kind: 'mcp' as const, - tools: [{ name: `mcp__runtime_review_${suffix}__send_email`, originalName: 'send_email', - description: 'Synthetic only', - inputSchema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.input_schema, - outputSchema: appKit.SANDBOX_EMAIL_SEND_PRIVATE_CONTRACT.output_schema, - connectionId, connectionSlug: `runtime-review-${suffix}`, isWrite: true, - approvalTier: 'full-review' as const, rawTool: { name: 'send_email' } }], - snapshot: discovery, - }; }, - async invoke() { throw new Error('fixture review connector must never invoke'); }, - }; - const reviewRequest = { - app_version_id: version.id, expected_package_digest: version.package_digest, + const appReviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, expected_requested_snapshot_digest: requested.snapshot_digest, - expected_lifecycle_epoch: connected.lifecycle_epoch, - expected_grant_epoch: connected.grant_epoch, - connector_selections: [{ connector_requirement_key: 'mail_provider', - mcp_connection_id: connectionId }], - }; - const review = await reviewService.prepareConnectedAppReview( - owner, connected.id, reviewRequest, capability); - await reviewService.activateConnectedAppInstallation(owner, connected.id, { - ...reviewRequest, expected_review_digest: review.review_digest, - accept_host_policy: true, - }, capability); - const [installation] = await db.select().from(schema.appInstallations).where(and( - eq(schema.appInstallations.org_id, orgId), eq(schema.appInstallations.id, connected.id), - )); - assert.ok(installation?.active_grant_snapshot_id); - - // Synthetic host-reviewed Runtime binding. Released v0-v2 package did not - // author this contract, and no public intake accepts Runtime Runs yet. - await db.insert(schema.capabilityProviderSnapshots).values({ - id: snapshotId, org_id: orgId, provider_kind: 'app_runtime', - provider_instance_id: registrationId, - adapter_contract_version: 'deft.app_runtime_channel.v1', - snapshot_digest: `sha256:${'a'.repeat(64)}`, - safe_snapshot: { fixture: true }, captured_at: new Date(), - }); - await db.insert(schema.appRuntimeRegistrations).values({ - id: registrationId, org_id: orgId, app_installation_id: connected.id, - app_version_id: version.id, grant_snapshot_id: installation.active_grant_snapshot_id, - operator_user_id: userId, contract_version: 'deft.app_runtime_channel.v1', - }); - await assert.rejects(db.update(schema.appRuntimeRegistrations).set({ - state: 'active', runtime_epoch: 1, contract_version: 'substituted', - reviewed_by_user_id: userId, reviewed_at: new Date(), - }).where(eq(schema.appRuntimeRegistrations.id, registrationId)), - (error: unknown) => (error as { cause?: { message?: string } }).cause?.message === 'APP_RUNTIME_IMMUTABLE_FIELD'); - await db.update(schema.appRuntimeRegistrations).set({ state: 'active', runtime_epoch: 1, - reviewed_by_user_id: userId, reviewed_at: new Date() }).where(and( - eq(schema.appRuntimeRegistrations.org_id, orgId), - eq(schema.appRuntimeRegistrations.id, registrationId), - )); - await db.insert(schema.appRuntimeBindings).values({ - id: bindingId, org_id: orgId, app_installation_id: connected.id, - app_version_id: version.id, grant_snapshot_id: installation.active_grant_snapshot_id, - runtime_registration_id: registrationId, action_key: 'fixture_action', - interface_identity: `deft.runtime.v1:${orgId.toLowerCase()}:${connected.id.toLowerCase()}:fixture_action`, - provider_instance_id: registrationId, provider_snapshot_id: snapshotId, - operation_name: 'fixture_action', risk_class: 'external_write', - review_requirement: 'always', retry_class: 'unsafe_or_unknown', - retention_class: 'standard', - }); - await assert.rejects(db.update(schema.appRuntimeBindings).set({ - state: 'active', operation_name: 'substituted', - reviewed_by_user_id: userId, reviewed_at: new Date(), - }).where(eq(schema.appRuntimeBindings.id, bindingId)), - (error: unknown) => (error as { cause?: { message?: string } }).cause?.message === 'APP_RUNTIME_IMMUTABLE_FIELD'); - await db.update(schema.appRuntimeBindings).set({ state: 'active', - reviewed_by_user_id: userId, reviewed_at: new Date() }).where(and( - eq(schema.appRuntimeBindings.org_id, orgId), eq(schema.appRuntimeBindings.id, bindingId), - )); - const keys = keyringModule.parseEnvironmentAppRunKeyrings(process.env.DEFT_APP_RUN_KEYRINGS); - const secrets = new secretModule.AppRunSecretService(keys); - const secretRepository = new secretRepositoryModule.AppRunSecretRepository(secrets); - const repository = new repositoryModule.PostgresAppRunRepository(); - const receiptWriter = new receiptModule.PostgresAppRunReceiptWriter(secrets, secretRepository); - const receiptReader = new receiptModule.PostgresAppRunReceiptReader(secrets); - let clockOffsetMs = 0; - const runner = new runnerModule.AppRunAttemptRunner(repository, secretRepository, secrets, - new providerExecutor.PinnedMcpAppRunProviderExecutor(), undefined, - () => new Date(Date.now() + clockOffsetMs), 60_000, 20_000, receiptWriter); - const channel = new channelModule.AppRuntimeChannel(runner); - const session = await channel.issueSession({ org_id: orgId, - runtime_binding_id: bindingId, operator_user_id: userId }); + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const appReviewValue = await appReview.prepareRuntimeAppReview(owner, staged.id, appReviewInput); + const active = await appReview.activateRuntimeApp(owner, staged.id, { ...appReviewInput, + expected_review_digest: appReviewValue.review_digest, accept_host_policy: true }); + const [grant] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), eq(schema.appGrantSnapshots.id, active.grant_snapshot_id))); + assert.ok(grant); + const bindInput = { installation_id: staged.id, action_key: 'deliver', + operator_user_id: userId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: active.installation.lifecycle_epoch, + expected_grant_epoch: active.installation.grant_epoch }; + const bindingReview = await management.prepareRuntimeBindingReview(owner, bindInput); + const binding = await management.activateRuntimeBinding(owner, { ...bindInput, + expected_review_digest: bindingReview.review_digest, accept_host_policy: true }); + const secondOwnerId = randomUUID(); + await db.insert(schema.users).values({ id: secondOwnerId, + email: `runtime-owner2-${suffix}@example.test`, name: 'Second owner' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: secondOwnerId, role: 'owner', is_active: true }); + const secondOwner = moduleService.humanModuleActor({ orgId, userId: secondOwnerId, + role: 'owner', source: 'ui' }); + const reciprocalReviews = await Promise.all([ + management.prepareRuntimeBindingReview(owner, + { ...bindInput, operator_user_id: secondOwnerId }), + management.prepareRuntimeBindingReview(secondOwner, + { ...bindInput, operator_user_id: userId }), + ]); + assert.equal(reciprocalReviews.length, 2, + 'reciprocal reviewer/operator locks must serialize without deadlock'); + const otherUserId = randomUUID(); + await db.insert(schema.users).values({ id: otherUserId, + email: `runtime-other-${suffix}@example.test`, name: 'Other member' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: otherUserId, role: 'member', is_active: true }); + const otherMember = moduleService.humanModuleActor({ orgId, userId: otherUserId, + role: 'member', source: 'ui' }); + await assert.rejects(management.prepareRuntimeBindingReview(otherMember, bindInput), + (error: unknown) => (error as { code?: string }).code === 'APP_ACCESS_DENIED'); + await assert.rejects(management.issueRuntimeOperatorSession(otherMember, binding.binding_id), + (error: unknown) => (error as { code?: string }).code === 'APP_ACCESS_DENIED'); + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'false'; + await assert.rejects(management.issueRuntimeOperatorSession(owner, binding.binding_id), + (error: unknown) => (error as { code?: string }).code === 'APP_FEATURE_DISABLED'); + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + runtime = await runtimeModule.getAppRunRuntime(); + const actions = new actionsModule.AppRuntimeActionService(runtime.service); + const channel = runtime.runtimeChannel; + const session = await management.issueRuntimeOperatorSession(owner, binding.binding_id); assert.ok(session); - - async function createSyntheticRun() { - const runId = randomUUID(); - const attemptId = randomUUID(); - const input = { fixture: 'exact input' }; - const membershipVersion = `sha256:${createHash('sha256') - .update('deft.app_run.authority.v1\0membership\0') - .update(shared.canonicalCapabilityJson({ id: membership.id, - authority_version: membership.app_run_authorization_version })).digest('hex')}`; - const inputFingerprint = secrets.fingerprintJson('input', input); - const idemFingerprint = secrets.fingerprintText('idempotency', runId); - await db.insert(schema.appRuns).values({ - id: runId, org_id: orgId, contract_version: shared.APP_RUN_CONTRACT_VERSIONS.run, - origin_kind: 'app', initiating_actor_type: 'human', initiating_actor_id: userId, - execution_actor_type: 'human', execution_actor_id: userId, - provider_kind: 'app_runtime', provider_instance_id: registrationId, - operation_name: 'fixture_action', provider_snapshot_id: snapshotId, - origin_app_installation_id: connected.id, origin_app_version_id: version.id, - origin_app_grant_snapshot_id: installation.active_grant_snapshot_id!, - origin_runtime_binding_id: bindingId, - risk_class: 'external_write', review_requirement: 'always', - review_scope: 'per_invocation', retry_class: 'unsafe_or_unknown', - retention_class: 'standard', idempotency_key_version: idemFingerprint.key_version, - idempotency_fingerprint: idemFingerprint.fingerprint, - input_fingerprint_key_version: inputFingerprint.key_version, - input_fingerprint: inputFingerprint.fingerprint, - authorization_snapshot: { schema_version: shared.APP_RUN_CONTRACT_VERSIONS.run, - authenticated_subject: { actor_type: 'human', user_id: userId }, - authority_refs: [{ authority_kind: 'membership', authority_id: userId, - version: membershipVersion }] }, - safe_preview: { schema_version: shared.APP_RUN_CONTRACT_VERSIONS.run, - title: 'Runtime fixture', resource_refs: [] }, - root_run_id: runId, depth: 0, - input_expires_at: new Date(Date.now() + 300_000), - result_expires_at: new Date(Date.now() + 600_000), - idempotency_expires_at: new Date(Date.now() + 900_000), - attempt_limit: 1, execution_release_kind: 'approved', - execution_released_at: new Date(), - }); - await db.transaction((tx) => secretRepository.insertInput(tx, { - org_id: orgId, run_id: runId, value: input, - expires_at: new Date(Date.now() + 300_000), - })); - await db.insert(schema.appRunAttempts).values({ - id: attemptId, org_id: orgId, run_id: runId, - attempt_number: 1, state: 'pending', + let runNumber = 0; + async function approvedRun() { + const itemId = `item-${++runNumber}`; + const run = await actions.invoke({ org_id: orgId, user_id: userId }, { + runtime_binding_id: binding.binding_id, + idempotency_key: `runtime-channel-v3:${suffix}:${itemId}`, + input: { item_id: itemId }, }); - return { runId, attemptId, input }; + assert.equal(run.state, 'pending_approval'); + const [approval] = await db.select().from(schema.agentActions).where(and( + eq(schema.agentActions.org_id, orgId), eq(schema.agentActions.app_run_id, run.id))); + assert.ok(approval); + assert.equal((await runtime!.approvalResolver.approve(approval.id, userId)).status, 'approved'); + return { run, input: { item_id: itemId } }; + } + async function claimFor(expectedRunId: string) { + const claim = await channel.claim({ schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); + assert.ok(claim); + assert.equal(claim.run_id, expectedRunId); + return { claim, request: { schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, session_token: session.session_token, + run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence } }; } - const run = await createSyntheticRun(); - const claim = await channel.claim({ schema_version: 'deft.app_runtime_channel.v1', - session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); - assert.equal(claim?.run_id, run.runId); - assert.equal(claim?.attempt_id, run.attemptId); - const request = { schema_version: 'deft.app_runtime_channel.v1', - session_id: session.session_id, session_token: session.session_token, - run_id: run.runId, attempt_id: run.attemptId, - claim_token: claim!.claim_token, sequence: claim!.sequence }; - const foreignSession = await channel.issueSession({ org_id: orgId, - runtime_binding_id: bindingId, operator_user_id: userId }); - assert.ok(foreignSession); - assert.equal(await channel.start({ ...request, - session_id: foreignSession.session_id, session_token: foreignSession.session_token }), null); - const originalReadInput = secretRepository.readInput.bind(secretRepository); - let enteredInput!: () => void; - let releaseInput!: () => void; - const inputEntered = new Promise((resolve) => { enteredInput = resolve; }); - const inputReleased = new Promise((resolve) => { releaseInput = resolve; }); - secretRepository.readInput = async (...args) => { - enteredInput(); - await inputReleased; - return originalReadInput(...args); + const first = await approvedRun(); + const { claim, request } = await claimFor(first.run.id); + // A released Run may be rescheduled while its Runtime lease is renewed. + // A manager's App UPDATE must not complete an App -> Run -> App lock cycle. + const originalAuthorize = runtime.liveAuthorization.authorizeExecution.bind(runtime.liveAuthorization); + const originalLockRun = runtime.repository.lockRun.bind(runtime.repository); + let resumeSchedule!: () => void; + let scheduleHoldingRun!: () => void; + let channelAtRun!: () => void; + const resumeSchedulePromise = new Promise((resolve) => { resumeSchedule = resolve; }); + const scheduleHoldingRunPromise = new Promise((resolve) => { scheduleHoldingRun = resolve; }); + const channelAtRunPromise = new Promise((resolve) => { channelAtRun = resolve; }); + async function awaitLockBoundary(boundary: Promise, name: string) { + let timer: ReturnType | undefined; + try { + await Promise.race([boundary, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${name} boundary not reached`)), 5000); + })]); + } finally { if (timer) clearTimeout(timer); } + } + let channelStarting = false; + let appLockAvailableAtChannelRun = false; + runtime.liveAuthorization.authorizeExecution = async (params) => { + if (params.run.id === first.run.id && params.stage === 'prepare') { + scheduleHoldingRun(); + await resumeSchedulePromise; + } + return originalAuthorize(params); }; - const startPending = channel.start(request); - let waitTimer: ReturnType | undefined; + runtime.repository.lockRun = async (...args) => { + if (channelStarting && args[2] === first.run.id) { + try { + await db.transaction((tx) => tx.execute(sql`SELECT id FROM app_installations + WHERE org_id = ${orgId} AND id = ${staged.id} FOR UPDATE NOWAIT`)); + appLockAvailableAtChannelRun = true; + } catch { /* An authority-first channel already holds App SHARE. */ } + channelAtRun(); + } + return originalLockRun(...args); + }; + let scheduling: Promise | undefined; + let heartbeating: Promise | undefined; + try { + scheduling = runtime.attemptRunner.prepareAttempt(orgId, first.run.id); + await awaitLockBoundary(scheduleHoldingRunPromise, 'schedule'); + channelStarting = true; + heartbeating = channel.heartbeat(request); + await awaitLockBoundary(channelAtRunPromise, 'channel'); + const managerLock = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '4s'`); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${orgId} + AND id = ${staged.id} FOR UPDATE`); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + resumeSchedule(); + const results = await Promise.allSettled([scheduling, heartbeating, managerLock]); + assert.equal(appLockAvailableAtChannelRun, true, + 'Runtime channel must reach the Run lock before acquiring App authority'); + assert.ok(results.every((item) => item.status === 'fulfilled'), + `released Run schedule/Runtime heartbeat/manager App lock must not deadlock: ${ + results.map((item) => item.status === 'rejected' ? String(item.reason) : 'ok').join('; ')}`); + assert.equal(results[1].status === 'fulfilled' ? results[1].value : false, true); + } finally { + resumeSchedule(); + runtime.liveAuthorization.authorizeExecution = originalAuthorize; + runtime.repository.lockRun = originalLockRun; + await Promise.allSettled([scheduling, heartbeating]); + } + const otherSession = await management.issueRuntimeOperatorSession(owner, binding.binding_id); + assert.equal(await channel.start({ ...request, session_id: otherSession.session_id, + session_token: otherSession.session_token }), null); + const readInput = runtime.secretRepository.readInput.bind(runtime.secretRepository); + let entered!: () => void; + let release!: () => void; + const enteredPromise = new Promise((resolve) => { entered = resolve; }); + const releasePromise = new Promise((resolve) => { release = resolve; }); + runtime.secretRepository.readInput = async (...args) => { + entered(); await releasePromise; return readInput(...args); + }; + const pendingStart = channel.start(request); try { - await Promise.race([inputEntered, new Promise((_, reject) => { - waitTimer = setTimeout(() => reject(new Error('Input disclosure boundary was not reached')), 5000); - })]); + await Promise.race([enteredPromise, new Promise((_, reject) => + setTimeout(() => reject(new Error('Input release boundary not reached')), 5000))]); for (const revoke of ['binding', 'membership']) { await assert.rejects(db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`); - if (revoke === 'binding') { - await tx.update(schema.appRuntimeBindings).set({ state: 'revoked' }) - .where(eq(schema.appRuntimeBindings.id, bindingId)); - } else { - await tx.update(schema.orgMembers).set({ is_active: false }) - .where(eq(schema.orgMembers.id, membership.id)); - } - }), (error: unknown) => (error as { cause?: { code?: string } }).cause?.code === '55P03', - 'revocation must wait for the authorized input read'); + if (revoke === 'binding') await tx.update(schema.appRuntimeBindings) + .set({ state: 'revoked' }).where(eq(schema.appRuntimeBindings.id, binding.binding_id)); + else await tx.update(schema.orgMembers).set({ is_active: false }) + .where(eq(schema.orgMembers.id, member.id)); + }), (error: unknown) => (error as { cause?: { code?: string } }).cause?.code === '55P03'); } } finally { - if (waitTimer) clearTimeout(waitTimer); - releaseInput(); - secretRepository.readInput = originalReadInput; + release(); runtime.secretRepository.readInput = readInput; } - const started = await startPending; - assert.deepEqual(started?.input, run.input); + assert.deepEqual((await pendingStart)?.input, first.input); assert.equal(await channel.heartbeat(request), true); const result = { ...request, status: 'returned' as const, - provider_succeeded: true, output: { provider_receipt: 'fixture-effect-1' } }; + provider_succeeded: true, output: { receipt_id: 'effect-1' } }; assert.equal((await channel.complete(result))?.state, 'succeeded'); assert.equal((await channel.complete(result))?.state, 'succeeded'); - const rotatedEnvironment = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); - rotatedEnvironment.fingerprint.current = 'runtime-test-fp-v2'; - rotatedEnvironment.fingerprint.keys['runtime-test-fp-v2'] = key('fingerprint-v2'); - const rotatedKeys = keyringModule.parseEnvironmentAppRunKeyrings(JSON.stringify(rotatedEnvironment)); + const rotated = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + rotated.fingerprint.current = 'runtime-channel-fp-v2'; + rotated.fingerprint.keys['runtime-channel-fp-v2'] = key('fingerprint-v2'); + const rotatedKeys = keyringModule.parseEnvironmentAppRunKeyrings(JSON.stringify(rotated)); const rotatedSecrets = new secretModule.AppRunSecretService(rotatedKeys); - const rotatedSecretRepository = new secretRepositoryModule.AppRunSecretRepository(rotatedSecrets); - const rotatedRunner = new runnerModule.AppRunAttemptRunner(repository, - rotatedSecretRepository, rotatedSecrets, - new providerExecutor.PinnedMcpAppRunProviderExecutor(), undefined, - () => new Date(), 60_000, 20_000, - new receiptModule.PostgresAppRunReceiptWriter(rotatedSecrets, rotatedSecretRepository)); - const rotatedChannel = new channelModule.AppRuntimeChannel(rotatedRunner); - assert.equal((await rotatedChannel.complete(result))?.state, 'succeeded', - 'retained fingerprint keys must accept exact replay after rotation'); + const rotatedRunner = new runnerModule.AppRunAttemptRunner(runtime.repository, + runtime.secretRepository, rotatedSecrets, new providerModule.PinnedMcpAppRunProviderExecutor(), + runtime.liveAuthorization, () => new Date(), 60_000, 20_000, + new receiptModule.PostgresAppRunReceiptWriter(rotatedSecrets, runtime.secretRepository)); + assert.equal((await new (await import('../src/lib/app-runtime-channel.js')) + .AppRuntimeChannel(rotatedRunner).complete(result))?.state, 'succeeded'); rotatedKeys.destroy(); - assert.equal((await receiptReader.readVerified(orgId, run.runId)) + assert.equal((await runtime.receiptReader.readVerified(orgId, first.run.id)) .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); - assert.equal(await channel.complete({ ...result, output: { provider_receipt: 'substituted' } }), null); + assert.equal(await channel.complete({ ...result, output: { receipt_id: 'substituted' } }), null); - // Exercise the actual HTTP router in a separate host process. The client - // has only the session credential and wire requests; it cannot call runner - // methods in the server process. Intake above remains a synthetic fixture. - const httpRun = await createSyntheticRun(); + const httpRun = await approvedRun(); const child = fork(fileURLToPath(new URL('./fixtures/app-runtime-http-host.ts', import.meta.url)), { execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { ...process.env, DEFT_RUNTIME_HTTP_FIXTURE: 'true' }, }); - let childErrors = ''; - child.stderr?.on('data', (chunk) => { childErrors = (childErrors + String(chunk)).slice(-4000); }); + let stderr = ''; + child.stderr?.on('data', (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); }); const closed = new Promise((resolve) => child.once('exit', () => resolve())); try { const port = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Runtime fixture startup timed out')), 30_000); + const timer = setTimeout(() => reject(new Error('Runtime HTTP host startup timed out')), 30_000); child.once('error', (error) => { clearTimeout(timer); reject(error); }); - child.once('exit', (code) => { clearTimeout(timer); reject(new Error(`Runtime fixture exited ${code}: ${childErrors}`)); }); + child.once('exit', (code) => { clearTimeout(timer); reject(new Error(`Runtime HTTP host exited ${code}: ${stderr}`)); }); child.once('message', (message) => { clearTimeout(timer); const value = (message as { port?: number }).port; - if (!Number.isInteger(value) || !value || value < 1 || value > 65535) reject(new Error('Invalid fixture port')); + if (!Number.isInteger(value) || !value || value < 1 || value > 65535) reject(new Error('Invalid HTTP host port')); else resolve(value); }); }); - async function post(path: string, body: Record, extraHeaders: Record = {}) { - return fetch(`http://127.0.0.1:${port}/${path}`, { - method: 'POST', headers: { 'content-type': 'application/json', - authorization: `AppRuntime ${session!.session_token}`, ...extraHeaders }, - body: JSON.stringify(body), signal: AbortSignal.timeout(20_000), - }); + async function post(path: string, body: Record, headers: Record = {}) { + return fetch(`http://127.0.0.1:${port}/${path}`, { method: 'POST', + headers: { 'content-type': 'application/json', authorization: `AppRuntime ${session.session_token}`, + ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) }); } const wireSession = { schema_version: 'deft.app_runtime_channel.v1', session_id: session.session_id }; - assert.equal((await post('claim', { ...wireSession, max_claims: 1 }, { cookie: 'session=forged' })).status, 403); - assert.equal((await post('claim', { ...wireSession, max_claims: 1, session_token: session.session_token })).status, 400); + assert.equal((await post('claim', { ...wireSession, max_claims: 1 }, { cookie: 'forged=1' })).status, 403); + assert.equal((await post('claim', { ...wireSession, max_claims: 1, + session_token: session.session_token })).status, 400); assert.equal((await post('claim', { ...wireSession, max_claims: 1, padding: 'x'.repeat(5000) })).status, 413); - const claimedResponse = await post('claim', { ...wireSession, max_claims: 1 }); - assert.equal(claimedResponse.status, 200); - const wireClaim = (await claimedResponse.json() as { claim: { run_id: string; attempt_id: string; claim_token: string; sequence: number } }).claim; - assert.equal(wireClaim.run_id, httpRun.runId); - const wireRequest = { ...wireSession, run_id: wireClaim.run_id, attempt_id: wireClaim.attempt_id, - claim_token: wireClaim.claim_token, sequence: wireClaim.sequence }; + const response = await post('claim', { ...wireSession, max_claims: 1 }); + assert.equal(response.status, 200); + const wireClaim = (await response.json() as { claim: { run_id: string; attempt_id: string; + claim_token: string; sequence: number } }).claim; + assert.equal(wireClaim.run_id, httpRun.run.id); + const wireRequest = { ...wireSession, run_id: wireClaim.run_id, + attempt_id: wireClaim.attempt_id, claim_token: wireClaim.claim_token, + sequence: wireClaim.sequence }; assert.equal((await post('start', { ...wireRequest, sequence: wireClaim.sequence + 1 })).status, 403); - const wireStart = await post('start', wireRequest); - assert.equal(wireStart.status, 200); - assert.deepEqual((await wireStart.json() as { started: { input: unknown } }).started.input, httpRun.input); + const started = await post('start', wireRequest); + assert.equal(started.status, 200); + assert.deepEqual((await started.json() as { started: { input: unknown } }).started.input, httpRun.input); assert.equal((await post('heartbeat', wireRequest)).status, 200); - const wireResult = { ...wireRequest, status: 'returned', provider_succeeded: true, output: { provider_receipt: 'http-fixture-effect' } }; + const wireResult = { ...wireRequest, status: 'returned', provider_succeeded: true, + output: { receipt_id: 'http-effect' } }; for (let i = 0; i < 2; i++) { - const response = await post('result', wireResult); - assert.equal(response.status, 200); - assert.equal((await response.json() as { run: { state: string } }).run.state, 'succeeded'); + const returned = await post('result', wireResult); + assert.equal(returned.status, 200); + assert.equal((await returned.json() as { run: { state: string } }).run.state, 'succeeded'); } - assert.equal((await receiptReader.readVerified(orgId, httpRun.runId)) + assert.equal((await runtime.receiptReader.readVerified(orgId, httpRun.run.id)) .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + + // The provider is a separate process using the public SDK. Kill it + // after its external fixture ledger is fsynced but before result ACK. + const killedRun = await approvedRun(); + const ledgerDir = await mkdtemp(join(tmpdir(), 'deft-runtime-provider-')); + const ledgerPath = join(ledgerDir, 'carrier-ledger.jsonl'); + const providerFixture = fileURLToPath(new URL('./fixtures/app-runtime-provider-child.ts', import.meta.url)); + async function provider(mode: 'normal' | 'pause_after_effect') { + const worker = fork(providerFixture, { execArgv: ['--import', 'tsx'], + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, DEFT_RUNTIME_PROVIDER_FIXTURE: 'true' } }); + let errors = ''; + worker.stderr?.on('data', (chunk) => { errors = (errors + String(chunk)).slice(-4000); }); + const signal = new Promise<{ type: string; run_id?: string; attempt_id?: string }>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Runtime provider child timed out: ${errors}`)), 30_000); + let attemptId: string | undefined; + worker.on('message', (message) => { + const event = message as { type: string; run_id?: string; attempt_id?: string; code?: string }; + if (event.type === 'claimed') attemptId = event.attempt_id; + if (event.type === 'error') { + clearTimeout(timer); reject(new Error(`Runtime provider child failed: ${event.code}`)); + } + if (event.type === (mode === 'pause_after_effect' ? 'effect_committed' : 'idle')) { + clearTimeout(timer); resolve({ ...event, attempt_id: attemptId }); + } + }); + worker.once('exit', (code) => { + clearTimeout(timer); + if (code !== 0) reject(new Error(`Runtime provider child exited ${code}: ${errors}`)); + }); + }); + worker.send({ type: 'start', channel_url: `http://127.0.0.1:${port}`, + session_id: session.session_id, session_token: session.session_token, + ledger_path: ledgerPath, mode }); + return { worker, signal }; + } + try { + const killed = await provider('pause_after_effect'); + let effect: Awaited; + try { effect = await killed.signal; } + finally { + const exited = new Promise((resolve) => killed.worker.once('exit', () => resolve())); + killed.worker.kill('SIGKILL'); + await exited; + } + assert.equal(effect.run_id, killedRun.run.id); + assert.ok(effect.attempt_id); + const ledger = (await readFile(ledgerPath, 'utf8')).trim().split('\n') + .map((line) => JSON.parse(line) as { run_id: string; effect: string }); + assert.deepEqual(ledger, [{ run_id: killedRun.run.id, + item_id: `item-${runNumber}`, effect: 'synthetic_carrier_label' }]); + const secrets = new secretModule.AppRunSecretService(runtime.keys); + const recovery = new runnerModule.AppRunAttemptRunner(runtime.repository, + runtime.secretRepository, secrets, new providerModule.PinnedMcpAppRunProviderExecutor(), + runtime.liveAuthorization, () => new Date(Date.now() + 120_000), 60_000, 20_000, + new receiptModule.PostgresAppRunReceiptWriter(secrets, runtime.secretRepository)); + assert.equal(await recovery.recoverRun(orgId, killedRun.run.id, effect.attempt_id!), 1); + assert.equal((await runtime.repository.inspect(orgId, killedRun.run.id))?.state, 'unknown_outcome'); + assert.equal((await runtime.receiptReader.readVerified(orgId, killedRun.run.id)) + .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + const restarted = await provider('normal'); + try { assert.equal((await restarted.signal).type, 'idle'); } + finally { restarted.worker.kill(); } + assert.equal((await readFile(ledgerPath, 'utf8')).trim().split('\n').length, 1); + } finally { + await unlink(ledgerPath).catch(() => {}); + await rmdir(ledgerDir).catch(() => {}); + } } finally { if (child.connected) child.send('stop'); const timer = setTimeout(() => child.kill(), 5000); - await closed; - clearTimeout(timer); + await closed; clearTimeout(timer); } - const unknown = await createSyntheticRun(); - const unknownClaim = await channel.claim({ schema_version: 'deft.app_runtime_channel.v1', - session_id: session.session_id, session_token: session.session_token, max_claims: 1 }); - assert.equal(unknownClaim?.run_id, unknown.runId); - const unknownRequest = { schema_version: 'deft.app_runtime_channel.v1', - session_id: session.session_id, session_token: session.session_token, - run_id: unknown.runId, attempt_id: unknown.attemptId, - claim_token: unknownClaim!.claim_token, sequence: unknownClaim!.sequence }; - assert.ok(await channel.start(unknownRequest)); - await db.update(schema.appRuntimeBindings).set({ state: 'revoked' }).where(and( - eq(schema.appRuntimeBindings.org_id, orgId), eq(schema.appRuntimeBindings.id, bindingId), - )); - assert.equal(await channel.complete({ ...unknownRequest, status: 'returned', - provider_succeeded: true, output: { provider_receipt: 'late' } }), null); - assert.equal(await channel.start(unknownRequest), null); - assert.equal(await channel.heartbeat(unknownRequest), false); - clockOffsetMs = 120_000; - assert.equal(await runner.recoverRun(orgId, unknown.runId, unknown.attemptId), 1); - assert.equal((await repository.inspect(orgId, unknown.runId))?.state, 'unknown_outcome'); - assert.equal((await receiptReader.readVerified(orgId, unknown.runId)) + const invalid = await approvedRun(); + const invalidClaim = await claimFor(invalid.run.id); + assert.ok(await channel.start(invalidClaim.request)); + assert.equal((await channel.complete({ ...invalidClaim.request, status: 'returned', + provider_succeeded: true, output: { unexpected: 'value' } }))?.state, 'unknown_outcome'); + assert.equal((await runtime.receiptReader.readVerified(orgId, invalid.run.id)) + .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); + + const revoked = await approvedRun(); + const revokedClaim = await claimFor(revoked.run.id); + assert.ok(await channel.start(revokedClaim.request)); + assert.deepEqual(await management.revokeRuntimeBinding(owner, binding.binding_id), { revoked: true }); + assert.equal(await channel.complete({ ...revokedClaim.request, status: 'returned', + provider_succeeded: true, output: { receipt_id: 'late' } }), null); + assert.equal(await channel.start(revokedClaim.request), null); + assert.equal(await channel.heartbeat(revokedClaim.request), false); + const offsetSecrets = new secretModule.AppRunSecretService(runtime.keys); + const offsetRunner = new runnerModule.AppRunAttemptRunner(runtime.repository, + runtime.secretRepository, offsetSecrets, new providerModule.PinnedMcpAppRunProviderExecutor(), + runtime.liveAuthorization, () => new Date(Date.now() + 120_000), 60_000, 20_000, + new receiptModule.PostgresAppRunReceiptWriter(offsetSecrets, runtime.secretRepository)); + assert.equal(await offsetRunner.recoverRun(orgId, revoked.run.id, + revokedClaim.claim.attempt_id), 1); + assert.equal((await runtime.repository.inspect(orgId, revoked.run.id))?.state, 'unknown_outcome'); + assert.equal((await runtime.receiptReader.readVerified(orgId, revoked.run.id)) .filter((row) => row.receipt_kind === 'attempt_terminal').length, 1); - assert.equal(await authorityModule.issueAppRuntimeSession({ org_id: orgId, - runtime_binding_id: bindingId, operator_user_id: userId }), null); - keys.destroy(); + const status = await management.inspectRuntimeBinding(owner, binding.binding_id); + assert.equal(status.binding.state, 'revoked'); + assert.equal(status.drain.drained, false); + assert.ok(status.drain.run_state_counts.unknown_outcome >= 2); + assert.ok(status.sessions.every((item) => item.revoked)); + await assert.rejects(management.issueRuntimeOperatorSession(owner, binding.binding_id)); } finally { + await runtimeModule.shutdownAppRunRuntime(); await closeDb(); } }); diff --git a/apps/api/test/app-runtime-review-db.test.ts b/apps/api/test/app-runtime-review-db.test.ts new file mode 100644 index 00000000..aa93a521 --- /dev/null +++ b/apps/api/test/app-runtime-review-db.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03_(?:review|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('v3 review is tenant- and epoch-bound and reactivation requires a fresh grant', { skip: !safe }, async () => { + const [{ db, closeDb }, schema, kit, appService, reviewService, management, + moduleService, authority] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/app-runtime-management.js'), import('../src/lib/module-service.js'), + import('../src/lib/app-runtime-authority.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + try { + const suffix = randomUUID(); + const orgId = randomUUID(); + const foreignOrgId = randomUUID(); + const ownerId = randomUUID(); + const ordinaryId = randomUUID(); + const foreignOwnerId = randomUUID(); + await db.insert(schema.orgs).values([ + { id: orgId, name: 'Runtime review fixture', slug: `review-${suffix}` }, + { id: foreignOrgId, name: 'Foreign review fixture', slug: `foreign-review-${suffix}` }, + ]); + await db.insert(schema.users).values([ + { id: ownerId, name: 'Owner', email: `owner-review-${suffix}@example.test` }, + { id: ordinaryId, name: 'Member', email: `member-review-${suffix}@example.test` }, + { id: foreignOwnerId, name: 'Foreign owner', email: `foreign-review-${suffix}@example.test` }, + ]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: ordinaryId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: foreignOrgId, user_id: foreignOwnerId, role: 'owner', is_active: true }, + ]); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'ui' }); + const spoofedOwner = moduleService.humanModuleActor({ orgId, userId: ordinaryId, role: 'owner', source: 'ui' }); + const foreignOwner = moduleService.humanModuleActor({ orgId: foreignOrgId, + userId: foreignOwnerId, role: 'owner', source: 'ui' }); + const object = { type: 'object' as const, properties: { shipment_id: { type: 'string' as const, maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false as const }; + const pkg = await kit.buildDeftAppPackage({ manifest: { + schema_version: '3', id: `community.example.review.a${suffix.replace(/-/g, '')}`, + version: '1.0.0', name: 'Runtime review fixture', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '3' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'label', version: '1', input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'create_label', label: 'Create label', + capability_key: 'label', runtime_requirement_key: 'carrier' }], + }, artifacts: [] }); + const staged = await appService.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const request = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + await assert.rejects(reviewService.prepareRuntimeAppReview(spoofedOwner, staged.id, request)); + await assert.rejects(reviewService.prepareRuntimeAppReview(foreignOwner, staged.id, request)); + await assert.rejects(reviewService.prepareRuntimeAppReview(owner, staged.id, { + ...request, expected_requested_snapshot_digest: `sha256:${'0'.repeat(64)}`, + })); + await assert.rejects(management.prepareRuntimeBindingReview(owner, { + installation_id: staged.id, action_key: 'create_label', operator_user_id: ownerId, + expected_app_version_id: version.id, expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch, + })); + const review = await reviewService.prepareRuntimeAppReview(owner, staged.id, request); + const active = await reviewService.activateRuntimeApp(owner, staged.id, { + ...request, expected_review_digest: review.review_digest, accept_host_policy: true, + }); + assert.equal(active.installation.state, 'active'); + assert.ok(active.grant_snapshot_id); + const [grant] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, active.grant_snapshot_id))); + assert.ok(grant); + const bindRequest = { installation_id: staged.id, action_key: 'create_label', + operator_user_id: ownerId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: active.installation.lifecycle_epoch, + expected_grant_epoch: active.installation.grant_epoch }; + const bindReview = await management.prepareRuntimeBindingReview(owner, bindRequest); + const oldBinding = await management.activateRuntimeBinding(owner, { + ...bindRequest, expected_review_digest: bindReview.review_digest, accept_host_policy: true, + }); + const disabled = await appService.disableAppInstallation(owner, staged.id, + active.installation.lifecycle_epoch); + assert.equal(disabled.state, 'disabled'); + await assert.rejects(reviewService.activateRuntimeApp(owner, staged.id, { + ...request, expected_review_digest: review.review_digest, accept_host_policy: true, + })); + const freshRequest = { ...request, expected_lifecycle_epoch: disabled.lifecycle_epoch, + expected_grant_epoch: disabled.grant_epoch }; + const freshReview = await reviewService.prepareRuntimeAppReview(owner, staged.id, freshRequest); + assert.notEqual(freshReview.review_digest, review.review_digest); + const reactivated = await reviewService.activateRuntimeApp(owner, staged.id, { + ...freshRequest, expected_review_digest: freshReview.review_digest, accept_host_policy: true, + }); + assert.equal(reactivated.installation.state, 'active'); + assert.notEqual(reactivated.grant_snapshot_id, active.grant_snapshot_id); + assert.equal(reactivated.installation.active_grant_snapshot_id, reactivated.grant_snapshot_id); + assert.equal(await authority.issueAppRuntimeSession({ org_id: orgId, + runtime_binding_id: oldBinding.binding_id, operator_user_id: ownerId }), null); + const [newGrant] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, reactivated.grant_snapshot_id))); + assert.ok(newGrant); + const newBindRequest = { ...bindRequest, + expected_grant_snapshot_digest: newGrant.snapshot_digest, + expected_lifecycle_epoch: reactivated.installation.lifecycle_epoch, + expected_grant_epoch: reactivated.installation.grant_epoch }; + const newBindReview = await management.prepareRuntimeBindingReview(owner, newBindRequest); + const newBinding = await management.activateRuntimeBinding(owner, { + ...newBindRequest, expected_review_digest: newBindReview.review_digest, accept_host_policy: true, + }); + assert.notEqual(newBinding.binding_id, oldBinding.binding_id); + assert.ok(await authority.issueAppRuntimeSession({ org_id: orgId, + runtime_binding_id: newBinding.binding_id, operator_user_id: ownerId })); + } finally { + await closeDb(); + } +}); diff --git a/apps/api/test/fixtures/app-runtime-provider-child.ts b/apps/api/test/fixtures/app-runtime-provider-child.ts new file mode 100644 index 00000000..c8c3ef67 --- /dev/null +++ b/apps/api/test/fixtures/app-runtime-provider-child.ts @@ -0,0 +1,85 @@ +import { open, readFile } from 'node:fs/promises'; +import { createAppRuntimeClient, type AppRuntimeClaim } from '@deft/app-kit'; + +type Start = Readonly<{ + type: 'start'; + channel_url: string; + session_id: string; + session_token: string; + ledger_path: string; + mode: 'normal' | 'pause_before_effect' | 'pause_after_effect'; +}>; + +function send(message: Record) { + process.send?.(message); +} + +async function durableSyntheticEffect(path: string, claim: AppRuntimeClaim, input: unknown) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('INVALID_REVIEWED_INPUT'); + } + const itemId = (input as Record).item_id + ?? (input as Record).shipment_id; + if (typeof itemId !== 'string' || itemId.length < 1 || itemId.length > 120) { + throw new Error('INVALID_REVIEWED_INPUT'); + } + // This file is an external-effect *fixture*, not a Deft production ledger. + // Append and fsync are deliberately in this worker process so SIGKILL after + // the notification leaves a durable effect while the host lacks a result. + const prior = await readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return ''; + throw error; + }); + if (prior.split('\n').filter(Boolean).some((line) => { + const row = JSON.parse(line) as { run_id?: string }; + return row.run_id === claim.run_id; + })) return; + const handle = await open(path, 'a'); + try { + await handle.write(`${JSON.stringify({ run_id: claim.run_id, + item_id: itemId, effect: 'synthetic_carrier_label' })}\n`); + await handle.sync(); + } finally { await handle.close(); } +} + +async function run(config: Start) { + const client = createAppRuntimeClient({ + channel_url: config.channel_url, + credential: { session_id: config.session_id, session_token: config.session_token }, + }); + const claim = await client.claim(); + if (!claim) { send({ type: 'idle' }); return; } + send({ type: 'claimed', run_id: claim.run_id, attempt_id: claim.attempt_id }); + const started = await client.start(claim); + send({ type: 'started', run_id: claim.run_id }); + if (config.mode === 'pause_before_effect') { + await new Promise(() => { setInterval(() => {}, 1000); }); + } + await durableSyntheticEffect(config.ledger_path, claim, started.input); + send({ type: 'effect_committed', run_id: claim.run_id }); + if (config.mode === 'pause_after_effect') { + await new Promise(() => { setInterval(() => {}, 1000); }); + } + const settled = await client.result(claim, { status: 'returned', provider_succeeded: true, + output: { label_id: `synthetic-${claim.run_id}` } }); + send({ type: 'result', run_id: claim.run_id, + state: settled && typeof settled === 'object' && 'state' in settled + ? (settled as { state: unknown }).state : 'unknown' }); +} + +if (!process.send || process.env.DEFT_RUNTIME_PROVIDER_FIXTURE !== 'true' + || !process.env.DEFT_TEST_DATABASE_URL + || process.env.DEFT_TEST_DATABASE_URL !== process.env.DATABASE_URL) { + throw new Error('Runtime provider child requires explicit disposable test process'); +} +process.once('message', (value: unknown) => { + if (!value || typeof value !== 'object' || (value as { type?: unknown }).type !== 'start') { + send({ type: 'error', code: 'INVALID_FIXTURE_CONFIG' }); + process.exitCode = 1; + return; + } + void run(value as Start).then(() => { process.exitCode = 0; }, (error: unknown) => { + send({ type: 'error', code: error instanceof Error ? error.message : 'FIXTURE_FAILED' }); + process.exitCode = 1; + }); +}); diff --git a/apps/api/test/fixtures/app-runtime-review-lock.ts b/apps/api/test/fixtures/app-runtime-review-lock.ts new file mode 100644 index 00000000..ed849d18 --- /dev/null +++ b/apps/api/test/fixtures/app-runtime-review-lock.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { sql } from 'drizzle-orm'; +import { db } from '../../src/lib/db.js'; + +/** Exercise the actual pending Runtime input-review method while an approval + * holds its Run row and a manager requests an App UPDATE lock. A review that + * holds App SHARE while waiting on Run blocks that manager. */ +export async function assertRuntimeInputReviewLockOrder(input: Readonly<{ + org_id: string; + run_id: string; + installation_id: string; + review: () => Promise; +}>) { + let releaseHolder!: () => void; + let signalRunLocked!: () => void; + const release = new Promise((resolve) => { releaseHolder = resolve; }); + const runLocked = new Promise((resolve) => { signalRunLocked = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '6000ms'`); + await tx.execute(sql`SELECT id FROM app_runs WHERE org_id = ${input.org_id} + AND id = ${input.run_id} FOR UPDATE`); + signalRunLocked(); + await release; + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${input.org_id} + AND id = ${input.installation_id} FOR SHARE`); + }); + await runLocked; + const review = input.review(); + let manager: Promise | undefined; + let managerWaiting = false; + try { + // A waiting Run FOR UPDATE proves review entered its transaction before + // manager attempts its parent-App lock; no timing-only success is accepted. + let waiting = false; + for (let attempt = 0; attempt < 100 && !waiting; attempt += 1) { + const result = await db.execute(sql`SELECT count(*)::int AS value FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock' + AND query ILIKE '%SELECT id FROM app_runs%' + AND query ILIKE '%FOR UPDATE%'`); + waiting = Number(result.rows[0]?.value ?? 0) > 0; + if (!waiting) await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(waiting, 'review must be observed waiting on the held Run row'); + manager = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = '6000ms'`); + await tx.execute(sql`SELECT set_config('application_name', + 'gate_g_runtime_review_manager', true)`); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${input.org_id} + AND id = ${input.installation_id} FOR UPDATE`); + }); + void manager.catch(() => undefined); + for (let attempt = 0; attempt < 25 && !managerWaiting; attempt += 1) { + const result = await db.execute(sql`SELECT count(*)::int AS value FROM pg_stat_activity + WHERE datname = current_database() AND application_name = 'gate_g_runtime_review_manager' + AND wait_event_type = 'Lock'`); + managerWaiting = Number(result.rows[0]?.value ?? 0) > 0; + if (!managerWaiting) await new Promise((resolve) => setTimeout(resolve, 20)); + } + } finally { + releaseHolder(); + } + const outcomes = await Promise.allSettled([holder, review, manager!]); + assert.equal(managerWaiting, false, + 'review must not hold App SHARE while waiting for the approval-held Run'); + assert.deepEqual(outcomes.map((outcome) => outcome.status), + ['fulfilled', 'fulfilled', 'fulfilled'], + `Runtime review lock cycle: ${outcomes.map((outcome) => outcome.status === 'rejected' + ? String((outcome.reason as { cause?: { code?: string } })?.cause?.code + ?? (outcome.reason as { code?: string })?.code ?? outcome.reason) + : 'ok').join(', ')}`); +} diff --git a/apps/api/test/fixtures/runtime-v3-package.ts b/apps/api/test/fixtures/runtime-v3-package.ts new file mode 100644 index 00000000..9d553c0a --- /dev/null +++ b/apps/api/test/fixtures/runtime-v3-package.ts @@ -0,0 +1,25 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { buildDeftAppPackage } from '@deft/app-kit'; + +/** External packed artifact is preferred for acceptance; a fresh local Kit + * fixture keeps focused tests runnable without the acceptance workspace. */ +export async function runtimeV3PackageJson(): Promise { + if (process.env.DEFT_RUNTIME_AUTHOR_PACKAGE) { + return readFile(process.env.DEFT_RUNTIME_AUTHOR_PACKAGE, 'utf8'); + } + const suffix = randomUUID().replace(/-/g, ''); + return (await buildDeftAppPackage({ manifest: { + schema_version: '3', id: `community.example.shipping.${suffix}`, + version: '1.0.0', name: 'Shipping', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '3' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'create_shipping_label', version: '1', + input_schema: { type: 'object', properties: { shipment_id: { type: 'string', maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false }, + output_schema: { type: 'object', properties: { label_id: { type: 'string', maxLength: 120 } }, + required: ['label_id'], additionalProperties: false } }], + runtime_actions: [{ key: 'create_shipping_label', label: 'Create shipping label', + capability_key: 'create_shipping_label', runtime_requirement_key: 'carrier' }], + }, artifacts: [] })).json; +} diff --git a/apps/web/src/components/agent-action-card.tsx b/apps/web/src/components/agent-action-card.tsx index 19e1932b..348eb2d6 100644 --- a/apps/web/src/components/agent-action-card.tsx +++ b/apps/web/src/components/agent-action-card.tsx @@ -28,6 +28,7 @@ import { } from 'lucide-react'; import { ReceiptViewer } from './receipt-viewer'; import { AppRunInspector } from './apps/app-run-inspector'; +import { RuntimeAppInputReview, type RuntimeReviewIdentity } from './runtime-app-input-review'; import { humanizeToolName } from '@/lib/tool-display'; import { stripHtml } from '@/lib/strip-html'; import { @@ -500,11 +501,22 @@ export function AgentActionCard({ const [messageReview, setMessageReview] = useState<{ actionId: string; to: string; subject: string; body_text: string } | null>(null); const [taskLinkReview, setTaskLinkReview] = useState<{ actionId: string; record: { label: string; href: string }; task: { identifier: string; title: string; project_name: string; href: string } } | null>(null); const [reviewLoading, setReviewLoading] = useState(false); + const [runtimeReviewed, setRuntimeReviewed] = useState<(RuntimeReviewIdentity & { actionId: string }) | null>(null); // Only the supported App-origin email contract has this message presenter. // Other governed operations keep their existing review flow. const needsMessageReview = action.action === 'app_run_invoke' && action.params.capability_label === 'send_email' && typeof action.params.safe_preview?.fields?.app_id === 'string'; + const needsRuntimeReview = action.action === 'app_run_invoke' + && action.params.safe_preview?.fields?.provider_kind === 'app_runtime'; + const runtimeRunId = typeof action.params.run_id === 'string' ? action.params.run_id : null; + const runtimeBindingId = typeof action.params.safe_preview?.fields?.runtime_binding_id === 'string' + ? action.params.safe_preview.fields.runtime_binding_id : null; + const runtimeReviewReady = needsRuntimeReview && runtimeReviewed?.actionId === action.id + && runtimeReviewed.runId === runtimeRunId && runtimeReviewed.bindingId === runtimeBindingId; + const onRuntimeReviewed = useCallback((identity: RuntimeReviewIdentity | null) => { + setRuntimeReviewed(identity ? { actionId: action.id, ...identity } : null); + }, [action.id]); const reviewedMessage = messageReview?.actionId === action.id ? messageReview : null; const needsTaskLinkReview = action.action === 'module_record_task_link' || action.action === 'module_record_task_unlink'; const reviewedTaskLink = taskLinkReview?.actionId === action.id ? taskLinkReview : null; @@ -650,6 +662,10 @@ export function AgentActionCard({ ) : null; + const runtimeReviewPanel = needsRuntimeReview ? ( + + ) : null; const appRunInspectorButton = isAppRunAction && appRunReference ? ( + + ); +} diff --git a/apps/web/src/lib/agent-action-presentation.test.ts b/apps/web/src/lib/agent-action-presentation.test.ts index 44b27397..3fea0eae 100644 --- a/apps/web/src/lib/agent-action-presentation.test.ts +++ b/apps/web/src/lib/agent-action-presentation.test.ts @@ -150,6 +150,23 @@ test('App Run approvals present only the safe preview without raw orchestration assert.equal(JSON.stringify(genericDetails).includes('campaign-secret-id'), true, 'safe_preview remains the only resource presentation'); }); +test('host-marked Runtime action uses Runtime wording and requires exact input review', () => { + const presentation = getAgentActionPresentation({ + action: 'app_run_invoke', source: 'app_run', + params: { run_id: 'opaque-run', capability_label: 'create_label', + provider_label: 'opaque-registration', safe_preview: { + title: 'create_label', summary: 'Generic summary', + fields: { provider_kind: 'app_runtime', runtime_binding_id: 'opaque-binding' }, + resource_refs: [], + } }, + }); + assert.equal(presentation.eyebrow, 'Runtime App action'); + assert.equal(presentation.badge, 'Runtime'); + assert.equal(presentation.approveLabel, 'Approve Runtime action'); + assert.match(presentation.summary, /exact input/); + assert.doesNotMatch(presentation.sourceLabel, /Connected App|opaque-registration/); +}); + test('sandbox email approval keeps human resource identity and sandbox context prominent', () => { const presentation = getAgentActionPresentation({ action: 'app_run_invoke', diff --git a/apps/web/src/lib/agent-action-presentation.ts b/apps/web/src/lib/agent-action-presentation.ts index 170de7ac..1658528b 100644 --- a/apps/web/src/lib/agent-action-presentation.ts +++ b/apps/web/src/lib/agent-action-presentation.ts @@ -433,6 +433,8 @@ export function getAgentActionPresentation(action: AgentActionForPresentation): const preview = params.safe_preview && typeof params.safe_preview === 'object' && !Array.isArray(params.safe_preview) ? params.safe_preview as Record : {}; + const previewFields = objectValue(preview.fields); + const isRuntime = previewFields?.provider_kind === 'app_runtime'; const isSandboxEmail = capability === 'send_email'; const resourceRefs = Array.isArray(preview.resource_refs) ? preview.resource_refs : []; for (const candidate of resourceRefs) { @@ -444,18 +446,23 @@ export function getAgentActionPresentation(action: AgentActionForPresentation): return { kind: 'app_run', icon: 'generic', - eyebrow: 'Connected App action', - headline: 'An App prepared a governed action', + eyebrow: isRuntime ? 'Runtime App action' : 'Connected App action', + headline: isRuntime ? 'A Runtime App prepared an external action' : 'An App prepared a governed action', title: previewTitle || capability || 'Run App action', - summary: isSandboxEmail + summary: isRuntime + ? 'Review the exact input and external-write policy before approving this invocation.' + : isSandboxEmail ? 'Sandbox only. Review the exact recipient and message before approving; no external message will be delivered.' : previewSummary ? truncateApprovalText(previewSummary, 150) : 'Review the safe preview before Deft releases this action to the selected provider.', - approveLabel: 'Approve App action', + approveLabel: isRuntime ? 'Approve Runtime action' : 'Approve App action', doneLabel: 'App action approved', - sourceLabel: isSandboxEmail ? 'Provider: Deft email sandbox' : provider ? `Provider: ${provider}` : 'Source: Connected App', - detailsLabel: 'Safe App preview', - emptyDetails: 'Provider input remains sealed. Deft revalidates App, grant, connector, and resource authority before execution.', - badge: isSandboxEmail ? 'Sandbox' : 'App action', + sourceLabel: isRuntime ? 'Source: reviewed Runtime App' + : isSandboxEmail ? 'Provider: Deft email sandbox' : provider ? `Provider: ${provider}` : 'Source: Connected App', + detailsLabel: isRuntime ? 'Runtime action review' : 'Safe App preview', + emptyDetails: isRuntime + ? 'Open the exact input below. Deft rechecks the current App, grant, binding, and policy before execution.' + : 'Provider input remains sealed. Deft revalidates App, grant, connector, and resource authority before execution.', + badge: isRuntime ? 'Runtime' : isSandboxEmail ? 'Sandbox' : 'App action', badgeTone: 'caution', chips, }; diff --git a/apps/web/src/lib/app-experience-bridge.test.ts b/apps/web/src/lib/app-experience-bridge.test.ts new file mode 100644 index 00000000..759f3477 --- /dev/null +++ b/apps/web/src/lib/app-experience-bridge.test.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createExperienceBridge, type ExperiencePin, type ExperiencePort } from './app-experience-bridge'; + +const pin: ExperiencePin = Object.freeze({ + org_id: 'org_a', user_id: 'user_a', app_installation_id: 'installation_a', + app_version_id: 'version_a', grant_snapshot_id: 'grant_a', + lifecycle_epoch: 3, grant_epoch: 4, session_id: 'session_12345678', session_epoch: 1, +}); +class FakePort implements ExperiencePort { + onmessage: ((event: MessageEvent) => void) | null = null; + sent: unknown[] = []; + closed = false; + postMessage(value: unknown) { this.sent.push(value); } + close() { this.closed = true; } + receive(value: unknown) { this.onmessage?.({ data: value } as MessageEvent); } +} +const view = { root: { kind:'stack', id:'root', children:[ + { kind:'grid', id:'orders', columns:['Order','Status'], + rows:[{ id:'alpha', cells:['Alpha','Open'] }] }, + { kind:'canvas', id:'sketch', strokes:[{ points:[{x:0,y:0},{x:1,y:1}] }] }, +] } }; +const message = (sequence: number, value: Record) => ({ + version:'deft.experience_bridge.v1', session_id:pin.session_id, sequence, ...value, +}); +const delay = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test('bounded rich view accepted; spoofed session and replay close the port', async () => { + const port = new FakePort(); + const views: unknown[] = []; + const bridge = createExperienceBridge({ + port, pin, resourceKeys:[], actionKeys:[], + broker:{ isLive:()=>true }, onView:(value)=>views.push(value), + }); + port.receive(message(1, { kind:'view', view })); + await delay(); + assert.equal(views.length, 1); + port.receive(message(1, { kind:'view', view })); + assert.equal(bridge.active, false); + assert.equal(port.closed, true); + const other = new FakePort(); + const spoof = createExperienceBridge({ + port:other, pin, resourceKeys:[], actionKeys:[], + broker:{ isLive:()=>true }, onView:()=>undefined, + }); + other.receive({ ...message(1, {kind:'view', view}), session_id:'session_foreign' }); + assert.equal(spoof.active, false); +}); + +test('host stamps exact pin and drops late resource response after revocation', async () => { + const port = new FakePort(); + let finish!: (value: unknown) => void; + let suppliedPin: ExperiencePin | undefined; + let suppliedSignal: AbortSignal | undefined; + const pending = new Promise((resolve) => { finish = resolve; }); + const bridge = createExperienceBridge({ + port, pin, resourceKeys:['orders'], actionKeys:[], + broker:{ isLive:()=>true, resource:async (authority, key, input, signal) => { + suppliedPin = authority; suppliedSignal = signal; + assert.equal(key, 'orders'); assert.deepEqual(input, { page:1 }); + return pending; + } }, onView:()=>undefined, + }); + port.receive(message(1, { kind:'request', request_id:'request_1', + operation:'resource', key:'orders', input:{page:1} })); + await delay(); + assert.deepEqual(suppliedPin, pin); + bridge.revoke(); + finish({ records:['late'] }); + await delay(); + assert.equal(suppliedSignal?.aborted, true); + assert.equal(port.sent.length, 0); +}); + +test('wrong action key, malformed payload and flood fail closed before callbacks', async () => { + let calls = 0; + const port = new FakePort(); + const bridge = createExperienceBridge({ + port, pin, resourceKeys:[], actionKeys:['submit'], + broker:{ isLive:()=>true, action:async()=>{ calls++; return {run_id:'run_a'}; } }, + onView:()=>undefined, + }); + port.receive(message(1, { kind:'request', request_id:'request_1', + operation:'action', key:'other', input:{} })); + assert.equal(bridge.active, false); + assert.equal(calls, 0); + + const cycle: {self?: unknown} = {}; cycle.self = cycle; + const cyclePort = new FakePort(); + const cycleBridge = createExperienceBridge({ + port:cyclePort, pin, resourceKeys:[], actionKeys:[], + broker:{ isLive:()=>true }, onView:()=>undefined, + }); + cyclePort.receive(message(1, { kind:'view', view:cycle })); + assert.equal(cycleBridge.active, false); + + const floodPort = new FakePort(); + const flood = createExperienceBridge({ + port:floodPort, pin, resourceKeys:[], actionKeys:[], + broker:{ isLive:()=>true }, onView:()=>undefined, now:()=>1000, + }); + for (let sequence=1; sequence<=101; sequence++) { + floodPort.receive(message(sequence, { kind:'view', view })); + } + assert.equal(flood.active, false); +}); + +test('missing action callback fails closed and stale live authority drops result', async () => { + const port = new FakePort(); + let live = true; + const bridge = createExperienceBridge({ + port, pin, resourceKeys:[], actionKeys:['submit'], + broker:{ isLive:()=>live }, onView:()=>undefined, + }); + port.receive(message(1, { kind:'request', request_id:'request_1', + operation:'action', key:'submit', input:{} })); + await delay(); + assert.deepEqual(port.sent[0], { + version:'deft.experience_bridge.v1', kind:'response', + session_id:pin.session_id, request_id:'request_1', ok:false, code:'UNAVAILABLE', + }); + live = false; + port.receive(message(2, {kind:'view', view})); + await delay(); + assert.equal(bridge.active, false); +}); + +test('UI events are dropped when the live pin is revoked', async () => { + const port = new FakePort(); + let live = true; + const bridge = createExperienceBridge({ + port, pin, resourceKeys:[], actionKeys:[], + broker:{ isLive:()=>live }, onView:()=>undefined, + }); + assert.equal(await bridge.sendUiEvent({ kind:'click', node_id:'submit' }), true); + assert.equal(port.sent.length, 1); + live = false; + assert.equal(await bridge.sendUiEvent({ kind:'click', node_id:'submit' }), false); + assert.equal(port.sent.length, 1); + assert.equal(bridge.active, false); +}); diff --git a/apps/web/src/lib/app-experience-bridge.ts b/apps/web/src/lib/app-experience-bridge.ts new file mode 100644 index 00000000..84ce0cd5 --- /dev/null +++ b/apps/web/src/lib/app-experience-bridge.ts @@ -0,0 +1,247 @@ +export type ExperiencePin = Readonly<{ + org_id: string; user_id: string; app_installation_id: string; + app_version_id: string; grant_snapshot_id: string; + lifecycle_epoch: number; grant_epoch: number; + session_id: string; session_epoch: number; +}>; + +export type ExperienceNode = + | Readonly<{ kind: 'text'; id: string; text: string }> + | Readonly<{ kind: 'button'; id: string; label: string }> + | Readonly<{ kind: 'input'; id: string; label: string; value: string }> + | Readonly<{ kind: 'stack'; id: string; title?: string; children: readonly ExperienceNode[] }> + | Readonly<{ kind: 'grid'; id: string; columns: readonly string[]; + rows: readonly Readonly<{ id: string; cells: readonly string[] }>[]; selected_row_id?: string }> + | Readonly<{ kind: 'canvas'; id: string; + strokes: readonly Readonly<{ points: readonly Readonly<{ x: number; y: number }>[] }>[] }>; + +export type ExperienceView = Readonly<{ root: ExperienceNode }>; +export type ExperienceIntent = Readonly<{ + kind: 'resource' | 'action' | 'run_status' | 'run_cancel' | 'navigate' | 'dialog'; + key?: string; + input?: unknown; +}>; + +export type ExperienceBroker = Readonly<{ + isLive(pin: ExperiencePin): boolean | Promise; + resource?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal) => Promise; + action?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal) => Promise; + runStatus?: (pin: ExperiencePin, input: unknown, signal: AbortSignal) => Promise; + runCancel?: (pin: ExperiencePin, input: unknown, signal: AbortSignal) => Promise; + navigate?: (pin: ExperiencePin, key: string, signal: AbortSignal) => Promise; + dialog?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal) => Promise; +}>; +export type ExperiencePort = Pick; + +const MAX_MESSAGE_BYTES = 64 * 1024; +const MAX_NODES = 256; +const MAX_DEPTH = 8; +const MAX_ROWS = 100; +const MAX_COLUMNS = 16; +const MAX_POINTS = 4096; +const MAX_PENDING = 16; +const MAX_PER_SECOND = 100; +const encoder = new TextEncoder(); +const id = (x: unknown): x is string => typeof x === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(x); +const text = (x: unknown, max = 4096): x is string => typeof x === 'string' && x.length <= max; +const record = (x: unknown): x is Record => + x !== null && typeof x === 'object' && !Array.isArray(x); +const exact = (x: Record, allowed: readonly string[]) => + Object.keys(x).every((key) => allowed.includes(key)); +const integer = (x: unknown): x is number => Number.isSafeInteger(x) && (x as number) >= 0; + +function boundedJson(value: unknown): boolean { + const seen = new Set(); + let remaining = 2048; + const visit = (item: unknown, depth: number): boolean => { + if (--remaining < 0 || depth > 12) return false; + if (item === null || typeof item === 'boolean') return true; + if (typeof item === 'string') return item.length <= 4096; + if (typeof item === 'number') return Number.isFinite(item); + if (typeof item !== 'object' || seen.has(item)) return false; + seen.add(item); + if (Array.isArray(item)) return item.length <= 256 && item.every((child) => visit(child, depth + 1)); + return Object.keys(item).length <= 64 && Object.entries(item).every(([key, child]) => + key.length <= 64 && key !== '__proto__' && key !== 'constructor' && visit(child, depth + 1)); + }; + if (!visit(value, 0)) return false; + try { return encoder.encode(JSON.stringify(value)).byteLength <= MAX_MESSAGE_BYTES; } + catch { return false; } +} + +function parseNode(input: unknown, depth: number, budget: { nodes: number; points: number }): ExperienceNode | null { + if (!record(input) || !id(input.id) || depth > MAX_DEPTH || ++budget.nodes > MAX_NODES) return null; + switch (input.kind) { + case 'text': + return exact(input, ['kind', 'id', 'text']) && text(input.text) + ? { kind: 'text', id: input.id, text: input.text } : null; + case 'button': + return exact(input, ['kind', 'id', 'label']) && text(input.label, 128) + ? { kind: 'button', id: input.id, label: input.label } : null; + case 'input': + return exact(input, ['kind', 'id', 'label', 'value']) && text(input.label, 128) && text(input.value) + ? { kind: 'input', id: input.id, label: input.label, value: input.value } : null; + case 'stack': { + if (!exact(input, ['kind', 'id', 'title', 'children']) + || (input.title !== undefined && !text(input.title, 128)) + || !Array.isArray(input.children) || input.children.length > 64) return null; + const children = input.children.map((child) => parseNode(child, depth + 1, budget)); + return children.every((child) => child !== null) + ? { kind: 'stack', id: input.id, ...(input.title ? { title: input.title } : {}), + children: children as ExperienceNode[] } : null; + } + case 'grid': { + if (!exact(input, ['kind', 'id', 'columns', 'rows', 'selected_row_id']) + || !Array.isArray(input.columns) || input.columns.length < 1 || input.columns.length > MAX_COLUMNS + || !input.columns.every((column) => text(column, 80)) + || !Array.isArray(input.rows) || input.rows.length > MAX_ROWS + || (input.selected_row_id !== undefined && !id(input.selected_row_id))) return null; + const columns = input.columns as string[]; + const rows = input.rows.map((row) => { + if (!record(row) || !exact(row, ['id', 'cells']) || !id(row.id) + || !Array.isArray(row.cells) || row.cells.length !== columns.length + || !row.cells.every((cell) => text(cell, 512))) return null; + return { id: row.id, cells: row.cells as string[] }; + }); + return rows.every((row) => row !== null) + ? { kind: 'grid', id: input.id, columns, + rows: rows as { id: string; cells: string[] }[], + ...(input.selected_row_id ? { selected_row_id: input.selected_row_id } : {}) } : null; + } + case 'canvas': { + if (!exact(input, ['kind', 'id', 'strokes']) || !Array.isArray(input.strokes) + || input.strokes.length > 256) return null; + const strokes = input.strokes.map((stroke) => { + if (!record(stroke) || !exact(stroke, ['points']) || !Array.isArray(stroke.points) + || stroke.points.length > 512) return null; + budget.points += stroke.points.length; + if (budget.points > MAX_POINTS) return null; + const points = stroke.points.map((point) => + record(point) && exact(point, ['x', 'y']) + && typeof point.x === 'number' && typeof point.y === 'number' + && Number.isFinite(point.x) && Number.isFinite(point.y) + && point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1 + ? { x: point.x, y: point.y } : null); + return points.every((point) => point !== null) + ? { points: points as { x: number; y: number }[] } : null; + }); + return strokes.every((stroke) => stroke !== null) + ? { kind: 'canvas', id: input.id, + strokes: strokes as { points: { x: number; y: number }[] }[] } : null; + } + default: return null; + } +} + +export function parseExperienceView(value: unknown): ExperienceView | null { + if (!record(value) || !exact(value, ['root'])) return null; + const root = parseNode(value.root, 0, { nodes: 0, points: 0 }); + return root ? { root } : null; +} + +export function createExperienceBridge(input: Readonly<{ + port: ExperiencePort; + pin: ExperiencePin; + resourceKeys: readonly string[]; + actionKeys: readonly string[]; + navigationKeys?: readonly string[]; + dialogKeys?: readonly string[]; + broker: ExperienceBroker; + onView(view: ExperienceView): void; + now?: () => number; +}>) { + const pin = Object.freeze({ ...input.pin }); + const resourceKeys = new Set(input.resourceKeys); + const actionKeys = new Set(input.actionKeys); + const navigationKeys = new Set(input.navigationKeys ?? []); + const dialogKeys = new Set(input.dialogKeys ?? []); + const now = input.now ?? Date.now; + const controller = new AbortController(); + let active = true; + let sequence = 0; + let pending = 0; + let windowStart = now(); + let inWindow = 0; + const revoke = () => { + if (!active) return; + active = false; + controller.abort(); + input.port.onmessage = null; + input.port.close(); + }; + const send = (message: unknown) => { if (active) input.port.postMessage(message); }; + input.port.onmessage = (event: MessageEvent) => { + const value: unknown = event.data; + if (!active || !boundedJson(value) || !record(value) + || value.version !== 'deft.experience_bridge.v1' + || value.session_id !== pin.session_id || !integer(value.sequence) + || value.sequence !== sequence + 1) { revoke(); return; } + const at = now(); + if (at < windowStart || at - windowStart >= 1000) { windowStart = at; inWindow = 0; } + if (++inWindow > MAX_PER_SECOND) { revoke(); return; } + sequence = value.sequence; + if (value.kind === 'view') { + if (!exact(value, ['version', 'session_id', 'sequence', 'kind', 'view'])) { revoke(); return; } + const view = parseExperienceView(value.view); + if (!view) { revoke(); return; } + void Promise.resolve(input.broker.isLive(pin)).then((live) => { + if (!live) revoke(); + else if (active) input.onView(view); + }).catch(revoke); + return; + } + if (value.kind !== 'request' + || !exact(value, ['version', 'session_id', 'sequence', 'kind', 'request_id', 'operation', 'key', 'input']) + || !id(value.request_id) || value.request_id !== `request_${value.sequence}` + || pending >= MAX_PENDING + || !['resource', 'action', 'run_status', 'run_cancel', 'navigate', 'dialog'].includes(String(value.operation)) + || (value.input !== undefined && !boundedJson(value.input))) { revoke(); return; } + const requestId = value.request_id; + const operation = value.operation; + const key = value.key; + if ((operation === 'resource' && (!id(key) || !resourceKeys.has(key))) + || (operation === 'action' && (!id(key) || !actionKeys.has(key))) + || (operation === 'navigate' && (!id(key) || !navigationKeys.has(key))) + || (operation === 'dialog' && (!id(key) || !dialogKeys.has(key))) + || (['run_status', 'run_cancel'].includes(String(operation)) && key !== undefined)) { + revoke(); return; + } + pending += 1; + void (async () => { + try { + if (!await input.broker.isLive(pin) || !active) { revoke(); return; } + let output: unknown; + if (operation === 'resource') output = await input.broker.resource?.(pin, key as string, value.input, controller.signal); + else if (operation === 'action') output = await input.broker.action?.(pin, key as string, value.input, controller.signal); + else if (operation === 'run_status') output = await input.broker.runStatus?.(pin, value.input, controller.signal); + else if (operation === 'run_cancel') output = await input.broker.runCancel?.(pin, value.input, controller.signal); + else if (operation === 'navigate') output = await input.broker.navigate?.(pin, key as string, controller.signal); + else output = await input.broker.dialog?.(pin, key as string, value.input, controller.signal); + if (!active || !await input.broker.isLive(pin)) { revoke(); return; } + if (output === undefined) { + send({ version: 'deft.experience_bridge.v1', kind: 'response', + session_id: pin.session_id, request_id: requestId, ok: false, code: 'UNAVAILABLE' }); + } else if (boundedJson(output)) { + send({ version: 'deft.experience_bridge.v1', kind: 'response', + session_id: pin.session_id, request_id: requestId, ok: true, output }); + } else { + revoke(); + } + } catch { + if (active) send({ version: 'deft.experience_bridge.v1', kind: 'response', + session_id: pin.session_id, request_id: requestId, ok: false, code: 'UNAVAILABLE' }); + } finally { pending -= 1; } + })(); + }; + const sendUiEvent = async (uiEvent: unknown): Promise => { + if (!active || !boundedJson(uiEvent)) return false; + try { + if (!await input.broker.isLive(pin)) { revoke(); return false; } + } catch { revoke(); return false; } + if (!active) return false; + send({ version: 'deft.experience_bridge.v1', kind: 'ui_event', + session_id: pin.session_id, event: uiEvent }); + return true; + }; + return Object.freeze({ revoke, sendUiEvent, get active() { return active; } }); +} diff --git a/apps/web/src/lib/app-experience-renderer.ts b/apps/web/src/lib/app-experience-renderer.ts new file mode 100644 index 00000000..2188c962 --- /dev/null +++ b/apps/web/src/lib/app-experience-renderer.ts @@ -0,0 +1,192 @@ +import type { ExperienceNode, ExperienceView } from './app-experience-bridge'; + +export type ExperienceUiEvent = Readonly<{ + kind: 'click' | 'input' | 'grid_select' | 'grid_edit' | 'canvas_stroke'; + node_id: string; + row_id?: string; + column?: number; + value?: string; + points?: readonly Readonly<{ x: number; y: number }>[]; +}>; + +export const EXPERIENCE_RENDERER_CSS = ` +.deft-experience { font: 14px/1.45 system-ui,sans-serif; color:#eaf1ff; background:#111827; min-height:100%; padding:16px; box-sizing:border-box } +.deft-experience * { box-sizing:border-box } +.deft-experience .ex-stack { display:grid; gap:14px; min-width:0 } +.deft-experience .ex-stack-title { margin:0; font-size:18px; font-weight:700 } +.deft-experience .ex-grid-scroll { overflow:auto; border:1px solid #334155; border-radius:10px; max-width:100% } +.deft-experience table { width:100%; min-width:560px; border-collapse:collapse; background:#172235 } +.deft-experience th,.deft-experience td { border-bottom:1px solid #334155; padding:7px 8px; text-align:left } +.deft-experience th { background:#24334b; color:#cbd5e1; font-size:12px; white-space:nowrap } +.deft-experience tr[aria-selected=true] { background:#233f57 } +.deft-experience input { width:100%; min-width:80px; padding:5px 7px; border:1px solid transparent; border-radius:5px; color:#eaf1ff; background:transparent; font:inherit } +.deft-experience input:focus { outline:2px solid #60a5fa; border-color:#60a5fa; background:#0f172a } +.deft-experience button { border:1px solid #4b75a8; background:#244b77; color:#fff; border-radius:7px; padding:8px 12px; cursor:pointer; font:inherit } +.deft-experience button:focus-visible { outline:2px solid #93c5fd; outline-offset:2px } +.deft-experience .ex-text { margin:0; white-space:pre-wrap; overflow-wrap:anywhere } +.deft-experience .ex-field { display:grid; gap:4px; min-width:0 } +.deft-experience .ex-field label { color:#a9b9d1; font-size:12px } +.deft-experience canvas { display:block; width:100%; height:200px; border:1px solid #4b75a8; border-radius:9px; background:#0d1a2c; touch-action:none } +@media(max-width:420px) { .deft-experience { padding:10px } .deft-experience table { min-width:510px } .deft-experience .ex-stack-title { font-size:16px } } +`; + +function el(tag: K, className?: string): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + return element; +} + +function paint(canvas: HTMLCanvasElement, strokes: ExperienceNode & { kind: 'canvas' }): void { + const rect = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(rect.width * ratio)); + canvas.height = Math.max(1, Math.round(rect.height * ratio)); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.scale(ratio, ratio); + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.strokeStyle = '#7dd3fc'; + for (const stroke of strokes.strokes) { + if (stroke.points.length < 2) continue; + ctx.beginPath(); + stroke.points.forEach((point, index) => { + const x = point.x * rect.width; + const y = point.y * rect.height; + if (index === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + } +} + +function renderNode(node: ExperienceNode, emit: (event: ExperienceUiEvent) => void): HTMLElement { + if (node.kind === 'text') { + const p = el('p', 'ex-text'); + p.textContent = node.text; + return p; + } + if (node.kind === 'button') { + const button = el('button'); + button.type = 'button'; + button.textContent = node.label; + button.dataset.focusKey = node.id; + button.addEventListener('click', () => emit({ kind: 'click', node_id: node.id })); + return button; + } + if (node.kind === 'input') { + const wrap = el('div', 'ex-field'); + const label = el('label'); + const input = el('input'); + input.value = node.value; + input.dataset.focusKey = node.id; + label.textContent = node.label; + input.addEventListener('change', () => emit({ kind: 'input', node_id: node.id, value: input.value })); + wrap.append(label, input); + return wrap; + } + if (node.kind === 'stack') { + const wrap = el('section', 'ex-stack'); + if (node.title) { + const title = el('h2', 'ex-stack-title'); + title.textContent = node.title; + wrap.append(title); + } + node.children.forEach((child) => wrap.append(renderNode(child, emit))); + return wrap; + } + if (node.kind === 'grid') { + const scroll = el('div', 'ex-grid-scroll'); + const table = el('table'); + table.setAttribute('role', 'grid'); + table.setAttribute('aria-label', node.id.replaceAll('_', ' ')); + const head = el('thead'); + const heading = el('tr'); + node.columns.forEach((column) => { + const th = el('th'); th.textContent = column; heading.append(th); + }); + head.append(heading); + const body = el('tbody'); + node.rows.forEach((row, rowIndex) => { + const tr = el('tr'); + tr.setAttribute('aria-selected', String(row.id === node.selected_row_id)); + tr.addEventListener('click', () => emit({ kind: 'grid_select', node_id: node.id, row_id: row.id })); + row.cells.forEach((cell, column) => { + const td = el('td'); + const input = el('input'); + input.value = cell; + input.setAttribute('aria-label', `${node.columns[column]}, row ${rowIndex + 1}`); + input.dataset.focusKey = `${node.id}:${row.id}:${column}`; + input.dataset.gridRow = String(rowIndex); + input.dataset.gridColumn = String(column); + input.addEventListener('change', () => emit({ + kind: 'grid_edit', node_id: node.id, row_id: row.id, column, value: input.value, + })); + input.addEventListener('keydown', (event) => { + let targetRow = rowIndex; + let targetColumn = column; + if (event.key === 'ArrowDown') targetRow += 1; + else if (event.key === 'ArrowUp') targetRow -= 1; + else if (event.key === 'ArrowRight' && input.selectionStart === input.value.length) targetColumn += 1; + else if (event.key === 'ArrowLeft' && input.selectionStart === 0) targetColumn -= 1; + else if (event.key === 'Enter') { + emit({ kind: 'grid_edit', node_id: node.id, row_id: row.id, column, value: input.value }); + targetRow += 1; + } else return; + const next = body.querySelector( + `input[data-grid-row="${targetRow}"][data-grid-column="${targetColumn}"]`); + if (next) { event.preventDefault(); next.focus(); next.select(); } + }); + td.append(input); tr.append(td); + }); + body.append(tr); + }); + table.append(head, body); scroll.append(table); + return scroll; + } + const canvas = el('canvas'); + canvas.setAttribute('aria-label', node.id.replaceAll('_', ' ')); + canvas.setAttribute('role', 'img'); + let points: { x: number; y: number }[] | null = null; + const relative = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + return { x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)), + y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)) }; + }; + canvas.addEventListener('pointerdown', (event) => { + points = [relative(event)]; canvas.setPointerCapture(event.pointerId); + }); + canvas.addEventListener('pointermove', (event) => { + if (points && points.length < 512) points.push(relative(event)); + }); + canvas.addEventListener('pointerup', (event) => { + if (!points) return; + points.push(relative(event)); + emit({ kind: 'canvas_stroke', node_id: node.id, points }); + points = null; + }); + requestAnimationFrame(() => paint(canvas, node)); + return canvas; +} + +/** Author content becomes text/value/canvas strokes only; never HTML or URL sinks. */ +export function renderExperienceView( + container: HTMLElement, view: ExperienceView, emit: (event: ExperienceUiEvent) => void, +): void { + const focused = document.activeElement instanceof HTMLElement && container.contains(document.activeElement) + ? document.activeElement.dataset.focusKey : undefined; + const selection = document.activeElement instanceof HTMLInputElement + ? { start: document.activeElement.selectionStart, end: document.activeElement.selectionEnd } : undefined; + container.classList.add('deft-experience'); + container.replaceChildren(renderNode(view.root, emit)); + if (focused) { + const candidate = Array.from(container.querySelectorAll('[data-focus-key]')) + .find((element) => element.dataset.focusKey === focused); + candidate?.focus(); + if (candidate instanceof HTMLInputElement && selection?.start !== null + && selection?.start !== undefined && selection.end !== null && selection.end !== undefined) { + candidate.setSelectionRange(selection.start, selection.end); + } + } +} diff --git a/apps/web/src/lib/runtime-app-review.test.ts b/apps/web/src/lib/runtime-app-review.test.ts new file mode 100644 index 00000000..b5e4ae8b --- /dev/null +++ b/apps/web/src/lib/runtime-app-review.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseRuntimeAppReview } from './runtime-app-review'; + +const valid = { review: { + run_id: 'run-1', action_key: 'create_label', app_installation_id: 'install-1', + app_version_id: 'version-1', grant_snapshot_id: 'grant-1', + runtime_binding_id: 'binding-1', contract_digest: `sha256:${'a'.repeat(64)}`, + policy: { risk_class: 'external_write', review_requirement: 'always', + review_scope: 'per_invocation', retry_class: 'unsafe_or_unknown' }, + input: { shipment_id: 'synthetic-one', quantity: 2, urgent: false }, +} }; + +test('exact Runtime input and host policy admit only matching run and binding', () => { + assert.deepEqual(parseRuntimeAppReview(valid, 'run-1', 'binding-1'), valid.review); + assert.equal(parseRuntimeAppReview(valid, 'another-run', 'binding-1'), null); + assert.equal(parseRuntimeAppReview(valid, 'run-1', 'another-binding'), null); + assert.equal(parseRuntimeAppReview({ review: { ...valid.review, + policy: { ...valid.review.policy, review_requirement: 'never' } } }, 'run-1', 'binding-1'), null); +}); + +test('Runtime input review rejects extra data, nested values and overlarge input', () => { + assert.equal(parseRuntimeAppReview({ review: { ...valid.review, secret: 'extra' } }, 'run-1', 'binding-1'), null); + assert.equal(parseRuntimeAppReview({ review: { ...valid.review, + input: { shipment_id: { nested: 'not scalar' } } } }, 'run-1', 'binding-1'), null); + assert.equal(parseRuntimeAppReview({ review: { ...valid.review, + input: { shipment_id: 'x'.repeat(16_385) } } }, 'run-1', 'binding-1'), null); +}); diff --git a/apps/web/src/lib/runtime-app-review.ts b/apps/web/src/lib/runtime-app-review.ts new file mode 100644 index 00000000..45dc3560 --- /dev/null +++ b/apps/web/src/lib/runtime-app-review.ts @@ -0,0 +1,53 @@ +export type RuntimeAppReview = Readonly<{ + run_id: string; + action_key: string; + app_installation_id: string; + app_version_id: string; + grant_snapshot_id: string; + runtime_binding_id: string; + contract_digest: string; + policy: Readonly<{ + risk_class: 'external_write'; + review_requirement: 'always'; + review_scope: 'per_invocation'; + retry_class: 'unsafe_or_unknown'; + }>; + input: Readonly>; +}>; + +const record = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); + +/** Parse the transient server response before letting the card enable approval. */ +export function parseRuntimeAppReview(value: unknown, runId: string, bindingId: string): RuntimeAppReview | null { + if (!record(value) || !exact(value, ['review']) || !record(value.review)) return null; + const review = value.review; + if (!exact(review, ['run_id', 'action_key', 'app_installation_id', 'app_version_id', + 'grant_snapshot_id', 'runtime_binding_id', 'contract_digest', 'policy', 'input']) + || review.run_id !== runId || review.runtime_binding_id !== bindingId + || typeof review.action_key !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(review.action_key) + || typeof review.app_installation_id !== 'string' || !review.app_installation_id + || typeof review.app_version_id !== 'string' || !review.app_version_id + || typeof review.grant_snapshot_id !== 'string' || !review.grant_snapshot_id + || typeof review.contract_digest !== 'string' + || !/^sha256:[a-f0-9]{64}$/.test(review.contract_digest) + || !record(review.policy) || !exact(review.policy, + ['risk_class', 'review_requirement', 'review_scope', 'retry_class']) + || review.policy.risk_class !== 'external_write' + || review.policy.review_requirement !== 'always' + || review.policy.review_scope !== 'per_invocation' + || review.policy.retry_class !== 'unsafe_or_unknown' + || !record(review.input)) return null; + const entries = Object.entries(review.input); + if (entries.length > 32 || entries.some(([key, item]) => + !/^[a-z][a-z0-9_]{0,47}$/.test(key) + || (typeof item !== 'boolean' && typeof item !== 'string' + && !(typeof item === 'number' && Number.isFinite(item))) + || (typeof item === 'string' && item.length > 16_384))) return null; + try { + if (new TextEncoder().encode(JSON.stringify(review.input)).byteLength > 65_536) return null; + } catch { return null; } + return review as RuntimeAppReview; +} diff --git a/package.json b/package.json index 120deec5..c968c140 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "typecheck": "pnpm -r --parallel typecheck", "crm:package": "tsx scripts/build-crm-app.mts", "test:crm-author": "tsx --test scripts/build-crm-app.test.mts scripts/export-crm-author-project.test.mts scripts/crm-packed-author.test.mts", + "test:runtime-author": "tsx --test scripts/runtime-packed-author.test.mts", "docs:check": "node scripts/check-public-docs.mjs && node --test scripts/docs-operations.test.mjs && pnpm module:check docs/examples/vendor-pilot" }, "engines": { diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 0a4c2b21..924d62d0 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@deft/app-kit", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "description": "Portable authoring and packaging contract for declarative Deft apps.", "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/app-kit/src/cli.ts b/packages/app-kit/src/cli.ts index ad1ce203..6739e0d9 100644 --- a/packages/app-kit/src/cli.ts +++ b/packages/app-kit/src/cli.ts @@ -46,7 +46,7 @@ async function assertRegularUnslinkedFile(relativePath: string): Promise return current; } -type AppTemplate = 'declarative' | 'connected' | 'connected-automation'; +type AppTemplate = 'declarative' | 'connected' | 'connected-automation' | 'runtime'; function parseInitTemplate(): AppTemplate { const args = process.argv.slice(4); @@ -54,9 +54,9 @@ function parseInitTemplate(): AppTemplate { if ( args.length !== 2 || args[0] !== '--template' - || (args[1] !== 'declarative' && args[1] !== 'connected' && args[1] !== 'connected-automation') + || (args[1] !== 'declarative' && args[1] !== 'connected' && args[1] !== 'connected-automation' && args[1] !== 'runtime') ) { - throw new Error('Usage: deft app init [--template declarative|connected|connected-automation]'); + throw new Error('Usage: deft app init [--template declarative|connected|connected-automation|runtime]'); } return args[1]; } @@ -275,6 +275,23 @@ async function initializeConnected(automation: boolean): Promise { } async function initialize(template: AppTemplate): Promise { + if (template === 'runtime') { + if (await exists(resolve(cwd, 'deft.app.json'))) throw new Error('deft.app.json already exists'); + await writeJson(resolve(cwd, 'deft.app.json'), { + schema_version: '3', id: 'community.example.shipping', version: '1.0.0', + name: 'Shipping Label', license: 'AGPL-3.0-only', compatibility: { app_protocol: '3' }, + modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'create_shipping_label', version: '1', + input_schema: { type: 'object', properties: { shipment_id: { type: 'string', maxLength: 120 } }, required: ['shipment_id'], additionalProperties: false }, + output_schema: { type: 'object', properties: { label_id: { type: 'string', maxLength: 120 } }, required: ['label_id'], additionalProperties: false }, + }], + runtime_actions: [{ key: 'create_shipping_label', label: 'Create shipping label', capability_key: 'create_shipping_label', runtime_requirement_key: 'carrier' }], + }); + await writeFile(resolve(cwd, 'APP_BRIEF.md'), '# Shipping Label\n\nCandidate Runtime App. Build and stage with the public Kit. A workspace operator must review the App and separately register and bind the external Runtime. Every invocation requires approval. Never place provider credentials or commands in this package.\n'); + console.log('Initialized candidate Runtime App; no provider authority granted.'); + return; + } if (template === 'connected' || template === 'connected-automation') { return initializeConnected(template === 'connected-automation'); } @@ -463,6 +480,8 @@ async function main(): Promise { console.log(`Valid App Protocol v0 package ${built.digest}; connected permissions: none`); } else if (protocol === '1') { console.log(`Valid App Protocol v1 connected package ${built.digest}; staging grants zero authority; review and activation are explicit; execution is rollout-gated`); + } else if (protocol === '3') { + console.log(`Valid App Protocol v3 Runtime package ${built.digest}; staging grants zero authority; App and Runtime binding reviews are required`); } else { console.log(`Valid App Protocol v2 automation-request package ${built.digest}; staging grants zero authority; automation requests are requested-only and non-executable; provider access: none`); } diff --git a/packages/app-kit/src/experience-sdk.ts b/packages/app-kit/src/experience-sdk.ts new file mode 100644 index 00000000..32d0b6d4 --- /dev/null +++ b/packages/app-kit/src/experience-sdk.ts @@ -0,0 +1,55 @@ +/** Public author-side SDK leaf. Bundlers inline this into the immutable Worker. + * It contains no credential, URL, fetch or direct workspace API surface. */ +export const DEFT_EXPERIENCE_SDK_VERSION = 'deft.experience_bridge.v1' as const; + +export type ExperienceSdkPort = Pick; +export type ExperienceSdkIntent = 'resource' | 'action' | 'run_status' | 'run_cancel' | 'navigate' | 'dialog'; + +export function createDeftExperienceSdk(port: ExperienceSdkPort, sessionId: string) { + if (!/^[a-zA-Z0-9_-]{8,128}$/.test(sessionId)) throw new Error('Invalid Experience session'); + let sequence = 0; + let closed = false; + const pending = new Map(); + let onUiEvent: ((event: unknown) => void) | undefined; + port.onmessage = (message: MessageEvent) => { + const value: unknown = message.data; + if (!value || typeof value !== 'object' || Array.isArray(value)) return; + const reply = value as Record; + if (reply.version !== DEFT_EXPERIENCE_SDK_VERSION || reply.session_id !== sessionId) return; + if (reply.kind === 'ui_event') { onUiEvent?.(reply.event); return; } + if (reply.kind !== 'response' || typeof reply.request_id !== 'string') return; + const promise = pending.get(reply.request_id); + if (!promise) return; + pending.delete(reply.request_id); + if (reply.ok === true) promise.resolve(reply.output); + else promise.reject(new Error(typeof reply.code === 'string' ? reply.code : 'UNAVAILABLE')); + }; + const post = (message: Record): number => { + if (closed) throw new Error('Experience session closed'); + sequence += 1; + port.postMessage({ version: DEFT_EXPERIENCE_SDK_VERSION, + session_id: sessionId, sequence, ...message }); + return sequence; + }; + return Object.freeze({ + render(view: unknown): void { post({ kind: 'view', view }); }, + request(operation: ExperienceSdkIntent, key?: string, input?: unknown): Promise { + if (pending.size >= 16) return Promise.reject(new Error('Experience request limit')); + const requestId = `request_${sequence + 1}`; + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }); + try { post({ kind: 'request', request_id: requestId, operation, key, input }); } + catch (error) { pending.delete(requestId); reject(error); } + }); + }, + onEvent(handler: (event: unknown) => void): void { onUiEvent = handler; }, + close(): void { + if (closed) return; + closed = true; + for (const request of pending.values()) request.reject(new Error('Experience session closed')); + pending.clear(); + port.onmessage = null; + port.close(); + }, + }); +} diff --git a/packages/app-kit/src/experience.ts b/packages/app-kit/src/experience.ts new file mode 100644 index 00000000..8b3b1fcf --- /dev/null +++ b/packages/app-kit/src/experience.ts @@ -0,0 +1,108 @@ +import { z } from 'zod'; + +/** Closed, candidate App Protocol v3 Experience artifact. The Kit dispatcher + * owns package integration; this leaf never executes author bytes on install. */ +export const DEFT_EXPERIENCE_BUNDLE_VERSION = 'deft.experience_bundle.v1' as const; +export const DEFT_EXPERIENCE_BRIDGE_VERSION = 'deft.experience_bridge.v1' as const; +export const DEFT_EXPERIENCE_RENDERER_VERSION = 'deft.trusted_renderer.v1' as const; +export const DEFT_EXPERIENCE_MEDIA_TYPE = 'application/vnd.deft.experience+json' as const; + +const MAX_BUNDLE_BYTES = 128 * 1024; +const MAX_WORKER_BYTES = 64 * 1024; +const MAX_REFERENCES = 16; +const encoder = new TextEncoder(); +const digestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/); +const keySchema = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/); + +const experiencePathSchema = z.string().min(1).max(240) + .regex(/^experiences\/[a-z0-9][a-z0-9._/-]*\.json$/) + .refine((path) => !path.split('/').some((part) => + part === '' || part === '.' || part === '..' || part === '.git' || part === 'node_modules')); + +const uniqueKeys = z.array(keySchema).max(MAX_REFERENCES) + .refine((items) => new Set(items).size === items.length, 'Keys must be unique') + .refine((items) => items.every((item, index) => index === 0 || items[index - 1]! < item), + 'Keys must be sorted'); + +export const DeftExperienceReferenceSchema = z.strictObject({ + artifact_path: experiencePathSchema, + artifact_digest: digestSchema, + bridge_version: z.literal(DEFT_EXPERIENCE_BRIDGE_VERSION), + renderer_version: z.literal(DEFT_EXPERIENCE_RENDERER_VERSION), +}); +export type DeftExperienceReference = z.infer; + +export const DeftExperienceBundleSchema = z.strictObject({ + schema_version: z.literal(DEFT_EXPERIENCE_BUNDLE_VERSION), + worker_source: z.string().min(1), + entry_view: keySchema, + resource_keys: uniqueKeys, + action_keys: uniqueKeys, +}).superRefine((value, ctx) => { + if (encoder.encode(value.worker_source).byteLength > MAX_WORKER_BYTES) { + ctx.addIssue({ code: 'custom', path: ['worker_source'], message: 'Worker source exceeds 64 KiB' }); + } +}); +export type DeftExperienceBundle = z.infer; + +export const DeftExperienceArtifactSchema = z.strictObject({ + path: experiencePathSchema, + media_type: z.literal(DEFT_EXPERIENCE_MEDIA_TYPE), + content: z.string(), + byte_length: z.number().int().nonnegative().max(MAX_BUNDLE_BYTES), + digest: digestSchema, +}); +export type DeftExperienceArtifact = z.infer; + +function canonicalBundleJson(input: unknown): string { + const bundle = DeftExperienceBundleSchema.parse(input); + const content = JSON.stringify({ + schema_version: bundle.schema_version, + worker_source: bundle.worker_source, + entry_view: bundle.entry_view, + resource_keys: bundle.resource_keys, + action_keys: bundle.action_keys, + }); + if (encoder.encode(content).byteLength > MAX_BUNDLE_BYTES) { + throw new Error('Experience bundle exceeds 128 KiB'); + } + return content; +} + +async function sha256(content: string): Promise { + const bytes = encoder.encode(content); + const hash = await crypto.subtle.digest('SHA-256', bytes); + return 'sha256:' + Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function prepareDeftExperienceArtifact( + path: string, bundle: unknown, +): Promise { + const parsedPath = experiencePathSchema.parse(path); + const content = canonicalBundleJson(bundle); + return DeftExperienceArtifactSchema.parse({ + path: parsedPath, media_type: DEFT_EXPERIENCE_MEDIA_TYPE, + content, byte_length: encoder.encode(content).byteLength, digest: await sha256(content), + }); +} + +export async function verifyDeftExperienceArtifact( + referenceInput: unknown, artifactInput: unknown, +): Promise { + const reference = DeftExperienceReferenceSchema.parse(referenceInput); + const artifact = DeftExperienceArtifactSchema.parse(artifactInput); + if (reference.artifact_path !== artifact.path || reference.artifact_digest !== artifact.digest) { + throw new Error('Experience artifact reference mismatch'); + } + if (encoder.encode(artifact.content).byteLength !== artifact.byte_length) { + throw new Error('Experience artifact byte length mismatch'); + } + let raw: unknown; + try { raw = JSON.parse(artifact.content) as unknown; } + catch { throw new Error('Experience artifact is not JSON'); } + const canonical = canonicalBundleJson(raw); + if (canonical !== artifact.content || await sha256(canonical) !== artifact.digest) { + throw new Error('Experience artifact digest or canonical bytes mismatch'); + } + return DeftExperienceBundleSchema.parse(raw); +} diff --git a/packages/app-kit/src/index.ts b/packages/app-kit/src/index.ts index 338a551a..503317e9 100644 --- a/packages/app-kit/src/index.ts +++ b/packages/app-kit/src/index.ts @@ -1,4 +1,9 @@ import { z } from 'zod'; +import { RuntimeAuthoringShape, RuntimeAuthoringSchema, RuntimeRequestedAuthoritySchema } from './runtime-authoring.js'; +export * from './runtime-authoring.js'; +export * from './runtime-client.js'; +export * from './experience.js'; +export * from './experience-sdk.js'; import { abortOnUnknownContractKeys } from './module-contract/zod-compat.js'; import { classifyAppAutomationOccurrence, @@ -32,9 +37,12 @@ export const DEFT_APP_PACKAGE_FORMAT_V1 = 'deft.app.package.v1' as const; export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 = '2' as const; export const DEFT_APP_PROTOCOL_VERSION_V2 = '2' as const; export const DEFT_APP_PACKAGE_FORMAT_V2 = 'deft.app.package.v2' as const; +export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 = '3' as const; +export const DEFT_APP_PROTOCOL_VERSION_V3 = '3' as const; +export const DEFT_APP_PACKAGE_FORMAT_V3 = 'deft.app.package.v3' as const; export const DEFT_MODULE_ARTIFACT_MEDIA_TYPE = 'application/vnd.deft.module+json' as const; export const DEFT_APP_KIT_PACKAGE_NAME = '@deft/app-kit' as const; -export const DEFT_APP_KIT_VERSION = '0.1.0-alpha.3' as const; +export const DEFT_APP_KIT_VERSION = '0.1.0-alpha.4' as const; export const DEFT_APP_DEVELOPER_COMPATIBILITY_SCHEMA = 'deft.app_developer.compatibility.v1' as const; export const DEFT_APP_DEVELOPER_CONTRACT_CHECK_SCHEMA = 'deft.app_developer.contract_check.v1' as const; export const DEFT_APP_REQUESTED_AUTHORITY_REPORT_SCHEMA = 'deft.app.requested_authority.v1' as const; @@ -68,6 +76,7 @@ export const DeftAppDeveloperCompatibilitySchema = z.strictObject({ '0': DeftAppDeveloperProtocolV0FlowSchema, '1': DeftAppDeveloperProtocolV1FlowSchema, '2': DeftAppDeveloperProtocolV2FlowSchema.optional(), + '3': z.strictObject({ package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V3), install_mode: z.literal('stage_only') }).optional(), }), }).superRefine((value, ctx) => { if (abortOnUnknownContractKeys(ctx)) return; @@ -87,9 +96,10 @@ export const DEFT_APP_DEVELOPER_COMPATIBILITY = Object.freeze({ schema: DEFT_APP_DEVELOPER_COMPATIBILITY_SCHEMA, app_kit: Object.freeze({ package: DEFT_APP_KIT_PACKAGE_NAME, - versions: Object.freeze([DEFT_APP_KIT_VERSION, '0.1.0-alpha.2', '0.1.0-alpha.1']), + versions: Object.freeze([DEFT_APP_KIT_VERSION, '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1']), }), protocol_flows: Object.freeze({ + '3': Object.freeze({ package_format: DEFT_APP_PACKAGE_FORMAT_V3, install_mode: 'stage_only' as const }), '0': Object.freeze({ package_format: DEFT_APP_PACKAGE_FORMAT, install_mode: 'stage_and_activate' as const, @@ -121,6 +131,7 @@ export function resolveDeftAppDeveloperProtocolFlow( protocol !== DEFT_APP_PROTOCOL_VERSION && protocol !== DEFT_APP_PROTOCOL_VERSION_V1 && protocol !== DEFT_APP_PROTOCOL_VERSION_V2 + && protocol !== DEFT_APP_PROTOCOL_VERSION_V3 ) { throw new Error(`Host does not support App Protocol v${protocol}`); } @@ -865,6 +876,15 @@ const V2_HANDLER_MATRIX = handlerMatrix({ }); export const DEFT_APP_PROTOCOL_SUPPORT = Object.freeze({ + '3': Object.freeze({ + manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions']), + atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'runtime.private_actions'], handlerMatrix({ + authoring: 'app-kit:v3', inspect: 'app-service:inspect-v3', stage: 'app-service:stage-v3', + review: 'app-runtime-review:v3', activate: 'app-runtime-review:v3', + route: 'app-runtime-actions:v3', invoke: 'app-runtime-actions:v3', + })), + private_interfaces: Object.freeze([]), + }), '0': Object.freeze({ manifest_keys: Object.freeze([ 'schema_version', 'id', 'version', 'name', 'description', 'license', @@ -979,15 +999,38 @@ export const DeftAppPackageV2Schema = z.strictObject({ artifacts: z.array(DeftAppPackageArtifactV0Schema).min(1).max(APP_LIMITS.artifacts_per_app), }); +/** Runtime-only candidate. Other surfaces require their own validated contract. */ +export const DeftAppManifestV3Schema = z.strictObject({ + ...DeftAppManifestV0Schema.shape, + schema_version: z.literal('3'), + compatibility: z.strictObject({ app_protocol: z.literal('3') }), + modules: z.array(DeftAppModuleReferenceV0Schema).max(0), + navigation: z.array(DeftAppNavigationItemV0Schema).max(0).default([]), + ...RuntimeAuthoringShape, +}).superRefine((manifest, ctx) => { + const result = RuntimeAuthoringSchema.safeParse({ runtime_requirements: manifest.runtime_requirements, + private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions }); + if (!result.success) for (const issue of result.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); +}); +export const DeftAppPackageV3Schema = z.strictObject({ + package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V3), manifest: DeftAppManifestV3Schema, + manifest_digest: AppDigestSchema, artifacts: z.array(DeftAppPackageArtifactV0Schema).max(0), +}); +export type DeftAppManifestV3 = z.infer; +export type DeftAppManifestV3Input = z.input; +export type DeftAppPackageV3 = z.infer; + export const DeftAppManifestSchema = z.union([ DeftAppManifestV0Schema, DeftAppManifestV1Schema, DeftAppManifestV2Schema, + DeftAppManifestV3Schema, ]); export const DeftAppPackageSchema = z.union([ DeftAppPackageV0Schema, DeftAppPackageV1Schema, DeftAppPackageV2Schema, + DeftAppPackageV3Schema, ]); export type DeftAppManifestV0 = z.infer; @@ -1099,11 +1142,11 @@ export type DeftAppRequestedAuthorityProjection = export type DeftAppRequestedAuthorityProjectionV2 = z.infer; export type DeftAppRequestedAuthorityProjectionAny = - DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2; + DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2 | z.infer; export type DeftAppRequestedAuthorityReport = z.infer; export type DeftAppRequestedAuthorityReportV2 = z.infer; export type DeftAppRequestedAuthorityReportAny = - DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2; + DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2 | { schema: 'deft.app.requested_authority.v3'; app: {id: string; version: string; protocol_version: '3'}; requested_authority: z.infer }; const DeftAppRequestedAuthorityAtomSchema = z.enum([ 'dependencies', @@ -1190,6 +1233,9 @@ export async function diffDeftAppRequestedAuthority(input: Readonly<{ const priorDigest = prior === null ? null : await digest(prior); const atoms = DeftAppRequestedAuthorityAtomSchema.options; const requirement = (value: DeftAppRequestedAuthorityProjectionAny, atom: typeof atoms[number]) => { + if ('runtime_actions' in value.requirements) return atom === 'capabilities' + ? value.requirements.private_capabilities : atom === 'connectors' + ? value.requirements.runtime_requirements : atom === 'actions' ? value.requirements.runtime_actions : []; if (atom === 'automation_requests') { return 'automation_requests' in value.requirements ? value.requirements.automation_requests : []; } @@ -1310,6 +1356,10 @@ export function projectDeftAppRequestedAuthority( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityProjectionAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '3') return RuntimeRequestedAuthoritySchema.parse({ + requirements: { runtime_requirements: manifest.runtime_requirements, private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions }, + classification: { authority_state: 'requested_only', executable: false, provider_access: false, review_required: true }, + }); const connected = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION ? null : manifest; @@ -1362,6 +1412,11 @@ export function buildDeftAppRequestedAuthorityReport( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityReportAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '3') return { + schema: 'deft.app.requested_authority.v3', + app: { id: manifest.id, version: manifest.version, protocol_version: '3' }, + requested_authority: RuntimeRequestedAuthoritySchema.parse(projectDeftAppRequestedAuthority(manifest)), + }; if (manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2) { return DeftAppRequestedAuthorityReportV2Schema.parse({ schema: DEFT_APP_REQUESTED_AUTHORITY_REPORT_SCHEMA_V2, @@ -1469,7 +1524,9 @@ export function parseDeftAppManifest(value: unknown): DeftAppManifest { // v1 or v2 continues through the original direct v0 schema instead of a // union branch. const schemaVersion = recordWithString(value, 'schema_version'); - const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 + const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 + ? DeftAppManifestV3Schema.parse(value) + : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 ? DeftAppManifestV2Schema.parse(value) : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V1 ? DeftAppManifestV1Schema.parse(value) @@ -1530,6 +1587,8 @@ export function getDeftAppManifestV2JsonSchema(): Record { } export function getDeftAppManifestJsonSchema(schemaVersion: string): Record { + if (schemaVersion === '3') return { title: 'Deft Runtime App manifest v3', + ...z.toJSONSchema(DeftAppManifestV3Schema, { target: 'draft-2020-12', unrepresentable: 'any' }) }; if (schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2) { return getDeftAppManifestV2JsonSchema(); } @@ -1816,7 +1875,9 @@ export async function buildDeftAppPackage(input: { manifest_digest: await digestAppManifest(manifest), artifacts: [...input.artifacts].sort((left, right) => left.path.localeCompare(right.path)), }; - const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 + const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 + ? DeftAppPackageV3Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V3, ...packageInput }) + : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 ? DeftAppPackageV2Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V2, ...packageInput }) : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V1 ? DeftAppPackageV1Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V1, ...packageInput }) @@ -1840,7 +1901,9 @@ export async function verifyDeftAppPackageJson( // As with manifest parsing, direct dispatch keeps invalid-v0 issue shapes // stable while allowing the explicitly versioned v1 and v2 formats. const packageFormat = recordWithString(raw, 'package_format'); - const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V2 + const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V3 + ? DeftAppPackageV3Schema.parse(raw) + : packageFormat === DEFT_APP_PACKAGE_FORMAT_V2 ? DeftAppPackageV2Schema.parse(raw) : packageFormat === DEFT_APP_PACKAGE_FORMAT_V1 ? DeftAppPackageV1Schema.parse(raw) diff --git a/packages/app-kit/src/runtime-authoring.ts b/packages/app-kit/src/runtime-authoring.ts new file mode 100644 index 00000000..e10be7b5 --- /dev/null +++ b/packages/app-kit/src/runtime-authoring.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; + +const key = z.string().min(1).max(48).regex(/^[a-z][a-z0-9_]*$/) + .refine((value) => !['constructor', 'prototype', '__proto__'].includes(value)); +const field = z.discriminatedUnion('type', [ + z.strictObject({ type: z.literal('string'), maxLength: z.number().int().min(1).max(16_384) }), + z.strictObject({ type: z.literal('number'), minimum: z.number().finite(), maximum: z.number().finite() }) + .refine((value) => value.minimum <= value.maximum), + z.strictObject({ type: z.literal('boolean') }), +]); + +/** Closed first Runtime interface: bounded scalar objects, no refs or executable validators. */ +export const RuntimeObjectSchema = z.strictObject({ + type: z.literal('object'), + properties: z.record(key, field), + required: z.array(key).max(32), + additionalProperties: z.literal(false), +}).superRefine((value, ctx) => { + if (Object.keys(value.properties).length > 32 || new Set(value.required).size !== value.required.length + || value.required.some((name) => !Object.hasOwn(value.properties, name))) { + ctx.addIssue({ code: 'custom', message: 'Runtime object fields must be bounded, unique and declared' }); + } +}); +export type RuntimeObjectContract = z.infer; + +export function parseRuntimeObjectInput(contract: RuntimeObjectContract, value: unknown): Record { + const schema = RuntimeObjectSchema.parse(contract); + const shape: Record = Object.create(null); + for (const [name, definition] of Object.entries(schema.properties)) { + const validator = definition.type === 'string' ? z.string().max(definition.maxLength) + : definition.type === 'number' ? z.number().finite().min(definition.minimum).max(definition.maximum) + : z.boolean(); + shape[name] = schema.required.includes(name) ? validator : validator.optional(); + } + return z.strictObject(shape).parse(value) as Record; +} + +export const RuntimeRequirementSchema = z.strictObject({ + key, + protocol_version: z.literal('deft.app_runtime_channel.v1'), +}); +export const RuntimePrivateCapabilitySchema = z.strictObject({ + key, + version: z.literal('1'), + input_schema: RuntimeObjectSchema, + output_schema: RuntimeObjectSchema, +}); +export const RuntimeActionSchema = z.strictObject({ + key: key.refine((value) => !/^(deft|core|system)(_|$)/.test(value), 'Action keys must not use a reserved host prefix'), + label: z.string().min(1).max(80).regex(/^[^\u0000-\u001f\u007f<>]+$/), + capability_key: key, + runtime_requirement_key: key, +}); +export const RuntimeAuthoringShape = { + runtime_requirements: z.array(RuntimeRequirementSchema).min(1).max(8), + private_capabilities: z.array(RuntimePrivateCapabilitySchema).min(1).max(8), + runtime_actions: z.array(RuntimeActionSchema).min(1).max(16), +}; +export const RuntimeAuthoringSchema = z.strictObject(RuntimeAuthoringShape).superRefine((value, ctx) => { + for (const name of ['runtime_requirements', 'private_capabilities', 'runtime_actions'] as const) { + if (new Set(value[name].map((item) => item.key)).size !== value[name].length) { + ctx.addIssue({ code: 'custom', path: [name], message: 'Keys must be unique' }); + } + } + for (const [index, action] of value.runtime_actions.entries()) { + if (!value.private_capabilities.some((item) => item.key === action.capability_key) + || !value.runtime_requirements.some((item) => item.key === action.runtime_requirement_key)) { + ctx.addIssue({ code: 'custom', path: ['runtime_actions', index], message: 'Action must reference declared capability and Runtime requirements' }); + } + } +}); + +/** This policy is selected by the host; authors cannot weaken it. */ +export const RUNTIME_ACTION_HOST_POLICY = Object.freeze({ + risk_class: 'external_write', review_requirement: 'always', review_scope: 'per_invocation', + retry_class: 'unsafe_or_unknown', retention_class: 'standard', +} as const); + +export const RuntimeRequestedAuthoritySchema = z.strictObject({ + requirements: RuntimeAuthoringSchema, + classification: z.strictObject({ + authority_state: z.literal('requested_only'), executable: z.literal(false), + provider_access: z.literal(false), review_required: z.literal(true), + }), +}); diff --git a/packages/app-kit/src/runtime-client.ts b/packages/app-kit/src/runtime-client.ts new file mode 100644 index 00000000..d228dc03 --- /dev/null +++ b/packages/app-kit/src/runtime-client.ts @@ -0,0 +1,141 @@ +/** A credential-scoped transport for the host-reviewed App Runtime channel. + * This module does not mint sessions or retry effects. The host issues a + * short-lived App Runtime credential to the named operator separately. */ +export type AppRuntimeCredential = Readonly<{ + session_id: string; + session_token: string; +}>; + +export type AppRuntimeClaim = Readonly<{ + schema_version: 'deft.app_runtime_channel.v1'; + run_id: string; + attempt_id: string; + claim_token: string; + sequence: number; + operation_name: string; + provider_idempotency_key?: string; + lease_expires_at: string; +}>; + +export type AppRuntimeStarted = Readonly<{ + schema_version: 'deft.app_runtime_channel.v1'; + run_id: string; + attempt_id: string; + input: unknown; + provider_idempotency_key?: string; + lease_expires_at: string; +}>; + +export type AppRuntimeResult = + | Readonly<{ status: 'returned'; provider_succeeded: boolean; output: unknown }> + | Readonly<{ status: 'not_attempted'; error_code: 'APP_RUN_PROVIDER_UNAVAILABLE' | 'APP_RUN_PROVIDER_TIMEOUT' }> + | Readonly<{ status: 'indeterminate' }>; + +export type AppRuntimeClientOptions = Readonly<{ + /** Full channel route prefix, for example https://host/api/app-runtime/channel. */ + channel_url: string; + credential: AppRuntimeCredential; + fetch?: typeof globalThis.fetch; + timeout_ms?: number; +}>; + +const VERSION = 'deft.app_runtime_channel.v1' as const; +const MAX_RESPONSE_BYTES = 1_100_000; + +export class AppRuntimeClientError extends Error { + constructor(readonly status: number, readonly code: string) { + super(`App Runtime channel request failed: ${code}`); + this.name = 'AppRuntimeClientError'; + } +} + +export function createAppRuntimeClient(options: AppRuntimeClientOptions) { + const base = new URL(options.channel_url); + if (base.username || base.password || base.search || base.hash + || (base.protocol !== 'https:' && !(base.protocol === 'http:' + && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))) { + throw new TypeError('Runtime channel URL must be HTTPS or loopback HTTP without credentials'); + } + if (!options.credential.session_id || !/^[A-Za-z0-9_-]{32,512}$/.test(options.credential.session_token)) { + throw new TypeError('Invalid App Runtime credential'); + } + const fetcher = options.fetch ?? globalThis.fetch; + const timeout = options.timeout_ms ?? 15_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60_000) { + throw new TypeError('Invalid App Runtime request deadline'); + } + + async function post(operation: 'claim' | 'start' | 'heartbeat' | 'result', body: Record): Promise { + const url = new URL(`${base.pathname.replace(/\/$/, '')}/${operation}`, base); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const response = await fetcher(url, { + method: 'POST', + credentials: 'omit', + redirect: 'error', + signal: controller.signal, + headers: { + authorization: `AppRuntime ${options.credential.session_token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ schema_version: VERSION, session_id: options.credential.session_id, ...body }), + }); + const declared = Number(response.headers.get('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared > MAX_RESPONSE_BYTES) { + throw new AppRuntimeClientError(response.status, 'APP_RUNTIME_RESPONSE_TOO_LARGE'); + } + const reader = response.body?.getReader(); + if (!reader) throw new AppRuntimeClientError(response.status, 'APP_RUNTIME_EMPTY_RESPONSE'); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_RESPONSE_BYTES) throw new AppRuntimeClientError(response.status, 'APP_RUNTIME_RESPONSE_TOO_LARGE'); + chunks.push(next.value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + const payload: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new AppRuntimeClientError(response.status, 'APP_RUNTIME_INVALID_RESPONSE'); + } + const result = payload as Record; + if (!response.ok) { + throw new AppRuntimeClientError(response.status, + typeof result.code === 'string' ? result.code : 'APP_RUNTIME_REQUEST_FAILED'); + } + return result as T; + } finally { clearTimeout(timer); } + } + + function claimFields(claim: AppRuntimeClaim) { + if (claim.schema_version !== VERSION) throw new TypeError('Unsupported App Runtime claim version'); + return { run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + } + + return Object.freeze({ + async claim(): Promise { + const reply = await post<{ claim: AppRuntimeClaim | null }>('claim', { max_claims: 1 }); + return reply.claim; + }, + async start(claim: AppRuntimeClaim): Promise { + const reply = await post<{ started: AppRuntimeStarted }>('start', claimFields(claim)); + return reply.started; + }, + async heartbeat(claim: AppRuntimeClaim): Promise { + const reply = await post<{ renewed: boolean }>('heartbeat', claimFields(claim)); + return reply.renewed === true; + }, + async result(claim: AppRuntimeClaim, result: AppRuntimeResult): Promise { + const reply = await post<{ run: unknown }>('result', { ...claimFields(claim), ...result }); + return reply.run; + }, + }); +} diff --git a/packages/app-kit/test/cli.test.ts b/packages/app-kit/test/cli.test.ts index 511cd99d..e737f9eb 100644 --- a/packages/app-kit/test/cli.test.ts +++ b/packages/app-kit/test/cli.test.ts @@ -51,9 +51,9 @@ test('external authoring loop initializes and builds deterministically without c `${filename} must remain byte-identical for the compatibility default`, ); } - const invalidTemplate = run(await mkdtemp(resolve(tmpdir(), 'deft-app-kit-invalid-')), 'init', '--template', 'runtime'); + const invalidTemplate = run(await mkdtemp(resolve(tmpdir(), 'deft-app-kit-invalid-')), 'init', '--template', 'unknown'); assert.notEqual(invalidTemplate.status, 0); - assert.equal(invalidTemplate.stderr.trim(), 'Usage: deft app init [--template declarative|connected|connected-automation]'); + assert.equal(invalidTemplate.stderr.trim(), 'Usage: deft app init [--template declarative|connected|connected-automation|runtime]'); const checked = run(project, 'check'); assert.equal(checked.status, 0, checked.stderr); @@ -149,7 +149,7 @@ test('connected template emits a Module v2 dependency App and requested authorit single_use_install: true, compatibility: { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, @@ -184,7 +184,7 @@ test('connected template emits a Module v2 dependency App and requested authorit assert.equal(diagnosed.status, 0, diagnosed.stderr); assert.equal( diagnosed.stdout.trim(), - `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.3; App Protocol v1; ` + `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.4; App Protocol v1; ` + `package format deft.app.package.v1; install mode stage_only; host ${hostUrl}`, ); assert.doesNotMatch(diagnosed.stdout, /registry|signature|signed|trusted|verified/i); @@ -413,7 +413,7 @@ test('Protocol v2 check, build, requested-authority, and doctor paths stay stage single_use_install: true, compatibility: { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, @@ -431,7 +431,7 @@ test('Protocol v2 check, build, requested-authority, and doctor paths stay stage assert.equal(diagnosed.status, 0, diagnosed.stderr); assert.equal( diagnosed.stdout.trim(), - `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.3; App Protocol v2; ` + `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.4; App Protocol v2; ` + `package format deft.app.package.v2; install mode stage_only; host ${hostUrl}\n` + 'Bounded automation contracts ready; run `deft app simulate-automation --fixture ` before staging.', ); diff --git a/packages/app-kit/test/developer-contract.test.ts b/packages/app-kit/test/developer-contract.test.ts index d8812597..69455e71 100644 --- a/packages/app-kit/test/developer-contract.test.ts +++ b/packages/app-kit/test/developer-contract.test.ts @@ -44,11 +44,12 @@ test('freezes one additive App Kit and protocol-flow compatibility contract', as assert.equal(DEFT_APP_KIT_VERSION, packageJson.version); assert.deepEqual(DEFT_APP_DEVELOPER_COMPATIBILITY, { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, '2': { package_format: 'deft.app.package.v2', install_mode: 'stage_only' }, + '3': { package_format: 'deft.app.package.v3', install_mode: 'stage_only' }, }, }); assert.equal(Object.isFrozen(DEFT_APP_DEVELOPER_COMPATIBILITY), true); @@ -77,7 +78,7 @@ test('freezes one additive App Kit and protocol-flow compatibility contract', as ...DEFT_APP_DEVELOPER_COMPATIBILITY, app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.0'] }, }, '1'), - /does not support @deft\/app-kit 0\.1\.0-alpha\.3/, + /does not support @deft\/app-kit 0\.1\.0-alpha\.4/, ); assert.throws( () => parseDeftAppDeveloperCompatibility({ @@ -92,7 +93,7 @@ test('candidate Kit refuses a host advertising only the preceding authoring cont assert.throws(() => resolveDeftAppDeveloperProtocolFlow({ ...DEFT_APP_DEVELOPER_COMPATIBILITY, app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.2', '0.1.0-alpha.1'] }, - }, '1'), /does not support @deft\/app-kit 0\.1\.0-alpha\.3/); + }, '1'), /does not support @deft\/app-kit 0\.1\.0-alpha\.4/); }); test('checks a connected package against only the public host and provider contracts', async () => { diff --git a/packages/app-kit/test/experience-sdk.test.ts b/packages/app-kit/test/experience-sdk.test.ts new file mode 100644 index 00000000..e4e0337f --- /dev/null +++ b/packages/app-kit/test/experience-sdk.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createDeftExperienceSdk } from '../src/experience-sdk.js'; + +class Port { + onmessage: ((event: MessageEvent) => void) | null = null; + sent: unknown[] = []; + closed = false; + postMessage(value: unknown) { this.sent.push(value); } + close() { this.closed = true; } + receive(value: unknown) { this.onmessage?.({ data:value } as MessageEvent); } +} + +test('SDK sequences views and requests and ignores foreign-session responses', async () => { + const port = new Port(); + const sdk = createDeftExperienceSdk(port, 'session_12345678'); + sdk.render({ root:{kind:'text',id:'intro',text:'Hi'} }); + const pending = sdk.request('resource','records',{page:1}); + assert.deepEqual(port.sent.map((value) => (value as {sequence:number}).sequence), [1,2]); + assert.equal((port.sent[1] as {request_id:string}).request_id, 'request_2'); + port.receive({ version:'deft.experience_bridge.v1', kind:'response', + session_id:'session_foreign', request_id:'request_2', ok:true, output:'wrong' }); + port.receive({ version:'deft.experience_bridge.v1', kind:'response', + session_id:'session_12345678', request_id:'request_2', ok:true, output:{rows:[]} }); + assert.deepEqual(await pending, {rows:[]}); + sdk.close(); + assert.equal(port.closed, true); +}); + +test('SDK closes pending calls without leaking reusable credentials', async () => { + const port = new Port(); + const sdk = createDeftExperienceSdk(port, 'session_12345678'); + const pending = sdk.request('action','submit',{value:'x'}); + sdk.close(); + await assert.rejects(pending, /closed/); + assert.equal(JSON.stringify(port.sent).includes('token'), false); + assert.throws(() => sdk.render({root:{kind:'text',id:'x',text:'late'}}), /closed/); +}); diff --git a/packages/app-kit/test/experience.test.ts b/packages/app-kit/test/experience.test.ts new file mode 100644 index 00000000..75636dc8 --- /dev/null +++ b/packages/app-kit/test/experience.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + DEFT_EXPERIENCE_BRIDGE_VERSION, DEFT_EXPERIENCE_RENDERER_VERSION, + prepareDeftExperienceArtifact, verifyDeftExperienceArtifact, +} from '../src/experience.js'; + +const bundle = { + schema_version: 'deft.experience_bundle.v1', + worker_source: 'self.onmessage = () => postMessage({kind:"view"});', + entry_view: 'workspace', + resource_keys: ['records'], + action_keys: ['create_label'], +} as const; +const path = 'experiences/workspace.json'; + +test('canonical Experience artifact verifies its exact path, MIME, length and digest', async () => { + const artifact = await prepareDeftExperienceArtifact(path, bundle); + const ref = { artifact_path: path, artifact_digest: artifact.digest, + bridge_version: DEFT_EXPERIENCE_BRIDGE_VERSION, + renderer_version: DEFT_EXPERIENCE_RENDERER_VERSION }; + assert.deepEqual(await verifyDeftExperienceArtifact(ref, artifact), bundle); + await assert.rejects(() => verifyDeftExperienceArtifact(ref, { + ...artifact, content: artifact.content.replace('workspace', 'wrong_view'), + }), /mismatch/); + await assert.rejects(() => verifyDeftExperienceArtifact(ref, { + ...artifact, media_type: 'text/html', + })); + await assert.rejects(() => verifyDeftExperienceArtifact({ + ...ref, artifact_path: 'experiences/other.json', + }, artifact), /reference mismatch/); + await assert.rejects(() => verifyDeftExperienceArtifact({ + ...ref, artifact_digest: 'sha256:' + '0'.repeat(64), + }, artifact), /reference mismatch/); +}); + +test('Experience bundle rejects unknown fields, traversal, unsorted keys and oversized source', async () => { + await assert.rejects(() => prepareDeftExperienceArtifact('experiences/../escape.json', bundle)); + await assert.rejects(() => prepareDeftExperienceArtifact(path, { ...bundle, install_script: 'npm run postinstall' })); + await assert.rejects(() => prepareDeftExperienceArtifact(path, { + ...bundle, action_keys: ['z_action', 'a_action'], + })); + await assert.rejects(() => prepareDeftExperienceArtifact(path, { + ...bundle, worker_source: 'x'.repeat(65 * 1024), + })); +}); diff --git a/packages/app-kit/test/packed-external.test.ts b/packages/app-kit/test/packed-external.test.ts index 0628cafb..c4e2ec58 100644 --- a/packages/app-kit/test/packed-external.test.ts +++ b/packages/app-kit/test/packed-external.test.ts @@ -57,7 +57,7 @@ test('packed App Kit builds Contacts, connected Campaigns, and scheduled Campaig const installedRoot = await realpath(resolve(consumer, 'node_modules', '@deft', 'app-kit')); assert.equal(installedRoot.startsWith(await realpath(consumer)), true); const installedPackage = JSON.parse(await readFile(resolve(installedRoot, 'package.json'), 'utf8')) as any; - assert.equal(installedPackage.version, '0.1.0-alpha.3'); + assert.equal(installedPackage.version, '0.1.0-alpha.4'); assert.equal(installedPackage.bin.deft, './dist/cli.js'); const installedFiles = await readdir(installedRoot, { recursive: true }); assert.equal(installedFiles.some((entry) => /^src(?:[\\/]|$)/.test(entry)), false); diff --git a/packages/app-kit/test/protocol-v2.test.ts b/packages/app-kit/test/protocol-v2.test.ts index 60427572..d2e32c1f 100644 --- a/packages/app-kit/test/protocol-v2.test.ts +++ b/packages/app-kit/test/protocol-v2.test.ts @@ -125,7 +125,7 @@ describe('App Protocol v2 bounded automation request contract', () => { assert.deepEqual(getDeftAppManifestJsonSchema('2'), schema); assert.deepEqual(getDeftAppManifestJsonSchema('1'), getDeftAppManifestV1JsonSchema()); assert.deepEqual(getDeftAppManifestJsonSchema('0'), getDeftAppManifestV0JsonSchema()); - assert.throws(() => getDeftAppManifestJsonSchema('3'), /schema v3 is not supported/); + assert.throws(() => getDeftAppManifestJsonSchema('4'), /schema v4 is not supported/); }); test('accepts only one bounded daily trigger declaration over a resolved action', async () => { diff --git a/packages/app-kit/test/runtime-authoring.test.ts b/packages/app-kit/test/runtime-authoring.test.ts new file mode 100644 index 00000000..4b7738d3 --- /dev/null +++ b/packages/app-kit/test/runtime-authoring.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildDeftAppPackage, parseDeftAppManifest, verifyDeftAppPackageJson, + buildDeftAppRequestedAuthorityReport, diffDeftAppRequestedAuthority, parseRuntimeObjectInput } from '../dist/index.js'; + +const object = { type: 'object' as const, properties: { shipment_id: { type: 'string' as const, maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false as const }; +const manifest = { schema_version: '3' as const, id: 'community.example.shipping', version: '1.0.0', + name: 'Shipping', license: 'AGPL-3.0-only', compatibility: { app_protocol: '3' as const }, modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' as const }], + private_capabilities: [{ key: 'label', version: '1' as const, input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'create_shipping_label', label: 'Create shipping label', capability_key: 'label', runtime_requirement_key: 'carrier' }], +}; + +test('Runtime candidate builds deterministically and requests no effective authority', async () => { + const built = await buildDeftAppPackage({ manifest, artifacts: [] }); + assert.equal(built.package.package_format, 'deft.app.package.v3'); + assert.deepEqual(await verifyDeftAppPackageJson(built.json), built); + assert.equal((await buildDeftAppPackage({ manifest, artifacts: [] })).json, built.json); + const report = buildDeftAppRequestedAuthorityReport(manifest); + assert.equal(report.schema, 'deft.app.requested_authority.v3'); + assert.equal(report.requested_authority.classification.provider_access, false); + const diff = await diffDeftAppRequestedAuthority({ proposed: manifest }); + assert.deepEqual(diff.changed_atoms.sort(), ['actions', 'capabilities', 'connectors']); + assert.equal((await diffDeftAppRequestedAuthority({ prior: manifest, proposed: manifest })).kind, 'unchanged'); + const changed = structuredClone(manifest); changed.private_capabilities[0]!.input_schema.properties.shipment_id.maxLength = 121; + assert.equal((await diffDeftAppRequestedAuthority({ prior: manifest, proposed: changed })).kind, 'widening_or_incompatible'); +}); + +test('Runtime manifest rejects executable config, missing references, widened policy and tampering', async () => { + for (const patch of [{ command: 'node run.js' }, { credentials: 'secret' }, { experience: {} }, + { runtime_actions: [{ ...manifest.runtime_actions[0], host_policy: { review_requirement: 'never' } }] }, + { runtime_actions: [{ ...manifest.runtime_actions[0], capability_key: 'missing' }] }, + { runtime_actions: [{ ...manifest.runtime_actions[0], key: 'deft_action' }] }, + { runtime_actions: [{ ...manifest.runtime_actions[0], key: 'core_action' }] }, + { runtime_actions: [{ ...manifest.runtime_actions[0], key: 'system_action' }] }, + { runtime_requirements: [...manifest.runtime_requirements, ...manifest.runtime_requirements] }]) { + assert.throws(() => parseDeftAppManifest({ ...manifest, ...patch })); + } + const built = await buildDeftAppPackage({ manifest, artifacts: [] }); + const tampered = JSON.parse(built.json); tampered.manifest.runtime_actions[0].label = 'Changed'; + await assert.rejects(() => verifyDeftAppPackageJson(JSON.stringify(tampered)), /digest mismatch/); +}); + +test('Runtime schema bounds inputs and rejects undeclared or executable schema features', () => { + assert.deepEqual(parseRuntimeObjectInput(object, { shipment_id: 'synthetic-1' }), { shipment_id: 'synthetic-1' }); + for (const value of [{}, { shipment_id: 'x', extra: true }, { shipment_id: 1 }, { shipment_id: 'x'.repeat(121) }]) { + assert.throws(() => parseRuntimeObjectInput(object, value)); + } + assert.throws(() => parseRuntimeObjectInput({ ...object, $ref: 'https://example.test/schema' } as never, {})); + const bad = structuredClone(manifest); bad.private_capabilities[0]!.input_schema.required = ['missing']; + assert.throws(() => parseDeftAppManifest(bad)); +}); diff --git a/packages/app-kit/test/runtime-client.test.ts b/packages/app-kit/test/runtime-client.test.ts new file mode 100644 index 00000000..a99d065f --- /dev/null +++ b/packages/app-kit/test/runtime-client.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAppRuntimeClient, AppRuntimeClientError } from '../src/runtime-client.js'; + +const credential = { session_id: 'session_test', session_token: 's'.repeat(43) }; +const claim = { + schema_version: 'deft.app_runtime_channel.v1' as const, + run_id: 'run_test', attempt_id: 'attempt_test', claim_token: 'claim_test', + sequence: 1, operation_name: 'send_notice', lease_expires_at: new Date().toISOString(), +}; + +test('Runtime client scopes the bearer to the channel and never retries an effect', async () => { + const calls: Array<{ path: string; body: Record; credentials: RequestCredentials | undefined }> = []; + const fetcher: typeof fetch = async (url, init) => { + const path = new URL(String(url)).pathname; + assert.equal((init?.headers as Record).authorization, + `AppRuntime ${credential.session_token}`); + const body = JSON.parse(String(init?.body)) as Record; + assert.equal(body.session_id, credential.session_id); + assert.equal(body.schema_version, 'deft.app_runtime_channel.v1'); + assert.equal(Object.hasOwn(body, 'session_token'), false); + calls.push({ path, body, credentials: init?.credentials }); + if (path.endsWith('/claim')) return Response.json({ claim }); + if (path.endsWith('/start')) return Response.json({ started: { ...claim, input: { note: 'hello' } } }); + if (path.endsWith('/heartbeat')) return Response.json({ renewed: true }); + return Response.json({ run: { id: claim.run_id, state: 'succeeded' } }); + }; + const client = createAppRuntimeClient({ channel_url: 'http://127.0.0.1:4321/api/app-runtime/channel', + credential, fetch: fetcher }); + const claimed = await client.claim(); + assert.deepEqual(claimed, claim); + const started = await client.start(claim); + assert.deepEqual(started.input, { note: 'hello' }); + assert.equal(await client.heartbeat(claim), true); + assert.deepEqual(await client.result(claim, { status: 'returned', provider_succeeded: true, + output: { delivered: true } }), { id: claim.run_id, state: 'succeeded' }); + assert.deepEqual(calls.map((call) => call.path), [ + '/api/app-runtime/channel/claim', '/api/app-runtime/channel/start', + '/api/app-runtime/channel/heartbeat', '/api/app-runtime/channel/result', + ]); + assert(calls.every((call) => call.credentials === 'omit')); +}); + +test('Runtime client rejects unsafe endpoint credentials and server errors', async () => { + assert.throws(() => createAppRuntimeClient({ + channel_url: 'https://user:password@example.com/runtime', credential, + }), /Runtime channel URL/); + assert.throws(() => createAppRuntimeClient({ + channel_url: 'http://example.com/runtime', credential, + }), /Runtime channel URL/); + const client = createAppRuntimeClient({ channel_url: 'https://example.com/runtime', credential, + fetch: async () => Response.json({ error: 'denied', code: 'APP_RUNTIME_ACCESS_DENIED' }, { status: 403 }) }); + await assert.rejects(() => client.claim(), (error: unknown) => + error instanceof AppRuntimeClientError && error.status === 403 + && error.code === 'APP_RUNTIME_ACCESS_DENIED'); +}); diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index 81039d7a..6b5844c9 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -145,6 +145,7 @@ async function main() { for (const platformFile of [ '0.3.0-preview.31-app-runtime-channel.sql', '0.3.0-preview.32-app-public-claims.sql', + '0.3.0-preview.33-app-runtime-authoring.sql', ]) { await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); console.log(`[apply-extras] reconciled ${platformFile}`); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 976082f8..97abb6bd 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1685,7 +1685,7 @@ export const appVersions = pgTable('app_versions', { uniqueIndex('app_versions_one_active_unique') .on(t.org_id, t.installation_id) .where(sql`${t.state} = 'active'`), - check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2')`), + check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2', '3')`), check('app_versions_connected_request_check', sql` ${t.protocol_version} = '0' OR ${t.requested_grant_snapshot_id} IS NOT NULL `), diff --git a/packages/db/upgrades/0.3.0-preview.33-app-runtime-authoring.sql b/packages/db/upgrades/0.3.0-preview.33-app-runtime-authoring.sql new file mode 100644 index 00000000..7c542a10 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.33-app-runtime-authoring.sql @@ -0,0 +1,103 @@ +-- Additive protocol. Rollback keeps v3 rows inert; do not downgrade the constraint with v3 data present. +ALTER TABLE app_versions DROP CONSTRAINT IF EXISTS app_versions_protocol_supported_check; +ALTER TABLE app_versions ADD CONSTRAINT app_versions_protocol_supported_check CHECK (protocol_version IN ('0', '1', '2', '3')); + +CREATE OR REPLACE FUNCTION assert_app_installation_grant_coherence( + checked_org_id text, + checked_installation_id text +) RETURNS void AS $$ +DECLARE + installation app_installations%ROWTYPE; + version_protocol text; + version_state text; +BEGIN + SELECT * INTO installation FROM app_installations + WHERE org_id = checked_org_id AND id = checked_installation_id; + IF NOT FOUND THEN RETURN; END IF; + + IF installation.active_version_id IS NULL THEN + IF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_GRANT_POINTER_WITHOUT_VERSION' USING ERRCODE = '23514'; + END IF; + RETURN; + END IF; + + SELECT protocol_version, state INTO version_protocol, version_state + FROM app_versions + WHERE org_id = checked_org_id + AND installation_id = checked_installation_id + AND id = installation.active_version_id; + IF NOT FOUND OR version_state <> 'active' THEN + RAISE EXCEPTION 'APP_ACTIVE_VERSION_INVALID' USING ERRCODE = '23514'; + END IF; + + IF installation.state = 'active' AND version_protocol IN ('1', '2', '3') THEN + IF installation.active_grant_snapshot_id IS NULL + OR installation.active_grant_snapshot_kind <> 'effective' + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_REQUIRED' USING ERRCODE = '23514'; + END IF; + ELSIF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_NOT_ALLOWED' USING ERRCODE = '23514'; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION enforce_app_grant_snapshot_lineage() RETURNS trigger AS $$ +BEGIN + IF NEW.snapshot_kind = 'requested' THEN + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND requested_grant_snapshot_id = NEW.id + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_POINTER_MISMATCH' USING ERRCODE = '23514'; + END IF; + ELSE + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND protocol_version IN ('1', '2', '3') + ) THEN + RAISE EXCEPTION 'APP_GRANT_EFFECTIVE_PROTOCOL_UNSUPPORTED' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND app_version_id = NEW.app_version_id + AND id = NEW.requested_snapshot_id + AND snapshot_kind = 'requested' + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM org_members + WHERE org_id = NEW.org_id + AND user_id = NEW.reviewed_by_actor_id + AND is_active = true + AND role IN ('owner', 'admin') + ) THEN + RAISE EXCEPTION 'APP_GRANT_REVIEWER_NOT_AUTHORIZED' USING ERRCODE = '23514'; + END IF; + IF NEW.supersedes_snapshot_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND id = NEW.supersedes_snapshot_id + AND snapshot_kind = 'effective' + ) THEN + RAISE EXCEPTION 'APP_GRANT_SUPERSEDES_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index 763dc1c2..fc5c69de 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -182,6 +182,11 @@ export const upgradeManifest = { file: '0.3.0-preview.32-app-public-claims.sql', description: 'Add dormant public endpoints, retained ingress and canonical exclusive claims', }, + { + version: '0.3.0-preview.33', + file: '0.3.0-preview.33-app-runtime-authoring.sql', + description: 'Permit explicitly reviewed Runtime App protocol v3 with effective grant coherence', + }, ] satisfies UpgradeMigration[], } as const; diff --git a/packages/shared/src/app-runs.ts b/packages/shared/src/app-runs.ts index 37162d61..783250eb 100644 --- a/packages/shared/src/app-runs.ts +++ b/packages/shared/src/app-runs.ts @@ -194,7 +194,7 @@ export const AppRunActorSchema = z.discriminatedUnion('actor_type', [ ]); export type AppRunActor = z.infer; -export const AppRunOriginSchema = z.discriminatedUnion('origin_kind', [ +export const AppRunOriginSchema = z.union([ z.object({ origin_kind: z.literal('core') }).strict(), z.object({ origin_kind: z.literal('legacy_connector'), @@ -207,6 +207,13 @@ export const AppRunOriginSchema = z.discriminatedUnion('origin_kind', [ binding_key: ExactIdentitySchema, grant_snapshot_id: ExactIdentitySchema, }).strict(), + z.object({ + origin_kind: z.literal('app'), + installation_id: ExactIdentitySchema, + app_version_id: ExactIdentitySchema, + runtime_binding_id: ExactIdentitySchema, + grant_snapshot_id: ExactIdentitySchema, + }).strict(), ]); export type AppRunOrigin = z.infer; @@ -306,6 +313,8 @@ export const AppRunAuthorityRefSchema = z.object({ 'app_version', 'app_grant', 'app_binding', + 'app_runtime_registration', + 'app_runtime_binding', 'app_dependency', 'app_automation_request', 'app_automation_definition', diff --git a/packages/shared/test/app-runs.test.ts b/packages/shared/test/app-runs.test.ts index 0aeda138..8be62cf1 100644 --- a/packages/shared/test/app-runs.test.ts +++ b/packages/shared/test/app-runs.test.ts @@ -10,6 +10,7 @@ import { AppRunRetainedProviderResultSchema, AppRunAttemptStateSchema, AppRunStateSchema, + AppRunOriginSchema, isAppRunAttemptStateTransitionAllowed, classifyAppRunCrashRecovery, isAppRunStateTransitionAllowed, @@ -22,6 +23,15 @@ import { const digest = `sha256:${'a'.repeat(64)}`; +test('Runtime origin is distinct from the legacy App action binding and rejects mixed authority', () => { + const base = { origin_kind: 'app', installation_id: 'app-1', app_version_id: 'version-1', grant_snapshot_id: 'grant-1' }; + assert.equal(AppRunOriginSchema.safeParse({ ...base, runtime_binding_id: 'runtime-1' }).success, true); + assert.equal(AppRunOriginSchema.safeParse({ ...base, binding_key: 'send' }).success, true); + assert.equal(AppRunOriginSchema.safeParse({ ...base, runtime_binding_id: 'runtime-1', binding_key: 'send' }).success, false); + assert.equal(AppRunOriginSchema.safeParse(base).success, false); + assert.equal(AppRunOriginSchema.safeParse({ ...base, runtime_binding_id: 'runtime-1', org_id: 'foreign' }).success, false); +}); + function submission(overrides: Record = {}) { return { schema_version: APP_RUN_CONTRACT_VERSIONS.run, diff --git a/scripts/gate-g/README.md b/scripts/gate-g/README.md index 58f71ec0..136e40d4 100644 --- a/scripts/gate-g/README.md +++ b/scripts/gate-g/README.md @@ -5,7 +5,7 @@ These fixtures exercise proposed boundaries. They do not implement or certify th - `experiences/`: loopback browser egress and bounded interaction experiment. - `runtime/`: separate-process recovery experiment with independent synthetic host/provider ledgers. - `public/`: transaction/claim experiment on an explicitly assigned disposable PostgreSQL database. -- `required-tests.json`: reviewed inventory of 63 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, and eleven Runtime/public foundation cases. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. +- `required-tests.json`: reviewed inventory of 72 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, eleven Runtime/public foundation cases, and four reviewed Runtime journey/concurrency cases. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. - `verify-upgrade.mjs`: read-only retained-data fingerprints and schema snapshots for the assigned disposable PostgreSQL cluster. Capture a tracked predecessor before candidate upgrades, compare retained columns afterward, and compare candidate fresh/upgrade schemas separately. ## Capture and check test execution diff --git a/scripts/gate-g/experiences/author-rich-worker.js b/scripts/gate-g/experiences/author-rich-worker.js new file mode 100644 index 00000000..6378fe58 --- /dev/null +++ b/scripts/gate-g/experiences/author-rich-worker.js @@ -0,0 +1,68 @@ +let sdk; +let rows = [ + { id: 'alpha', cells: ['Alpha', 'Open', 'Owner A'] }, + { id: 'beta', cells: ['Beta', 'Review', 'Owner B'] }, + { id: 'gamma', cells: ['Gamma', 'Done', 'Owner C'] }, +]; +let selected = 'alpha'; +let strokes = []; +let probe = 'pending'; +let storage = 'pending'; + +function view() { + const row = rows.find((item) => item.id === selected) ?? rows[0]; + sdk.render({ root: { kind: 'stack', id: 'workspace', title: 'Parcel Studio', + children: [ + { kind: 'text', id: 'intro', text: 'Edit a cell with Enter. Arrow keys move between cells. Select a row to inspect it.' }, + { kind: 'grid', id: 'orders', columns: ['Order', 'Status', 'Owner'], rows, selected_row_id: selected }, + { kind: 'stack', id: 'detail', title: 'Order detail', children: [ + { kind: 'text', id: 'selected_name', text: 'Selected: ' + row.cells[0] }, + { kind: 'text', id: 'selected_status', text: 'Status: ' + row.cells[1] }, + { kind: 'text', id: 'probe', text: 'Network probe: ' + probe }, + { kind: 'text', id: 'storage_probe', text: 'Storage probe: ' + storage }, + ] }, + { kind: 'stack', id: 'drawing', title: 'Route sketch', children: [ + { kind: 'canvas', id: 'route_canvas', strokes }, + ] }, + ] } }); +} +async function noEgressProbe() { + try { + await fetch('http://127.0.0.1:4317/sink?kind=author-fetch', { mode: 'no-cors' }); + probe = 'unexpected fetch completion'; + } catch { probe = 'fetch blocked'; } + view(); +} +async function storageProbe(marker) { + try { + const cache = await caches.open('experience_probe'); + const markerUrl = 'http://localhost:4318/marker'; + const prior = await cache.match(markerUrl); + storage = prior ? 'prior marker visible: ' + await prior.text() : 'empty before write'; + await cache.put(markerUrl, new Response(marker)); + } catch (error) { + storage = 'unavailable: ' + (error?.name || 'error'); + } + view(); +} +self.onmessage = (message) => { + if (message.data?.kind !== 'start') return; + sdk = createDeftExperienceSdk(message.data.port, message.data.session_id); + sdk.onEvent((event) => { + if (!event || typeof event !== 'object') return; + if (event.kind === 'grid_select') selected = event.row_id; + if (event.kind === 'grid_edit') { + const row = rows.find((item) => item.id === event.row_id); + if (row && Number.isInteger(event.column) && event.column >= 0 && event.column < row.cells.length) { + row.cells[event.column] = String(event.value).slice(0, 512); + } + } + if (event.kind === 'canvas_stroke' && Array.isArray(event.points)) { + strokes = [...strokes, { points: event.points }].slice(-64); + } + view(); + }); + view(); + void noEgressProbe(); + void storageProbe(message.data.session_id); +}; diff --git a/scripts/gate-g/experiences/rich-browser-check.mjs b/scripts/gate-g/experiences/rich-browser-check.mjs new file mode 100644 index 00000000..e5dbac66 --- /dev/null +++ b/scripts/gate-g/experiences/rich-browser-check.mjs @@ -0,0 +1,102 @@ +import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, firefox, webkit } from 'playwright'; + +const serverPath = new URL('./rich-server.mjs', import.meta.url); +const evidenceDir = process.env.GATE_G_EXPERIENCE_EVIDENCE_DIR + ?? 'C:/Users/Osheen Pradhan/Documents/Codex/2026-09-24/deft-gate-g/experiences/checkpoint-03a-rich'; +await mkdir(evidenceDir, { recursive: true }); +const server = spawn(process.execPath, [fileURLToPath(serverPath)], { cwd: process.cwd(), stdio:['ignore','pipe','pipe'] }); +let serverOutput = ''; +server.stdout.on('data', (chunk) => { serverOutput += chunk; }); +server.stderr.on('data', (chunk) => { serverOutput += chunk; }); +for (let i = 0; i < 100 && !serverOutput.includes('hostOrigin'); i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); +} +if (!serverOutput.includes('hostOrigin')) throw new Error('rich server failed: ' + serverOutput); +const results = []; +try { + for (const [name, engine] of Object.entries({ chromium, firefox, webkit })) { + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + const storageText = async () => { + await page.getByText(/^Storage probe: (?:empty before write|unavailable:|prior marker visible)/).waitFor(); + return page.getByText(/^Storage probe:/).first().textContent(); + }; + await page.goto('http://127.0.0.1:4317/', { waitUntil:'domcontentloaded' }); + await page.getByRole('grid', { name:'orders' }).waitFor({ timeout:15000 }); + await page.getByText('Network probe: fetch blocked').waitFor({ timeout:15000 }); + const gridInput = page.getByRole('textbox', { name:'Order, row 1' }); + await gridInput.fill('Alpha edited'); + await gridInput.press('Enter'); + const focused = await page.evaluate(() => document.activeElement?.getAttribute('aria-label')); + if (focused !== 'Order, row 2') throw new Error(name + ' keyboard focus failed: ' + focused); + await page.getByRole('textbox', { name:'Order, row 2' }).click(); + await page.getByText('Selected: Beta').waitFor(); + const canvas = page.getByRole('img', { name:'route canvas' }); + const box = await canvas.boundingBox(); + if (!box) throw new Error(name + ' canvas missing'); + await page.mouse.move(box.x + 25, box.y + 30); + await page.mouse.down(); + await page.mouse.move(box.x + 100, box.y + 80, { steps:10 }); + await page.mouse.up(); + const interactions = await page.evaluate(() => window.__experienceEvidence.events); + if (!interactions.some((event) => event.kind === 'canvas_stroke')) throw new Error(name + ' canvas event missing'); + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: width === 1280 ? 800 : 740 }); + await page.screenshot({ path: resolve(evidenceDir, `${name}-${width}.png`), fullPage: true }); + } + const horizontalReach = await page.locator('.ex-grid-scroll').evaluate((element) => { + element.scrollLeft = element.scrollWidth; + return { left: element.scrollLeft, hiddenWidth: element.scrollWidth - element.clientWidth }; + }); + if (horizontalReach.hiddenWidth <= 0 || horizontalReach.left <= 0) { + throw new Error(name + ' mobile grid has no horizontal reach'); + } + const ownerCell = page.getByRole('textbox', { name:'Owner, row 2' }); + await ownerCell.fill('Mobile owner'); + await ownerCell.press('Enter'); + await page.locator('.ex-grid-scroll').evaluate((element) => { element.scrollLeft = element.scrollWidth; }); + await page.screenshot({ path: resolve(evidenceDir, `${name}-320-grid-right.png`), fullPage: true }); + const initialStorage = await storageText(); + await page.getByRole('button', { name:'Reload' }).click(); + await page.getByRole('grid', { name:'orders' }).waitFor(); + await page.getByText('Network probe: fetch blocked').waitFor(); + const reloadStorage = await storageText(); + await page.getByRole('button', { name:'New version' }).click(); + await page.getByRole('grid', { name:'orders' }).waitFor(); + await page.getByText('Network probe: fetch blocked').waitFor(); + const versionStorage = await storageText(); + await page.getByRole('button', { name:'Disable' }).click(); + await page.getByText('Disabled').waitFor(); + if (await page.getByRole('grid').count()) throw new Error(name + ' stale grid after disable'); + await page.getByRole('button', { name:'Enable' }).click(); + await page.getByRole('grid', { name:'orders' }).waitFor(); + await page.getByText('Network probe: fetch blocked').waitFor(); + const reenabledStorage = await storageText(); + await page.getByRole('button', { name:'Disable' }).click(); + await page.getByText('Disabled').waitFor(); + const observations = await (await page.request.get('http://127.0.0.1:4317/observations')).json(); + const appBootstrapCookie = observations.requests.filter((entry) => + entry.side === 'app' && entry.path === '/bootstrap').some((entry) => entry.cookie); + if (appBootstrapCookie || observations.sink.length) throw new Error(name + ' egress or credential leak'); + if ([reloadStorage, versionStorage, reenabledStorage].some((value) => value?.includes('prior marker visible'))) { + throw new Error(name + ' opaque storage survived reload/version/disable'); + } + results.push({ browser:name, focused, horizontalReach, + interactions:interactions.map((event)=>event.kind), + initialStorage, reloadStorage, versionStorage, reenabledStorage, + sinkHits:observations.sink.length, + appBootstrapCookie, pageErrors:errors }); + await browser.close(); + } + await writeFile(resolve(evidenceDir, 'rich-results.json'), JSON.stringify(results,null,2)); + console.log(JSON.stringify(results,null,2)); +} finally { + server.kill(); +} diff --git a/scripts/gate-g/experiences/rich-host.html b/scripts/gate-g/experiences/rich-host.html new file mode 100644 index 00000000..cc98a1a9 --- /dev/null +++ b/scripts/gate-g/experiences/rich-host.html @@ -0,0 +1,72 @@ + + + +Deft Experience candidate + + +
Parcel Studio · Experience HostStarting +
+
+ diff --git a/scripts/gate-g/experiences/rich-server.mjs b/scripts/gate-g/experiences/rich-server.mjs new file mode 100644 index 00000000..d44609c1 --- /dev/null +++ b/scripts/gate-g/experiences/rich-server.mjs @@ -0,0 +1,79 @@ +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const dir = new URL('.', import.meta.url); +const root = fileURLToPath(new URL('../../..', dir)); +const hostPort = 4317; +const appPort = 4318; +const hostOrigin = `http://127.0.0.1:${hostPort}`; +const appOrigin = `http://localhost:${appPort}`; +const observations = { requests: [], sink: [] }; +const read = (name) => readFileSync(new URL(name, dir), 'utf8'); +function compiled(path) { + return ts.transpileModule(readFileSync(resolve(root, path), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; +} +const sdk = compiled('packages/app-kit/src/experience-sdk.ts').replace(/^export /gm, ''); +const workerSource = sdk + '\n' + read('author-rich-worker.js'); +const bridgeJs = compiled('apps/web/src/lib/app-experience-bridge.ts'); +const rendererJs = compiled('apps/web/src/lib/app-experience-renderer.ts'); + +function response(res, status, type, body, headers = {}) { + res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', ...headers }); + res.end(body); +} +const host = createServer((req, res) => { + const url = new URL(req.url ?? '/', hostOrigin); + observations.requests.push({ side: 'host', path: url.pathname, cookie: Boolean(req.headers.cookie) }); + if (url.pathname === '/sink') observations.sink.push({ side:'host', search:url.search }); + if (url.pathname === '/observations') return response(res, 200, 'application/json', JSON.stringify(observations)); + if (url.pathname === '/bridge.js') return response(res, 200, 'text/javascript', bridgeJs); + if (url.pathname === '/renderer.js') return response(res, 200, 'text/javascript', rendererJs); + if (url.pathname === '/') return response(res, 200, 'text/html', + read('rich-host.html').replace('__APP_ORIGIN__', appOrigin), + { 'set-cookie':'host_private=fixture; HttpOnly; SameSite=Strict; Path=/' }); + return response(res, 404, 'text/plain', 'missing'); +}); +const app = createServer((req, res) => { + const url = new URL(req.url ?? '/', appOrigin); + observations.requests.push({ side: 'app', path: url.pathname, cookie: Boolean(req.headers.cookie) }); + if (url.pathname === '/sink') { + observations.sink.push({ side:'app', search:url.search }); + return response(res, 200, 'text/plain', 'sink'); + } + if (url.pathname !== '/bootstrap') return response(res, 404, 'text/plain', 'missing'); + const nonce = randomBytes(18).toString('base64'); + const sourceLiteral = JSON.stringify(workerSource).replaceAll('<', '\\u003c'); + const script = ` + const hostOrigin = ${JSON.stringify(hostOrigin)}; + let started = false; + window.addEventListener('message', event => { + if (started || event.source !== parent || event.origin !== hostOrigin + || event.data?.kind !== 'start' || !event.ports?.[0]) return; + started = true; + const url = URL.createObjectURL(new Blob([${sourceLiteral}], { type:'text/javascript' })); + const worker = new Worker(url); + URL.revokeObjectURL(url); + worker.postMessage({ kind:'start', session_id:event.data.session_id, + port:event.ports[0] }, [event.ports[0]]); + }); + `; + const csp = `default-src 'none'; script-src 'nonce-${nonce}'; worker-src blob:; connect-src 'none'; img-src 'none'; style-src 'none'; font-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'`; + const html = ``; + return response(res, 200, 'text/html', html, { + 'content-security-policy': csp, + 'cross-origin-resource-policy': 'cross-origin', + }); +}); +await Promise.all([ + new Promise((resolve) => host.listen(hostPort, '127.0.0.1', resolve)), + new Promise((resolve) => app.listen(appPort, 'localhost', resolve)), +]); +console.log(JSON.stringify({ hostOrigin, appOrigin })); +process.on('SIGTERM', () => { host.close(); app.close(); }); diff --git a/scripts/gate-g/experiences/runtime-approval-card-browser-entry.tsx b/scripts/gate-g/experiences/runtime-approval-card-browser-entry.tsx new file mode 100644 index 00000000..184b0993 --- /dev/null +++ b/scripts/gate-g/experiences/runtime-approval-card-browser-entry.tsx @@ -0,0 +1,23 @@ +import { AgentActionCard, type AgentAction } from '../../../apps/web/src/components/agent-action-card'; +import { createRoot } from 'react-dom/client'; + +const action: AgentAction = { + id: 'approval-one', action: 'app_run_invoke', source: 'app_run', + approval_status: 'pending', + params: { + run_id: 'run-one', capability_label: 'create_label', + provider_label: 'opaque-registration', + safe_preview: { + title: 'Create shipping label', + summary: 'One reviewed external write', + fields: { app_installation_id: 'install-one', action_key: 'create_label', + runtime_binding_id: 'binding-one', provider_kind: 'app_runtime' }, + resource_refs: [], + }, + }, +}; + +createRoot(document.getElementById('root')!).render( + ({ status: 'approved' })} + onReject={async () => ({ status: 'rejected' })} />, +); diff --git a/scripts/gate-g/experiences/runtime-review-browser-entry.tsx b/scripts/gate-g/experiences/runtime-review-browser-entry.tsx new file mode 100644 index 00000000..9822ee44 --- /dev/null +++ b/scripts/gate-g/experiences/runtime-review-browser-entry.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { RuntimeAppInputReview, type RuntimeReviewIdentity } from '../../../apps/web/src/components/runtime-app-input-review'; + +function Fixture() { + const [bindingId, setBindingId] = useState('binding-one'); + const [reviewed, setReviewed] = useState(null); + const ready = reviewed?.runId === 'run-one' && reviewed.bindingId === bindingId; + return
+
Runtime App action
+

Create shipping label

+

Review the exact input before approving this external write.

+ +
+ + +
+
; +} + +createRoot(document.getElementById('root')!).render(); diff --git a/scripts/gate-g/experiences/runtime-review-browser.mjs b/scripts/gate-g/experiences/runtime-review-browser.mjs new file mode 100644 index 00000000..ea19d9a6 --- /dev/null +++ b/scripts/gate-g/experiences/runtime-review-browser.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, firefox, webkit } from 'playwright'; + +const require = createRequire(import.meta.url); +const root = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const { build } = require(resolve(root, 'node_modules/.pnpm/esbuild@0.28.2/node_modules/esbuild')); +const bundled = await build({ + entryPoints: [resolve(root, 'scripts/gate-g/experiences/runtime-review-browser-entry.tsx')], + bundle: true, write: false, platform: 'browser', format: 'iife', jsx: 'automatic', + nodePaths: [resolve(root, 'apps/web/node_modules')], + define: { 'process.env.NODE_ENV': '"production"', + 'process.env.NEXT_PUBLIC_API_URL': '"http://localhost:3001"' }, + alias: { '@': resolve(root, 'apps/web/src') }, + loader: { '.tsx': 'tsx' }, +}); +const js = bundled.outputFiles[0].contents; +const cardBundled = await build({ + entryPoints: [resolve(root, 'scripts/gate-g/experiences/runtime-approval-card-browser-entry.tsx')], + bundle: true, write: false, platform: 'browser', format: 'iife', jsx: 'automatic', + nodePaths: [resolve(root, 'apps/web/node_modules')], + define: { 'process.env.NODE_ENV': '"production"', + 'process.env.NEXT_PUBLIC_API_URL': '"http://localhost:3001"' }, + alias: { '@': resolve(root, 'apps/web/src') }, + loader: { '.tsx': 'tsx' }, +}); +const cardJs = cardBundled.outputFiles[0].contents; +const html = ` + +
`; +const cardHtml = html.replace('/bundle.js', '/card.js'); +const server = createServer((req, res) => { + if (req.url === '/bundle.js') { + res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store' }); + res.end(js); + } else if (req.url === '/card.js') { + res.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store' }); + res.end(cardJs); + } else { + res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + res.end(req.url === '/card' ? cardHtml : html); + } +}); +await new Promise((done) => server.listen(4319, '127.0.0.1', done)); +const evidenceDir = process.env.GATE_G_EXPERIENCE_EVIDENCE_DIR + ?? 'C:/Users/Osheen Pradhan/Documents/Codex/2026-09-24/deft-gate-g/experiences/checkpoint-03a-runtime-review'; +await mkdir(evidenceDir, { recursive: true }); +const results = []; +try { + for (const [name, engine] of Object.entries({ chromium, firefox, webkit })) { + const browser = await engine.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + let failReview = false; + let requestCount = 0; + page.on('pageerror', (error) => { throw error; }); + await page.route('http://localhost:3001/api/app-runtime-actions/run-one/review', async (route) => { + requestCount += 1; + if (failReview) return route.fulfill({ status: 409, + headers: { 'Cache-Control': 'no-store' }, + body: JSON.stringify({ code: 'APP_RUN_AUTHORIZATION_STALE' }) }); + return route.fulfill({ status: 200, headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' }, + body: JSON.stringify({ review: { run_id: 'run-one', action_key: 'create_label', + app_installation_id: 'install-one', app_version_id: 'version-one', + grant_snapshot_id: 'grant-one', runtime_binding_id: 'binding-one', + contract_digest: `sha256:${'a'.repeat(64)}`, + policy: { risk_class: 'external_write', review_requirement: 'always', + review_scope: 'per_invocation', retry_class: 'unsafe_or_unknown' }, + input: { shipment_id: 'synthetic-shipment-123', quantity: 2, urgent: false }, + } }), + }); + }); + await page.goto('http://127.0.0.1:4319/'); + const approve = page.locator('#approve'); + assert.equal(await approve.isDisabled(), true); + await page.getByRole('button', { name: 'Review exact input' }).click(); + await page.getByText('synthetic-shipment-123').waitFor(); + assert.equal(await approve.isEnabled(), true); + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: width === 1280 ? 800 : 720 }); + await page.screenshot({ path: resolve(evidenceDir, `${name}-${width}.png`), fullPage: true }); + } + failReview = true; + await page.getByRole('button', { name: 'Refresh exact input' }).click(); + await page.getByRole('alert').getByText(/Input review is unavailable/).waitFor(); + assert.equal(await approve.isDisabled(), true); + await page.screenshot({ path: resolve(evidenceDir, `${name}-error-320.png`), fullPage: true }); + await page.getByRole('button', { name: 'Change binding' }).click(); + assert.equal(await approve.isDisabled(), true); + failReview = false; + await page.goto('http://127.0.0.1:4319/card'); + await page.getByText('Runtime App action').waitFor(); + const cardApprove = page.getByRole('button', { name: 'Approve Runtime action' }); + assert.equal(await cardApprove.isDisabled(), true); + await page.getByRole('button', { name: 'Review exact input' }).click(); + await page.getByText('synthetic-shipment-123').waitFor(); + assert.equal(await cardApprove.isEnabled(), true); + for (const width of [1280, 390, 320]) { + await page.setViewportSize({ width, height: width === 1280 ? 800 : 720 }); + await page.screenshot({ path: resolve(evidenceDir, `${name}-card-${width}.png`), fullPage: true }); + } + results.push({ browser: name, requestCount, inputVisible: true, + gateBefore: 'disabled', gateAfterReview: 'enabled', gateAfterRevocation: 'disabled', + gateAfterBindingChange: 'disabled', actualCardGate: 'disabled-to-enabled' }); + await browser.close(); + } + await writeFile(resolve(evidenceDir, 'results.json'), JSON.stringify(results, null, 2)); + process.stdout.write(JSON.stringify(results, null, 2)); +} finally { + server.close(); +} diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 956d46b8..905bbc67 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -3,7 +3,7 @@ "scope": "Package/unit baselines and explicitly selected database ancestry and legacy cutover-on profiles. This inventory is not the complete Gate G acceptance matrix.", "profiles": [ { - "description": "All 63 current App Kit cases. Packed offline consumer tests require registry cache primed for the pinned Zod version.", + "description": "All current App Kit baseline and candidate Runtime/Experience leaf cases; offline packed consumers use pinned cached dependencies.", "cases": [ { "name": "builds and verifies byte-identical deterministic packages", @@ -256,12 +256,48 @@ { "name": "developer compatibility keeps unknown-key diagnostics without accepting duplicate versions", "file": "packages/app-kit/test/validation-compatibility.test.ts" + }, + { + "file": "packages/app-kit/test/runtime-authoring.test.ts", + "name": "Runtime candidate builds deterministically and requests no effective authority" + }, + { + "file": "packages/app-kit/test/runtime-authoring.test.ts", + "name": "Runtime manifest rejects executable config, missing references, widened policy and tampering" + }, + { + "file": "packages/app-kit/test/runtime-authoring.test.ts", + "name": "Runtime schema bounds inputs and rejects undeclared or executable schema features" + }, + { + "file": "packages/app-kit/test/runtime-client.test.ts", + "name": "Runtime client scopes the bearer to the channel and never retries an effect" + }, + { + "file": "packages/app-kit/test/runtime-client.test.ts", + "name": "Runtime client rejects unsafe endpoint credentials and server errors" + }, + { + "file": "packages/app-kit/test/experience.test.ts", + "name": "canonical Experience artifact verifies its exact path, MIME, length and digest" + }, + { + "file": "packages/app-kit/test/experience.test.ts", + "name": "Experience bundle rejects unknown fields, traversal, unsorted keys and oversized source" + }, + { + "file": "packages/app-kit/test/experience-sdk.test.ts", + "name": "SDK sequences views and requests and ignores foreign-session responses" + }, + { + "file": "packages/app-kit/test/experience-sdk.test.ts", + "name": "SDK closes pending calls without leaking reusable credentials" } ], "id": "app-kit" }, { - "description": "51 focused unit/service contract cases; DATABASE_URL points to unreachable 127.0.0.1:1 so this is not DB acceptance.", + "description": "51 focused unit/service contract cases. Their execution alone is not database acceptance; DB journeys are inventoried separately.", "cases": [ { "name": "automation review input is closed, bounded, and has no caller authority vector", @@ -512,11 +548,11 @@ }, { "id": "platform-channel-foundation", - "description": "Eleven mandatory Runtime/public DB, HTTP, default-off, result-bound, and ingress admission cases. Use an explicit disposable DB; the Runtime test launches its own loopback HTTP host process. Synthetic host-reviewed intake remains a limitation.", + "description": "Runtime/public DB, separate-process HTTP, default-off, result-bound and ingress admission cases. Runtime intake uses reviewed v3 grants.", "cases": [ { "file": "apps/api/test/app-runtime-channel-db.test.ts", - "name": "reviewed Runtime claim, start, known result and replay use the App Run ledger" + "name": "reviewed v3 Runtime channel preserves fencing, replay, revocation and unknown outcomes" }, { "file": "apps/api/test/app-runtime-channel.test.ts", @@ -559,6 +595,28 @@ "name": "global concurrency cap spans different verified peers" } ] + }, + { + "cases": [ + { + "name": "v3 review is tenant- and epoch-bound and reactivation requires a fresh grant", + "file": "apps/api/test/app-runtime-review-db.test.ts" + }, + { + "name": "packed Runtime Kit follows reviewed human Run, approval, claim, result, and signed receipt", + "file": "apps/api/test/app-runtime-actions-db.test.ts" + }, + { + "name": "authenticated HTTP pairing, packed install, reviews, action and Runtime receipt", + "file": "apps/api/test/app-runtime-actions-http-db.test.ts" + }, + { + "file": "apps/api/test/app-runtime-actions-db.test.ts", + "name": "pending input review does not hold App lock while waiting on approval-held Run" + } + ], + "id": "runtime-author-journey", + "description": "Reviewed v3 lifecycle, actual packed Runtime Kit, authenticated HTTP review and separate SDK worker with signed receipt on the assigned disposable PostgreSQL database." } ], "schema_version": "deft.gate_g.test_inventory.v1" diff --git a/scripts/gate-g/verify-upgrade.mjs b/scripts/gate-g/verify-upgrade.mjs index 66e92b27..c94d39c2 100644 --- a/scripts/gate-g/verify-upgrade.mjs +++ b/scripts/gate-g/verify-upgrade.mjs @@ -28,14 +28,15 @@ function stable(value) { await client.connect(); try { if (mode === 'schema') { - const selected = ['app_runs', 'app_run_attempts', 'capability_provider_snapshots']; + const selected = ['app_installations', 'app_versions', 'app_grant_snapshots', 'app_runs', 'app_run_attempts', 'capability_provider_snapshots']; const discovered = await client.query("SELECT tablename FROM pg_tables WHERE schemaname='public' AND (tablename LIKE 'app_runtime_%' OR tablename LIKE 'app_public_%' OR tablename='app_canonical_claims') ORDER BY tablename"); selected.push(...discovered.rows.map((row) => row.tablename)); const columns = await client.query("SELECT table_name,column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' AND table_name=ANY($1) ORDER BY table_name,column_name", [selected]); const constraints = await client.query("SELECT c.relname AS table_name,k.conname AS name,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=ANY($1) ORDER BY c.relname,k.conname", [selected]); const indexes = await client.query("SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' AND tablename=ANY($1) ORDER BY tablename,indexname", [selected]); const triggers = await client.query("SELECT c.relname AS table_name,t.tgname AS name,pg_get_triggerdef(t.oid) AS definition,pg_get_functiondef(t.tgfoid) AS function FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relname=ANY($1) AND NOT t.tgisinternal ORDER BY c.relname,t.tgname", [selected]); - writeFileSync(path, JSON.stringify({ columns: columns.rows, constraints: constraints.rows, indexes: indexes.rows, triggers: triggers.rows }, null, 2)); + const functions = await client.query("SELECT proname AS name,pg_get_functiondef(p.oid) AS definition FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND proname=ANY($1) ORDER BY proname", [['assert_app_installation_grant_coherence', 'enforce_app_grant_snapshot_lineage']]); + writeFileSync(path, JSON.stringify({ columns: columns.rows, constraints: constraints.rows, indexes: indexes.rows, triggers: triggers.rows, functions: functions.rows }, null, 2)); console.log(`Captured ${selected.length} table definitions`); } else { const previous = mode === 'compare' ? JSON.parse(readFileSync(path, 'utf8')) : null; diff --git a/scripts/runtime-packed-author.test.mts b/scripts/runtime-packed-author.test.mts new file mode 100644 index 00000000..fec6788d --- /dev/null +++ b/scripts/runtime-packed-author.test.mts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import test from 'node:test'; + +const repo = resolve(import.meta.dirname, '..'); +function run(args: string[], cwd: string) { + const result = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', timeout: 180_000 }); + assert.equal(result.status, 0, `${result.error?.message ?? ''}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} + +test('outside author consumes packed Runtime Kit without workspace imports and builds a verified non-email App', async () => { + assert.ok(process.env.npm_execpath, 'Run with pnpm'); + const root = await mkdtemp(join(tmpdir(), 'deft-runtime-author-')); + const pack = join(root, 'pack'); const project = join(root, 'author'); + await mkdir(pack); await mkdir(project); + run([process.env.npm_execpath, '--dir', join(repo, 'packages/app-kit'), 'pack', '--pack-destination', pack, '--json'], repo); + const archive = (await readdir(pack)).find((name) => name.endsWith('.tgz')); + assert.ok(archive); + await writeFile(join(project, 'package.json'), JSON.stringify({ name: 'independent-runtime-author', private: true, type: 'module', + dependencies: { '@deft/app-kit': `file:${join(pack, archive).replaceAll('\\', '/')}` } })); + run([process.env.npm_execpath, 'install', '--ignore-workspace', '--offline', '--ignore-scripts'], project); + const installed = await realpath(join(project, 'node_modules/@deft/app-kit')); + assert.ok(installed.startsWith(await realpath(project))); + assert.ok(!(await readdir(installed)).includes('src')); + const cli = join(installed, 'dist/cli.js'); + run([cli, 'app', 'init', '--template', 'runtime'], project); + run([cli, 'app', 'check'], project); run([cli, 'app', 'build'], project); + const artifact = await readFile(join(project, '.deft/app.deftapp.json'), 'utf8'); + run([cli, 'app', 'build'], project); + assert.equal(await readFile(join(project, '.deft/app.deftapp.json'), 'utf8'), artifact); + await writeFile(join(project, 'verify.mjs'), "import {verifyDeftAppPackageJson,parseRuntimeObjectInput,createAppRuntimeClient} from '@deft/app-kit'; import {readFile} from 'node:fs/promises'; if(typeof createAppRuntimeClient!=='function') throw Error('runtime transport export'); const p=await verifyDeftAppPackageJson(await readFile('.deft/app.deftapp.json','utf8')); if(p.package.manifest.schema_version!=='3') throw Error('protocol'); parseRuntimeObjectInput(p.package.manifest.private_capabilities[0].input_schema,{shipment_id:'synthetic-1'}); console.log(p.digest);\n"); + assert.match(run(['verify.mjs'], project), /sha256:[a-f0-9]{64}/); + if (process.env.DEFT_RUNTIME_AUTHOR_PACKAGE) await writeFile(process.env.DEFT_RUNTIME_AUTHOR_PACKAGE, artifact); + console.log(`Independent author artifact: ${join(project, '.deft/app.deftapp.json')}`); +}); From d946506084449c033d323218dc55743689c57a2f Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:41:15 +0530 Subject: [PATCH 003/161] feat(apps): install reviewed experiences and public Runtime claims --- apps/api/src/index.ts | 9 + apps/api/src/lib/app-experience-service.ts | 234 +++++++++++++ apps/api/src/lib/app-public-management.ts | 201 +++++++++++ apps/api/src/lib/app-public-service.ts | 24 +- apps/api/src/lib/app-public-worker-handler.ts | 65 +++- apps/api/src/lib/app-run-approval-adapter.ts | 14 + apps/api/src/lib/app-run-authorization.ts | 3 +- .../api/src/lib/app-run-live-authorization.ts | 161 ++++++++- apps/api/src/lib/app-run-prepared-input.ts | 1 + apps/api/src/lib/app-run-repository.ts | 14 + apps/api/src/lib/app-run-service.ts | 246 +++++++++++++- .../api/src/lib/app-runtime-action-service.ts | 18 +- apps/api/src/lib/app-runtime-authority.ts | 56 +++- apps/api/src/lib/app-runtime-review.ts | 54 ++- apps/api/src/middleware/auth.ts | 1 + apps/api/src/routes/app-experiences.ts | 102 ++++++ apps/api/src/routes/app-public-management.ts | 88 +++++ apps/api/src/routes/app-public.ts | 4 +- apps/api/test/app-experience-browser.mts | 163 +++++++++ apps/api/test/app-experience-db.test.ts | 179 ++++++++++ apps/api/test/app-installed-review-db.test.ts | 83 +++++ apps/api/test/app-public-route-flags.test.ts | 41 +++ .../test/app-public-runtime-http-db.test.ts | 316 ++++++++++++++++++ apps/api/test/app-runtime-actions-db.test.ts | 2 +- .../test/app-runtime-actions-http-db.test.ts | 2 +- apps/api/test/app-runtime-review-db.test.ts | 2 +- .../api/test/apps-connected-grants-db.test.ts | 28 +- .../test/fixtures/track-a-restore-child.ts | 125 +++++++ .../test/track-a-restore-process-db.test.ts | 265 +++++++++++++++ apps/web/next.config.ts | 10 + apps/web/public/app-experience-bootstrap.js | 28 ++ .../[installationId]/[experienceKey]/page.tsx | 12 + .../app/(app)/settings/apps/apps-client.tsx | 20 +- .../src/app/app-experience-bootstrap/route.ts | 11 + .../apps/installed-app-experience.tsx | 165 +++++++++ .../web/src/lib/app-experience-bridge.test.ts | 21 ++ apps/web/src/lib/app-experience-bridge.ts | 32 +- apps/web/src/lib/app-experience-renderer.ts | 5 +- apps/web/src/lib/app-experience-session.ts | 63 ++++ apps/web/src/lib/apps.ts | 54 ++- .../server.mjs | 49 ++- packages/app-kit/package.json | 2 +- packages/app-kit/src/cli.ts | 66 +++- packages/app-kit/src/index.ts | 120 ++++++- packages/app-kit/src/installed-authoring.ts | 43 +++ packages/app-kit/test/cli.test.ts | 10 +- .../app-kit/test/developer-contract.test.ts | 7 +- .../app-kit/test/installed-authoring.test.ts | 63 ++++ packages/app-kit/test/packed-external.test.ts | 2 +- packages/app-kit/test/protocol-v2.test.ts | 2 +- packages/db/scripts/apply-extras.ts | 3 + packages/db/src/schema.ts | 106 +++++- ...3.0-preview.34-app-installed-authoring.sql | 103 ++++++ .../0.3.0-preview.35-app-public-runtime.sql | 153 +++++++++ ...3.0-preview.36-app-experience-sessions.sql | 46 +++ packages/db/upgrades/manifest.ts | 15 + packages/shared/src/app-runs.ts | 14 + scripts/gate-g/README.md | 2 +- scripts/gate-g/required-tests.json | 40 +++ scripts/gate-g/verify-upgrade.mjs | 2 +- scripts/runtime-packed-author.test.mts | 11 + 61 files changed, 3656 insertions(+), 125 deletions(-) create mode 100644 apps/api/src/lib/app-experience-service.ts create mode 100644 apps/api/src/lib/app-public-management.ts create mode 100644 apps/api/src/routes/app-experiences.ts create mode 100644 apps/api/src/routes/app-public-management.ts create mode 100644 apps/api/test/app-experience-browser.mts create mode 100644 apps/api/test/app-experience-db.test.ts create mode 100644 apps/api/test/app-installed-review-db.test.ts create mode 100644 apps/api/test/app-public-route-flags.test.ts create mode 100644 apps/api/test/app-public-runtime-http-db.test.ts create mode 100644 apps/api/test/fixtures/track-a-restore-child.ts create mode 100644 apps/api/test/track-a-restore-process-db.test.ts create mode 100644 apps/web/public/app-experience-bootstrap.js create mode 100644 apps/web/src/app/(app)/apps/[installationId]/[experienceKey]/page.tsx create mode 100644 apps/web/src/app/app-experience-bootstrap/route.ts create mode 100644 apps/web/src/components/apps/installed-app-experience.tsx create mode 100644 apps/web/src/lib/app-experience-session.ts create mode 100644 packages/app-kit/src/installed-authoring.ts create mode 100644 packages/app-kit/test/installed-authoring.test.ts create mode 100644 packages/db/upgrades/0.3.0-preview.34-app-installed-authoring.sql create mode 100644 packages/db/upgrades/0.3.0-preview.35-app-public-runtime.sql create mode 100644 packages/db/upgrades/0.3.0-preview.36-app-experience-sessions.sql diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index eb08fb67..1b5c8ff0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -77,6 +77,10 @@ import { appRuntimeChannelRoutes } from './routes/app-runtime-channel.js'; import { appRuntimeManagementRoutes } from './routes/app-runtime-management.js'; import { appRuntimeReviewRoutes } from './routes/app-runtime-review.js'; import { appRuntimeActionRoutes } from './routes/app-runtime-actions.js'; +import { appExperienceRoutes } from './routes/app-experiences.js'; +import { createAppPublicRoutes } from './routes/app-public.js'; +import { AppPublicClaimService } from './lib/app-public-service.js'; +import { appPublicManagementRoutes } from './routes/app-public-management.js'; import { APPS_ENABLED, APP_DEVELOPER_PAIRING_ENABLED } from './lib/env.js'; import { moduleTaskLinkRoutes } from './routes/module-task-links.js'; import { authMiddleware } from './middleware/auth.js'; @@ -193,6 +197,9 @@ if (APPS_ENABLED) { app.use('/api/app-runtime/channel/*', authLimiter); app.route('/api/app-runtime/channel', appRuntimeChannelRoutes); } +if (APPS_ENABLED && process.env.DEFT_APP_PUBLIC_INGRESS_ENABLED === 'true') { + app.route('/api/public/apps', createAppPublicRoutes(new AppPublicClaimService({ enabled: true }))); +} app.use('/api/*', authMiddleware); app.use('/api/*', defaultLimiter); app.use('/api/agent/*', agentLimiter); @@ -254,9 +261,11 @@ app.route('/api/task-templates', taskTemplateRoutes); app.route('/api/work-intents', workIntentRoutes); app.route('/api/modules', moduleRoutes); if (APPS_ENABLED) { + app.route('/api/apps/public', appPublicManagementRoutes); app.route('/api/apps/runtime', appRuntimeManagementRoutes); app.route('/api/app-runtime-review', appRuntimeReviewRoutes); app.route('/api/app-runtime-actions', appRuntimeActionRoutes); + app.route('/api/app-experiences', appExperienceRoutes); app.route('/api/apps', appRoutes); app.route('/api/app-actions', appActionRoutes); app.route('/api/app-runs', appRunRoutes); diff --git a/apps/api/src/lib/app-experience-service.ts b/apps/api/src/lib/app-experience-service.ts new file mode 100644 index 00000000..9472d651 --- /dev/null +++ b/apps/api/src/lib/app-experience-service.ts @@ -0,0 +1,234 @@ +import { randomUUID } from 'node:crypto'; +import { and, eq, gt, isNull, lt, or, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { appExperienceSessions, appGrantSnapshots, appInstallations, appRuntimeBindings, + appVersions, orgMembers, users, webSessions } from '@deft/db/schema'; +import { parseRuntimeAppManifest, verifyDeftAppPackageJson, + verifyDeftExperienceArtifact } from '@deft/app-kit'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { AppRuntimeActionService, appRuntimeActionService } from './app-runtime-action-service.js'; +import type { AppRunTransaction } from './app-run-repository.js'; + +const SESSION_MS = 15 * 60_000; +const MAX_ACTIVE_PER_WEB_APP = 8; +const uuid = z.string().uuid(); +const key = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/); +const actionRequest = z.strictObject({ + request_id: z.string().regex(/^request_[1-9][0-9]{0,8}$/), + input: z.unknown(), +}); +export type ExperienceCaller = Readonly<{ org_id: string; user_id: string; sid: string }>; +type Executor = Pick; +const denied = () => new AppError('Experience access denied', 'APP_ACCESS_DENIED', 403); +const stale = () => new AppError('Experience session is no longer current', 'APP_STALE', 409); + +async function assertLiveHuman(tx: Executor, caller: ExperienceCaller) { + const [web] = await tx.select().from(webSessions).where(and( + eq(webSessions.id, caller.sid), eq(webSessions.org_id, caller.org_id), + eq(webSessions.user_id, caller.user_id), + )).limit(1).for('share'); + if (!web || web.revoked_at) throw denied(); + const [user] = await tx.select({ kind: users.kind }).from(users) + .where(eq(users.id, caller.user_id)).limit(1).for('share'); + const [member] = await tx.select({ is_active: orgMembers.is_active }).from(orgMembers) + .where(and(eq(orgMembers.org_id, caller.org_id), + eq(orgMembers.user_id, caller.user_id))).limit(1).for('share'); + if (user?.kind !== 'human' || !member?.is_active || web.expires_at <= new Date()) throw denied(); + return web; +} + +async function verifiedBundle(version: typeof appVersions.$inferSelect, experienceKey: string) { + if (version.protocol_version !== '4') throw stale(); + const manifest = parseRuntimeAppManifest(version.manifest); + if (manifest.schema_version !== '4') throw stale(); + const reference = manifest.experiences.find((item) => item.key === experienceKey); + if (!reference) throw denied(); + const verified = await verifyDeftAppPackageJson(JSON.stringify(version.package)); + if (verified.digest !== version.package_digest + || verified.package.manifest_digest !== version.manifest_digest + || verified.package.manifest.schema_version !== '4') throw stale(); + const artifact = verified.package.artifacts.find((item) => item.path === reference.artifact_path); + if (!artifact) throw stale(); + const bundle = await verifyDeftExperienceArtifact({ + artifact_path: reference.artifact_path, + artifact_digest: reference.artifact_digest, + bridge_version: reference.bridge_version, + renderer_version: reference.renderer_version, + }, artifact); + if (bundle.resource_keys.length !== 0 + || bundle.action_keys.some((action) => !manifest.runtime_actions.some((item) => item.key === action))) { + throw stale(); + } + return { manifest, reference, bundle }; +} + +export class AppExperienceService { + constructor(private readonly runtime: AppRuntimeActionService = appRuntimeActionService) {} + + async create(caller: ExperienceCaller, installationId: string, experienceKey: string) { + uuid.parse(installationId); key.parse(experienceKey); + return db.transaction(async (tx) => { + const web = await assertLiveHuman(tx, caller); + // The cap is serialized across API processes, including parallel tabs. + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended( + ${`experience:${caller.org_id}:${caller.sid}:${installationId}`}, 0))`); + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, caller.org_id), eq(appInstallations.id, installationId), + )).limit(1).for('share'); + if (!installation || installation.state !== 'active' || !installation.active_version_id + || !installation.active_grant_snapshot_id || installation.active_grant_snapshot_kind !== 'effective') throw stale(); + const [version] = await tx.select().from(appVersions).where(and( + eq(appVersions.org_id, caller.org_id), eq(appVersions.installation_id, installationId), + eq(appVersions.id, installation.active_version_id), eq(appVersions.state, 'active'), + )).limit(1).for('share'); + const [grant] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, caller.org_id), + eq(appGrantSnapshots.app_installation_id, installationId), + eq(appGrantSnapshots.app_version_id, installation.active_version_id), + eq(appGrantSnapshots.id, installation.active_grant_snapshot_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).limit(1).for('share'); + if (!version || !grant || grant.package_digest !== version.package_digest + || grant.manifest_digest !== version.manifest_digest) throw stale(); + const { reference, bundle } = await verifiedBundle(version, experienceKey); + const now = new Date(); + if (web.expires_at <= now) throw denied(); + await tx.delete(appExperienceSessions).where(and( + eq(appExperienceSessions.org_id, caller.org_id), + eq(appExperienceSessions.web_session_id, caller.sid), + eq(appExperienceSessions.app_installation_id, installationId), + or(lt(appExperienceSessions.expires_at, now), + lt(appExperienceSessions.revoked_at, now)), + )); + const active = await tx.select({ id: appExperienceSessions.id }).from(appExperienceSessions) + .where(and(eq(appExperienceSessions.org_id, caller.org_id), + eq(appExperienceSessions.web_session_id, caller.sid), + eq(appExperienceSessions.app_installation_id, installationId), + gt(appExperienceSessions.expires_at, now), + isNull(appExperienceSessions.revoked_at))).limit(MAX_ACTIVE_PER_WEB_APP); + if (active.length >= MAX_ACTIVE_PER_WEB_APP) { + throw new AppError('Too many open Experience sessions', 'APP_STATE_CONFLICT', 409); + } + const sessionId = randomUUID(); + const expiresAt = new Date(now.getTime() + SESSION_MS); + await tx.insert(appExperienceSessions).values({ + id: sessionId, org_id: caller.org_id, user_id: caller.user_id, + web_session_id: caller.sid, app_installation_id: installation.id, + app_version_id: version.id, grant_snapshot_id: grant.id, + experience_key: experienceKey, artifact_digest: reference.artifact_digest, + lifecycle_epoch: installation.lifecycle_epoch, grant_epoch: installation.grant_epoch, + created_at: now, expires_at: expiresAt, + }); + return { pin: { org_id: caller.org_id, user_id: caller.user_id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, lifecycle_epoch: installation.lifecycle_epoch, + grant_epoch: installation.grant_epoch, session_id: sessionId, session_epoch: 0 }, + experience: { key: experienceKey, label: reference.label, + artifact_digest: reference.artifact_digest, + bridge_version: reference.bridge_version, renderer_version: reference.renderer_version }, + bundle, expires_at: expiresAt.toISOString() }; + }); + } + + private async lockedLiveContext(tx: Executor, caller: ExperienceCaller, sessionId: string) { + uuid.parse(sessionId); + const web = await assertLiveHuman(tx, caller); + const [session] = await tx.select().from(appExperienceSessions).where(and( + eq(appExperienceSessions.id, sessionId), eq(appExperienceSessions.org_id, caller.org_id), + )).limit(1).for('share'); + if (!session || session.user_id !== caller.user_id || session.web_session_id !== caller.sid) throw denied(); + if (session.revoked_at) throw stale(); + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, caller.org_id), + eq(appInstallations.id, session.app_installation_id), + )).limit(1).for('share'); + if (!installation || installation.state !== 'active' + || installation.active_version_id !== session.app_version_id + || installation.active_grant_snapshot_id !== session.grant_snapshot_id + || installation.active_grant_snapshot_kind !== 'effective' + || installation.lifecycle_epoch !== session.lifecycle_epoch + || installation.grant_epoch !== session.grant_epoch) throw stale(); + const [version] = await tx.select().from(appVersions).where(and( + eq(appVersions.org_id, caller.org_id), + eq(appVersions.installation_id, session.app_installation_id), + eq(appVersions.id, session.app_version_id), + eq(appVersions.state, 'active'), + )).limit(1).for('share'); + const [grant] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, caller.org_id), + eq(appGrantSnapshots.app_installation_id, session.app_installation_id), + eq(appGrantSnapshots.app_version_id, session.app_version_id), + eq(appGrantSnapshots.id, session.grant_snapshot_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).limit(1).for('share'); + if (!version || !grant || grant.package_digest !== version.package_digest + || grant.manifest_digest !== version.manifest_digest) throw stale(); + const verified = await verifiedBundle(version, session.experience_key); + if (verified.reference.artifact_digest !== session.artifact_digest) throw stale(); + // The clock is read after every potentially blocking lock and digest. + const checkedAt = new Date(); + if (web.expires_at <= checkedAt || session.expires_at <= checkedAt) throw stale(); + return { session, bundle: verified.bundle }; + } + + private async liveContext(caller: ExperienceCaller, sessionId: string) { + return db.transaction((tx) => this.lockedLiveContext(tx, caller, sessionId)); + } + + async live(caller: ExperienceCaller, sessionId: string) { + const { session } = await this.liveContext(caller, sessionId); + return { session_id: session.id, expires_at: session.expires_at.toISOString(), live: true as const }; + } + + async revoke(caller: ExperienceCaller, sessionId: string) { + await this.liveContext(caller, sessionId); + await db.update(appExperienceSessions).set({ revoked_at: new Date() }).where(and( + eq(appExperienceSessions.id, sessionId), eq(appExperienceSessions.org_id, caller.org_id), + eq(appExperienceSessions.user_id, caller.user_id), + eq(appExperienceSessions.web_session_id, caller.sid), + isNull(appExperienceSessions.revoked_at), + )); + return { revoked: true as const }; + } + + async action(caller: ExperienceCaller, sessionId: string, actionKey: string, raw: unknown) { + key.parse(actionKey); + const request = actionRequest.parse(raw); + const { session, bundle } = await this.liveContext(caller, sessionId); + if (!bundle.action_keys.includes(actionKey)) throw denied(); + const [binding] = await db.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, caller.org_id), + eq(appRuntimeBindings.app_installation_id, session.app_installation_id), + eq(appRuntimeBindings.app_version_id, session.app_version_id), + eq(appRuntimeBindings.grant_snapshot_id, session.grant_snapshot_id), + eq(appRuntimeBindings.action_key, actionKey), + eq(appRuntimeBindings.state, 'active'), + )).limit(1); + if (!binding) throw new AppError('Experience action unavailable', 'APP_ACTION_UNAVAILABLE', 409); + const run = await this.runtime.invokeFromExperience({ org_id: caller.org_id, user_id: caller.user_id }, { + runtime_binding_id: binding.id, + idempotency_key: `experience:${session.id}:${request.request_id}`, + input: request.input, + }, async (tx: AppRunTransaction) => { + const current = await this.lockedLiveContext(tx, caller, sessionId); + if (!current.bundle.action_keys.includes(actionKey) + || current.session.app_version_id !== binding.app_version_id + || current.session.grant_snapshot_id !== binding.grant_snapshot_id) throw stale(); + const [lockedBinding] = await tx.select().from(appRuntimeBindings).where(and( + eq(appRuntimeBindings.org_id, caller.org_id), + eq(appRuntimeBindings.id, binding.id), + eq(appRuntimeBindings.app_installation_id, current.session.app_installation_id), + eq(appRuntimeBindings.app_version_id, current.session.app_version_id), + eq(appRuntimeBindings.grant_snapshot_id, current.session.grant_snapshot_id), + eq(appRuntimeBindings.action_key, actionKey), + eq(appRuntimeBindings.state, 'active'), + )).limit(1).for('share'); + if (!lockedBinding) throw stale(); + }); + await this.liveContext(caller, sessionId); + return { run }; + } +} + +export const appExperienceService = new AppExperienceService(); diff --git a/apps/api/src/lib/app-public-management.ts b/apps/api/src/lib/app-public-management.ts new file mode 100644 index 00000000..975f63ee --- /dev/null +++ b/apps/api/src/lib/app-public-management.ts @@ -0,0 +1,201 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { and, eq, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { parseRuntimeAppManifest } from '@deft/app-kit'; +import { appModuleBindings, appPublicEndpoints, appVersions, moduleInstallations, + moduleVersions } from '@deft/db/schema'; +import type { ModuleActor } from '@deft/shared/modules'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; +import { PostgresAppRunLiveAuthorization } from './app-run-live-authorization.js'; +import { digestAppGrantValue } from './app-grant-service.js'; +import { publicEndpointReviewDigest } from './app-public-service.js'; +import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; + +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/); +const Digest = z.string().regex(/^sha256:[a-f0-9]{64}$/); +const ActionKey = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/); +export const StagePublicEndpointSchema = z.strictObject({ + installation_id: Id, public_action_key: ActionKey, runtime_binding_id: Id, + approver_user_id: Id, public_label: z.string().min(1).max(200) + .regex(/^[^\u0000-\u001f\u007f<>]+$/), + max_body_bytes: z.number().int().min(128).max(8192), + expected_app_version_id: Id, expected_grant_snapshot_id: Id, + expected_lifecycle_epoch: z.number().int().nonnegative(), + expected_grant_epoch: z.number().int().positive(), +}); +export const ActivatePublicEndpointSchema = z.strictObject({ + expected_review_digest: Digest, expected_endpoint_epoch: z.number().int().positive(), + accept_host_policy: z.literal(true), +}); +const stale = () => new AppError('Public endpoint authority changed', 'APP_STALE', 409); +const hash = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; +const liveAuthorizer = new PostgresAppRunLiveAuthorization(); +type Tx = Parameters[0]>[0]; + +function manager(actor: ModuleActor): void { + if (actor.kind !== 'human' || (actor.role !== 'owner' && actor.role !== 'admin') + || (actor.source !== 'ui' && actor.source !== 'rest')) { + throw new AppError('Only interactive workspace owners and admins can review public endpoints', + 'APP_ACCESS_DENIED', 403); + } +} + +async function reviewedSetup(tx: Tx, input: Readonly<{ + org_id: string; installation_id: string; public_action_key: string; + runtime_binding_id: string; approver_user_id: string; +}>) { + const runtime = await liveAuthorizer.captureReviewedRuntimeInTransaction(tx, { + org_id: input.org_id, user_id: input.approver_user_id, + runtime_binding_id: input.runtime_binding_id, + }); + if (runtime.binding.app_installation_id !== input.installation_id + || runtime.binding.risk_class !== 'external_write' + || runtime.binding.review_requirement !== 'always' + || runtime.binding.retry_class !== 'unsafe_or_unknown' + || runtime.binding.retention_class !== 'standard' + || runtime.action.host_policy.review_scope !== 'per_invocation') throw stale(); + const [version] = await tx.select({ manifest: appVersions.manifest, + protocol_version: appVersions.protocol_version }).from(appVersions).where(and( + eq(appVersions.org_id, input.org_id), eq(appVersions.id, runtime.binding.app_version_id), + )).limit(1); + if (!version || version.protocol_version !== '4') throw stale(); + const manifest = parseRuntimeAppManifest(version.manifest); + if (manifest.schema_version !== '4') throw stale(); + const declaration = manifest.public_actions.find((item) => item.key === input.public_action_key); + if (!declaration || declaration.action_key !== runtime.action.action_key) throw stale(); + const [moduleBinding] = await tx.select().from(appModuleBindings).where(and( + eq(appModuleBindings.org_id, input.org_id), + eq(appModuleBindings.app_installation_id, input.installation_id), + eq(appModuleBindings.app_version_id, runtime.binding.app_version_id), + eq(appModuleBindings.module_id, declaration.module_id), + eq(appModuleBindings.ownership, 'app'), + )).limit(1); + if (!moduleBinding) throw stale(); + const [module] = await tx.select().from(moduleInstallations).where(and( + eq(moduleInstallations.org_id, input.org_id), + eq(moduleInstallations.id, moduleBinding.module_installation_id), + )).limit(1).for('share'); + const [moduleVersion] = await tx.select({ id: moduleVersions.id }).from(moduleVersions).where(and( + eq(moduleVersions.org_id, input.org_id), + eq(moduleVersions.installation_id, moduleBinding.module_installation_id), + eq(moduleVersions.id, moduleBinding.module_version_id), + eq(moduleVersions.is_active, true), + )).limit(1); + if (!module || module.is_deleted || !module.is_enabled + || module.module_id !== declaration.module_id || !moduleVersion) throw stale(); + return { runtime, declaration, moduleBinding }; +} + +export async function stagePublicEndpoint(actor: ModuleActor, raw: unknown) { + manager(actor); + if (!appRuntimeChannelEnabled()) throw new AppError('App Runtime unavailable', 'APP_FEATURE_DISABLED', 503); + const input = StagePublicEndpointSchema.parse(raw); + const endpointId = randomUUID(); + const slug = randomBytes(32).toString('base64url'); + const slugDigest = hash(slug); + return db.transaction(async (tx) => { + await assertCurrentModuleManagerWithExecutor(tx, actor); + const { runtime, declaration, moduleBinding } = await reviewedSetup(tx, { + org_id: actor.org_id, installation_id: input.installation_id, + public_action_key: input.public_action_key, + runtime_binding_id: input.runtime_binding_id, + approver_user_id: input.approver_user_id, + }); + if (runtime.binding.app_version_id !== input.expected_app_version_id + || runtime.binding.grant_snapshot_id !== input.expected_grant_snapshot_id + || runtime.installation_lifecycle_epoch !== input.expected_lifecycle_epoch + || runtime.installation_grant_epoch !== input.expected_grant_epoch) throw stale(); + const now = new Date(); + const fields = { id: endpointId, org_id: actor.org_id, slug_digest: slugDigest, + app_installation_id: input.installation_id, + app_version_id: runtime.binding.app_version_id, + grant_snapshot_id: runtime.binding.grant_snapshot_id, + installation_lifecycle_epoch: runtime.installation_lifecycle_epoch, + installation_grant_epoch: runtime.installation_grant_epoch, + module_installation_id: moduleBinding.module_installation_id, + collection_key: declaration.collection_key, + public_action_key: declaration.key, + runtime_binding_id: runtime.binding.id, + approver_user_id: input.approver_user_id, + input_mapping: declaration.input_mapping, + mapping_digest: digestAppGrantValue(declaration.input_mapping), + state: 'disabled' as const, endpoint_epoch: 1, + public_label: input.public_label, max_body_bytes: input.max_body_bytes, + reviewed_by_user_id: actor.actor_id, reviewed_at: now }; + const reviewDigest = publicEndpointReviewDigest(fields); + await tx.insert(appPublicEndpoints).values({ ...fields, review_digest: reviewDigest }); + return { endpoint_id: endpointId, slug, state: 'disabled' as const, + review_digest: reviewDigest, endpoint_epoch: 1, + app_version_id: runtime.binding.app_version_id, + grant_snapshot_id: runtime.binding.grant_snapshot_id }; + }); +} + +export async function activatePublicEndpoint(actor: ModuleActor, endpointId: string, raw: unknown) { + manager(actor); + const request = ActivatePublicEndpointSchema.parse(raw); + return db.transaction(async (tx) => { + await assertCurrentModuleManagerWithExecutor(tx, actor); + const [locator] = await tx.select({ org_id: appPublicEndpoints.org_id, + app_installation_id: appPublicEndpoints.app_installation_id, + public_action_key: appPublicEndpoints.public_action_key, + runtime_binding_id: appPublicEndpoints.runtime_binding_id, + approver_user_id: appPublicEndpoints.approver_user_id, + }).from(appPublicEndpoints).where(and(eq(appPublicEndpoints.org_id, actor.org_id), + eq(appPublicEndpoints.id, endpointId))).limit(1); + if (!locator?.public_action_key || !locator.runtime_binding_id || !locator.approver_user_id) throw stale(); + const setup = await reviewedSetup(tx, { org_id: actor.org_id, + installation_id: locator.app_installation_id, + public_action_key: locator.public_action_key, + runtime_binding_id: locator.runtime_binding_id, + approver_user_id: locator.approver_user_id }); + const [endpoint] = await tx.select().from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, actor.org_id), eq(appPublicEndpoints.id, endpointId), + )).limit(1).for('update'); + if (!endpoint || endpoint.state !== 'disabled' + || endpoint.endpoint_epoch !== request.expected_endpoint_epoch + || endpoint.review_digest !== request.expected_review_digest + || endpoint.review_digest !== publicEndpointReviewDigest(endpoint) + || endpoint.runtime_binding_id !== setup.runtime.binding.id + || endpoint.approver_user_id !== locator.approver_user_id + || endpoint.module_installation_id !== setup.moduleBinding.module_installation_id + || endpoint.app_version_id !== setup.runtime.binding.app_version_id + || endpoint.grant_snapshot_id !== setup.runtime.binding.grant_snapshot_id + || endpoint.installation_lifecycle_epoch !== setup.runtime.installation_lifecycle_epoch + || endpoint.installation_grant_epoch !== setup.runtime.installation_grant_epoch) throw stale(); + const epoch = endpoint.endpoint_epoch + 1; + const reviewDigest = publicEndpointReviewDigest({ ...endpoint, endpoint_epoch: epoch }); + await tx.update(appPublicEndpoints).set({ state: 'enabled', endpoint_epoch: epoch, + review_digest: reviewDigest, reviewed_by_user_id: actor.actor_id, + reviewed_at: new Date() }).where(and(eq(appPublicEndpoints.org_id, actor.org_id), + eq(appPublicEndpoints.id, endpointId))); + return { endpoint_id: endpointId, state: 'enabled' as const, + endpoint_epoch: epoch, review_digest: reviewDigest }; + }); +} + +export async function disablePublicEndpoint(actor: ModuleActor, endpointId: string) { + manager(actor); + return db.transaction(async (tx) => { + await assertCurrentModuleManagerWithExecutor(tx, actor); + const [locator] = await tx.select({ app_installation_id: appPublicEndpoints.app_installation_id }) + .from(appPublicEndpoints).where(and(eq(appPublicEndpoints.org_id, actor.org_id), + eq(appPublicEndpoints.id, endpointId))).limit(1); + if (!locator) throw stale(); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${locator.app_installation_id} FOR SHARE`); + const [endpoint] = await tx.select().from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, actor.org_id), eq(appPublicEndpoints.id, endpointId), + )).limit(1).for('update'); + if (!endpoint) throw stale(); + if (endpoint.state === 'disabled') return { endpoint_id: endpointId, + state: 'disabled' as const, endpoint_epoch: endpoint.endpoint_epoch }; + const epoch = endpoint.endpoint_epoch + 1; + await tx.update(appPublicEndpoints).set({ state: 'disabled', endpoint_epoch: epoch, + review_digest: publicEndpointReviewDigest({ ...endpoint, endpoint_epoch: epoch }) }) + .where(and(eq(appPublicEndpoints.org_id, actor.org_id), eq(appPublicEndpoints.id, endpointId))); + return { endpoint_id: endpointId, state: 'disabled' as const, endpoint_epoch: epoch }; + }); +} diff --git a/apps/api/src/lib/app-public-service.ts b/apps/api/src/lib/app-public-service.ts index 193aa5aa..428b1c6b 100644 --- a/apps/api/src/lib/app-public-service.ts +++ b/apps/api/src/lib/app-public-service.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { AppInstallationAuthoritySchema, @@ -39,7 +39,7 @@ export type PublicClaimInput = z.infer; export type PublicClaimResult = Readonly<{ claim_id: string; claim_state: 'confirmed'; - follow_up_state: 'pending' | 'unsupported'; + follow_up_state: 'pending' | 'unsupported' | 'run_created'; replayed: boolean; }>; @@ -70,8 +70,10 @@ const hash = (value: string) => `sha256:${createHash('sha256').update(value).dig export function publicEndpointReviewDigest(endpoint: Pick): string { - return hash(JSON.stringify({ + | 'collection_key' | 'endpoint_epoch' | 'public_label' | 'max_body_bytes'> + & Partial>): string { + const core = { review_version: 'deft.app_public_review.v1', endpoint_id: endpoint.id, org_id: endpoint.org_id, @@ -86,6 +88,15 @@ export function publicEndpointReviewDigest(endpoint: Pick { + // Anonymous work never waits indefinitely for a lock or a statement. + // These settings are transaction-local and cannot leak to pooled users. + await tx.execute(sql`SET LOCAL statement_timeout = 5000`); + await tx.execute(sql`SET LOCAL lock_timeout = 1000`); + await tx.execute(sql`SET LOCAL idle_in_transaction_session_timeout = 6000`); const { endpoint, principal, app } = await resolveEndpoint(tx, slug); await assertLiveAuthority(tx, endpoint, principal, app); const input = parseBody(rawBody, endpoint.max_body_bytes); diff --git a/apps/api/src/lib/app-public-worker-handler.ts b/apps/api/src/lib/app-public-worker-handler.ts index c01ee5d4..b4d7e418 100644 --- a/apps/api/src/lib/app-public-worker-handler.ts +++ b/apps/api/src/lib/app-public-worker-handler.ts @@ -1,13 +1,16 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appCanonicalClaims, appGrantSnapshots, appInstallations, appPublicEndpoints, appPublicIngress, appVersions, jobQueue, moduleInstallations, + appRuns, } from '@deft/db/schema'; import type { JobHandler } from '../workers/types.js'; import { db } from './db.js'; import { QUEUE_NAMES } from './queues.js'; import { publicEndpointReviewDigest } from './app-public-service.js'; +import { getAppRunRuntime } from './app-run-runtime.js'; +import { AppRunError } from './app-run-errors.js'; const PayloadSchema = z.strictObject({ organization_id: z.string().uuid(), @@ -16,12 +19,15 @@ const PayloadSchema = z.strictObject({ endpoint_epoch: z.number().int().positive(), }); -/** A validated queue handoff has no generic business callback yet. Persist a - * terminal unsupported result so the queue cannot silently imply delivery. */ +/** The reviewed v4 mapping is the only executable follow-up. Historical + * unmapped ingress stays terminal unsupported; no package callback is invoked. */ export const handleAppPublicIngress: JobHandler = async (job) => { if (job.name !== 'app-public-ingress' || job.signal?.aborted) throw new Error('Invalid public ingress job'); const payload = PayloadSchema.parse(job.data); + let approvalToProject: string | null = null; await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL statement_timeout = 15000`); + await tx.execute(sql`SET LOCAL lock_timeout = 5000`); const [queued] = await tx.select().from(jobQueue).where(eq(jobQueue.id, job.id)).limit(1); const queuedData = queued?.data; const fields = queuedData as Record | undefined; @@ -33,6 +39,44 @@ export const handleAppPublicIngress: JobHandler = async (job) => { || fields?.ingress_id !== payload.ingress_id || fields?.endpoint_epoch !== payload.endpoint_epoch) { throw new Error('Invalid public ingress queue identity'); } + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended( + ${`app-public-ingress:${payload.organization_id}:${payload.ingress_id}`}, 0))`); + const [actionLocator] = await tx.select({ public_action_key: appPublicEndpoints.public_action_key }) + .from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, payload.organization_id), + eq(appPublicEndpoints.id, payload.endpoint_id), + )).limit(1); + const [receiptLocator] = await tx.select({ follow_up_state: appPublicIngress.follow_up_state }) + .from(appPublicIngress).where(and( + eq(appPublicIngress.org_id, payload.organization_id), + eq(appPublicIngress.endpoint_id, payload.endpoint_id), + eq(appPublicIngress.id, payload.ingress_id), + )).limit(1); + if (actionLocator?.public_action_key && receiptLocator?.follow_up_state === 'pending') { + try { + if (job.signal?.aborted) throw new Error('Public ingress job aborted'); + const run = await (await getAppRunRuntime()).service.submitReviewedPublicRuntimeInTransaction(tx, { + org_id: payload.organization_id, endpoint_id: payload.endpoint_id, + ingress_id: payload.ingress_id, + }); + const [updated] = await tx.update(appPublicIngress).set({ + follow_up_state: 'run_created', handled_at: new Date(), + }).where(and(eq(appPublicIngress.org_id, payload.organization_id), + eq(appPublicIngress.endpoint_id, payload.endpoint_id), + eq(appPublicIngress.id, payload.ingress_id), + eq(appPublicIngress.follow_up_state, 'pending'))).returning({ id: appPublicIngress.id }); + if (!updated || run.initiating_actor_type !== 'app_public' + || run.initiating_actor_id !== payload.ingress_id) throw new Error('Public Run link failed'); + if (job.signal?.aborted) throw new Error('Public ingress job aborted'); + if (run.state === 'pending_approval') approvalToProject = run.id; + return; + } catch (error) { + if (!(error instanceof AppRunError) + || !['APP_RUN_AUTHORIZATION_STALE', 'APP_RUN_ACCESS_DENIED'].includes(error.code)) throw error; + // A stale/revoked mapping stays a terminal unsupported receipt. No + // part of a failed Run submission is committed by this branch. + } + } // Locator only. Preserve App -> endpoint lock order used by the claim and // lifecycle paths; no caller supplied principal or cookie enters here. const [locator] = await tx.select({ app_installation_id: appPublicEndpoints.app_installation_id }) @@ -76,6 +120,17 @@ export const handleAppPublicIngress: JobHandler = async (job) => { throw new Error('Public ingress claim is missing'); } if (ingress.follow_up_state === 'unsupported') return; + if (ingress.follow_up_state === 'run_created') { + const [run] = await tx.select({ id: appRuns.id, state: appRuns.state }).from(appRuns).where(and( + eq(appRuns.org_id, payload.organization_id), + eq(appRuns.origin_public_endpoint_id, endpoint.id), + eq(appRuns.origin_public_ingress_id, ingress.id), + eq(appRuns.initiating_actor_type, 'app_public'), + )).limit(1); + if (!run) throw new Error('Public ingress Run link is missing'); + if (run.state === 'pending_approval') approvalToProject = run.id; + return; + } if (ingress.follow_up_state !== 'pending') throw new Error('Invalid public follow-up state'); const live = endpoint.state === 'enabled' && endpoint.endpoint_epoch === payload.endpoint_epoch && endpoint.review_digest === publicEndpointReviewDigest(endpoint) @@ -91,4 +146,8 @@ export const handleAppPublicIngress: JobHandler = async (job) => { handled_at: new Date(), }).where(eq(appPublicIngress.id, ingress.id)); }); + if (approvalToProject) { + await (await getAppRunRuntime()).service.projectPendingApproval(payload.organization_id, + approvalToProject); + } }; diff --git a/apps/api/src/lib/app-run-approval-adapter.ts b/apps/api/src/lib/app-run-approval-adapter.ts index 0d5d2585..1b899209 100644 --- a/apps/api/src/lib/app-run-approval-adapter.ts +++ b/apps/api/src/lib/app-run-approval-adapter.ts @@ -53,6 +53,14 @@ async function approvalOwnerUserId( if (submission.initiating_actor.actor_type === 'human') { return submission.initiating_actor.user_id; } + if (submission.initiating_actor.actor_type === 'app_public' + && submission.execution_actor.actor_type === 'human' + && submission.origin.origin_kind === 'app' + && 'public_endpoint_id' in submission.origin + && submission.origin.public_endpoint_id === submission.initiating_actor.endpoint_id + && submission.origin.public_ingress_id === submission.initiating_actor.ingress_id) { + return submission.execution_actor.user_id; + } if (submission.initiating_actor.actor_type === 'agent_employee') { const [employee] = await tx.select({ user_id: agentEmployees.user_id }) .from(agentEmployees) @@ -117,6 +125,9 @@ export class PostgresAppRunApprovalResolver { } let run = await this.repository.lockRun(tx, action.org_id, action.app_run_id); if (!run) return { status: 'error', code: 'NOT_FOUND', message: 'App Run approval was not found' }; + if (run.initiating_actor_type === 'app_public' && action.user_id !== approverUserId) { + return { status: 'error', code: 'NOT_FOUND', message: 'App Run approval was not found' }; + } if (run.execution_release_kind === 'approved') { await this.#markApproved(tx, action.id, approverUserId, run); @@ -213,6 +224,9 @@ export class PostgresAppRunApprovalResolver { } let run = await this.repository.lockRun(tx, action.org_id, action.app_run_id); if (!run) return { status: 'error', code: 'NOT_FOUND', message: 'App Run approval was not found' }; + if (run.initiating_actor_type === 'app_public' && action.user_id !== rejecterUserId) { + return { status: 'error', code: 'NOT_FOUND', message: 'App Run approval was not found' }; + } if (run.execution_release_kind === 'approved') { await this.#markApproved(tx, action.id, action.approved_by_user_id ?? rejecterUserId, run); diff --git a/apps/api/src/lib/app-run-authorization.ts b/apps/api/src/lib/app-run-authorization.ts index 515d0fad..a66d29aa 100644 --- a/apps/api/src/lib/app-run-authorization.ts +++ b/apps/api/src/lib/app-run-authorization.ts @@ -35,7 +35,8 @@ function actorMatchesRun(actor: AppRunActor, run: AppRunSafeView): boolean { ? actor.agent_employee_id : actor.actor_type === 'system' ? actor.system_id - : actor.automation_id; + : actor.actor_type === 'automation' ? actor.automation_id : actor.ingress_id; + if (actor.actor_type === 'app_public') return false; return ( actor.actor_type === run.initiating_actor_type && actorId === run.initiating_actor_id ) || ( diff --git a/apps/api/src/lib/app-run-live-authorization.ts b/apps/api/src/lib/app-run-live-authorization.ts index c4f2b301..c6c11498 100644 --- a/apps/api/src/lib/app-run-live-authorization.ts +++ b/apps/api/src/lib/app-run-live-authorization.ts @@ -19,6 +19,10 @@ import { canonicalAppPrivateInterfaceIdentity, DeftAppManifestV1Schema, DeftAppManifestV2Schema, + PublicActionDeclarationSchema, + RuntimeObjectSchema, + parseRuntimeAppManifest, + parseRuntimeObjectInput, } from '@deft/app-kit'; import { agentEmployees, @@ -31,6 +35,9 @@ import { appModuleBindings, appRuntimeBindings, appRuntimeRegistrations, + appCanonicalClaims, + appPublicEndpoints, + appPublicIngress, appRuns, appVersions, capabilityProviderSnapshots, @@ -72,6 +79,7 @@ import { import { APP_AUTOMATION_POLICY_DIGEST, digestAppAutomationFireIdentity } from './app-automation-definition-service.js'; import { loadReviewedRuntimeAction } from './app-runtime-review.js'; import { APP_RUNTIME_CHANNEL_VERSION } from './app-runtime-contract.js'; +import { publicEndpointReviewDigest } from './app-public-service.js'; const HOST_POLICY_VERSION = 'deft.app_run.host_policy.v1'; const APP_MCP_INVOKE_SCOPES = Object.freeze(['read:modules', 'invoke:apps'] as const); @@ -118,6 +126,8 @@ type InternalRunAuthorization = Readonly<{ origin_app_version_id: string | null; origin_app_binding_key: string | null; origin_runtime_binding_id: string | null; + origin_public_endpoint_id: string | null; + origin_public_ingress_id: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id: string | null; origin_app_automation_fire_id: string | null; @@ -195,11 +205,12 @@ function actorIdentity(actor: AppRunActor): string { case 'agent_employee': return actor.agent_employee_id; case 'system': return actor.system_id; case 'automation': return actor.automation_id; + case 'app_public': return actor.ingress_id; } } function runActor( - type: AppRunSafeView['execution_actor_type'], + type: AppRunSafeView['execution_actor_type'] | AppRunSafeView['initiating_actor_type'], id: string, automationUserId?: string, ): AppRunActor { @@ -212,6 +223,7 @@ function runActor( automation_id: id, ...(automationUserId ? { user_id: automationUserId } : {}), }; + case 'app_public': throw new Error('Public actor requires reviewed ingress ancestry'); } } @@ -438,6 +450,115 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize installation_grant_epoch: installation.grant_epoch }); } + /** Host-derived public principal for one persisted canonical claim. The + * designated human is checked as approver, never substituted as initiator. */ + async captureReviewedPublicRuntimeInTransaction(tx: AppRunTransaction, input: Readonly<{ + org_id: string; endpoint_id: string; ingress_id: string; + }>) { + const [locator] = await tx.select({ approver_user_id: appPublicEndpoints.approver_user_id, + runtime_binding_id: appPublicEndpoints.runtime_binding_id }) + .from(appPublicEndpoints).where(and(eq(appPublicEndpoints.org_id, input.org_id), + eq(appPublicEndpoints.id, input.endpoint_id))).limit(1); + if (!locator?.approver_user_id || !locator.runtime_binding_id) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + // Membership -> App -> Runtime binding precedes endpoint/Module/ingress. + const runtime = await this.captureReviewedRuntimeInTransaction(tx, { + org_id: input.org_id, user_id: locator.approver_user_id, + runtime_binding_id: locator.runtime_binding_id, + }); + const [endpoint] = await tx.select().from(appPublicEndpoints).where(and( + eq(appPublicEndpoints.org_id, input.org_id), eq(appPublicEndpoints.id, input.endpoint_id), + )).limit(1).for('share'); + if (!endpoint || endpoint.state !== 'enabled' || endpoint.approver_user_id !== locator.approver_user_id + || endpoint.runtime_binding_id !== runtime.binding.id + || endpoint.app_installation_id !== runtime.binding.app_installation_id + || endpoint.app_version_id !== runtime.binding.app_version_id + || endpoint.grant_snapshot_id !== runtime.binding.grant_snapshot_id + || endpoint.installation_lifecycle_epoch !== runtime.installation_lifecycle_epoch + || endpoint.installation_grant_epoch !== runtime.installation_grant_epoch + || endpoint.review_digest !== publicEndpointReviewDigest(endpoint) + || !endpoint.public_action_key || !endpoint.input_mapping || !endpoint.mapping_digest + || endpoint.mapping_digest !== digestAppGrantValue(endpoint.input_mapping)) { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } + const [version] = await tx.select({ manifest: appVersions.manifest, + protocol_version: appVersions.protocol_version }).from(appVersions).where(and( + eq(appVersions.org_id, input.org_id), eq(appVersions.id, endpoint.app_version_id), + )).limit(1); + if (!version || version.protocol_version !== '4') throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const manifest = parseRuntimeAppManifest(version.manifest); + if (manifest.schema_version !== '4') throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const declaration = manifest.public_actions.find((item) => item.key === endpoint.public_action_key); + if (!declaration) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const reviewed = PublicActionDeclarationSchema.parse(declaration); + if (reviewed.action_key !== runtime.action.action_key + || reviewed.collection_key !== endpoint.collection_key + || canonicalCapabilityJson(reviewed.input_mapping) !== canonicalCapabilityJson(endpoint.input_mapping)) { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } + const [module] = await tx.select().from(moduleInstallations).where(and( + eq(moduleInstallations.org_id, input.org_id), + eq(moduleInstallations.id, endpoint.module_installation_id), + )).limit(1).for('share'); + const [moduleBinding] = await tx.select().from(appModuleBindings).where(and( + eq(appModuleBindings.org_id, input.org_id), + eq(appModuleBindings.app_installation_id, endpoint.app_installation_id), + eq(appModuleBindings.app_version_id, endpoint.app_version_id), + eq(appModuleBindings.module_installation_id, endpoint.module_installation_id), + eq(appModuleBindings.module_id, reviewed.module_id), + )).limit(1); + if (!module || module.is_deleted || !module.is_enabled || module.module_id !== reviewed.module_id + || !moduleBinding || moduleBinding.ownership !== 'app') throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const [moduleVersion] = await tx.select({ id: moduleVersions.id }).from(moduleVersions).where(and( + eq(moduleVersions.org_id, input.org_id), + eq(moduleVersions.installation_id, module.id), + eq(moduleVersions.id, moduleBinding.module_version_id), + eq(moduleVersions.is_active, true), + )).limit(1); + if (!moduleVersion) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const [ingress] = await tx.select().from(appPublicIngress).where(and( + eq(appPublicIngress.org_id, input.org_id), eq(appPublicIngress.endpoint_id, endpoint.id), + eq(appPublicIngress.id, input.ingress_id), + )).limit(1).for('share'); + if (!ingress || ingress.endpoint_epoch !== endpoint.endpoint_epoch || ingress.state !== 'confirmed' + || !['pending', 'run_created'].includes(ingress.follow_up_state)) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const [claim] = await tx.select().from(appCanonicalClaims).where(and( + eq(appCanonicalClaims.org_id, input.org_id), eq(appCanonicalClaims.endpoint_id, endpoint.id), + eq(appCanonicalClaims.ingress_id, ingress.id), + )).limit(1).for('share'); + if (!claim || claim.released_at || claim.claim_kind !== 'exclusive' + || claim.provider_kind !== 'module' || claim.provider_instance_id !== module.id + || claim.resource_type !== endpoint.collection_key) throw new Error('APP_RUN_AUTHORIZATION_STALE'); + const projected: Record = {}; + for (const [field, source] of Object.entries(reviewed.input_mapping)) { + projected[field] = source === 'claim.claim_id' ? claim.id : claim.resource_id; + } + const publicInput = parseRuntimeObjectInput(RuntimeObjectSchema.parse(runtime.action.input_schema), projected); + const refs: AuthorityRef[] = runtime.authorization_snapshot.authority_refs + .filter((ref) => ref.authority_kind !== 'app_surface'); + refs.push( + { authority_kind: 'app_surface', authority_id: 'public:ingress', + version: authorityVersion('app_surface', { surface: 'public:ingress', provider_kind: 'app_runtime' }) }, + { authority_kind: 'app_public_endpoint', authority_id: endpoint.id, + version: authorityVersion('app_public_endpoint', { review_digest: endpoint.review_digest, + endpoint_epoch: endpoint.endpoint_epoch, module_version_id: moduleVersion.id }) }, + { authority_kind: 'app_public_ingress', authority_id: ingress.id, + version: authorityVersion('app_public_ingress', { endpoint_epoch: ingress.endpoint_epoch, + request_key_digest: ingress.request_key_digest, input_digest: ingress.input_digest }) }, + { authority_kind: 'app_public_claim', authority_id: claim.id, + version: authorityVersion('app_public_claim', { provider_instance_id: claim.provider_instance_id, + resource_type: claim.resource_type, resource_id: claim.resource_id, + released_at: claim.released_at }) }, + ); + const authorization_snapshot = AppRunAuthorizationSnapshotSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + authenticated_subject: { actor_type: 'app_public', endpoint_id: endpoint.id, ingress_id: ingress.id }, + authority_refs: refs.sort((a, b) => `${a.authority_kind}\0${a.authority_id}` + .localeCompare(`${b.authority_kind}\0${b.authority_id}`)), + }); + return Object.freeze({ ...runtime, authorization_snapshot, endpoint, ingress, claim, + public_input: publicInput }); + } + /** Revalidate an exact MCP/OAuth token and its current scopes for actor- * scoped Run reads. This does not create or mutate Run authority. */ async assertTokenScopes(input: AppRunTokenScopeAuthorization): Promise { @@ -661,6 +782,8 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize origin_app_version_id: appRuns.origin_app_version_id, origin_app_binding_key: appRuns.origin_app_binding_key, origin_runtime_binding_id: appRuns.origin_runtime_binding_id, + origin_public_endpoint_id: appRuns.origin_public_endpoint_id, + origin_public_ingress_id: appRuns.origin_public_ingress_id, origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, @@ -679,13 +802,42 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize ): Promise { if (run.provider_kind === 'app_runtime') { try { - if (run.origin_kind !== 'app' || run.initiating_actor_type !== 'human' - || run.execution_actor_type !== 'human' - || run.initiating_actor_id !== run.execution_actor_id + if (run.origin_kind !== 'app' || run.execution_actor_type !== 'human' || !internal.origin_runtime_binding_id || internal.origin_app_binding_key !== null || internal.origin_app_automation_definition_id !== null || internal.origin_app_automation_fire_id !== null) return false; + if (run.initiating_actor_type === 'app_public') { + if (!internal.origin_public_endpoint_id || !internal.origin_public_ingress_id + || run.initiating_actor_id !== internal.origin_public_ingress_id) return false; + const current = await this.captureReviewedPublicRuntimeInTransaction(tx, { + org_id: run.org_id, endpoint_id: internal.origin_public_endpoint_id, + ingress_id: internal.origin_public_ingress_id, + }); + const stored = AppRunAuthorizationSnapshotSchema.parse(internal.authorization_snapshot); + return stored.authenticated_subject.actor_type === 'app_public' + && stored.authenticated_subject.endpoint_id === current.endpoint.id + && stored.authenticated_subject.ingress_id === current.ingress.id + && run.execution_actor_id === current.endpoint.approver_user_id + && internal.origin_app_installation_id === current.binding.app_installation_id + && internal.origin_app_version_id === current.binding.app_version_id + && internal.origin_app_grant_snapshot_id === current.binding.grant_snapshot_id + && internal.origin_runtime_binding_id === current.binding.id + && current.binding.provider_instance_id === run.provider_instance_id + && current.binding.provider_snapshot_id === internal.provider_snapshot_id + && current.binding.operation_name === run.operation_name + && current.binding.risk_class === run.risk_class + && current.binding.review_requirement === run.review_requirement + && current.binding.retry_class === run.retry_class + && current.binding.retention_class === run.retention_class + && current.action.host_policy.review_scope === run.review_scope + && sameAuthorityRefs(stored.authority_refs, + current.authorization_snapshot.authority_refs); + } + if (run.initiating_actor_type !== 'human' + || run.initiating_actor_id !== run.execution_actor_id + || internal.origin_public_endpoint_id !== null + || internal.origin_public_ingress_id !== null) return false; const current = await this.captureReviewedRuntimeInTransaction(tx, { org_id: run.org_id, user_id: run.initiating_actor_id, runtime_binding_id: internal.origin_runtime_binding_id, @@ -1419,6 +1571,7 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize add(await this.#membership(tx, input.org_id, actor.user_id)); return; } + if (actor.actor_type === 'app_public') throw new Error('APP_RUN_AUTHORIZATION_STALE'); let employee = employees.get(actor.agent_employee_id); if (!employee) { employee = await this.#employee(tx, input.org_id, actor.agent_employee_id); diff --git a/apps/api/src/lib/app-run-prepared-input.ts b/apps/api/src/lib/app-run-prepared-input.ts index c9fe256f..b8bbe094 100644 --- a/apps/api/src/lib/app-run-prepared-input.ts +++ b/apps/api/src/lib/app-run-prepared-input.ts @@ -357,6 +357,7 @@ function actorId(actor: AppRunActor): string { case 'agent_employee': return actor.agent_employee_id; case 'system': return actor.system_id; case 'automation': return actor.automation_id; + case 'app_public': throw new Error('Public ingress cannot prepare an App action'); } } diff --git a/apps/api/src/lib/app-run-repository.ts b/apps/api/src/lib/app-run-repository.ts index f1cd5bd5..215b95f7 100644 --- a/apps/api/src/lib/app-run-repository.ts +++ b/apps/api/src/lib/app-run-repository.ts @@ -67,6 +67,7 @@ export function appRunActorId(actor: AppRunActor): string { case 'agent_employee': return actor.agent_employee_id; case 'system': return actor.system_id; case 'automation': return actor.automation_id; + case 'app_public': return actor.ingress_id; } } @@ -145,6 +146,8 @@ export class PostgresAppRunRepository { origin_app_binding_key: appRuns.origin_app_binding_key, origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, origin_runtime_binding_id: appRuns.origin_runtime_binding_id, + origin_public_endpoint_id: appRuns.origin_public_endpoint_id, + origin_public_ingress_id: appRuns.origin_public_ingress_id, origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, risk_class: appRuns.risk_class, @@ -180,6 +183,8 @@ export class PostgresAppRunRepository { origin_app_version_id: string | null; origin_app_binding_key: string | null; origin_runtime_binding_id: string | null; + origin_public_endpoint_id: string | null; + origin_public_ingress_id: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id: string | null; origin_app_automation_fire_id: string | null; @@ -198,6 +203,8 @@ export class PostgresAppRunRepository { origin_app_version_id: appRuns.origin_app_version_id, origin_app_binding_key: appRuns.origin_app_binding_key, origin_runtime_binding_id: appRuns.origin_runtime_binding_id, + origin_public_endpoint_id: appRuns.origin_public_endpoint_id, + origin_public_ingress_id: appRuns.origin_public_ingress_id, origin_app_grant_snapshot_id: appRuns.origin_app_grant_snapshot_id, origin_app_automation_definition_id: appRuns.origin_app_automation_definition_id, origin_app_automation_fire_id: appRuns.origin_app_automation_fire_id, @@ -320,6 +327,7 @@ export class PostgresAppRunRepository { ): Promise { const initiating = actorColumns(input.submission.initiating_actor); const execution = actorColumns(input.submission.execution_actor); + if (execution.type === 'app_public') throw new Error('APP_RUN_ACCESS_DENIED'); const [run] = await tx.insert(appRuns).values({ id: input.id, org_id: input.submission.org_id, @@ -348,6 +356,12 @@ export class PostgresAppRunRepository { && typeof input.submission.origin.runtime_binding_id === 'string' ? input.submission.origin.runtime_binding_id : null, + origin_public_endpoint_id: input.submission.origin.origin_kind === 'app' + && 'public_endpoint_id' in input.submission.origin + ? input.submission.origin.public_endpoint_id : null, + origin_public_ingress_id: input.submission.origin.origin_kind === 'app' + && 'public_ingress_id' in input.submission.origin + ? input.submission.origin.public_ingress_id : null, origin_app_grant_snapshot_id: input.submission.origin.origin_kind === 'app' ? input.submission.origin.grant_snapshot_id : null, diff --git a/apps/api/src/lib/app-run-service.ts b/apps/api/src/lib/app-run-service.ts index 2decf55f..f7a5d0e2 100644 --- a/apps/api/src/lib/app-run-service.ts +++ b/apps/api/src/lib/app-run-service.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { sql } from 'drizzle-orm'; import { APP_RUN_DEFAULT_ATTEMPT_LIMIT, APP_RUN_CONTRACT_VERSIONS, @@ -62,6 +63,7 @@ import type { import { APP_RUN_APP_AUTHORITY_KINDS } from './app-run-prepared-input.js'; import type { AppRunPreparedAppVerification, + PostgresAppRunLiveAuthorization, } from './app-run-live-authorization.js'; import { bindAppAutomationFireRunWithExecutor } from './app-automation-repository.js'; import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; @@ -97,6 +99,8 @@ export type ReviewedRuntimeCapture = Readonly<{ installation_lifecycle_epoch: number; installation_grant_epoch: number; }>; +export type ReviewedPublicRuntimeCapture = Awaited>; export type AppRunTrustedContext = Readonly<{ org_id: string; @@ -125,6 +129,9 @@ export interface AppRunPreparedAppAuthorizer { captureReviewedRuntimeInTransaction?(tx: AppRunTransaction, input: Readonly<{ org_id: string; user_id: string; runtime_binding_id: string; }>): Promise; + captureReviewedPublicRuntimeInTransaction?(tx: AppRunTransaction, input: Readonly<{ + org_id: string; endpoint_id: string; ingress_id: string; + }>): Promise; } function sameActor(left: AppRunActor, right: AppRunActor): boolean { @@ -282,6 +289,8 @@ export function appRunReplayAuthorityMatches( origin_app_version_id: string | null; origin_app_binding_key: string | null; origin_runtime_binding_id?: string | null; + origin_public_endpoint_id?: string | null; + origin_public_ingress_id?: string | null; origin_app_grant_snapshot_id: string | null; origin_app_automation_definition_id?: string | null; origin_app_automation_fire_id?: string | null; @@ -297,11 +306,17 @@ export function appRunReplayAuthorityMatches( const actionBindingKey = 'binding_key' in submission.origin ? submission.origin.binding_key : null; + const publicEndpointId = 'public_endpoint_id' in submission.origin + ? submission.origin.public_endpoint_id : null; + const publicIngressId = 'public_ingress_id' in submission.origin + ? submission.origin.public_ingress_id : null; if ( replay.origin_app_installation_id !== submission.origin.installation_id || replay.origin_app_version_id !== submission.origin.app_version_id || replay.origin_app_binding_key !== actionBindingKey || (replay.origin_runtime_binding_id ?? null) !== runtimeBindingId + || (replay.origin_public_endpoint_id ?? null) !== publicEndpointId + || (replay.origin_public_ingress_id ?? null) !== publicIngressId || replay.origin_app_grant_snapshot_id !== submission.origin.grant_snapshot_id ) return false; if (trustedAppVector?.schema_version === 'deft.app_action_authority.v2') { @@ -340,6 +355,17 @@ export class AppRunService { private readonly appAutomationsEnabled: () => boolean = () => false, ) {} + /** The public worker calls this only after its ingress/Run transaction + * commits; duplicate queue delivery may repair a missed projection. */ + async projectPendingApproval(orgId: string, runId: string): Promise { + try { + await this.attention.projectApprovalRequested(orgId, runId); + } catch (error) { + console.warn('[app-runs] approval Attention projection failed:', + error instanceof Error ? error.message : 'unknown error'); + } + } + async submit(context: AppRunTrustedContext, rawSubmission: unknown): Promise { return this.#submit(context, rawSubmission, null); } @@ -428,6 +454,7 @@ export class AppRunService { async submitReviewedRuntime( caller: ReviewedRuntimeCaller, request: ReviewedRuntimeInvoke, + hostAdmission?: (tx: AppRunTransaction) => Promise, ): Promise { if (!this.appOriginEnabled() || !appRuntimeChannelEnabled() || !this.appLiveAuthorization?.captureReviewedRuntimeForPreparation @@ -503,7 +530,77 @@ export class AppRunService { }), }; return this.#submit({ org_id: caller.org_id, initiating_actor: actor, - execution_actor: actor }, submission, null, undefined, undefined, capture); + execution_actor: actor }, submission, null, undefined, undefined, capture, + undefined, undefined, hostAdmission); + } + + /** Only the validated public-ingress worker calls this inside the ingress + * transaction. No caller-supplied actor, action, policy, or input is used. */ + async submitReviewedPublicRuntimeInTransaction(tx: AppRunTransaction, identity: Readonly<{ + org_id: string; endpoint_id: string; ingress_id: string; + }>): Promise { + if (!this.appOriginEnabled() || !appRuntimeChannelEnabled() + || !this.appLiveAuthorization?.captureReviewedPublicRuntimeInTransaction) { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + // Serialize duplicate queue deliveries before any share lock is taken on + // the ingress. Otherwise two readers can deadlock when the winner upgrades + // its ingress lock after the Run insert while the loser waits on #submit. + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended( + ${`app-public-ingress:${identity.org_id}:${identity.ingress_id}`}, 0))`); + let capture: ReviewedPublicRuntimeCapture; + try { + capture = await this.appLiveAuthorization.captureReviewedPublicRuntimeInTransaction(tx, identity); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + const { binding, endpoint, ingress, claim } = capture; + if (binding.risk_class !== 'external_write' || binding.review_requirement !== 'always' + || binding.retry_class !== 'unsafe_or_unknown' || binding.retention_class !== 'standard' + || capture.action.host_policy.review_scope !== 'per_invocation' + || endpoint.approver_user_id === null || endpoint.id !== identity.endpoint_id + || ingress.id !== identity.ingress_id || claim.ingress_id !== ingress.id) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + const initiator: AppRunActor = { actor_type: 'app_public', endpoint_id: endpoint.id, + ingress_id: ingress.id }; + const executor: AppRunActor = { actor_type: 'human', user_id: endpoint.approver_user_id }; + const submission = { + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + org_id: identity.org_id, + initiating_actor: initiator, + execution_actor: executor, + origin: { origin_kind: 'app' as const, + installation_id: binding.app_installation_id, + app_version_id: binding.app_version_id, + grant_snapshot_id: binding.grant_snapshot_id, + runtime_binding_id: binding.id, + public_endpoint_id: endpoint.id, + public_ingress_id: ingress.id }, + operation: { provider: { org_id: identity.org_id, + provider_kind: 'app_runtime' as const, + provider_instance_id: binding.provider_instance_id }, + operation_name: binding.operation_name }, + provider_snapshot_digest: capture.provider_snapshot_digest, + policy: { risk_class: binding.risk_class, review_requirement: binding.review_requirement, + review_scope: 'per_invocation' as const, retry_class: binding.retry_class }, + retention_class: binding.retention_class, + idempotency_key: `app-public-ingress:${ingress.id}`, + input: capture.public_input, + authorization_snapshot: capture.authorization_snapshot, + safe_preview: AppRunSafePreviewSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + title: capture.action.action_key, + summary: 'Public claim awaiting one human approval.', + resource_refs: [{ resource_kind: claim.resource_type, resource_id: claim.resource_id }], + fields: { provider_kind: 'app_runtime', app_installation_id: binding.app_installation_id, + runtime_binding_id: binding.id, public_endpoint_id: endpoint.id, + public_claim_id: claim.id }, + }), + }; + return this.#submit({ org_id: identity.org_id, initiating_actor: initiator, + execution_actor: executor }, submission, null, undefined, undefined, undefined, + capture, tx); } /** Transient approval review: disclose exact retained input only to the @@ -516,6 +613,9 @@ export class AppRunService { } return this.repository.transaction(async (tx) => { const locator = await this.repository.findRuntimeReviewPin(tx, caller.org_id, runId); + if (locator?.initiating_actor_type === 'app_public') { + return this.#reviewPublicRuntimeInput(tx, caller, runId, locator); + } if (!locator || locator.initiating_actor_type !== 'human' || locator.initiating_actor_id !== caller.user_id || locator.org_id !== caller.org_id @@ -596,6 +696,82 @@ export class AppRunService { }); } + async #reviewPublicRuntimeInput(tx: AppRunTransaction, caller: ReviewedRuntimeCaller, + runId: string, locator: NonNullable>>) { + if (!this.appLiveAuthorization?.captureReviewedPublicRuntimeInTransaction + || locator.execution_actor_type !== 'human' + || locator.execution_actor_id !== caller.user_id + || locator.origin_kind !== 'app' || locator.provider_kind !== 'app_runtime' + || !locator.origin_public_endpoint_id || !locator.origin_public_ingress_id + || locator.initiating_actor_id !== locator.origin_public_ingress_id) { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + const locked = await this.repository.lockRun(tx, caller.org_id, runId); + const pin = await this.repository.findRuntimeReviewPin(tx, caller.org_id, runId); + if (!locked || !pin || pin.state !== 'pending_approval' + || pin.input_expires_at <= this.now() + || pin.origin_public_endpoint_id !== locator.origin_public_endpoint_id + || pin.origin_public_ingress_id !== locator.origin_public_ingress_id + || pin.execution_actor_type !== 'human' || pin.execution_actor_id !== caller.user_id) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let current: ReviewedPublicRuntimeCapture; + try { + current = await this.appLiveAuthorization.captureReviewedPublicRuntimeInTransaction(tx, { + org_id: caller.org_id, endpoint_id: locator.origin_public_endpoint_id, + ingress_id: locator.origin_public_ingress_id, + }); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let authorityMatches = false; + try { + authorityMatches = canonicalAuthorization(AppRunAuthorizationSnapshotSchema.parse(pin.authorization_snapshot)) + === canonicalAuthorization(current.authorization_snapshot); + } catch { /* Invalid retained authority cannot disclose input. */ } + if (pin.input_expires_at <= this.now() || pin.state !== 'pending_approval' + || pin.initiating_actor_type !== 'app_public' + || pin.initiating_actor_id !== current.ingress.id + || pin.execution_actor_id !== current.endpoint.approver_user_id + || pin.origin_runtime_binding_id !== current.binding.id + || pin.origin_app_installation_id !== current.binding.app_installation_id + || pin.origin_app_version_id !== current.binding.app_version_id + || pin.origin_app_grant_snapshot_id !== current.binding.grant_snapshot_id + || pin.provider_instance_id !== current.binding.provider_instance_id + || pin.provider_snapshot_id !== current.binding.provider_snapshot_id + || pin.operation_name !== current.binding.operation_name + || pin.risk_class !== current.binding.risk_class + || pin.review_requirement !== current.binding.review_requirement + || pin.review_scope !== current.action.host_policy.review_scope + || pin.retry_class !== current.binding.retry_class + || pin.retention_class !== current.binding.retention_class + || !authorityMatches + || !await this.repository.hasPendingRuntimeApproval(tx, caller.org_id, runId, caller.user_id)) { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + let reviewedInput: Record; + try { + const retained = await this.secretRepository.readInput(caller.org_id, runId, tx); + reviewedInput = parseRuntimeObjectInput(RuntimeObjectSchema.parse(current.action.input_schema), retained); + if (canonicalCapabilityJson(reviewedInput) !== canonicalCapabilityJson(current.public_input)) { + throw new Error('Public input differs from canonical claim'); + } + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + return Object.freeze({ run_id: runId, action_key: current.action.action_key, + app_installation_id: current.binding.app_installation_id, + app_version_id: current.binding.app_version_id, + grant_snapshot_id: current.binding.grant_snapshot_id, + runtime_binding_id: current.binding.id, + contract_digest: current.action.contract_digest, + policy: Object.freeze({ risk_class: current.binding.risk_class, + review_requirement: current.binding.review_requirement, + review_scope: current.action.host_policy.review_scope, + retry_class: current.binding.retry_class }), + input: reviewedInput }); + } + async submitChild( context: AppRunTrustedContext, parentRunId: string, @@ -614,6 +790,9 @@ export class AppRunService { trustedAppVector?: AppRunPreparedAppVerification['authority_vector'], automationClaimToken?: string, trustedRuntimeCapture?: ReviewedRuntimeCapture, + trustedPublicCapture?: ReviewedPublicRuntimeCapture, + existingTx?: AppRunTransaction, + hostAdmission?: (tx: AppRunTransaction) => Promise, ): Promise { let submission: AppRunSubmission; try { @@ -626,15 +805,21 @@ export class AppRunService { || !sameActor(context.initiating_actor, submission.initiating_actor) || !sameActor(context.execution_actor, submission.execution_actor) || !sameActor(context.initiating_actor, submission.authorization_snapshot.authenticated_subject) - || (submission.origin.origin_kind === 'app' && !trustedAppVector && !trustedRuntimeCapture) + || (submission.origin.origin_kind === 'app' && !trustedAppVector && !trustedRuntimeCapture && !trustedPublicCapture) || (submission.origin.origin_kind !== 'app' - && (trustedAppVector !== undefined || trustedRuntimeCapture !== undefined)) - || (trustedAppVector !== undefined && trustedRuntimeCapture !== undefined) + && (trustedAppVector !== undefined || trustedRuntimeCapture !== undefined || trustedPublicCapture !== undefined)) + || [trustedAppVector, trustedRuntimeCapture, trustedPublicCapture].filter(Boolean).length > 1 || (submission.origin.origin_kind === 'app' && 'runtime_binding_id' in submission.origin - && !trustedRuntimeCapture) + && !trustedRuntimeCapture && !trustedPublicCapture) || (submission.origin.origin_kind === 'app' && 'binding_key' in submission.origin && !trustedAppVector) - || (!trustedAppVector && !trustedRuntimeCapture && submission.authorization_snapshot.authority_refs.some( + || (submission.origin.origin_kind === 'app' && 'public_endpoint_id' in submission.origin + && !trustedPublicCapture) + || (trustedPublicCapture !== undefined && (submission.origin.origin_kind !== 'app' + || !('public_endpoint_id' in submission.origin) + || submission.initiating_actor.actor_type !== 'app_public' + || submission.execution_actor.actor_type !== 'human')) + || (!trustedAppVector && !trustedRuntimeCapture && !trustedPublicCapture && submission.authorization_snapshot.authority_refs.some( (ref) => APP_AUTHORITY_KINDS.has(ref.authority_kind), )) || (submission.origin.origin_kind === 'legacy_connector' @@ -655,8 +840,9 @@ export class AppRunService { const resultExpiresAt = retentionDeadline(submission.retention_class, now); const idempotencyExpiresAt = idempotencyDeadline(submission.retention_class, now); - const submitted = await this.repository.transaction(async (tx) => { + const submitInTransaction = async (tx: AppRunTransaction) => { await this.repository.acquireSubmissionLock(tx, lock); + if (hostAdmission) await hostAdmission(tx); if (trustedAppVector) { if (!this.appLiveAuthorization) throw new AppRunError('APP_RUN_ACCESS_DENIED'); let live: AppRunAuthorizationSnapshot; @@ -718,6 +904,44 @@ export class AppRunService { throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); } } + if (trustedPublicCapture) { + if (!this.appLiveAuthorization?.captureReviewedPublicRuntimeInTransaction + || submission.origin.origin_kind !== 'app' + || !('public_endpoint_id' in submission.origin) + || !('public_ingress_id' in submission.origin) + || !('runtime_binding_id' in submission.origin) + || submission.initiating_actor.actor_type !== 'app_public' + || submission.execution_actor.actor_type !== 'human') { + throw new AppRunError('APP_RUN_ACCESS_DENIED'); + } + let live: ReviewedPublicRuntimeCapture; + try { + live = await this.appLiveAuthorization.captureReviewedPublicRuntimeInTransaction(tx, { + org_id: submission.org_id, + endpoint_id: submission.origin.public_endpoint_id, + ingress_id: submission.origin.public_ingress_id, + }); + } catch { + throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } + const binding = live.binding; + const matchingInput = canonicalCapabilityJson(live.public_input) + === canonicalCapabilityJson(submission.input); + if (canonicalAuthorization(live.authorization_snapshot) + !== canonicalAuthorization(submission.authorization_snapshot) + || runtimeCaptureIdentity(live) !== runtimeCaptureIdentity(trustedPublicCapture) + || live.endpoint.approver_user_id !== submission.execution_actor.user_id + || live.endpoint.id !== submission.initiating_actor.endpoint_id + || live.ingress.id !== submission.initiating_actor.ingress_id + || binding.id !== submission.origin.runtime_binding_id + || binding.app_installation_id !== submission.origin.installation_id + || binding.app_version_id !== submission.origin.app_version_id + || binding.grant_snapshot_id !== submission.origin.grant_snapshot_id + || binding.provider_instance_id !== submission.operation.provider.provider_instance_id + || binding.operation_name !== submission.operation.operation_name + || live.provider_snapshot_digest !== submission.provider_snapshot_digest + || !matchingInput) throw new AppRunError('APP_RUN_AUTHORIZATION_STALE'); + } const replay = await this.repository.findReplay( tx, submission, @@ -743,6 +967,8 @@ export class AppRunService { origin_app_version_id: _appVersion, origin_app_binding_key: _binding, origin_runtime_binding_id: _runtimeBinding, + origin_public_endpoint_id: _publicEndpoint, + origin_public_ingress_id: _publicIngress, origin_app_grant_snapshot_id: _grant, origin_app_automation_definition_id: _automationDefinition, origin_app_automation_fire_id: _automationFire, @@ -837,8 +1063,10 @@ export class AppRunService { await this.attemptScheduler.scheduleInTransaction(tx, run, now); } return run; - }); - if (submitted.state === 'pending_approval') { + }; + const submitted = existingTx ? await submitInTransaction(existingTx) + : await this.repository.transaction(submitInTransaction); + if (!existingTx && submitted.state === 'pending_approval') { try { await this.attention.projectApprovalRequested(submitted.org_id, submitted.id); } catch (error) { diff --git a/apps/api/src/lib/app-runtime-action-service.ts b/apps/api/src/lib/app-runtime-action-service.ts index 9b2cada5..e793dc4a 100644 --- a/apps/api/src/lib/app-runtime-action-service.ts +++ b/apps/api/src/lib/app-runtime-action-service.ts @@ -3,6 +3,7 @@ import type { AppRunSafeView } from './app-run-repository.js'; import { getAppRunRuntime } from './app-run-runtime.js'; import { appRuntimeChannelEnabled } from './app-runtime-channel.js'; import { AppRunError } from './app-run-errors.js'; +import type { AppRunTransaction } from './app-run-repository.js'; export const ReviewedRuntimeInvokeSchema = z.strictObject({ runtime_binding_id: z.string().uuid(), @@ -11,15 +12,17 @@ export const ReviewedRuntimeInvokeSchema = z.strictObject({ }); export type ReviewedRuntimeInvoke = z.infer; export type ReviewedRuntimeCaller = Readonly<{ org_id: string; user_id: string }>; +export type ReviewedRuntimeHostAdmission = (tx: AppRunTransaction) => Promise; export interface ReviewedRuntimeRunPort { - submitReviewedRuntime(caller: ReviewedRuntimeCaller, request: ReviewedRuntimeInvoke): Promise; + submitReviewedRuntime(caller: ReviewedRuntimeCaller, request: ReviewedRuntimeInvoke, + hostAdmission?: ReviewedRuntimeHostAdmission): Promise; reviewRuntimeInput(caller: ReviewedRuntimeCaller, runId: string): Promise; } const lazyRuns: ReviewedRuntimeRunPort = { - async submitReviewedRuntime(caller, request) { - return (await getAppRunRuntime()).service.submitReviewedRuntime(caller, request); + async submitReviewedRuntime(caller, request, hostAdmission) { + return (await getAppRunRuntime()).service.submitReviewedRuntime(caller, request, hostAdmission); }, async reviewRuntimeInput(caller, runId) { return (await getAppRunRuntime()).service.reviewRuntimeInput(caller, runId); @@ -38,6 +41,15 @@ export class AppRuntimeActionService { return this.runs.submitReviewedRuntime(caller, request); } + /** Only an in-process host broker may supply this guard. HTTP request data + * cannot construct callbacks or bypass the normal Runtime authority capture. */ + invokeFromExperience(caller: ReviewedRuntimeCaller, raw: unknown, + hostAdmission: ReviewedRuntimeHostAdmission): Promise { + if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); + const request = ReviewedRuntimeInvokeSchema.parse(raw); + return this.runs.submitReviewedRuntime(caller, request, hostAdmission); + } + review(caller: ReviewedRuntimeCaller, runId: string): Promise { if (!appRuntimeChannelEnabled()) throw new AppRunError('APP_RUNS_DISABLED'); return this.runs.reviewRuntimeInput(caller, z.string().uuid().parse(runId)); diff --git a/apps/api/src/lib/app-runtime-authority.ts b/apps/api/src/lib/app-runtime-authority.ts index 82c4e97c..686da98b 100644 --- a/apps/api/src/lib/app-runtime-authority.ts +++ b/apps/api/src/lib/app-runtime-authority.ts @@ -57,13 +57,41 @@ export async function runtimeRunMatchesAuthority( || run.review_requirement !== authority.review_requirement || run.retry_class !== authority.retry_class || run.retention_class !== authority.retention_class - || run.initiating_actor_type !== 'human' - || run.execution_actor_type !== 'human' - || run.initiating_actor_id !== run.execution_actor_id) return null; + || run.execution_actor_type !== 'human') return null; const snapshot = AppRunAuthorizationSnapshotSchema.safeParse(run.authorization_snapshot); - if (!snapshot.success || snapshot.data.authenticated_subject.actor_type !== 'human' - || snapshot.data.authenticated_subject.user_id !== run.initiating_actor_id) return null; + if (!snapshot.success) return null; try { + if (run.initiating_actor_type === 'app_public') { + if (!run.origin_public_endpoint_id || !run.origin_public_ingress_id + || run.initiating_actor_id !== run.origin_public_ingress_id + || snapshot.data.authenticated_subject.actor_type !== 'app_public' + || snapshot.data.authenticated_subject.endpoint_id !== run.origin_public_endpoint_id + || snapshot.data.authenticated_subject.ingress_id !== run.origin_public_ingress_id) return null; + const current = await runtimeLiveAuthorizer.captureReviewedPublicRuntimeInTransaction(tx, { + org_id: orgId, endpoint_id: run.origin_public_endpoint_id, + ingress_id: run.origin_public_ingress_id, + }); + const matches = current.registration.id === authority.pin.runtime_registration_id + && current.registration.runtime_epoch === authority.pin.runtime_epoch + && current.endpoint.approver_user_id === run.execution_actor_id + && current.binding.id === run.origin_runtime_binding_id + && current.binding.provider_instance_id === run.provider_instance_id + && current.binding.provider_snapshot_id === run.provider_snapshot_id + && current.binding.operation_name === run.operation_name + && current.binding.risk_class === run.risk_class + && current.binding.review_requirement === run.review_requirement + && current.binding.retry_class === run.retry_class + && current.binding.retention_class === run.retention_class + && current.action.host_policy.review_scope === run.review_scope + && canonicalCapabilityJson(snapshot.data.authority_refs) + === canonicalCapabilityJson(current.authorization_snapshot.authority_refs); + return matches ? current.action : null; + } + if (run.initiating_actor_type !== 'human' + || run.initiating_actor_id !== run.execution_actor_id + || run.origin_public_endpoint_id !== null || run.origin_public_ingress_id !== null + || snapshot.data.authenticated_subject.actor_type !== 'human' + || snapshot.data.authenticated_subject.user_id !== run.initiating_actor_id) return null; const current = await runtimeLiveAuthorizer.captureReviewedRuntimeInTransaction(tx, { org_id: orgId, user_id: run.initiating_actor_id, runtime_binding_id: authority.pin.runtime_binding_id, @@ -108,12 +136,20 @@ export async function loadLiveRuntimeAuthority( let prelockedRunActorId: string | undefined; if (runId) { const [runLocator] = await tx.select({ actor_type: appRuns.initiating_actor_type, - actor_id: appRuns.initiating_actor_id }).from(appRuns).where(and( - eq(appRuns.org_id, orgId), eq(appRuns.id, runId), - )).limit(1); - if (!runLocator || runLocator.actor_type !== 'human') return null; + actor_id: appRuns.initiating_actor_id, + execution_actor_type: appRuns.execution_actor_type, + execution_actor_id: appRuns.execution_actor_id, + origin_public_endpoint_id: appRuns.origin_public_endpoint_id, + origin_public_ingress_id: appRuns.origin_public_ingress_id }).from(appRuns).where(and( + eq(appRuns.org_id, orgId), eq(appRuns.id, runId), + )).limit(1); + if (!runLocator || (runLocator.actor_type !== 'human' && runLocator.actor_type !== 'app_public')) return null; + if (runLocator.actor_type === 'app_public' && (runLocator.execution_actor_type !== 'human' + || !runLocator.origin_public_endpoint_id || !runLocator.origin_public_ingress_id + || runLocator.actor_id !== runLocator.origin_public_ingress_id)) return null; prelockedRunActorId = runLocator.actor_id; - memberIds.push(runLocator.actor_id); + memberIds.push(runLocator.actor_type === 'human' + ? runLocator.actor_id : runLocator.execution_actor_id); } memberIds.push(locator.operator_user_id); for (const userId of [...new Set(memberIds)].sort()) { diff --git a/apps/api/src/lib/app-runtime-review.ts b/apps/api/src/lib/app-runtime-review.ts index 52eba5ee..fa12363f 100644 --- a/apps/api/src/lib/app-runtime-review.ts +++ b/apps/api/src/lib/app-runtime-review.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto'; -import { and, desc, eq, sql } from 'drizzle-orm'; +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { appInstallations, appVersions, appGrantSnapshots, auditLog } from '@deft/db/schema'; -import { DeftAppManifestV3Schema, RUNTIME_ACTION_HOST_POLICY, AppDigestSchema, type DeftAppManifestV3 } from '@deft/app-kit'; +import { appInstallations, appVersions, appGrantSnapshots, appModuleBindings, moduleInstallations, auditLog } from '@deft/db/schema'; +import { parseRuntimeAppManifest, RUNTIME_ACTION_HOST_POLICY, AppDigestSchema, type RuntimeAppManifest, type DeftAppPackage } from '@deft/app-kit'; import type { ModuleActor } from '@deft/shared/modules'; import { db } from './db.js'; import { AppError } from './app-errors.js'; -import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; +import { assertCurrentModuleManagerWithExecutor, installModuleFromManifestWithExecutor, invalidateModuleCatalogCaches, type ModuleLifecyclePostCommit } from './module-service.js'; import { APP_GRANT_SNAPSHOT_VERSION, buildRequestedAppGrantProjection, digestAppGrantValue } from './app-grant-service.js'; type Executor = Pick; @@ -22,7 +22,7 @@ export const RuntimeAppActivateRequestSchema = RuntimeAppReviewRequestSchema.ext expected_review_digest: AppDigestSchema, accept_host_policy: z.literal(true), }); -export function runtimeActionDescriptors(manifest: DeftAppManifestV3) { +export function runtimeActionDescriptors(manifest: RuntimeAppManifest) { return manifest.runtime_actions.map((action) => { const capability = manifest.private_capabilities.find((item) => item.key === action.capability_key)!; const identity = { namespace: 'app_lineage' as const, key: capability.key, version: capability.version }; @@ -48,10 +48,10 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s eq(appVersions.org_id, actor.org_id), eq(appVersions.installation_id, installationId), eq(appVersions.id, request.app_version_id), )).limit(1).for('share'); - if (!version || version.protocol_version !== '3' || !['staged', 'active'].includes(version.state) + if (!version || !['3', '4'].includes(version.protocol_version) || !['staged', 'active'].includes(version.state) || version.package_digest !== request.expected_package_digest || (installation.active_version_id && installation.active_version_id !== version.id)) throw stale(); - const manifest = DeftAppManifestV3Schema.parse(version.manifest); + const manifest = parseRuntimeAppManifest(version.manifest); const [requested] = await tx.select().from(appGrantSnapshots).where(and( eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), eq(appGrantSnapshots.app_version_id, version.id), eq(appGrantSnapshots.id, version.requested_grant_snapshot_id ?? ''), @@ -65,7 +65,8 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s || digestAppGrantValue(requested.canonical_snapshot) !== expected.snapshot_digest) throw stale(); const authority = { schema: 'deft.app_runtime_grant.v1' as const, lineage_key: installation.lineage_key, package_digest: version.package_digest, manifest_digest: version.manifest_digest, - runtime_actions: runtimeActionDescriptors(manifest) }; + runtime_actions: runtimeActionDescriptors(manifest), + ...(manifest.schema_version === '4' ? { modules: manifest.modules, experiences: manifest.experiences, public_actions: manifest.public_actions } : {}) }; const review = { ...request, installation_id: installationId, organization_id: actor.org_id, authority, requested_snapshot_id: requested.id }; return { installation, version, requested, authority, review: { ...review, review_digest: digestAppGrantValue(review) } }; @@ -78,9 +79,33 @@ export async function prepareRuntimeAppReview(actor: ModuleActor, installationId export async function activateRuntimeApp(actor: ModuleActor, installationId: string, raw: unknown) { const { expected_review_digest, accept_host_policy: _accept, ...request } = RuntimeAppActivateRequestSchema.parse(raw); - return db.transaction(async (tx) => { + const postCommit: ModuleLifecyclePostCommit[] = []; + const activated = await db.transaction(async (tx) => { const context = await reviewContext(tx, actor, installationId, request); if (context.review.review_digest !== expected_review_digest) throw stale(); + const manifest = parseRuntimeAppManifest(context.version.manifest); + if (manifest.schema_version === '4') { + if (context.version.state === 'staged') { + const pkg = context.version.package as unknown as DeftAppPackage; + for (const reference of [...manifest.modules].sort((a, b) => a.module_id.localeCompare(b.module_id))) { + const artifact = pkg.artifacts.find((item) => item.path === reference.manifest_path); + if (!artifact || artifact.digest !== reference.manifest_digest) throw stale(); + const installed = await installModuleFromManifestWithExecutor(tx, actor, JSON.parse(artifact.content) as unknown, { source: 'sideloaded' }); + postCommit.push(installed.postCommit); + await tx.insert(appModuleBindings).values({ org_id: actor.org_id, + app_installation_id: installationId, app_version_id: context.version.id, + module_installation_id: installed.row.installation.id, module_version_id: installed.row.version.id, + module_id: reference.module_id, ownership: 'app' }); + } + } else { + const owned = await tx.select().from(appModuleBindings).where(and(eq(appModuleBindings.org_id, actor.org_id), + eq(appModuleBindings.app_installation_id, installationId), eq(appModuleBindings.app_version_id, context.version.id), + eq(appModuleBindings.ownership, 'app'))); + for (const binding of owned) await tx.update(moduleInstallations).set({ is_enabled: true }).where(and( + eq(moduleInstallations.org_id, actor.org_id), eq(moduleInstallations.id, binding.module_installation_id), + eq(moduleInstallations.is_deleted, false))); + } + } const [prior] = await tx.select().from(appGrantSnapshots).where(and( eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), eq(appGrantSnapshots.snapshot_kind, 'effective'), @@ -119,6 +144,10 @@ export async function activateRuntimeApp(actor: ModuleActor, installationId: str metadata: { source: actor.source } }); return { installation, grant_snapshot_id: effectiveId }; }); + for (const effect of postCommit) effect.emit(); + await Promise.all(postCommit.map((effect) => effect.invalidate())); + await invalidateModuleCatalogCaches(actor.org_id); + return activated; } /** Callers lock membership first. This reader locks the installation/version and @@ -131,18 +160,21 @@ export async function loadReviewedRuntimeAction(tx: Executor, orgId: string, ins || !installation.active_grant_snapshot_id || installation.active_grant_snapshot_kind !== 'effective') throw stale(); const [version] = await tx.select().from(appVersions).where(and(eq(appVersions.org_id, orgId), eq(appVersions.installation_id, installationId), eq(appVersions.id, installation.active_version_id), - eq(appVersions.state, 'active'), eq(appVersions.protocol_version, '3'))).limit(1).for('share'); + eq(appVersions.state, 'active'), inArray(appVersions.protocol_version, ['3', '4']))).limit(1).for('share'); const [grant] = await tx.select().from(appGrantSnapshots).where(and(eq(appGrantSnapshots.org_id, orgId), eq(appGrantSnapshots.app_installation_id, installationId), eq(appGrantSnapshots.id, installation.active_grant_snapshot_id), eq(appGrantSnapshots.snapshot_kind, 'effective'))).limit(1); if (!version || !grant || grant.app_version_id !== version.id || digestAppGrantValue(grant.canonical_snapshot) !== grant.snapshot_digest) throw stale(); - const manifest = DeftAppManifestV3Schema.parse(version.manifest); + const manifest = parseRuntimeAppManifest(version.manifest); const expected = runtimeActionDescriptors(manifest); const stored = grant.canonical_snapshot; if (stored.schema !== 'deft.app_runtime_grant.v1' || stored.lineage_key !== installation.lineage_key || stored.package_digest !== version.package_digest || stored.manifest_digest !== version.manifest_digest || digestAppGrantValue(stored.runtime_actions) !== digestAppGrantValue(expected)) throw stale(); + if (manifest.schema_version === '4' && (digestAppGrantValue(stored.experiences) !== digestAppGrantValue(manifest.experiences) + || digestAppGrantValue(stored.public_actions) !== digestAppGrantValue(manifest.public_actions) + || digestAppGrantValue(stored.modules) !== digestAppGrantValue(manifest.modules))) throw stale(); const action = expected.find((item) => item.action_key === actionKey); if (!action) throw stale(); return { installation, version, grant, action }; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 371977b2..0a68401b 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -6,6 +6,7 @@ export type AuthUser = { id: string; email: string; org_id: string; + sid: string; role?: OrgRole; }; diff --git a/apps/api/src/routes/app-experiences.ts b/apps/api/src/routes/app-experiences.ts new file mode 100644 index 00000000..14a40c41 --- /dev/null +++ b/apps/api/src/routes/app-experiences.ts @@ -0,0 +1,102 @@ +import { Hono, type Context } from 'hono'; +import type { AuthUser } from '../middleware/auth.js'; +import { AppExperienceService, appExperienceService } from '../lib/app-experience-service.js'; +import { appHttpFailure } from './app-http-errors.js'; + +const MAX_ACTION_BYTES = 65_536; +const READ_DEADLINE_MS = 10_000; + +async function boundedJson(stream: ReadableStream | null): Promise { + if (!stream) throw new Error('APP_EXPERIENCE_BODY_INVALID'); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('APP_EXPERIENCE_BODY_INVALID'); + let timer: ReturnType | undefined; + const { done, value } = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('APP_EXPERIENCE_BODY_INVALID')), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_ACTION_BYTES) throw new Error('APP_EXPERIENCE_BODY_INVALID'); + chunks.push(value); + } + } catch (error) { + void reader.cancel().catch(() => undefined); + throw error; + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; } + try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)); } + catch { throw new Error('APP_EXPERIENCE_BODY_INVALID'); } +} + +function caller(user: AuthUser | undefined) { + if (!user?.id || !user.org_id || !user.sid) throw new Error('APP_EXPERIENCE_NO_AUTH'); + return { org_id: user.org_id, user_id: user.id, sid: user.sid }; +} + +function failure(c: Context, error: unknown) { + if (error instanceof Error && error.message === 'APP_EXPERIENCE_BODY_INVALID') { + return c.json({ error: 'Invalid Experience action request', code: 'VALIDATION_ERROR' }, 400); + } + if (error instanceof Error && error.message === 'APP_EXPERIENCE_NO_AUTH') { + return c.json({ error: 'Experience access denied', code: 'APP_ACCESS_DENIED' }, 403); + } + return appHttpFailure(c, error, 'App action', 'app-actions'); +} + +/** Mount behind the existing web Bearer-authenticated API group. */ +export function createAppExperienceRoutes(service: AppExperienceService = appExperienceService) { + const routes = new Hono(); + routes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + await next(); + }); + routes.post('/:installationId/:experienceKey/sessions', async (c) => { + try { + return c.json(await service.create(caller(c.get('user') as AuthUser | undefined), + c.req.param('installationId'), c.req.param('experienceKey'))); + } catch (error) { return failure(c, error); } + }); + routes.get('/sessions/:sessionId/live', async (c) => { + try { + return c.json(await service.live(caller(c.get('user') as AuthUser | undefined), + c.req.param('sessionId'))); + } catch (error) { return failure(c, error); } + }); + routes.delete('/sessions/:sessionId', async (c) => { + try { + return c.json(await service.revoke(caller(c.get('user') as AuthUser | undefined), + c.req.param('sessionId'))); + } catch (error) { return failure(c, error); } + }); + routes.post('/sessions/:sessionId/actions/:actionKey', async (c) => { + try { + if (!/^application\/json(?:\s*;|$)/i.test(c.req.header('content-type') ?? '')) { + throw new Error('APP_EXPERIENCE_BODY_INVALID'); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_ACTION_BYTES) { + throw new Error('APP_EXPERIENCE_BODY_INVALID'); + } + const body = await boundedJson(c.req.raw.body); + return c.json(await service.action(caller(c.get('user') as AuthUser | undefined), + c.req.param('sessionId'), c.req.param('actionKey'), body)); + } catch (error) { return failure(c, error); } + }); + return routes; +} + +export const appExperienceRoutes = createAppExperienceRoutes(); diff --git a/apps/api/src/routes/app-public-management.ts b/apps/api/src/routes/app-public-management.ts new file mode 100644 index 00000000..5743792e --- /dev/null +++ b/apps/api/src/routes/app-public-management.ts @@ -0,0 +1,88 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import type { AuthUser } from '../middleware/auth.js'; +import { humanModuleActor } from '../lib/module-service.js'; +import { AppError, isAppError } from '../lib/app-errors.js'; +import { isModuleError } from '../lib/module-errors.js'; +import { appRuntimeChannelEnabled } from '../lib/app-runtime-channel.js'; +import { activatePublicEndpoint, disablePublicEndpoint, + stagePublicEndpoint } from '../lib/app-public-management.js'; + +export const appPublicManagementRoutes = new Hono(); +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/); +const MAX_BODY_BYTES = 8192; +const READ_DEADLINE_MS = 10_000; + +function actor(c: Context) { + const user = c.get('user') as AuthUser | undefined; + if (!user?.id || !user.org_id) throw new AppError('Authentication required', 'APP_ACCESS_DENIED', 403); + return humanModuleActor({ orgId: user.org_id, userId: user.id, + role: user.role ?? 'member', source: 'rest' }); +} + +async function body(c: Context): Promise { + if (c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_BODY_BYTES) { + throw new AppError('Public endpoint request too large', 'APP_ACTION_INVALID', 413); + } + const reader = c.req.raw.body?.getReader(); + if (!reader) throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + const chunks: Uint8Array[] = []; + let size = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new AppError('Public endpoint request timed out', 'APP_ACTION_INVALID', 400); + let timer: ReturnType | undefined; + const next = await Promise.race([reader.read(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new AppError('Public endpoint request timed out', 'APP_ACTION_INVALID', 400)), remaining); + })]).finally(() => { if (timer) clearTimeout(timer); }); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_BODY_BYTES) throw new AppError('Public endpoint request too large', 'APP_ACTION_INVALID', 413); + chunks.push(next.value); + } + } catch (error) { + void reader.cancel().catch(() => {}); + throw error; + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; +} + +function failure(c: Context, error: unknown) { + if (isAppError(error) || isModuleError(error)) return c.json({ error: error.message, code: error.code }, error.status); + if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError) { + return c.json({ error: 'Invalid public endpoint request', code: 'VALIDATION_ERROR' }, 400); + } + console.error('[app-public-management] request failed'); + return c.json({ error: 'Public endpoint request failed', code: 'INTERNAL_ERROR' }, 500); +} + +appPublicManagementRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!appRuntimeChannelEnabled()) { + return c.json({ error: 'Runtime unavailable', code: 'APP_RUNTIME_DISABLED' }, 503); + } + await next(); +}); +appPublicManagementRoutes.post('/endpoints/stage', async (c) => { + try { return c.json(await stagePublicEndpoint(actor(c), await body(c)), 201); } + catch (error) { return failure(c, error); } +}); +appPublicManagementRoutes.post('/endpoints/:endpointId/activate', async (c) => { + try { return c.json(await activatePublicEndpoint(actor(c), + Id.parse(c.req.param('endpointId')), await body(c))); } + catch (error) { return failure(c, error); } +}); +appPublicManagementRoutes.post('/endpoints/:endpointId/disable', async (c) => { + try { return c.json(await disablePublicEndpoint(actor(c), + Id.parse(c.req.param('endpointId')))); } + catch (error) { return failure(c, error); } +}); diff --git a/apps/api/src/routes/app-public.ts b/apps/api/src/routes/app-public.ts index 7d4b42cd..b511f76e 100644 --- a/apps/api/src/routes/app-public.ts +++ b/apps/api/src/routes/app-public.ts @@ -2,8 +2,8 @@ import { Hono } from 'hono'; import { AppPublicError, AppPublicClaimService, appPublicClaimService } from '../lib/app-public-service.js'; import { appPublicLimits } from '../middleware/app-public-limits.js'; -// Deliberately unmounted until the public gateway review and limits are wired. -// This route never reads workspace cookies, bearer headers or c.get('user'). +// The gateway mounts only with an explicit host opt-in and applies limits +// before body parsing. This route never reads cookies, bearer headers or user. const HARD_BODY_LIMIT = 8192; const READ_DEADLINE_MS = 10_000; diff --git a/apps/api/test/app-experience-browser.mts b/apps/api/test/app-experience-browser.mts new file mode 100644 index 00000000..1e735b50 --- /dev/null +++ b/apps/api/test/app-experience-browser.mts @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { chromium, firefox, webkit } from 'playwright'; + +const database = process.env.DATABASE_URL; +const target = process.env.DEFT_TEST_DATABASE_URL; +assert.ok(database && database === target && /^postgresql:\/\/gate_g_test@127\.0\.0\.1:55435\/gate_g_phase5_test_c03_(?:b_experience|root(?:_v[0-9]+)?)$/.test(database)); +const packagePath = process.env.GATE_G_INSTALLED_PACKAGE_PATH; +const evidenceDir = process.env.GATE_G_EXPERIENCE_EVIDENCE_DIR; +assert.ok(packagePath && evidenceDir, 'Set installed package and external evidence directory'); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; +process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:4315'; +const key = (purpose: string) => createHash('sha256').update(`experience-browser:${purpose}`).digest('base64'); +process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fp') } }, +}); +const [{ app }, { db, closeDb }, schema, appService, reviewService, + management, moduleService, webSessions, ringFixture, nodeServer] = await Promise.all([ + import('../src/index.js'), import('../src/lib/db.js'), + import('@deft/db/schema'), import('../src/lib/app-service.js'), + import('../src/lib/app-runtime-review.js'), + import('../src/lib/app-runtime-management.js'), + import('../src/lib/module-service.js'), + import('../src/lib/web-sessions.js'), + import('./fixtures/app-run-test-keyrings.js'), + import('@hono/node-server'), +]); +const { and, eq } = await import('drizzle-orm'); +const ring = await ringFixture.databaseCompleteAppRunTestKeyringFixture('experience-browser'); +process.env.DEFT_APP_RUN_KEYRINGS = ring.environment; +ring.keys.destroy(); +const suffix = randomUUID(); +const orgId = randomUUID(); +const userId = randomUUID(); +const email = `experience-browser-${suffix}@example.test`; +await db.insert(schema.orgs).values({ id: orgId, name: 'Experience browser fixture', slug: `experience-browser-${suffix}` }); +await db.insert(schema.users).values({ id: userId, name: 'Experience owner', email }); +await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, user_id: userId, + role: 'owner', is_active: true }); +const owner = moduleService.humanModuleActor({ orgId, userId, role: 'owner', source: 'rest' }); +const json = await readFile(packagePath, 'utf8'); +const staged = await appService.stageAppPackage(owner, json); +const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); +assert.ok(version?.requested_grant_snapshot_id); +const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); +assert.ok(requested); +const reviewRequest = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; +const review = await reviewService.prepareRuntimeAppReview(owner, staged.id, reviewRequest); +const active = await reviewService.activateRuntimeApp(owner, staged.id, { + ...reviewRequest, expected_review_digest: review.review_digest, accept_host_policy: true, +}); +const [grant] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), eq(schema.appGrantSnapshots.id, active.grant_snapshot_id))); +assert.ok(grant); +const bindingRequest = { installation_id: staged.id, action_key: 'create_shipping_label', + operator_user_id: userId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: active.installation.lifecycle_epoch, + expected_grant_epoch: active.installation.grant_epoch }; +const bindingReview = await management.prepareRuntimeBindingReview(owner, bindingRequest); +await management.activateRuntimeBinding(owner, { ...bindingRequest, + expected_review_digest: bindingReview.review_digest, accept_host_policy: true }); +const server = nodeServer.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 4316 }); +const results: unknown[] = []; +await mkdir(evidenceDir, { recursive: true }); +try { + for (const [browserName, engine] of Object.entries({ chromium, firefox, webkit })) { + if (process.env.GATE_G_BROWSER && browserName !== process.env.GATE_G_BROWSER) continue; + const browser = await engine.launch({ headless: true }); + try { + for (const width of [1280, 390, 320]) { + if (process.env.GATE_G_WIDTH && width !== Number(process.env.GATE_G_WIDTH)) continue; + const context = await browser.newContext({ viewport: { width, height: 850 } }); + const tokens = await webSessions.createWebSession({ id: userId, email, org_id: orgId }); + await context.addInitScript(({ access, refresh }) => { + if (self !== top) return; + localStorage.setItem('deft-access-token', access); + localStorage.setItem('deft-refresh-token', refresh); + }, { access: tokens.accessToken, refresh: tokens.refreshToken }); + const page = await context.newPage(); + page.setDefaultTimeout(60_000); + const consoleErrors: string[] = []; + page.on('pageerror', (error) => consoleErrors.push(error.message)); + await page.goto(`http://localhost:4315/apps/${staged.id}/main`, + { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.getByRole('heading', { name: 'Shipping Label' }).last().waitFor({ timeout: 30_000 }); + await page.getByRole('button', { name: 'Create shipping label' }).waitFor({ timeout: 30_000 }); + const input = page.getByLabel('Shipment identifier'); + await input.fill(`shipment-${browserName}-${width}`); + await input.press('Tab'); + await page.getByRole('button', { name: 'Create shipping label' }).click(); + await page.getByText('Submitted. Open approvals to review the exact input.').waitFor({ timeout: 30_000 }); + const frame = page.frame({ url: /app-experience-bootstrap/ }); + assert.ok(frame, 'trusted bootstrap frame loaded'); + const isolation = await frame.evaluate(() => { + let cookie = 'readable'; + try { cookie = document.cookie; } catch { cookie = 'blocked'; } + return { origin: self.origin, cookie }; + }); + const screenshot = `${evidenceDir}/${browserName}-${width}.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + results.push({ browser: browserName, width, screenshot, isolation, consoleErrors, + submitted: true, horizontalOverflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth) }); + assert.equal(isolation.origin, 'null', `${browserName} ${width}: opaque frame origin`); + assert.equal(isolation.cookie, 'blocked', `${browserName} ${width}: host cookie unavailable`); + assert.deepEqual(consoleErrors, []); + await context.close(); + } + } finally { await browser.close(); } + } + if (process.env.GATE_G_STALE_UI === 'true') { + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ viewport: { width: 390, height: 850 } }); + const tokens = await webSessions.createWebSession({ id: userId, email, org_id: orgId }); + await context.addInitScript(({ access, refresh }) => { + if (self !== top) return; + localStorage.setItem('deft-access-token', access); + localStorage.setItem('deft-refresh-token', refresh); + }, { access: tokens.accessToken, refresh: tokens.refreshToken }); + let releaseResponse!: () => void; + let markPending!: () => void; + const pending = new Promise((resolve) => { markPending = resolve; }); + const held = new Promise((resolve) => { releaseResponse = resolve; }); + await context.route('**/api/app-experiences/*/main/sessions', async (route) => { + if (route.request().method() === 'POST') { markPending(); await held; } + await route.continue(); + }); + const page = await context.newPage(); + await page.goto(`http://localhost:4315/apps/${staged.id}/main`, { waitUntil: 'domcontentloaded' }); + await pending; + await page.getByRole('link', { name: 'App settings' }).click(); + await page.waitForURL('**/settings/apps'); + releaseResponse(); + await page.waitForTimeout(1200); + assert.equal(await page.locator('[aria-label="App Experience"]').count(), 0, + 'late session response cannot restore old Experience after navigation'); + await context.close(); + } finally { await browser.close(); } + } + const runs = await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, orgId)); + assert.equal(runs.length, results.length, 'each browser/viewport action created one reviewed Run'); + await writeFile(`${evidenceDir}/results.json`, JSON.stringify({ app_installation_id: staged.id, + app_version_id: version.id, package_digest: version.package_digest, runs: runs.length, + results }, null, 2)); + process.stdout.write(JSON.stringify({ runs: runs.length, results }, null, 2) + '\n'); +} finally { + await new Promise((resolve) => server.close(() => resolve())); + await closeDb(); +} diff --git a/apps/api/test/app-experience-db.test.ts b/apps/api/test/app-experience-db.test.ts new file mode 100644 index 00000000..c64eac9b --- /dev/null +++ b/apps/api/test/app-experience-db.test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03(?:_b_experience|_root(?:_v[0-9]+)?|b_root(?:_v[0-9]+)?)$/.test(new URL(target).pathname); + +test('installed Experience session pins human web SID, App, grant and bounded active count', + { skip: !safe }, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`experience-db:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fp') } }, + }); + const [{ db, closeDb }, schema, kit, appService, reviewService, + moduleService, experience, management, keyringFixture, runtimeAction, runRuntime] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), import('../src/lib/app-experience-service.js'), + import('../src/lib/app-runtime-management.js'), import('./fixtures/app-run-test-keyrings.js'), + import('../src/lib/app-runtime-action-service.js'), import('../src/lib/app-run-runtime.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + try { + const ring = await keyringFixture.databaseCompleteAppRunTestKeyringFixture('experience-db'); + process.env.DEFT_APP_RUN_KEYRINGS = ring.environment; + ring.keys.destroy(); + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); + const otherOrgId = randomUUID(); + const ownerId = randomUUID(); + const otherId = randomUUID(); + const agentId = randomUUID(); + const sid = randomUUID(); + const nextSid = randomUUID(); + await db.insert(schema.orgs).values([ + { id: orgId, name: 'Experience test', slug: `experience-${suffix}` }, + { id: otherOrgId, name: 'Other Experience test', slug: `other-experience-${suffix}` }, + ]); + await db.insert(schema.users).values([ + { id: ownerId, name: 'Owner', email: `experience-owner-${suffix}@example.test` }, + { id: otherId, name: 'Other', email: `experience-other-${suffix}@example.test` }, + { id: agentId, kind: 'agent', name: 'Agent', email: `experience-agent-${suffix}@example.test` }, + ]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: otherOrgId, user_id: otherId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: agentId, role: 'member', is_active: true }, + ]); + const expiry = new Date(Date.now() + 86_400_000); + await db.insert(schema.webSessions).values([ + { id: sid, org_id: orgId, user_id: ownerId, refresh_token_hash: 'fixture', expires_at: expiry }, + { id: nextSid, org_id: orgId, user_id: ownerId, refresh_token_hash: 'fixture-next', expires_at: expiry }, + { id: randomUUID(), org_id: orgId, user_id: agentId, refresh_token_hash: 'fixture-agent', expires_at: expiry }, + ]); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const object = { type: 'object' as const, + properties: { shipment_id: { type: 'string' as const, maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false as const }; + const artifact = await kit.prepareDeftExperienceArtifact('experiences/main.json', { + schema_version: 'deft.experience_bundle.v1', + worker_source: 'self.onmessage = () => {};', entry_view: 'main', + resource_keys: [], action_keys: ['create_shipping_label'], + }); + const pkg = await kit.buildDeftAppPackage({ manifest: { + schema_version: '4', id: `community.example.experience.a${suffix}`, + version: '1.0.0', name: 'Experience fixture', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '4' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'label', version: '1', input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'create_shipping_label', label: 'Create shipping label', + capability_key: 'label', runtime_requirement_key: 'carrier' }], + experiences: [{ key: 'main', label: 'Shipping Label', artifact_path: artifact.path, + artifact_digest: artifact.digest, bridge_version: 'deft.experience_bridge.v1', + renderer_version: 'deft.trusted_renderer.v1' }], public_actions: [], + }, artifacts: [artifact] }); + const staged = await appService.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const reviewRequest = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const review = await reviewService.prepareRuntimeAppReview(owner, staged.id, reviewRequest); + const active = await reviewService.activateRuntimeApp(owner, staged.id, { + ...reviewRequest, expected_review_digest: review.review_digest, accept_host_policy: true, + }); + assert.equal(active.installation.state, 'active'); + const [effective] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, active.grant_snapshot_id))); + assert.ok(effective); + const bindRequest = { installation_id: staged.id, action_key: 'create_shipping_label', + operator_user_id: ownerId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: effective.snapshot_digest, + expected_lifecycle_epoch: active.installation.lifecycle_epoch, + expected_grant_epoch: active.installation.grant_epoch }; + const bindReview = await management.prepareRuntimeBindingReview(owner, bindRequest); + const binding = await management.activateRuntimeBinding(owner, { + ...bindRequest, expected_review_digest: bindReview.review_digest, accept_host_policy: true, + }); + assert.ok(binding.binding_id); + const caller = { org_id: orgId, user_id: ownerId, sid }; + const first = await experience.appExperienceService.create(caller, staged.id, 'main'); + assert.equal(first.bundle.worker_source, 'self.onmessage = () => {};'); + assert.equal(first.pin.app_version_id, version.id); + assert.equal(first.pin.grant_snapshot_id, active.grant_snapshot_id); + assert.equal((await experience.appExperienceService.live(caller, first.pin.session_id)).live, true); + await assert.rejects(experience.appExperienceService.live({ ...caller, sid: nextSid }, first.pin.session_id)); + await assert.rejects(experience.appExperienceService.live({ ...caller, user_id: otherId }, first.pin.session_id)); + await assert.rejects(experience.appExperienceService.live({ ...caller, org_id: otherOrgId }, first.pin.session_id)); + await assert.rejects(experience.appExperienceService.create({ org_id: orgId, user_id: agentId, + sid: (await db.select().from(schema.webSessions).where(eq(schema.webSessions.user_id, agentId)))[0]!.id }, + staged.id, 'main')); + await assert.rejects(experience.appExperienceService.create(caller, staged.id, 'unknown')); + const concurrent = await Promise.allSettled(Array.from({ length: 9 }, () => + experience.appExperienceService.create(caller, staged.id, 'main'))); + assert.equal(concurrent.filter((item) => item.status === 'fulfilled').length, 7, + 'one existing plus seven parallel sessions reach the cap of eight'); + assert.equal(concurrent.filter((item) => item.status === 'rejected').length, 2); + const invoked = await experience.appExperienceService.action(caller, first.pin.session_id, + 'create_shipping_label', { request_id: 'request_1', input: { shipment_id: 'shipment-1' } }); + assert.equal(invoked.run.state, 'pending_approval'); + const replay = await experience.appExperienceService.action(caller, first.pin.session_id, + 'create_shipping_label', { request_id: 'request_1', input: { shipment_id: 'shipment-1' } }); + assert.equal(replay.run.id, invoked.run.id); + await assert.rejects(experience.appExperienceService.action(caller, first.pin.session_id, + 'create_shipping_label', { request_id: 'request_1', input: { shipment_id: 'different' } })); + const beforeRace = await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, orgId)); + let signalEntered!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { signalEntered = resolve; }); + const barrier = new Promise((resolve) => { release = resolve; }); + const delayedRuntime = new runtimeAction.AppRuntimeActionService({ + async submitReviewedRuntime(actor, request, guard) { + signalEntered(); + await barrier; + return (await runRuntime.getAppRunRuntime()).service.submitReviewedRuntime(actor, request, guard); + }, + async reviewRuntimeInput(actor, runId) { + return (await runRuntime.getAppRunRuntime()).service.reviewRuntimeInput(actor, runId); + }, + }); + const delayedService = new experience.AppExperienceService(delayedRuntime); + const pending = delayedService.action(caller, first.pin.session_id, 'create_shipping_label', + { request_id: 'request_2', input: { shipment_id: 'race-shipment' } }); + await entered; + await db.update(schema.webSessions).set({ revoked_at: new Date() }).where(eq(schema.webSessions.id, sid)); + release(); + await assert.rejects(pending, 'revoked web SID must fail inside the real Run transaction'); + const afterRace = await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, orgId)); + assert.equal(afterRace.length, beforeRace.length, 'revoked request inserted no Run'); + await assert.rejects(experience.appExperienceService.live(caller, first.pin.session_id)); + const nextCaller = { ...caller, sid: nextSid }; + const afterLogin = await experience.appExperienceService.create(nextCaller, staged.id, 'main'); + await assert.rejects(experience.appExperienceService.live(caller, afterLogin.pin.session_id)); + const disabled = await appService.disableAppInstallation(owner, staged.id, + active.installation.lifecycle_epoch); + assert.equal(disabled.state, 'disabled'); + await assert.rejects(experience.appExperienceService.live(nextCaller, afterLogin.pin.session_id)); + await assert.rejects(experience.appExperienceService.create(nextCaller, staged.id, 'main')); + } finally { await closeDb(); } + }); diff --git a/apps/api/test/app-installed-review-db.test.ts b/apps/api/test/app-installed-review-db.test.ts new file mode 100644 index 00000000..ed84bbb0 --- /dev/null +++ b/apps/api/test/app-installed-review-db.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03(?:b)?_root(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('v4 activation rolls back earlier included Modules when a later Module conflicts', { skip: !safe }, async () => { + const [{ db, closeDb }, schema, kit, apps, review, modules] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + try { + const orgId = randomUUID(); + const ownerId = randomUUID(); + const suffix = randomUUID().replaceAll('-', ''); + await db.insert(schema.orgs).values({ id: orgId, name: 'Atomic installed App', slug: `atomic-${suffix}` }); + await db.insert(schema.users).values({ id: ownerId, name: 'Owner', email: `atomic-${suffix}@example.test` }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const moduleManifest = (name: string) => ({ schema_version: '1', + id: `community.example.${name}`, slug: name, version: '1.0.0', name, + collections: [{ key: 'items', name: 'Items', singular_name: 'Item', + fields: [{ key: 'title', label: 'Title', type: 'text', required: true }], + views: [{ key: 'all', name: 'All', type: 'table', fields: ['title'] }], + search: { title_field: 'title', subtitle_fields: [], fields: ['title'] } }], + navigation: { default_collection: 'items', default_view: 'all' } }); + const first = moduleManifest('a-atomic'); + const conflict = moduleManifest('z-existing'); + await modules.installModuleFromManifest(owner, conflict, { source: 'sideloaded' }); + const artifacts = await Promise.all([first, conflict].map((manifest) => + kit.prepareModuleArtifact({ path: `modules/${manifest.slug}/deft.module.json`, manifest }))); + const object = { type: 'object' as const, + properties: { resource_id: { type: 'string' as const, maxLength: 120 } }, + required: ['resource_id'], additionalProperties: false as const }; + const pkg = await kit.buildDeftAppPackage({ manifest: { + schema_version: '4', id: `community.example.atomic.a${suffix}`, version: '1.0.0', + name: 'Atomic App', license: 'AGPL-3.0-only', compatibility: { app_protocol: '4' }, + modules: artifacts.map((artifact, index) => ({ module_id: [first, conflict][index]!.id, + version: '1.0.0', manifest_path: artifact.path, manifest_digest: artifact.digest })), + navigation: [], runtime_requirements: [{ key: 'operator', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'action', version: '1', input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'perform_action', label: 'Perform action', capability_key: 'action', runtime_requirement_key: 'operator' }], + experiences: [], public_actions: [], + }, artifacts }); + const staged = await apps.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(eq(schema.appVersions.id, staged.version_id)); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots) + .where(eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + const request = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const prepared = await review.prepareRuntimeAppReview(owner, staged.id, request); + await assert.rejects(review.activateRuntimeApp(owner, staged.id, { ...request, + expected_review_digest: prepared.review_digest, accept_host_policy: true }), + (error: unknown) => error instanceof Error && 'code' in error && error.code === 'MODULE_ALREADY_INSTALLED'); + const installed = await db.select().from(schema.moduleInstallations).where(eq(schema.moduleInstallations.org_id, orgId)); + assert.equal(installed.length, 1); + assert.equal(installed[0]!.module_id, conflict.id); + assert.equal(installed[0]!.is_enabled, true); + assert.deepEqual(await db.select().from(schema.appModuleBindings) + .where(eq(schema.appModuleBindings.app_installation_id, staged.id)), []); + assert.deepEqual(await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.app_installation_id, staged.id), + eq(schema.appGrantSnapshots.snapshot_kind, 'effective'))), []); + const [after] = await db.select().from(schema.appInstallations).where(eq(schema.appInstallations.id, staged.id)); + assert.equal(after?.state, 'staged'); + assert.equal(after?.active_version_id, null); + assert.equal(after?.lifecycle_epoch, staged.lifecycle_epoch); + assert.equal(after?.grant_epoch, staged.grant_epoch); + const [afterVersion] = await db.select().from(schema.appVersions).where(eq(schema.appVersions.id, version.id)); + assert.equal(afterVersion?.state, 'staged'); + assert.equal(afterVersion?.activated_at, null); + assert.equal((await review.prepareRuntimeAppReview(owner, staged.id, request)).review_digest, prepared.review_digest); + } finally { await closeDb(); } +}); diff --git a/apps/api/test/app-public-route-flags.test.ts b/apps/api/test/app-public-route-flags.test.ts new file mode 100644 index 00000000..97672edd --- /dev/null +++ b/apps/api/test/app-public-route-flags.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03b_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('anonymous public route mounts only when Apps and explicit ingress opt-in are both enabled', + { skip: !safe }, () => { + const script = `import { app } from './src/index.ts'; + const response = await app.request('http://localhost/api/public/apps/invalid/claims', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' + }); + console.log('FLAG_RESULT:' + JSON.stringify({ status: response.status, + body: await response.json() })); + process.exit(0);`; + function result(apps: string, ingress: string) { + const output = execFileSync(process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', script], { + cwd: fileURLToPath(new URL('../', import.meta.url)), + env: { ...process.env, DEFT_APPS_ENABLED: apps, + DEFT_APP_PUBLIC_INGRESS_ENABLED: ingress }, + encoding: 'utf8', timeout: 60_000, + }); + const line = output.split(/\r?\n/).find((part) => part.startsWith('FLAG_RESULT:')); + assert.ok(line, output); + return JSON.parse(line.slice('FLAG_RESULT:'.length)) as { + status: number; body: { code?: string }; + }; + } + const defaultOff = result('true', 'false'); + const appsOff = result('false', 'true'); + const enabled = result('true', 'true'); + assert.notEqual(defaultOff.body.code, 'PUBLIC_NOT_FOUND'); + assert.notEqual(appsOff.body.code, 'PUBLIC_NOT_FOUND'); + assert.equal(enabled.status, 404); + assert.equal(enabled.body.code, 'PUBLIC_NOT_FOUND'); + }); diff --git a/apps/api/test/app-public-runtime-http-db.test.ts b/apps/api/test/app-public-runtime-http-db.test.ts new file mode 100644 index 00000000..3ca49a4f --- /dev/null +++ b/apps/api/test/app-public-runtime-http-db.test.ts @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { fork, type ChildProcess } from 'node:child_process'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03b_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); +const dbRejects = (code: string) => (error: unknown) => + error instanceof Error && 'cause' in error + && (error.cause as Error | undefined)?.message === code; + +test('packed v4 public claim creates a distinct principal Run, approved Runtime effect and signed receipt', + { skip: !safe }, async () => { + const artifactPath = process.env.DEFT_INSTALLED_AUTHOR_PACKAGE; + assert.ok(artifactPath, 'DEFT_INSTALLED_AUTHOR_PACKAGE must point to a packed v4 installed App artifact'); + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_DEVELOPER_PAIRING_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + process.env.DEFT_APP_PUBLIC_INGRESS_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`public-runtime-http:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fp') } } }); + const [{ app }, { db, closeDb }, schema, sessionModule, runModule, keyringFixture, + moduleService, queues, workers, serverModule, shared] = await Promise.all([ + import('../src/index.js'), import('../src/lib/db.js'), import('@deft/db/schema'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-runtime.js'), + import('./fixtures/app-run-test-keyrings.js'), import('../src/lib/module-service.js'), + import('../src/lib/queues.js'), import('../src/workers/index.js'), + import('@hono/node-server'), import('@deft/shared'), + ]); + const { and, eq } = await import('drizzle-orm'); + const base = 'http://127.0.0.1:4338'; + let server: ReturnType | undefined; + let child: ChildProcess | undefined; + try { + await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 4338 }, resolve); + }); + const ring = await keyringFixture.databaseCompleteAppRunTestKeyringFixture('public-runtime-http'); + process.env.DEFT_APP_RUN_KEYRINGS = ring.environment; + ring.keys.destroy(); + const suffix = randomUUID(); + const orgId = randomUUID(); + const ownerId = randomUUID(); + const otherId = randomUUID(); + const email = `public-runtime-${suffix}@example.test`; + await db.insert(schema.orgs).values({ id: orgId, name: 'Public Runtime journey', + slug: `public-runtime-${suffix}` }); + await db.insert(schema.users).values([{ id: ownerId, email, name: 'Public approver' }, + { id: otherId, email: `public-other-${suffix}@example.test`, name: 'Other owner' }]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: otherId, role: 'owner', is_active: true }, + ]); + const web = await sessionModule.createWebSession({ id: ownerId, email, org_id: orgId }); + const otherWeb = await sessionModule.createWebSession({ id: otherId, + email: `public-other-${suffix}@example.test`, org_id: orgId }); + const auth = { Authorization: `Bearer ${web.accessToken}` }; + async function call(path: string, method = 'GET', body?: unknown, token = auth.Authorization) { + const response = await fetch(new Request(`${base}${path}`, { method, + headers: { Authorization: token, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) })); + const value = await response.json() as any; + assert.ok(response.ok, `${method} ${path}: ${response.status} ${JSON.stringify(value)}`); + return value; + } + const pairing = (await call('/api/apps/pairings', 'POST')).pairing; + const exchanged = await call('/api/app-developer/pair/exchange', 'POST', { code: pairing.code }, ''); + const packed = await readFile(artifactPath, 'utf8'); + const installResponse = await fetch(new Request(`${base}/api/app-developer/install`, { + method: 'POST', headers: { Authorization: `Bearer ${exchanged.token}`, + 'Content-Type': 'application/json' }, body: packed, + })); + const installed = await installResponse.json() as any; + assert.equal(installResponse.status, 201, JSON.stringify(installed)); + const staged = installed.app; + const grants = (await call(`/api/apps/${staged.id}/grants`)).grants; + const version = grants.versions.find((row: any) => row.id === staged.version_id); + const requested = grants.snapshots.find((row: any) => row.id === version.requested_grant_snapshot_id); + assert.ok(requested); + const reviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: grants.installation.lifecycle_epoch, + expected_grant_epoch: grants.installation.grant_epoch }; + const review = await call(`/api/app-runtime-review/${staged.id}/review`, 'POST', reviewInput); + const activated = await call(`/api/app-runtime-review/${staged.id}/activate`, 'POST', { + ...reviewInput, expected_review_digest: review.review_digest, accept_host_policy: true, + }); + const effective = (await call(`/api/apps/${staged.id}/grants`)).grants; + const grant = effective.snapshots.find((row: any) => row.id === activated.grant_snapshot_id); + assert.ok(grant); + const bindingInput = { installation_id: staged.id, action_key: 'create_shipping_label', + operator_user_id: ownerId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: effective.installation.lifecycle_epoch, + expected_grant_epoch: effective.installation.grant_epoch }; + const bindingReview = (await call('/api/apps/runtime/reviews/prepare', 'POST', bindingInput)).review; + const binding = (await call('/api/apps/runtime/bindings/activate', 'POST', { + ...bindingInput, expected_review_digest: bindingReview.review_digest, + accept_host_policy: true, + })).binding; + const stage = await call('/api/apps/public/endpoints/stage', 'POST', { + installation_id: staged.id, public_action_key: 'claim_label', + runtime_binding_id: binding.binding_id, approver_user_id: ownerId, + public_label: 'Claim a shipping label', max_body_bytes: 1024, + expected_app_version_id: version.id, + expected_grant_snapshot_id: grant.id, + expected_lifecycle_epoch: effective.installation.lifecycle_epoch, + expected_grant_epoch: effective.installation.grant_epoch, + }); + assert.equal(stage.state, 'disabled'); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, + role: 'owner', source: 'ui' }); + const module = await moduleService.getModuleInstallation(owner, + { moduleId: 'community.example.hello-workspace' }); + const created = await moduleService.createModuleRecord(owner, { + module_id: module.module_id, collection_key: 'greetings', + data: { message: 'Synthetic shipment' }, relations: {}, + expected_manifest_digest: module.manifest_digest, + idempotency_key: `public-shipment-${suffix}`, + }); + assert.ok(created.record); + const claimBody = { resource_ref: { schema_version: shared.RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module', provider_instance_id: module.id }, + resource_type: 'greetings', resource_id: created.record.id }, + expected_revision: created.record.revision, idempotency_key: `claim-${suffix}` }; + const publicPath = `/api/public/apps/${stage.slug}/claims`; + const disabled = await fetch(`${base}${publicPath}`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(claimBody) }); + assert.equal(disabled.status, 404); + await call(`/api/apps/public/endpoints/${stage.endpoint_id}/activate`, 'POST', { + expected_review_digest: stage.review_digest, + expected_endpoint_epoch: stage.endpoint_epoch, accept_host_policy: true, + }); + const publicHeaders = { 'Content-Type': 'application/json', + Cookie: `access_token=${otherWeb.accessToken}; employee=forged`, + Authorization: `Bearer ${otherWeb.accessToken}` }; + const claimResponse = await fetch(`${base}${publicPath}`, { method: 'POST', + headers: publicHeaders, body: JSON.stringify(claimBody) }); + const claimResult = await claimResponse.json() as any; + assert.equal(claimResponse.status, 201, JSON.stringify(claimResult)); + assert.deepEqual(Object.keys(claimResult.result).sort(), + ['claim_id', 'claim_state', 'follow_up_state', 'replayed']); + assert.equal(claimResult.result.follow_up_state, 'pending'); + const [claim] = await db.select().from(schema.appCanonicalClaims).where(and( + eq(schema.appCanonicalClaims.org_id, orgId), + eq(schema.appCanonicalClaims.id, claimResult.result.claim_id))); + assert.ok(claim); + const job = await queues.dequeueJob(queues.QUEUE_NAMES.AGENT_JOBS, { orgId, + jobName: 'app-public-ingress', dataMatch: { key: 'ingress_id', value: claim.ingress_id } }); + assert.ok(job); + const handler = await workers._getAgentJobHandlerForTest('app-public-ingress'); + assert.ok(handler); + await assert.rejects(handler({ id: job.id, name: job.name, + data: { ...job.data, endpoint_id: randomUUID() }, attempts: job.attempts }), + /Invalid public ingress queue identity/); + await Promise.all([handler({ id: job.id, name: job.name, data: job.data, + attempts: job.attempts }), handler({ id: job.id, name: job.name, + data: job.data, attempts: job.attempts })]); + await workers._processDequeuedJobForTest(queues.QUEUE_NAMES.AGENT_JOBS, job); + const [run] = await db.select().from(schema.appRuns).where(and( + eq(schema.appRuns.org_id, orgId), + eq(schema.appRuns.origin_public_ingress_id, claim.ingress_id))); + assert.ok(run); + assert.equal(run.initiating_actor_type, 'app_public'); + assert.equal(run.initiating_actor_id, claim.ingress_id); + assert.equal(run.execution_actor_type, 'human'); + assert.equal(run.execution_actor_id, ownerId); + assert.equal(run.state, 'pending_approval'); + const [ingress] = await db.select().from(schema.appPublicIngress).where(eq(schema.appPublicIngress.id, claim.ingress_id)); + assert.equal(ingress?.follow_up_state, 'run_created'); + await handler({ id: job.id, name: job.name, data: job.data, attempts: job.attempts }); + const [runCount] = await db.select({ value: (await import('drizzle-orm')).count() }).from(schema.appRuns) + .where(eq(schema.appRuns.origin_public_ingress_id, claim.ingress_id)); + assert.equal(runCount?.value, 1); + const replay = await fetch(`${base}${publicPath}`, { method: 'POST', + headers: publicHeaders, body: JSON.stringify(claimBody) }); + assert.equal(replay.status, 200); + assert.equal((await replay.json() as any).result.follow_up_state, 'run_created'); + const reviewPath = `/api/app-runtime-actions/${run.id}/review`; + const foreignReview = await fetch(`${base}${reviewPath}`, { + headers: { Authorization: `Bearer ${otherWeb.accessToken}` }, + }); + assert.equal(foreignReview.status, 403); + const reviewResponse = await fetch(`${base}${reviewPath}`, { headers: auth }); + assert.equal(reviewResponse.status, 200); + assert.equal(reviewResponse.headers.get('cache-control'), 'no-store'); + const exact = (await reviewResponse.json() as any).review; + assert.deepEqual(exact.input, { shipment_id: created.record.id }); + const [approval] = await db.select().from(schema.agentActions).where(and( + eq(schema.agentActions.org_id, orgId), eq(schema.agentActions.app_run_id, run.id))); + assert.ok(approval); + assert.equal(approval.user_id, ownerId); + const [attention] = await db.select().from(schema.attentionItems).where(and( + eq(schema.attentionItems.org_id, orgId), + eq(schema.attentionItems.user_id, ownerId), + eq(schema.attentionItems.source_type, 'agent_action'), + eq(schema.attentionItems.source_id, approval.id), + )); + assert.ok(attention, 'post-commit public approval reaches the human Attention inbox'); + assert.equal(JSON.stringify({ preview: run.safe_preview, params: approval.params }) + .includes(created.record.id), true, 'canonical public resource id is safe preview metadata'); + const runtime = await runModule.getAppRunRuntime(); + assert.equal((await runtime.approvalResolver.approve(approval.id, otherId)).status, 'error'); + assert.equal((await runtime.approvalResolver.approve(approval.id, ownerId)).status, 'approved'); + const session = (await call(`/api/apps/runtime/bindings/${binding.binding_id}/sessions`, 'POST')).session; + const ledgerPath = join(await mkdtemp(join(tmpdir(), 'deft-public-runtime-')), 'carrier.jsonl'); + child = fork(fileURLToPath(new URL('./fixtures/app-runtime-provider-child.ts', import.meta.url)), [], { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, DEFT_RUNTIME_PROVIDER_FIXTURE: 'true' }, + }); + const events: Array> = []; + const completed = new Promise>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Runtime child timed out: ${JSON.stringify(events)}`)), 30_000); + child!.on('message', (message) => { + if (!message || typeof message !== 'object') return; + const event = message as Record; + events.push(event); + if (event.type === 'result' || event.type === 'error') { + clearTimeout(timer); + if (event.type === 'error') reject(new Error(`Runtime child: ${JSON.stringify(event)}`)); + else resolve(event); + } + }); + child!.once('exit', (code) => { clearTimeout(timer); + reject(new Error(`Runtime child exited ${code}: ${JSON.stringify(events)}`)); }); + }); + child.send({ type: 'start', channel_url: `${base}/api/app-runtime/channel`, + session_id: session.session_id, session_token: session.session_token, + ledger_path: ledgerPath, mode: 'normal' }); + assert.equal((await completed).type, 'result'); + assert.deepEqual((await readFile(ledgerPath, 'utf8')).trim().split('\n').map(JSON.parse), + [{ run_id: run.id, item_id: created.record.id, effect: 'synthetic_carrier_label' }]); + assert.equal((await runtime.repository.inspect(orgId, run.id))?.state, 'succeeded'); + assert.ok((await runtime.receiptReader.readVerified(orgId, run.id)) + .some((receipt) => receipt.receipt_kind === 'attempt_terminal' && receipt.verified)); + await assert.rejects(db.update(schema.appRuns).set({ origin_public_ingress_id: null }) + .where(eq(schema.appRuns.id, run.id)), dbRejects('APP_RUN_IMMUTABLE_FIELD')); + await assert.rejects(db.update(schema.appPublicEndpoints) + .set({ input_mapping: { shipment_id: 'claim.claim_id' } }) + .where(eq(schema.appPublicEndpoints.id, stage.endpoint_id)), + dbRejects('APP_PUBLIC_MAPPING_REVIEW_REQUIRED')); + + const second = await moduleService.createModuleRecord(owner, { + module_id: module.module_id, collection_key: 'greetings', + data: { message: 'Second synthetic shipment' }, relations: {}, + expected_manifest_digest: module.manifest_digest, + idempotency_key: `public-shipment-two-${suffix}`, + }); + assert.ok(second.record); + const secondBody = { ...claimBody, + resource_ref: { ...claimBody.resource_ref, resource_id: second.record.id }, + expected_revision: second.record.revision, + idempotency_key: `claim-two-${suffix}` }; + const secondClaimResponse = await fetch(`${base}${publicPath}`, { method: 'POST', + headers: publicHeaders, body: JSON.stringify(secondBody) }); + assert.equal(secondClaimResponse.status, 201); + const secondClaimId = (await secondClaimResponse.json() as any).result.claim_id as string; + const [secondClaim] = await db.select().from(schema.appCanonicalClaims).where(and( + eq(schema.appCanonicalClaims.org_id, orgId), eq(schema.appCanonicalClaims.id, secondClaimId))); + assert.ok(secondClaim); + await assert.rejects(db.transaction(async (tx) => { + await runtime.service.submitReviewedPublicRuntimeInTransaction(tx, { + org_id: orgId, endpoint_id: stage.endpoint_id, ingress_id: secondClaim.ingress_id, + }); + throw new Error('injected-after-run-before-ingress'); + }), /injected-after-run-before-ingress/); + assert.deepEqual(await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.origin_public_ingress_id, secondClaim.ingress_id)), []); + const [pendingIngress] = await db.select().from(schema.appPublicIngress) + .where(eq(schema.appPublicIngress.id, secondClaim.ingress_id)); + assert.equal(pendingIngress?.follow_up_state, 'pending'); + const secondJob = await queues.dequeueJob(queues.QUEUE_NAMES.AGENT_JOBS, { orgId, + jobName: 'app-public-ingress', dataMatch: { key: 'ingress_id', value: secondClaim.ingress_id } }); + assert.ok(secondJob); + await workers._processDequeuedJobForTest(queues.QUEUE_NAMES.AGENT_JOBS, secondJob); + const [pendingRun] = await db.select().from(schema.appRuns) + .where(eq(schema.appRuns.origin_public_ingress_id, secondClaim.ingress_id)); + assert.ok(pendingRun); + assert.equal(pendingRun.state, 'pending_approval'); + const disabledEndpoint = await call(`/api/apps/public/endpoints/${stage.endpoint_id}/disable`, 'POST'); + assert.equal(disabledEndpoint.state, 'disabled'); + const staleReview = await fetch(`${base}/api/app-runtime-actions/${pendingRun.id}/review`, + { headers: auth }); + assert.equal(staleReview.status, 409); + const [pendingApproval] = await db.select().from(schema.agentActions).where(and( + eq(schema.agentActions.org_id, orgId), eq(schema.agentActions.app_run_id, pendingRun.id))); + assert.ok(pendingApproval); + assert.equal((await runtime.approvalResolver.approve(pendingApproval.id, ownerId)).status, 'error'); + const disabledReplay = await fetch(`${base}${publicPath}`, { method: 'POST', + headers: publicHeaders, body: JSON.stringify(secondBody) }); + assert.equal(disabledReplay.status, 404); + await assert.rejects(db.update(schema.appPublicEndpoints) + .set({ input_mapping: { shipment_id: 'claim.claim_id' } }) + .where(eq(schema.appPublicEndpoints.id, stage.endpoint_id)), + dbRejects('APP_PUBLIC_MAPPING_REVIEW_REQUIRED')); + } finally { + if (child && child.exitCode === null) child.kill(); + if (server) { server.closeAllConnections(); + await new Promise((resolve) => server!.close(() => resolve())); } + await runModule.shutdownAppRunRuntime(); + await closeDb(); + } + }); diff --git a/apps/api/test/app-runtime-actions-db.test.ts b/apps/api/test/app-runtime-actions-db.test.ts index 486c34e2..6ef2f6a7 100644 --- a/apps/api/test/app-runtime-actions-db.test.ts +++ b/apps/api/test/app-runtime-actions-db.test.ts @@ -7,7 +7,7 @@ const target = process.env.DEFT_TEST_DATABASE_URL; const safe = target === process.env.DATABASE_URL && target !== undefined && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' - && /^\/gate_g_phase5_test_c03_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + && /^\/gate_g_phase5_test_c03(?:b)?_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); test('packed Runtime Kit follows reviewed human Run, approval, claim, result, and signed receipt', { skip: !safe, diff --git a/apps/api/test/app-runtime-actions-http-db.test.ts b/apps/api/test/app-runtime-actions-http-db.test.ts index ea7b97b5..0d25d77c 100644 --- a/apps/api/test/app-runtime-actions-http-db.test.ts +++ b/apps/api/test/app-runtime-actions-http-db.test.ts @@ -11,7 +11,7 @@ import { runtimeV3PackageJson } from './fixtures/runtime-v3-package.js'; const target = process.env.DEFT_TEST_DATABASE_URL; const safe = target === process.env.DATABASE_URL && target !== undefined && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' - && /^\/gate_g_phase5_test_c03_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + && /^\/gate_g_phase5_test_c03(?:b)?_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); test('authenticated HTTP pairing, packed install, reviews, action and Runtime receipt', { skip: !safe }, async () => { process.env.DEFT_APPS_ENABLED = 'true'; diff --git a/apps/api/test/app-runtime-review-db.test.ts b/apps/api/test/app-runtime-review-db.test.ts index aa93a521..19595847 100644 --- a/apps/api/test/app-runtime-review-db.test.ts +++ b/apps/api/test/app-runtime-review-db.test.ts @@ -6,7 +6,7 @@ const target = process.env.DEFT_TEST_DATABASE_URL; const safe = target === process.env.DATABASE_URL && target !== undefined && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' - && /^\/gate_g_phase5_test_c03_(?:review|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + && /^\/gate_g_phase5_test_c03(?:b)?_(?:review|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); test('v3 review is tenant- and epoch-bound and reactivation requires a fresh grant', { skip: !safe }, async () => { const [{ db, closeDb }, schema, kit, appService, reviewService, management, diff --git a/apps/api/test/apps-connected-grants-db.test.ts b/apps/api/test/apps-connected-grants-db.test.ts index 4633c491..760b7481 100644 --- a/apps/api/test/apps-connected-grants-db.test.ts +++ b/apps/api/test/apps-connected-grants-db.test.ts @@ -1,7 +1,7 @@ import './fixtures/app-run-enabled-env.js'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { after, before, test } from 'node:test'; @@ -611,6 +611,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', const providerRoot = resolve(import.meta.dirname, '..', '..', '..', 'examples', 'app-platform-sandbox-email-provider'); const providerOutboxRoot = await mkdtemp(resolve(tmpdir(), 'deft-track-a-outbox-')); const providerOutbox = resolve(providerOutboxRoot, 'effects.jsonl'); + const providerEffectCheckpoint = resolve(providerOutboxRoot, 'after-effect.json'); const providerEnvironment = { selfHosted: process.env.DEFT_SELF_HOSTED, unsafeStdio: process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO, @@ -623,7 +624,9 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', else process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = providerEnvironment.unsafeStdio; if (providerEnvironment.allowlist === undefined) delete process.env.MCP_STDIO_ALLOWED_COMMANDS; else process.env.MCP_STDIO_ALLOWED_COMMANDS = providerEnvironment.allowlist; - await rm(providerOutboxRoot, { recursive: true, force: true }); + if (!process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE) { + await rm(providerOutboxRoot, { recursive: true, force: true }); + } }); process.env.DEFT_SELF_HOSTED = 'true'; process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; @@ -695,7 +698,9 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', server_url: null, transport: 'stdio', stdio_command: process.execPath, - stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', providerOutbox], + stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', providerOutbox, + ...(process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE + ? ['--pause-after-effect-file', providerEffectCheckpoint] : [])], auth_type: 'none', is_active: true, created_by: userId, @@ -861,7 +866,9 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', }, { now: () => new Date(scheduledAt.getTime() + 60_000) }) ); - const primary = await createDefinition(1); + const primary = process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE + ? await createDefinition(1, 100, approvedAt, 3 * 24 * 60 * 60) + : await createDefinition(1); const definition = primary.definition; assert.equal(definition.state, 'active'); assert.equal(definition.interface_identity, actionBinding.interface_identity); @@ -870,6 +877,19 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', assert.equal((definition.authorization_vector as any).organization_id, orgId); assert.equal((definition.authorization_vector as any).approver.user_id, userId); assert.equal((definition.authorization_vector as any).relation.revision, relation.revision); + if (process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE) { + await writeFile(process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE, JSON.stringify({ + org_id: orgId, owner_user_id: userId, + app_installation_id: staged.id, definition_id: definition.id, + connection_id: connectionId, + definition_epoch: definition.definition_epoch, + scheduled_at: primary.scheduledAt.toISOString(), + provider_outbox: providerOutbox, + provider_effect_checkpoint: providerEffectCheckpoint, + }), { flag: 'wx' }); + await (await import('@deft/mcp')).mcpClientManager.disconnect(connectionId); + return; + } const newerDefinitions = await Promise.all(Array.from({ length: 100 }, async (_value, index) => { const createdAt = new Date(approvedAt.getTime() + index + 1); diff --git a/apps/api/test/fixtures/track-a-restore-child.ts b/apps/api/test/fixtures/track-a-restore-child.ts new file mode 100644 index 00000000..9890b194 --- /dev/null +++ b/apps/api/test/fixtures/track-a-restore-child.ts @@ -0,0 +1,125 @@ +import { mcpClientManager } from '@deft/mcp'; +import { appRuns } from '@deft/db/schema'; +import { and, eq } from 'drizzle-orm'; + +type Command = Readonly<{ + action: 'scan' | 'fire' | 'attempt' | 'recover' | 'verify' | 'cleanup'; + org_id: string; + definition_id: string; + connection_id: string; + now?: string; + run_id?: string; + pause_before_dispatch?: boolean; + pause_after_receipt?: boolean; +}>; + +function send(value: Record): Promise { + return new Promise((resolve, reject) => { + if (!process.send) { reject(new Error('Track A child requires IPC')); return; } + process.send(value, (error) => error ? reject(error) : resolve()); + }); +} + +async function waitForContinue(): Promise { + await new Promise((resolve) => process.once('message', (value) => { + if ((value as { action?: string }).action === 'continue') resolve(); + })); +} + +async function run(command: Command): Promise { + const [{ runAppAutomationScan, runAppAutomationFire }, queues, + { handleAppRunAttempt }, { getAppRunRuntime }, { db }] = await Promise.all([ + import('../../src/lib/app-automation-runtime.js'), + import('../../src/lib/queues.js'), + import('../../src/lib/app-run-worker-handler.js'), + import('../../src/lib/app-run-runtime.js'), + import('../../src/lib/db.js'), + ]); + if (command.action === 'scan') { + await runAppAutomationScan(new Date(command.now!)); + await send({ phase: 'done' }); + return; + } + if (command.action === 'cleanup') { + await send({ phase: 'done', recovered_jobs: await queues.cleanupStaleJobs() }); + return; + } + if (command.action === 'fire') { + const job = await queues.dequeueJob(queues.QUEUE_NAMES.SCHEDULED_JOBS, { + lockedBy: 'track-a-restore-fire', orgId: command.org_id, + jobName: 'app-automation-fire', leaseMs: command.pause_before_dispatch ? 1_000 : 60_000, + dataMatch: { key: 'definition_id', value: command.definition_id }, + }); + if (!job) throw new Error('No due automation fire job'); + await send({ phase: 'fire_claimed', job_id: job.id, fire_id: job.data.fire_id }); + if (command.pause_before_dispatch) await waitForContinue(); + await runAppAutomationFire({ id: job.id, name: job.name, data: job.data, + attempts: job.attempts, leaseExpiresAt: job.lockExpiresAt }, + new Date(command.now!)); + await queues.completeJob(job.id, job.lockToken); + await send({ phase: 'done', fire_id: job.data.fire_id }); + return; + } + if (command.action === 'attempt') { + const job = await queues.dequeueJob(queues.QUEUE_NAMES.AGENT_JOBS, { + lockedBy: 'track-a-restore-attempt', orgId: command.org_id, + jobName: 'app-run-attempt', leaseMs: command.pause_after_receipt ? 10_000 : 60_000, + dataMatch: { key: 'runId', value: command.run_id! }, + }); + if (!job) throw new Error('No due App Run attempt job'); + await send({ phase: 'attempt_claimed', job_id: job.id, + attempt_id: job.data.attemptId }); + await handleAppRunAttempt({ id: job.id, name: job.name, data: job.data, + attempts: job.attempts, leaseExpiresAt: job.lockExpiresAt }); + await send({ phase: 'receipt_committed', job_id: job.id }); + if (command.pause_after_receipt) await waitForContinue(); + await queues.completeJob(job.id, job.lockToken); + await send({ phase: 'done', job_id: job.id }); + return; + } + if (command.action === 'recover') { + const runtime = await getAppRunRuntime(); + // Deterministic clock crosses the provider lease without waiting a minute. + const { AppRunAttemptRunner } = await import('../../src/lib/app-run-attempt-runner.js'); + const { AppRunSecretService } = await import('../../src/lib/app-run-secrets.js'); + const { PinnedMcpAppRunProviderExecutor } = await import('../../src/lib/app-run-provider-executor.js'); + const { PostgresAppRunReceiptWriter } = await import('../../src/lib/app-run-receipts.js'); + const { PostgresAppRunAttentionProjector } = await import('../../src/lib/app-run-attention.js'); + const { postgresAppRunAttemptQueue } = await import('../../src/lib/app-run-scheduler.js'); + const secrets = new AppRunSecretService(runtime.keys); + const runner = new AppRunAttemptRunner(runtime.repository, + runtime.secretRepository, secrets, new PinnedMcpAppRunProviderExecutor(), + runtime.liveAuthorization, () => new Date(Date.now() + 120_000), + 60_000, 20_000, + new PostgresAppRunReceiptWriter(secrets, runtime.secretRepository), + new PostgresAppRunAttentionProjector(), postgresAppRunAttemptQueue); + const recovered = await runner.recoverRun(command.org_id, command.run_id!); + await send({ phase: 'done', recovered }); + return; + } + const runtime = await getAppRunRuntime(); + const receipts = await runtime.receiptReader.readVerified(command.org_id, command.run_id!); + const [row] = await db.select({ state: appRuns.state }).from(appRuns).where(and( + eq(appRuns.org_id, command.org_id), eq(appRuns.id, command.run_id!), + )); + await send({ phase: 'done', run_state: row?.state ?? null, + verified_receipts: receipts.length }); +} + +process.once('message', async (message) => { + const command = message as Command; + try { + await run(command); + process.exitCode = 0; + } catch (error) { + await send({ phase: 'error', message: error instanceof Error ? error.message : String(error) }); + process.exitCode = 1; + } finally { + await mcpClientManager.disconnect(command.connection_id).catch(() => undefined); + const { shutdownAppRunRuntime } = await import('../../src/lib/app-run-runtime.js'); + const { closeDb } = await import('../../src/lib/db.js'); + await shutdownAppRunRuntime(); + await closeDb(); + if (process.connected) process.disconnect(); + } +}); diff --git a/apps/api/test/track-a-restore-process-db.test.ts b/apps/api/test/track-a-restore-process-db.test.ts new file mode 100644 index 00000000..b066b991 --- /dev/null +++ b/apps/api/test/track-a-restore-process-db.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { fork, execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import pg from 'pg'; + +type State = Readonly<{ + org_id: string; owner_user_id: string; app_installation_id: string; + definition_id: string; definition_epoch: number; scheduled_at: string; + connection_id: string; provider_outbox: string; provider_effect_checkpoint: string; +}>; +type ChildCommand = Readonly<{ + action: 'scan' | 'fire' | 'attempt' | 'recover' | 'verify' | 'cleanup'; + org_id: string; definition_id: string; connection_id: string; + now?: string; run_id?: string; + pause_before_dispatch?: boolean; pause_after_receipt?: boolean; +}>; +type ChildMessage = Readonly<{ phase: string; [key: string]: unknown }>; + +const sourceUrl = process.env.DEFT_TEST_DATABASE_URL; +const statePath = process.env.DEFT_TRACK_A_BOOTSTRAP_STATE_FILE; +const evidencePath = process.env.DEFT_TRACK_A_EVIDENCE_FILE; +const restoreName = process.env.DEFT_TRACK_A_RESTORE_DB_NAME; +function isAssignedTestSource(value: string | undefined): boolean { + if (!value) return false; + try { + const url = new URL(value); + return ['postgres:', 'postgresql:'].includes(url.protocol) + && url.hostname === '127.0.0.1' && url.port === '55435' + && url.search === '' && url.hash === '' + && /^\/gate_g_phase5_test_c03b_runtime_a03a04_\d+$/.test(url.pathname); + } catch { return false; } +} +const safe = sourceUrl && sourceUrl === process.env.DATABASE_URL && statePath + && evidencePath && restoreName + && isAssignedTestSource(sourceUrl) + && /^gate_g_phase5_test_c03b_runtime_a03a04_restore_\d+$/.test(restoreName); + +function launch(command: ChildCommand, databaseUrl: string, keyring: string) { + const child = fork(fileURLToPath(new URL('./fixtures/track-a-restore-child.ts', import.meta.url)), { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, DATABASE_URL: databaseUrl, DEFT_TEST_DATABASE_URL: databaseUrl, + DEFT_APPS_ENABLED: 'true', DEFT_APP_RUNS_ENABLED: 'true', + DEFT_APP_RUN_APP_ORIGIN_ENABLED: 'true', DEFT_APP_AUTOMATIONS_ENABLED: 'true', + DEFT_SELF_HOSTED: 'true', DEFT_MCP_ENABLE_UNSAFE_STDIO: 'true', + MCP_STDIO_ALLOWED_COMMANDS: process.execPath, DEFT_APP_RUN_KEYRINGS: keyring }, + }); + let stderr = ''; + child.stderr?.on('data', (chunk) => { stderr = (stderr + String(chunk)).slice(-4000); }); + const inbox: ChildMessage[] = []; + const listeners: Array<(message: ChildMessage) => void> = []; + child.on('message', (raw) => { + const message = raw as ChildMessage; + inbox.push(message); + for (const listener of listeners.splice(0)) listener(message); + }); + const exited = new Promise((resolve) => child.once('exit', (code) => resolve(code))); + child.send(command); + async function waitFor(phase: string, timeoutMs = 40_000): Promise { + const existing = inbox.find((message) => message.phase === phase || message.phase === 'error'); + if (existing) { + if (existing.phase === 'error') throw new Error(`Track A child: ${existing.message}; ${stderr}`); + return existing; + } + let timer: ReturnType | undefined; + try { + return await Promise.race([ + new Promise((resolve, reject) => { + const listener = (message: ChildMessage) => { + if (message.phase === 'error') reject(new Error(`Track A child: ${message.message}; ${stderr}`)); + else if (message.phase === phase) resolve(message); + else listeners.push(listener); + }; + listeners.push(listener); + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Track A ${phase} timeout; ${stderr}`)), timeoutMs); + }), + exited.then((code) => { throw new Error(`Track A child exited ${code} before ${phase}; ${stderr}`); }), + ]); + } finally { if (timer) clearTimeout(timer); } + } + return { child, waitFor, exited, stderr: () => stderr }; +} + +async function one(command: ChildCommand, databaseUrl: string, keyring: string) { + console.log('TRACK_A_START', command.action, new URL(databaseUrl).pathname); + const process = launch(command, databaseUrl, keyring); + try { + const done = await process.waitFor('done'); + assert.equal(await process.exited, 0, process.stderr()); + console.log('TRACK_A_DONE', command.action, new URL(databaseUrl).pathname); + return done; + } finally { + if (process.child.exitCode === null) process.child.kill('SIGKILL'); + await process.exited; + } +} + +async function fireRun(databaseUrl: string, state: State, day: string) { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const result = await client.query<{ id: string; state: string; app_run_id: string | null }>( + `SELECT id, state, app_run_id FROM app_automation_fires + WHERE org_id = $1 AND definition_id = $2 AND logical_local_date = $3`, + [state.org_id, state.definition_id, day]); + assert.equal(result.rows.length, 1); + return result.rows[0]!; + } finally { await client.end(); } +} + +async function effectRows(path: string): Promise> { + if (!existsSync(path)) return []; + return (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); +} + +async function waitForEffect(path: string, count: number) { + for (let elapsed = 0; elapsed < 20_000; elapsed += 25) { + if ((await effectRows(path)).length >= count) return; + await delay(25); + } + throw new Error(`External effect ledger did not reach ${count}`); +} + +async function waitForFile(path: string) { + for (let elapsed = 0; elapsed < 20_000; elapsed += 25) { + if (existsSync(path)) return; + await delay(25); + } + throw new Error(`Provider checkpoint did not appear: ${path}`); +} + +function wslPg(tool: string, ...args: string[]) { + return execFileSync('wsl', ['-d', 'Deft-CRM-Test', '--', + `/usr/lib/postgresql/16/bin/${tool}`, ...args], { timeout: 90_000, encoding: 'utf8' }); +} + +test('Track A production automation survives process kills and DB/keyring restore', { + skip: !safe, timeout: 480_000, +}, async (t) => { + const state = JSON.parse(await readFile(statePath!, 'utf8')) as State; + const liveChildren = new Set>(); + const trackedLaunch = (command: ChildCommand, databaseUrl: string, keyring: string) => { + const running = launch(command, databaseUrl, keyring); + liveChildren.add(running); + void running.exited.then(() => liveChildren.delete(running)); + return running; + }; + t.after(async () => { + for (const running of liveChildren) running.child.kill('SIGKILL'); + await Promise.allSettled([...liveChildren].map((running) => running.exited)); + if (existsSync(state.provider_effect_checkpoint) + && !existsSync(`${state.provider_effect_checkpoint}.release`)) { + const marker = JSON.parse(await readFile(state.provider_effect_checkpoint, 'utf8')) as { pid: number }; + try { globalThis.process.kill(marker.pid, 'SIGKILL'); } catch { /* Already stopped. */ } + } + }); + const { databaseCompleteAppRunTestKeyringFixture } = await import('./fixtures/app-run-test-keyrings.js'); + const fixture = await databaseCompleteAppRunTestKeyringFixture('loop5-lifecycle'); + const keyring = fixture.environment; + fixture.keys.destroy(); + const base = new Date(state.scheduled_at); + const atDay = (days: number) => new Date(base.getTime() + days * 86_400_000 + 60_000).toISOString(); + const day = (days: number) => atDay(days).slice(0, 10); + const command = (action: ChildCommand['action'], extra: Partial = {}): ChildCommand => ({ + action, org_id: state.org_id, definition_id: state.definition_id, + connection_id: state.connection_id, ...extra, + }); + + // Crash before the queued fire is dispatched. The claimed queue job is + // rearmed by production lease cleanup in a fresh process. + await one(command('scan', { now: atDay(0) }), sourceUrl!, keyring); + const beforeDispatch = trackedLaunch(command('fire', { now: atDay(0), pause_before_dispatch: true }), + sourceUrl!, keyring); + await beforeDispatch.waitFor('fire_claimed'); + beforeDispatch.child.kill('SIGKILL'); + await beforeDispatch.exited; + assert.equal((await effectRows(state.provider_outbox)).length, 0); + await delay(1_200); + assert.equal((await one(command('cleanup'), sourceUrl!, keyring)).recovered_jobs, 1); + await delay(5_200); + await one(command('fire', { now: atDay(0) }), sourceUrl!, keyring); + const firstFire = await fireRun(sourceUrl!, state, day(0)); + assert.equal(firstFire.state, 'run_created'); + assert.ok(firstFire.app_run_id); + + // The sandbox MCP provider fsyncs its independent outbox, then waits before + // replying. Kill the actual App Run worker in that gap. + const afterEffect = trackedLaunch(command('attempt', { run_id: firstFire.app_run_id }), sourceUrl!, keyring); + await afterEffect.waitFor('attempt_claimed'); + await waitForEffect(state.provider_outbox, 1); + await waitForFile(state.provider_effect_checkpoint); + const marker = JSON.parse(await readFile(state.provider_effect_checkpoint, 'utf8')) as { pid: number }; + afterEffect.child.kill('SIGKILL'); + await afterEffect.exited; + try { globalThis.process.kill(marker.pid, 'SIGKILL'); } catch { /* Already stopped. */ } + assert.equal((await effectRows(state.provider_outbox)).length, 1); + const recovered = await one(command('recover', { run_id: firstFire.app_run_id }), sourceUrl!, keyring); + assert.equal(recovered.recovered, 1); + await one(command('attempt', { run_id: firstFire.app_run_id }), sourceUrl!, keyring); + assert.equal((await effectRows(state.provider_outbox)).length, 1, + 'idempotency-bound recovery must not create a second external effect'); + const firstVerified = await one(command('verify', { run_id: firstFire.app_run_id }), sourceUrl!, keyring); + assert.equal(firstVerified.run_state, 'succeeded'); + assert.ok(Number(firstVerified.verified_receipts) >= 1); + + // A second daily fire reaches a durable receipt; kill its worker before it + // acknowledges the queue job. Re-delivery cannot repeat the effect. + await writeFile(`${state.provider_effect_checkpoint}.release`, 'release', { flag: 'wx' }); + await one(command('scan', { now: atDay(1) }), sourceUrl!, keyring); + await one(command('fire', { now: atDay(1) }), sourceUrl!, keyring); + const secondFire = await fireRun(sourceUrl!, state, day(1)); + assert.ok(secondFire.app_run_id); + const afterReceipt = trackedLaunch(command('attempt', { run_id: secondFire.app_run_id, + pause_after_receipt: true }), sourceUrl!, keyring); + await afterReceipt.waitFor('receipt_committed'); + afterReceipt.child.kill('SIGKILL'); + await afterReceipt.exited; + assert.equal((await effectRows(state.provider_outbox)).length, 2); + assert.equal((await one(command('verify', { run_id: secondFire.app_run_id }), + sourceUrl!, keyring)).run_state, 'succeeded'); + await delay(10_200); + await one(command('cleanup'), sourceUrl!, keyring); + await delay(5_200); + await one(command('attempt', { run_id: secondFire.app_run_id }), sourceUrl!, keyring); + assert.equal((await effectRows(state.provider_outbox)).length, 2); + + // Physical data copy into a fresh database. The provider outbox is NOT in + // the dump and the exact synthetic keyring is passed to the new process. + const dumpPath = `/tmp/deft-track-a-${randomUUID()}.dump`; + wslPg('pg_dump', '-h', '127.0.0.1', '-p', '55435', '-U', 'gate_g_test', + '-Fc', '-f', dumpPath, new URL(sourceUrl!).pathname.slice(1)); + wslPg('createdb', '-h', '127.0.0.1', '-p', '55435', '-U', 'gate_g_test', restoreName!); + wslPg('pg_restore', '-h', '127.0.0.1', '-p', '55435', '-U', 'gate_g_test', + '-d', restoreName!, dumpPath); + const restoredUrl = new URL(sourceUrl!); + restoredUrl.pathname = `/${restoreName!}`; + const targetUrl = restoredUrl.toString(); + assert.equal((await one(command('verify', { run_id: firstFire.app_run_id }), + targetUrl, keyring)).run_state, 'succeeded'); + assert.equal((await one(command('verify', { run_id: secondFire.app_run_id }), + targetUrl, keyring)).run_state, 'succeeded'); + await one(command('scan', { now: atDay(2) }), targetUrl, keyring); + await one(command('fire', { now: atDay(2) }), targetUrl, keyring); + const thirdFire = await fireRun(targetUrl, state, day(2)); + assert.ok(thirdFire.app_run_id); + await one(command('attempt', { run_id: thirdFire.app_run_id }), targetUrl, keyring); + assert.equal((await one(command('verify', { run_id: thirdFire.app_run_id }), + targetUrl, keyring)).run_state, 'succeeded'); + const ledger = await effectRows(state.provider_outbox); + assert.equal(ledger.length, 3); + assert.equal(new Set(ledger.map((row) => row.idempotency_key)).size, 3); + await writeFile(evidencePath!, JSON.stringify({ + source_database: new URL(sourceUrl!).pathname.slice(1), restored_database: restoreName, + dump_path: dumpPath, keyring_sha256: createHash('sha256').update(keyring).digest('hex'), + source_runs: [firstFire.app_run_id, secondFire.app_run_id], restored_run: thirdFire.app_run_id, + provider_outbox: state.provider_outbox, external_effects: ledger.length, + crash_windows: ['before_fire_dispatch', 'after_effect_before_response', 'after_receipt_before_queue_ack'], + }, null, 2), { flag: 'wx' }); +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0bbeb757..4ac31c20 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -64,6 +64,16 @@ const nextConfig: NextConfig = { { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' }, ], }, + { + // The only frameable page is the trusted, opaque Experience bootstrap. + // Author bytes arrive only by checked parent-to-frame message. + source: '/app-experience-bootstrap', + headers: [ + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + { key: 'Content-Security-Policy', value: "default-src 'none'; script-src 'self'; worker-src blob:; connect-src 'none'; img-src 'none'; style-src 'none'; font-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + ], + }, ]; }, }; diff --git a/apps/web/public/app-experience-bootstrap.js b/apps/web/public/app-experience-bootstrap.js new file mode 100644 index 00000000..556e468f --- /dev/null +++ b/apps/web/public/app-experience-bootstrap.js @@ -0,0 +1,28 @@ +(() => { + 'use strict'; + let started = false; + addEventListener('message', (event) => { + const data = event.data; + const expectedOrigin = new URL(location.href).origin; + if (started || event.source !== parent || event.origin !== expectedOrigin + || !data || Object.keys(data).some((key) => !['kind', 'session_id', 'worker_source'].includes(key)) + || data.kind !== 'start' + || typeof data.session_id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(data.session_id) + || typeof data.worker_source !== 'string' + || data.worker_source.length < 1 || data.worker_source.length > 65536 + || event.ports.length !== 1) return; + started = true; + const port = event.ports[0]; + const url = URL.createObjectURL(new Blob([data.worker_source], { type: 'text/javascript' })); + try { + const worker = new Worker(url); + worker.postMessage({ kind: 'start', session_id: data.session_id, port }, [port]); + } catch { + port.close(); + } finally { + URL.revokeObjectURL(url); + } + }); + parent.postMessage({ kind: 'deft_experience_bootstrap_ready.v1' }, new URL(location.href).origin); +})(); diff --git a/apps/web/src/app/(app)/apps/[installationId]/[experienceKey]/page.tsx b/apps/web/src/app/(app)/apps/[installationId]/[experienceKey]/page.tsx new file mode 100644 index 00000000..d5d42484 --- /dev/null +++ b/apps/web/src/app/(app)/apps/[installationId]/[experienceKey]/page.tsx @@ -0,0 +1,12 @@ +import { notFound } from 'next/navigation'; +import { InstalledAppExperience } from '@/components/apps/installed-app-experience'; +import { APPS_ENABLED } from '@/lib/feature-flags'; + +export default async function InstalledExperiencePage({ params }: { + params: Promise<{ installationId: string; experienceKey: string }>; +}) { + if (!APPS_ENABLED) notFound(); + const { installationId, experienceKey } = await params; + return ; +} diff --git a/apps/web/src/app/(app)/settings/apps/apps-client.tsx b/apps/web/src/app/(app)/settings/apps/apps-client.tsx index 15663a96..902e4881 100644 --- a/apps/web/src/app/(app)/settings/apps/apps-client.tsx +++ b/apps/web/src/app/(app)/settings/apps/apps-client.tsx @@ -168,25 +168,35 @@ export function AppsClient({ selectedId }: { selectedId?: string } = {}) { function InspectionCard({ pending, upgradeTarget, busy, onCancel, onStage }: { pending: AppInspection; upgradeTarget: AppInstallation | null; busy: boolean; onCancel: () => void; onStage: () => void }) { const connectedManifest = isConnectedAppManifest(pending.manifest) ? pending.manifest : null; const connected = Boolean(connectedManifest); + const runtime = pending.manifest.compatibility.app_protocol === '3' + || pending.manifest.compatibility.app_protocol === '4'; return

Review {pending.manifest.name}{upgradeTarget ? ' upgrade' : ''}

{pending.manifest.description ?? 'Declarative workspace App.'}

{upgradeTarget &&

This stages {pending.manifest.version} beside active {upgradeTarget.version}. The active version and authority remain unchanged until an owner completes the exact upgrade review.

}

Exact included Modules

    {pending.manifest.modules.map((module) =>
  • {module.module_id}@{module.version} · {module.manifest_digest}
  • )}
-

{connected ? 'No authority before review' : 'No connected permissions'}

{connectedManifest ? `Staging grants no authority. Owners must bind ${connectedManifest.connector_requirements.length} connector requirement${connectedManifest.connector_requirements.length === 1 ? '' : 's'} and accept Deft’s host policy before activation.` : `Staging grants no authority and adds no navigation. Activation installs ${pending.manifest.modules.length} exact Module artifact${pending.manifest.modules.length === 1 ? '' : 's'}.`}

-
+

{connected || runtime ? 'No authority before review' : 'No connected permissions'}

{connectedManifest ? `Staging grants no authority. Owners must bind ${connectedManifest.connector_requirements.length} connector requirement${connectedManifest.connector_requirements.length === 1 ? '' : 's'} and accept Deft’s host policy before activation.` : runtime ? 'Staging grants no Runtime authority. An owner must review the exact version, grant and host policy before activation.' : `Staging grants no authority and adds no navigation. Activation installs ${pending.manifest.modules.length} exact Module artifact${pending.manifest.modules.length === 1 ? '' : 's'}.`}

+
; } function AppCard({ app, canManage, busy, onActivate, onEnable, onDisable, onChooseUpgrade }: { app: AppInstallation; canManage: boolean; busy: boolean; onActivate: () => void; onEnable: () => void; onDisable: () => void; onChooseUpgrade: () => void }) { - const connected = app.manifest.compatibility.app_protocol !== '0'; + const connected = isConnectedAppManifest(app.manifest); + const runtime = app.manifest.compatibility.app_protocol === '3' + || app.manifest.compatibility.app_protocol === '4'; const showConnectedManagement = connected || canStageConnectedUpgrade(app); const tone = app.state === 'active' ? 'var(--status-green)' : app.state === 'disabled' ? 'var(--outline)' : 'var(--status-amber)'; - return
+ return

{app.name}

{app.state}

{app.app_id}@{app.version}

{app.manifest.description ?? 'Declarative workspace App.'}

{app.manifest.modules.length} Module{app.manifest.modules.length === 1 ? '' : 's'} · Protocol v{app.manifest.compatibility.app_protocol} · {app.manifest.license}
- {!connected &&
No connected permissions{canManage && app.state === 'staged' ? : canManage && app.state === 'active' ? : canManage && canEnableAppWithoutReview(app) ? : null}
} + {!connected && !runtime &&
No connected permissions{canManage && app.state === 'staged' ? : canManage && app.state === 'active' ? : canManage && canEnableAppWithoutReview(app) ? : null}
} + {runtime &&
+ {app.state === 'active' ? 'Reviewed Runtime App' : 'Owner review required before use'} + {app.state === 'active' && app.manifest.schema_version === '4' && app.manifest.experiences.map((experience) => + Open {experience.label})} + {canManage && app.state === 'active' && } +
} {showConnectedManagement && }
; } diff --git a/apps/web/src/app/app-experience-bootstrap/route.ts b/apps/web/src/app/app-experience-bootstrap/route.ts new file mode 100644 index 00000000..974ccd84 --- /dev/null +++ b/apps/web/src/app/app-experience-bootstrap/route.ts @@ -0,0 +1,11 @@ +/** Trusted shell for the sandboxed opaque Experience Worker. No author code or + * credentials are in the response; headers are fixed in next.config.ts. */ +export function GET() { + return new Response('', { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }); +} diff --git a/apps/web/src/components/apps/installed-app-experience.tsx b/apps/web/src/components/apps/installed-app-experience.tsx new file mode 100644 index 00000000..c286f1b4 --- /dev/null +++ b/apps/web/src/components/apps/installed-app-experience.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { api } from '@/lib/api'; +import { useAuth } from '@/lib/auth-context'; +import { appApiError } from '@/lib/apps'; +import { createExperienceBridge } from '@/lib/app-experience-bridge'; +import { renderExperienceView, EXPERIENCE_RENDERER_CSS } from '@/lib/app-experience-renderer'; +import { normalizeInstalledExperienceSession, + type InstalledExperienceSession } from '@/lib/app-experience-session'; +import { getSocket } from '@/lib/socket'; + +const root = '/api/app-experiences'; +const livePath = (id: string) => `${root}/sessions/${encodeURIComponent(id)}/live`; + +export function InstalledAppExperience({ installationId, experienceKey }: { + installationId: string; experienceKey: string; +}) { + const { user, org, sessionCacheScope } = useAuth(); + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [ready, setReady] = useState(false); + const iframeHost = useRef(null); + const viewHost = useRef(null); + const rendered = useRef(false); + + useLayoutEffect(() => { + setSession(null); + setError(null); + setReady(false); + viewHost.current?.replaceChildren(); + rendered.current = false; + if (!user || !org || !sessionCacheScope) return; + let cancelled = false; + const abort = new AbortController(); + void (async () => { + try { + const response = await api.fetch(`${root}/${encodeURIComponent(installationId)}/${encodeURIComponent(experienceKey)}/sessions`, { + method: 'POST', signal: abort.signal, + }); + if (!response.ok) throw new Error(await appApiError(response, 'This Experience is unavailable.')); + const created = normalizeInstalledExperienceSession(await response.json()); + if (created.pin.org_id !== org.id || created.pin.user_id !== user.id + || created.pin.app_installation_id !== installationId + || created.experience.key !== experienceKey) throw new Error('Experience session identity changed.'); + if (!cancelled) { setSession(created); setReady(true); } + } catch (cause) { + if (!cancelled) setError(cause instanceof Error ? cause.message : 'This Experience is unavailable.'); + } + })(); + return () => { cancelled = true; abort.abort(); }; + }, [installationId, experienceKey, user?.id, org?.id, sessionCacheScope]); + + useEffect(() => { + if (!session || !iframeHost.current || !viewHost.current || !sessionCacheScope) return; + let stopped = false; + const knownRunIds = new Set(); + const port = new MessageChannel(); + const frame = document.createElement('iframe'); + frame.title = 'Isolated App Experience Worker'; + frame.sandbox.add('allow-scripts'); + frame.referrerPolicy = 'no-referrer'; + frame.setAttribute('aria-hidden', 'true'); + frame.style.cssText = 'position:absolute;width:1px;height:1px;border:0;opacity:0;pointer-events:none'; + const live = async () => { + if (stopped || !api.getAccessToken()) return false; + try { + const response = await api.get(livePath(session.pin.session_id)); + return !stopped && response.ok; + } catch { return false; } + }; + let bridge: ReturnType; + bridge = createExperienceBridge({ + port: port.port1, pin: session.pin, + resourceKeys: session.bundle.resource_keys, + actionKeys: session.bundle.action_keys, + broker: { + isLive: live, + async action(_pin, key, input, signal, requestId) { + const response = await api.fetch( + `${root}/sessions/${encodeURIComponent(session.pin.session_id)}/actions/${encodeURIComponent(key)}`, + { method: 'POST', signal, body: JSON.stringify({ request_id: requestId, input }) }, + ); + if (!response.ok) throw new Error(await appApiError(response, 'Experience action unavailable.')); + const body = await response.json() as { run?: { id?: unknown } }; + if (typeof body.run?.id === 'string') knownRunIds.add(body.run.id); + return body.run; + }, + async runStatus(_pin, input, signal) { + const runId = input && typeof input === 'object' && !Array.isArray(input) + ? (input as { run_id?: unknown }).run_id : null; + if (typeof runId !== 'string' || !knownRunIds.has(runId)) return undefined; + const response = await api.fetch(`/api/app-runs/${encodeURIComponent(runId)}`, { signal }); + if (!response.ok) return undefined; + const body = await response.json() as { run?: unknown }; + return body.run; + }, + }, + onView(view) { + if (stopped || !viewHost.current) return; + renderExperienceView(viewHost.current, view, (event) => { void bridge.sendUiEvent(event); }); + rendered.current = true; + }, + }); + const stop = () => { + if (stopped) return; + stopped = true; + bridge.revoke(); + frame.remove(); + if (rendered.current && viewHost.current) viewHost.current.replaceChildren(); + rendered.current = false; + setError('This Experience session ended. Reopen it to continue.'); + }; + let started = false; + const onBootstrapReady = (event: MessageEvent) => { + if (stopped || started || event.source !== frame.contentWindow || event.origin !== 'null' + || !event.data || event.data.kind !== 'deft_experience_bootstrap_ready.v1' + || !frame.contentWindow) return; + started = true; + frame.contentWindow.postMessage({ kind: 'start', session_id: session.pin.session_id, + worker_source: session.bundle.worker_source }, '*', [port.port2]); + }; + addEventListener('message', onBootstrapReady); + frame.src = '/app-experience-bootstrap'; + iframeHost.current.append(frame); + const poll = setInterval(() => { void live().then((current) => { if (!current) stop(); }); }, 5000); + const onStorage = (event: StorageEvent) => { + if (event.key === 'deft-access-token' || event.key === 'deft-refresh-token') { + void live().then((current) => { if (!current) stop(); }); + } + }; + addEventListener('storage', onStorage); + const token = api.getAccessToken(); + const socket = token ? getSocket(token) : null; + const onAppChange = () => { void live().then((current) => { if (!current) stop(); }); }; + socket?.on('app:changed', onAppChange); + return () => { + clearInterval(poll); + removeEventListener('message', onBootstrapReady); + removeEventListener('storage', onStorage); + socket?.off('app:changed', onAppChange); + stopped = true; + bridge.revoke(); + frame.remove(); + if (viewHost.current) viewHost.current.replaceChildren(); + rendered.current = false; + void api.fetch(`${root}/sessions/${encodeURIComponent(session.pin.session_id)}`, { method: 'DELETE' }).catch(() => undefined); + }; + }, [session, sessionCacheScope]); + + return
+ +
+

Installed App Experience

+

{session?.experience.label ?? 'Loading Experience'}

+ App settings +
+ {error ?

{error}

+ : !ready ?

Opening reviewed App Experience…

+ : null} +
+ ; +} diff --git a/apps/web/src/lib/app-experience-bridge.test.ts b/apps/web/src/lib/app-experience-bridge.test.ts index 759f3477..a8438eab 100644 --- a/apps/web/src/lib/app-experience-bridge.test.ts +++ b/apps/web/src/lib/app-experience-bridge.test.ts @@ -139,3 +139,24 @@ test('UI events are dropped when the live pin is revoked', async () => { assert.equal(port.sent.length, 1); assert.equal(bridge.active, false); }); + +test('input change reaches the Worker before a rapid submit click', async () => { + const port = new FakePort(); + let releaseFirst!: () => void; + const firstLive = new Promise((resolve) => { releaseFirst = () => resolve(true); }); + let checks = 0; + const bridge = createExperienceBridge({ + port, pin, resourceKeys: [], actionKeys: [], + broker: { isLive: () => ++checks === 1 ? firstLive : true }, + onView: () => undefined, + }); + const changed = bridge.sendUiEvent({ kind: 'input', node_id: 'shipment', value: 'parcel-1' }); + const clicked = bridge.sendUiEvent({ kind: 'click', node_id: 'submit' }); + await delay(); + assert.equal(port.sent.length, 0); + releaseFirst(); + assert.equal(await changed, true); + assert.equal(await clicked, true); + assert.deepEqual((port.sent as Array<{event: {kind: string}}>).map((item) => item.event.kind), + ['input', 'click']); +}); diff --git a/apps/web/src/lib/app-experience-bridge.ts b/apps/web/src/lib/app-experience-bridge.ts index 84ce0cd5..c3d130bf 100644 --- a/apps/web/src/lib/app-experience-bridge.ts +++ b/apps/web/src/lib/app-experience-bridge.ts @@ -25,7 +25,8 @@ export type ExperienceIntent = Readonly<{ export type ExperienceBroker = Readonly<{ isLive(pin: ExperiencePin): boolean | Promise; resource?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal) => Promise; - action?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal) => Promise; + action?: (pin: ExperiencePin, key: string, input: unknown, signal: AbortSignal, + requestId: string) => Promise; runStatus?: (pin: ExperiencePin, input: unknown, signal: AbortSignal) => Promise; runCancel?: (pin: ExperiencePin, input: unknown, signal: AbortSignal) => Promise; navigate?: (pin: ExperiencePin, key: string, signal: AbortSignal) => Promise; @@ -160,6 +161,8 @@ export function createExperienceBridge(input: Readonly<{ let active = true; let sequence = 0; let pending = 0; + let queuedUiEvents = 0; + let uiTail = Promise.resolve(); let windowStart = now(); let inWindow = 0; const revoke = () => { @@ -212,7 +215,8 @@ export function createExperienceBridge(input: Readonly<{ if (!await input.broker.isLive(pin) || !active) { revoke(); return; } let output: unknown; if (operation === 'resource') output = await input.broker.resource?.(pin, key as string, value.input, controller.signal); - else if (operation === 'action') output = await input.broker.action?.(pin, key as string, value.input, controller.signal); + else if (operation === 'action') output = await input.broker.action?.( + pin, key as string, value.input, controller.signal, requestId); else if (operation === 'run_status') output = await input.broker.runStatus?.(pin, value.input, controller.signal); else if (operation === 'run_cancel') output = await input.broker.runCancel?.(pin, value.input, controller.signal); else if (operation === 'navigate') output = await input.broker.navigate?.(pin, key as string, controller.signal); @@ -233,15 +237,21 @@ export function createExperienceBridge(input: Readonly<{ } finally { pending -= 1; } })(); }; - const sendUiEvent = async (uiEvent: unknown): Promise => { - if (!active || !boundedJson(uiEvent)) return false; - try { - if (!await input.broker.isLive(pin)) { revoke(); return false; } - } catch { revoke(); return false; } - if (!active) return false; - send({ version: 'deft.experience_bridge.v1', kind: 'ui_event', - session_id: pin.session_id, event: uiEvent }); - return true; + const sendUiEvent = (uiEvent: unknown): Promise => { + if (!active || !boundedJson(uiEvent) || queuedUiEvents >= MAX_PENDING) return Promise.resolve(false); + queuedUiEvents += 1; + const task = uiTail.then(async () => { + if (!active) return false; + try { + if (!await input.broker.isLive(pin)) { revoke(); return false; } + } catch { revoke(); return false; } + if (!active) return false; + send({ version: 'deft.experience_bridge.v1', kind: 'ui_event', + session_id: pin.session_id, event: uiEvent }); + return true; + }); + uiTail = task.then(() => { queuedUiEvents -= 1; }, () => { queuedUiEvents -= 1; }); + return task; }; return Object.freeze({ revoke, sendUiEvent, get active() { return active; } }); } diff --git a/apps/web/src/lib/app-experience-renderer.ts b/apps/web/src/lib/app-experience-renderer.ts index 2188c962..bade47cc 100644 --- a/apps/web/src/lib/app-experience-renderer.ts +++ b/apps/web/src/lib/app-experience-renderer.ts @@ -25,7 +25,7 @@ export const EXPERIENCE_RENDERER_CSS = ` .deft-experience button:focus-visible { outline:2px solid #93c5fd; outline-offset:2px } .deft-experience .ex-text { margin:0; white-space:pre-wrap; overflow-wrap:anywhere } .deft-experience .ex-field { display:grid; gap:4px; min-width:0 } -.deft-experience .ex-field label { color:#a9b9d1; font-size:12px } +.deft-experience .ex-field label { display:grid; gap:4px; color:#a9b9d1; font-size:12px } .deft-experience canvas { display:block; width:100%; height:200px; border:1px solid #4b75a8; border-radius:9px; background:#0d1a2c; touch-action:none } @media(max-width:420px) { .deft-experience { padding:10px } .deft-experience table { min-width:510px } .deft-experience .ex-stack-title { font-size:16px } } `; @@ -83,7 +83,8 @@ function renderNode(node: ExperienceNode, emit: (event: ExperienceUiEvent) => vo input.dataset.focusKey = node.id; label.textContent = node.label; input.addEventListener('change', () => emit({ kind: 'input', node_id: node.id, value: input.value })); - wrap.append(label, input); + label.append(input); + wrap.append(label); return wrap; } if (node.kind === 'stack') { diff --git a/apps/web/src/lib/app-experience-session.ts b/apps/web/src/lib/app-experience-session.ts new file mode 100644 index 00000000..fdfb703b --- /dev/null +++ b/apps/web/src/lib/app-experience-session.ts @@ -0,0 +1,63 @@ +import type { ExperiencePin } from './app-experience-bridge'; + +export type InstalledExperienceSession = Readonly<{ + pin: ExperiencePin; + experience: Readonly<{ key: string; label: string; artifact_digest: string; + bridge_version: 'deft.experience_bridge.v1'; renderer_version: 'deft.trusted_renderer.v1' }>; + bundle: Readonly<{ schema_version: 'deft.experience_bundle.v1'; worker_source: string; + entry_view: string; resource_keys: readonly string[]; action_keys: readonly string[] }>; + expires_at: string; +}>; + +const object = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Experience session.'); + return value as Record; +}; +const str = (value: unknown): string => { + if (typeof value !== 'string' || value.length < 1 || value.length > 256) throw new Error('Invalid Experience session.'); + return value; +}; +const epoch = (value: unknown): number => { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error('Invalid Experience session.'); + return value as number; +}; +const keys = (value: unknown): string[] => { + if (!Array.isArray(value) || value.length > 16 + || !value.every((item) => typeof item === 'string' && /^[a-z][a-z0-9_]{0,47}$/.test(item))) { + throw new Error('Invalid Experience session.'); + } + return value; +}; + +export function normalizeInstalledExperienceSession(value: unknown): InstalledExperienceSession { + const result = object(value); + const pin = object(result.pin); + const experience = object(result.experience); + const bundle = object(result.bundle); + if (experience.bridge_version !== 'deft.experience_bridge.v1' + || experience.renderer_version !== 'deft.trusted_renderer.v1' + || bundle.schema_version !== 'deft.experience_bundle.v1' + || typeof bundle.worker_source !== 'string' + || new TextEncoder().encode(bundle.worker_source).byteLength < 1 + || new TextEncoder().encode(bundle.worker_source).byteLength > 64 * 1024) { + throw new Error('Unsupported Experience bundle.'); + } + const normalizedPin = { + org_id: str(pin.org_id), user_id: str(pin.user_id), + app_installation_id: str(pin.app_installation_id), + app_version_id: str(pin.app_version_id), grant_snapshot_id: str(pin.grant_snapshot_id), + lifecycle_epoch: epoch(pin.lifecycle_epoch), grant_epoch: epoch(pin.grant_epoch), + session_id: str(pin.session_id), session_epoch: epoch(pin.session_epoch), + }; + if (!/^[0-9a-f-]{36}$/i.test(normalizedPin.session_id)) throw new Error('Invalid Experience session.'); + return { + pin: normalizedPin, + experience: { key: str(experience.key), label: str(experience.label), + artifact_digest: str(experience.artifact_digest), + bridge_version: 'deft.experience_bridge.v1', renderer_version: 'deft.trusted_renderer.v1' }, + bundle: { schema_version: 'deft.experience_bundle.v1', + worker_source: bundle.worker_source, entry_view: str(bundle.entry_view), + resource_keys: keys(bundle.resource_keys), action_keys: keys(bundle.action_keys) }, + expires_at: str(result.expires_at), + }; +} diff --git a/apps/web/src/lib/apps.ts b/apps/web/src/lib/apps.ts index 0b1d0ff7..ed10f1eb 100644 --- a/apps/web/src/lib/apps.ts +++ b/apps/web/src/lib/apps.ts @@ -75,12 +75,30 @@ export type AppManifestV2 = Omit; }; -export type AppManifest = AppManifestV0 | AppManifestV1 | AppManifestV2; +export type AppRuntimeManifestV3 = AppManifestBase & { + schema_version: '3'; compatibility: { app_protocol: '3' }; + runtime_requirements: unknown[]; private_capabilities: unknown[]; runtime_actions: unknown[]; +}; + +export type AppExperienceReference = { + key: string; label: string; artifact_path: string; artifact_digest: string; + bridge_version: 'deft.experience_bridge.v1'; + renderer_version: 'deft.trusted_renderer.v1'; +}; + +export type AppInstalledManifestV4 = Omit & { + schema_version: '4'; compatibility: { app_protocol: '4' }; + experiences: AppExperienceReference[]; public_actions: unknown[]; +}; + +export type AppManifest = AppManifestV0 | AppManifestV1 | AppManifestV2 + | AppRuntimeManifestV3 | AppInstalledManifestV4; export type ConnectedAppManifest = AppManifestV1 | AppManifestV2; -export type AppPackageFormat = 'deft.app.package.v0' | 'deft.app.package.v1' | 'deft.app.package.v2'; +export type AppPackageFormat = 'deft.app.package.v0' | 'deft.app.package.v1' + | 'deft.app.package.v2' | 'deft.app.package.v3' | 'deft.app.package.v4'; export function isConnectedAppManifest(manifest: AppManifest): manifest is ConnectedAppManifest { - return manifest.compatibility.app_protocol !== '0'; + return manifest.compatibility.app_protocol === '1' || manifest.compatibility.app_protocol === '2'; } /** Protocol v0 has no connected controls in its manifest, but an installed @@ -396,7 +414,9 @@ function stringValue(value: unknown, label: string): string { function packageFormat(value: unknown): AppPackageFormat { if (value !== 'deft.app.package.v0' && value !== 'deft.app.package.v1' - && value !== 'deft.app.package.v2') { + && value !== 'deft.app.package.v2' + && value !== 'deft.app.package.v3' + && value !== 'deft.app.package.v4') { throw new Error('Invalid App package format.'); } return value; @@ -489,7 +509,8 @@ function normalizeManifest(value: unknown): AppManifest { const row = object(value, 'App manifest'); const compatibility = object(row.compatibility, 'App compatibility'); const protocol = compatibility.app_protocol; - if (protocol !== '0' && protocol !== '1' && protocol !== '2') throw new Error('Unsupported App protocol.'); + if (protocol !== '0' && protocol !== '1' && protocol !== '2' + && protocol !== '3' && protocol !== '4') throw new Error('Unsupported App protocol.'); if (row.schema_version !== protocol) throw new Error('App manifest protocol and schema do not match.'); const base: AppManifestBase = { id: stringValue(row.id, 'App identity'), @@ -516,6 +537,29 @@ function normalizeManifest(value: unknown): AppManifest { } : {}), }; if (protocol === '0') return { ...base, schema_version: '0', compatibility: { app_protocol: '0' } }; + if (protocol === '3' || protocol === '4') { + const runtime = { + ...base, + runtime_requirements: recordArray(row.runtime_requirements, 'Runtime requirements'), + private_capabilities: recordArray(row.private_capabilities, 'private capabilities'), + runtime_actions: recordArray(row.runtime_actions, 'Runtime actions'), + }; + if (protocol === '3') return { ...runtime, schema_version: '3', compatibility: { app_protocol: '3' } }; + return { ...runtime, schema_version: '4', compatibility: { app_protocol: '4' }, + experiences: recordArray(row.experiences, 'App Experiences').map((item) => { + if (item.bridge_version !== 'deft.experience_bridge.v1' + || item.renderer_version !== 'deft.trusted_renderer.v1') { + throw new Error('Unsupported App Experience bridge or renderer.'); + } + return { key: stringValue(item.key, 'Experience key'), + label: stringValue(item.label, 'Experience label'), + artifact_path: stringValue(item.artifact_path, 'Experience path'), + artifact_digest: stringValue(item.artifact_digest, 'Experience digest'), + bridge_version: item.bridge_version, renderer_version: item.renderer_version }; + }), + public_actions: recordArray(row.public_actions, 'public actions'), + }; + } const connected = { ...base, dependencies: recordArray(row.dependencies ?? [], 'App dependencies').map((entry) => ({ diff --git a/examples/app-platform-sandbox-email-provider/server.mjs b/examples/app-platform-sandbox-email-provider/server.mjs index 7e2cd411..3b6263ba 100644 --- a/examples/app-platform-sandbox-email-provider/server.mjs +++ b/examples/app-platform-sandbox-email-provider/server.mjs @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { open, readFile } from 'node:fs/promises'; import { createInterface } from 'node:readline'; +import { setTimeout as delay } from 'node:timers/promises'; const SERVER_INFO = Object.freeze({ name: 'deft-app-platform-sandbox-email-provider', @@ -73,15 +74,26 @@ const SEND_EMAIL_TOOL = Object.freeze({ const OUTBOX_RECORD_VERSION = 'deft.app_platform.sandbox_email.outbox.v1'; -function parseOutboxPath(argv) { - if (argv.length === 0) return null; - if (argv.length !== 2 || argv[0] !== '--outbox-file' || !argv[1]?.trim()) { - throw new Error('Usage: server.mjs [--outbox-file ]'); +function parseFixturePaths(argv) { + if (argv.length === 0) return { outbox: null, pauseAfterEffect: null }; + if (argv.length !== 2 && argv.length !== 4) { + throw new Error('Usage: server.mjs [--outbox-file ] [--pause-after-effect-file ]'); } - return argv[1]; + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + if (!['--outbox-file', '--pause-after-effect-file'].includes(argv[index]) + || !argv[index + 1]?.trim() || values.has(argv[index])) { + throw new Error('Invalid sandbox provider fixture arguments'); + } + values.set(argv[index], argv[index + 1]); + } + if (!values.has('--outbox-file')) throw new Error('Sandbox outbox path is required'); + return { outbox: values.get('--outbox-file'), + pauseAfterEffect: values.get('--pause-after-effect-file') ?? null }; } -const outboxPath = parseOutboxPath(process.argv.slice(2)); +const fixturePaths = parseFixturePaths(process.argv.slice(2)); +const outboxPath = fixturePaths.outbox; const effects = new Map(); function isObject(value) { @@ -188,6 +200,25 @@ async function persistOutbox(record) { } } +async function pauseAfterDurableEffect(record) { + if (!fixturePaths.pauseAfterEffect) return; + const marker = await open(fixturePaths.pauseAfterEffect, 'w'); + try { + await marker.write(JSON.stringify({ pid: process.pid, + idempotency_key: record.idempotency_key })); + await marker.sync(); + } finally { await marker.close(); } + const releasePath = `${fixturePaths.pauseAfterEffect}.release`; + for (let elapsed = 0; elapsed < 20_000; elapsed += 25) { + try { await readFile(releasePath); return; } + catch (error) { + if (!error || typeof error !== 'object' || error.code !== 'ENOENT') throw error; + } + await delay(25); + } + throw new Error('Sandbox provider effect checkpoint timed out'); +} + async function sendEmail(input) { const validationError = validateInput(input); if (validationError) return toolError(`Invalid sandbox email input: ${validationError}`); @@ -203,13 +234,15 @@ async function sendEmail(input) { const messageId = `sandbox_${digest.slice(0, 24)}`; const response = acceptedResponse(messageId); - await persistOutbox({ + const record = { schema_version: OUTBOX_RECORD_VERSION, idempotency_key: input.idempotency_key, digest, message_id: messageId, - }); + }; + await persistOutbox(record); effects.set(input.idempotency_key, { digest, message_id: messageId, response }); + await pauseAfterDurableEffect(record); return response; } diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 924d62d0..37004c4a 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@deft/app-kit", - "version": "0.1.0-alpha.4", + "version": "0.1.0-alpha.5", "description": "Portable authoring and packaging contract for declarative Deft apps.", "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/app-kit/src/cli.ts b/packages/app-kit/src/cli.ts index 6739e0d9..d73455d3 100644 --- a/packages/app-kit/src/cli.ts +++ b/packages/app-kit/src/cli.ts @@ -15,6 +15,10 @@ import { diffDeftAppRequestedAuthority, parseDeftAppManifest, prepareModuleArtifact, + prepareDeftExperienceArtifact, + DEFT_EXPERIENCE_BUNDLE_VERSION, + DEFT_EXPERIENCE_BRIDGE_VERSION, + DEFT_EXPERIENCE_RENDERER_VERSION, simulateDeftAppAutomation, type DeftAppAutomationSimulationInput, type DeftAppManifestInput, @@ -46,7 +50,7 @@ async function assertRegularUnslinkedFile(relativePath: string): Promise return current; } -type AppTemplate = 'declarative' | 'connected' | 'connected-automation' | 'runtime'; +type AppTemplate = 'declarative' | 'connected' | 'connected-automation' | 'runtime' | 'installed'; function parseInitTemplate(): AppTemplate { const args = process.argv.slice(4); @@ -54,9 +58,9 @@ function parseInitTemplate(): AppTemplate { if ( args.length !== 2 || args[0] !== '--template' - || (args[1] !== 'declarative' && args[1] !== 'connected' && args[1] !== 'connected-automation' && args[1] !== 'runtime') + || (args[1] !== 'declarative' && args[1] !== 'connected' && args[1] !== 'connected-automation' && args[1] !== 'runtime' && args[1] !== 'installed') ) { - throw new Error('Usage: deft app init [--template declarative|connected|connected-automation|runtime]'); + throw new Error('Usage: deft app init [--template declarative|connected|connected-automation|runtime|installed]'); } return args[1]; } @@ -275,12 +279,50 @@ async function initializeConnected(automation: boolean): Promise { } async function initialize(template: AppTemplate): Promise { - if (template === 'runtime') { + if (template === 'runtime' || template === 'installed') { if (await exists(resolve(cwd, 'deft.app.json'))) throw new Error('deft.app.json already exists'); + let included: DeftAppManifestV0Input | undefined; + let installed: Record = {}; + if (template === 'installed') { + await initializeDeclarative(); + included = JSON.parse(await readFile(resolve(cwd, 'deft.app.json'), 'utf8')) as DeftAppManifestV0Input; + const bundle = { schema_version: DEFT_EXPERIENCE_BUNDLE_VERSION, entry_view: 'main', resource_keys: [], + action_keys: ['create_shipping_label'], + worker_source: `self.onmessage = ({data}) => { + if (data.kind !== 'start' || !data.port) return; + const port = data.port; let sequence = 0; let shipment = ''; let status = 'Enter a shipment identifier.'; + const send = (message) => port.postMessage({version:'deft.experience_bridge.v1',session_id:data.session_id,sequence:++sequence,...message}); + const render = () => send({kind:'view',view:{root:{kind:'stack',id:'main',title:'Shipping Label',children:[ + {kind:'text',id:'help',text:'Create a label for a shipment. You will review and approve the request before it runs.'}, + {kind:'input',id:'shipment',label:'Shipment identifier',value:shipment}, + {kind:'button',id:'create',label:'Create shipping label'}, {kind:'text',id:'status',text:status}]}}}); + port.onmessage = ({data:message}) => { + if (message.version !== 'deft.experience_bridge.v1' || message.session_id !== data.session_id) return; + if (message.kind === 'ui_event' && message.event?.kind === 'input' && message.event.node_id === 'shipment') shipment=String(message.event.value).slice(0,120); + if (message.kind === 'ui_event' && message.event?.kind === 'click' && message.event.node_id === 'create') { + if (!shipment) {status='Enter a shipment identifier.'; render(); return;} + send({kind:'request',request_id:'request_'+(sequence+1),operation:'action',key:'create_shipping_label',input:{shipment_id:shipment}}); + status='Submitting for review…'; render(); + } + if (message.kind === 'response') { status=message.ok ? 'Submitted. Open approvals to review the exact input.' : 'Request unavailable. Check the App configuration and try again.'; render(); } + }; + render(); +};` }; + const artifact = await prepareDeftExperienceArtifact('experiences/main.json', bundle); + await mkdir(resolve(cwd, 'experiences'), { recursive: true }); + await writeFile(resolve(cwd, artifact.path), artifact.content, 'utf8'); + installed = { experiences: [{ key: 'main', label: 'Shipping Label', artifact_path: artifact.path, + artifact_digest: artifact.digest, bridge_version: DEFT_EXPERIENCE_BRIDGE_VERSION, + renderer_version: DEFT_EXPERIENCE_RENDERER_VERSION }], + public_actions: [{ key: 'claim_label', action_key: 'create_shipping_label', + module_id: included.modules[0]!.module_id, collection_key: 'greetings', + input_mapping: { shipment_id: 'claim.resource_id' } }] }; + await writeFile(resolve(cwd, 'AGENTS.md'), '# Installed Runtime App\n\nUse the version-matched public App Kit. Keep author Worker code in the Experience bundle and all credentials outside the App. Runtime bindings and public endpoints require host review; every external action requires approval.\n'); + } await writeJson(resolve(cwd, 'deft.app.json'), { - schema_version: '3', id: 'community.example.shipping', version: '1.0.0', - name: 'Shipping Label', license: 'AGPL-3.0-only', compatibility: { app_protocol: '3' }, - modules: [], navigation: [], + schema_version: template === 'installed' ? '4' : '3', id: 'community.example.shipping', version: '1.0.0', + name: 'Shipping Label', license: 'AGPL-3.0-only', compatibility: { app_protocol: template === 'installed' ? '4' : '3' }, + modules: included?.modules ?? [], navigation: included?.navigation ?? [], ...installed, runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], private_capabilities: [{ key: 'create_shipping_label', version: '1', input_schema: { type: 'object', properties: { shipment_id: { type: 'string', maxLength: 120 } }, required: ['shipment_id'], additionalProperties: false }, @@ -300,7 +342,7 @@ async function initialize(template: AppTemplate): Promise { async function buildProject(writeOutput: boolean) { const source = JSON.parse(await readFile(resolve(cwd, 'deft.app.json'), 'utf8')) as DeftAppManifestInput; - const artifacts = []; + const artifacts: Parameters[0]['artifacts'] = []; const modules = []; for (const reference of source.modules ?? []) { const raw = JSON.parse(await readFile(await assertRegularUnslinkedFile(reference.manifest_path), 'utf8')) as unknown; @@ -308,7 +350,13 @@ async function buildProject(writeOutput: boolean) { artifacts.push(artifact); modules.push({ ...reference, manifest_digest: artifact.digest }); } - const manifest = parseDeftAppManifest({ ...source, modules }); + const experiences = source.schema_version === '4' ? await Promise.all(source.experiences.map(async (reference) => { + const raw = JSON.parse(await readFile(await assertRegularUnslinkedFile(reference.artifact_path), 'utf8')) as unknown; + const artifact = await prepareDeftExperienceArtifact(reference.artifact_path, raw); + artifacts.push(artifact); + return { ...reference, artifact_digest: artifact.digest }; + })) : undefined; + const manifest = parseDeftAppManifest({ ...source, modules, ...(experiences ? { experiences } : {}) }); const built = await buildDeftAppPackage({ manifest, artifacts }); if (writeOutput) { await mkdir(resolve(cwd, '.deft'), { recursive: true }); diff --git a/packages/app-kit/src/index.ts b/packages/app-kit/src/index.ts index 503317e9..bcd9e9c1 100644 --- a/packages/app-kit/src/index.ts +++ b/packages/app-kit/src/index.ts @@ -1,5 +1,8 @@ import { z } from 'zod'; import { RuntimeAuthoringShape, RuntimeAuthoringSchema, RuntimeRequestedAuthoritySchema } from './runtime-authoring.js'; +import { InstalledAuthoringShape, InstalledAuthoringSchema, InstalledRequestedAuthoritySchema } from './installed-authoring.js'; +import { DeftExperienceArtifactSchema, verifyDeftExperienceArtifact } from './experience.js'; +export * from './installed-authoring.js'; export * from './runtime-authoring.js'; export * from './runtime-client.js'; export * from './experience.js'; @@ -40,9 +43,12 @@ export const DEFT_APP_PACKAGE_FORMAT_V2 = 'deft.app.package.v2' as const; export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 = '3' as const; export const DEFT_APP_PROTOCOL_VERSION_V3 = '3' as const; export const DEFT_APP_PACKAGE_FORMAT_V3 = 'deft.app.package.v3' as const; +export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 = '4' as const; +export const DEFT_APP_PROTOCOL_VERSION_V4 = '4' as const; +export const DEFT_APP_PACKAGE_FORMAT_V4 = 'deft.app.package.v4' as const; export const DEFT_MODULE_ARTIFACT_MEDIA_TYPE = 'application/vnd.deft.module+json' as const; export const DEFT_APP_KIT_PACKAGE_NAME = '@deft/app-kit' as const; -export const DEFT_APP_KIT_VERSION = '0.1.0-alpha.4' as const; +export const DEFT_APP_KIT_VERSION = '0.1.0-alpha.5' as const; export const DEFT_APP_DEVELOPER_COMPATIBILITY_SCHEMA = 'deft.app_developer.compatibility.v1' as const; export const DEFT_APP_DEVELOPER_CONTRACT_CHECK_SCHEMA = 'deft.app_developer.contract_check.v1' as const; export const DEFT_APP_REQUESTED_AUTHORITY_REPORT_SCHEMA = 'deft.app.requested_authority.v1' as const; @@ -77,6 +83,7 @@ export const DeftAppDeveloperCompatibilitySchema = z.strictObject({ '1': DeftAppDeveloperProtocolV1FlowSchema, '2': DeftAppDeveloperProtocolV2FlowSchema.optional(), '3': z.strictObject({ package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V3), install_mode: z.literal('stage_only') }).optional(), + '4': z.strictObject({ package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V4), install_mode: z.literal('stage_only') }).optional(), }), }).superRefine((value, ctx) => { if (abortOnUnknownContractKeys(ctx)) return; @@ -96,10 +103,11 @@ export const DEFT_APP_DEVELOPER_COMPATIBILITY = Object.freeze({ schema: DEFT_APP_DEVELOPER_COMPATIBILITY_SCHEMA, app_kit: Object.freeze({ package: DEFT_APP_KIT_PACKAGE_NAME, - versions: Object.freeze([DEFT_APP_KIT_VERSION, '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1']), + versions: Object.freeze([DEFT_APP_KIT_VERSION, '0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1']), }), protocol_flows: Object.freeze({ '3': Object.freeze({ package_format: DEFT_APP_PACKAGE_FORMAT_V3, install_mode: 'stage_only' as const }), + '4': Object.freeze({ package_format: DEFT_APP_PACKAGE_FORMAT_V4, install_mode: 'stage_only' as const }), '0': Object.freeze({ package_format: DEFT_APP_PACKAGE_FORMAT, install_mode: 'stage_and_activate' as const, @@ -132,6 +140,7 @@ export function resolveDeftAppDeveloperProtocolFlow( && protocol !== DEFT_APP_PROTOCOL_VERSION_V1 && protocol !== DEFT_APP_PROTOCOL_VERSION_V2 && protocol !== DEFT_APP_PROTOCOL_VERSION_V3 + && protocol !== DEFT_APP_PROTOCOL_VERSION_V4 ) { throw new Error(`Host does not support App Protocol v${protocol}`); } @@ -876,6 +885,15 @@ const V2_HANDLER_MATRIX = handlerMatrix({ }); export const DEFT_APP_PROTOCOL_SUPPORT = Object.freeze({ + '4': Object.freeze({ + manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions', 'experiences', 'public_actions']), + atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'modules.included', 'navigation.host_rendered', 'runtime.private_actions', 'experiences.installed', 'public.claim_actions'], handlerMatrix({ + authoring: 'app-kit:v4', inspect: 'app-service:inspect-v4', stage: 'app-service:stage-v4', + review: 'app-runtime-review:v4', activate: 'app-runtime-review:v4', + route: 'app-experiences:v4', invoke: 'app-runtime-actions:v4', + })), + private_interfaces: Object.freeze([]), + }), '3': Object.freeze({ manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions']), atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'runtime.private_actions'], handlerMatrix({ @@ -1020,17 +1038,57 @@ export type DeftAppManifestV3 = z.infer; export type DeftAppManifestV3Input = z.input; export type DeftAppPackageV3 = z.infer; +export const DeftAppManifestV4Schema = z.strictObject({ + ...DeftAppManifestV0Schema.shape, + schema_version: z.literal('4'), compatibility: z.strictObject({ app_protocol: z.literal('4') }), + modules: z.array(DeftAppModuleReferenceV0Schema).max(APP_LIMITS.artifacts_per_app - 1), + ...InstalledAuthoringShape, +}).superRefine((manifest, ctx) => { + const installed = InstalledAuthoringSchema.safeParse({ runtime_requirements: manifest.runtime_requirements, + private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions, + experiences: manifest.experiences, public_actions: manifest.public_actions }); + if (!installed.success) for (const issue of installed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + if (manifest.modules.length) { + const declarative = DeftAppManifestV0Schema.safeParse({ ...Object.fromEntries( + Object.keys(DeftAppManifestV0Schema.shape).map((key) => [key, manifest[key as keyof typeof manifest]])), + schema_version: '0', compatibility: { app_protocol: '0' } }); + if (!declarative.success) for (const issue of declarative.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + } else if (manifest.navigation.length) ctx.addIssue({ code: 'custom', path: ['navigation'], message: 'Navigation requires included Modules' }); + for (const [index, item] of manifest.public_actions.entries()) { + if (!manifest.modules.some((module) => module.module_id === item.module_id)) ctx.addIssue({ code: 'custom', + path: ['public_actions', index, 'module_id'], message: 'Public claims require an included Module' }); + } + if (manifest.experiences.some((item) => manifest.modules.some((module) => module.manifest_path === item.artifact_path))) { + ctx.addIssue({ code: 'custom', path: ['experiences'], message: 'Artifact paths must be unique across surfaces' }); + } +}); +export const DeftAppPackageV4Schema = z.strictObject({ + package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V4), manifest: DeftAppManifestV4Schema, + manifest_digest: AppDigestSchema, + artifacts: z.array(z.union([DeftAppPackageArtifactV0Schema, DeftExperienceArtifactSchema])).max(APP_LIMITS.artifacts_per_app), +}); +export type DeftAppManifestV4 = z.infer; +export type DeftAppManifestV4Input = z.input; +export type DeftAppPackageV4 = z.infer; +export type RuntimeAppManifest = DeftAppManifestV3 | DeftAppManifestV4; +export function parseRuntimeAppManifest(value: unknown): RuntimeAppManifest { + return recordWithString(value, 'schema_version') === '4' + ? DeftAppManifestV4Schema.parse(value) : DeftAppManifestV3Schema.parse(value); +} + export const DeftAppManifestSchema = z.union([ DeftAppManifestV0Schema, DeftAppManifestV1Schema, DeftAppManifestV2Schema, DeftAppManifestV3Schema, + DeftAppManifestV4Schema, ]); export const DeftAppPackageSchema = z.union([ DeftAppPackageV0Schema, DeftAppPackageV1Schema, DeftAppPackageV2Schema, DeftAppPackageV3Schema, + DeftAppPackageV4Schema, ]); export type DeftAppManifestV0 = z.infer; @@ -1142,11 +1200,11 @@ export type DeftAppRequestedAuthorityProjection = export type DeftAppRequestedAuthorityProjectionV2 = z.infer; export type DeftAppRequestedAuthorityProjectionAny = - DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2 | z.infer; + DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2 | z.infer | z.infer; export type DeftAppRequestedAuthorityReport = z.infer; export type DeftAppRequestedAuthorityReportV2 = z.infer; export type DeftAppRequestedAuthorityReportAny = - DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2 | { schema: 'deft.app.requested_authority.v3'; app: {id: string; version: string; protocol_version: '3'}; requested_authority: z.infer }; + DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2 | { schema: 'deft.app.requested_authority.v3'; app: {id: string; version: string; protocol_version: '3'}; requested_authority: z.infer } | { schema: 'deft.app.requested_authority.v4'; app: {id: string; version: string; protocol_version: '4'}; requested_authority: z.infer }; const DeftAppRequestedAuthorityAtomSchema = z.enum([ 'dependencies', @@ -1155,6 +1213,8 @@ const DeftAppRequestedAuthorityAtomSchema = z.enum([ 'connectors', 'actions', 'automation_requests', + 'experiences', + 'public_actions', ]); export const DeftAppRequestedAuthorityDiffSchema = z.strictObject({ @@ -1233,6 +1293,8 @@ export async function diffDeftAppRequestedAuthority(input: Readonly<{ const priorDigest = prior === null ? null : await digest(prior); const atoms = DeftAppRequestedAuthorityAtomSchema.options; const requirement = (value: DeftAppRequestedAuthorityProjectionAny, atom: typeof atoms[number]) => { + if (atom === 'experiences') return 'experiences' in value.requirements ? value.requirements.experiences : []; + if (atom === 'public_actions') return 'public_actions' in value.requirements ? value.requirements.public_actions : []; if ('runtime_actions' in value.requirements) return atom === 'capabilities' ? value.requirements.private_capabilities : atom === 'connectors' ? value.requirements.runtime_requirements : atom === 'actions' ? value.requirements.runtime_actions : []; @@ -1356,6 +1418,11 @@ export function projectDeftAppRequestedAuthority( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityProjectionAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '4') return InstalledRequestedAuthoritySchema.parse({ + requirements: { runtime_requirements: manifest.runtime_requirements, private_capabilities: manifest.private_capabilities, + runtime_actions: manifest.runtime_actions, experiences: manifest.experiences, public_actions: manifest.public_actions }, + classification: { authority_state: 'requested_only', executable: false, provider_access: false, review_required: true }, + }); if (manifest.schema_version === '3') return RuntimeRequestedAuthoritySchema.parse({ requirements: { runtime_requirements: manifest.runtime_requirements, private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions }, classification: { authority_state: 'requested_only', executable: false, provider_access: false, review_required: true }, @@ -1412,6 +1479,11 @@ export function buildDeftAppRequestedAuthorityReport( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityReportAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '4') return { + schema: 'deft.app.requested_authority.v4', + app: { id: manifest.id, version: manifest.version, protocol_version: '4' }, + requested_authority: InstalledRequestedAuthoritySchema.parse(projectDeftAppRequestedAuthority(manifest)), + }; if (manifest.schema_version === '3') return { schema: 'deft.app.requested_authority.v3', app: { id: manifest.id, version: manifest.version, protocol_version: '3' }, @@ -1524,7 +1596,9 @@ export function parseDeftAppManifest(value: unknown): DeftAppManifest { // v1 or v2 continues through the original direct v0 schema instead of a // union branch. const schemaVersion = recordWithString(value, 'schema_version'); - const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 + const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 + ? DeftAppManifestV4Schema.parse(value) + : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 ? DeftAppManifestV3Schema.parse(value) : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 ? DeftAppManifestV2Schema.parse(value) @@ -1587,6 +1661,8 @@ export function getDeftAppManifestV2JsonSchema(): Record { } export function getDeftAppManifestJsonSchema(schemaVersion: string): Record { + if (schemaVersion === '4') return { title: 'Deft installed Runtime App manifest v4', + ...z.toJSONSchema(DeftAppManifestV4Schema, { target: 'draft-2020-12', unrepresentable: 'any' }) }; if (schemaVersion === '3') return { title: 'Deft Runtime App manifest v3', ...z.toJSONSchema(DeftAppManifestV3Schema, { target: 'draft-2020-12', unrepresentable: 'any' }) }; if (schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2) { @@ -1773,7 +1849,7 @@ export async function prepareModuleArtifact(input: { } async function verifyPackage(packageValue: DeftAppPackage): Promise { - const artifacts = new Map(); + const artifacts = new Map>(); const moduleManifests = new Map(); for (const artifact of packageValue.artifacts) { if (artifacts.has(artifact.path)) throw new Error(`Duplicate package artifact path ${artifact.path}`); @@ -1789,12 +1865,14 @@ async function verifyPackage(packageValue: DeftAppPackage): Promise { if ((await digestAppManifest(packageValue.manifest)) !== packageValue.manifest_digest) { throw new Error('App manifest digest mismatch'); } - if (artifacts.size !== packageValue.manifest.modules.length) { + const experiences = packageValue.manifest.schema_version === '4' ? packageValue.manifest.experiences : []; + if (artifacts.size !== packageValue.manifest.modules.length + experiences.length) { throw new Error('Package must contain exactly the artifacts declared by the app manifest'); } for (const moduleReference of packageValue.manifest.modules) { const artifact = artifacts.get(moduleReference.manifest_path); if (!artifact) throw new Error(`Missing module artifact ${moduleReference.manifest_path}`); + if (artifact.media_type !== DEFT_MODULE_ARTIFACT_MEDIA_TYPE) throw new Error('Module artifact media type mismatch'); if (artifact.digest !== moduleReference.manifest_digest) { throw new Error(`Manifest digest does not match artifact ${artifact.path}`); } @@ -1812,6 +1890,24 @@ async function verifyPackage(packageValue: DeftAppPackage): Promise { moduleManifests.set(identity.id, moduleManifest); } verifyNavigationBindings(packageValue.manifest, moduleManifests); + if (packageValue.manifest.schema_version === '4') { + const installedManifest = packageValue.manifest; + for (const reference of experiences) { + const bundle = await verifyDeftExperienceArtifact({ artifact_path: reference.artifact_path, + artifact_digest: reference.artifact_digest, bridge_version: reference.bridge_version, + renderer_version: reference.renderer_version }, artifacts.get(reference.artifact_path)); + if (bundle.resource_keys.length || bundle.action_keys.some((key) => + !installedManifest.runtime_actions.some((action) => action.key === key))) { + throw new Error('Experience must use only declared Runtime actions; resource bridge is not supported yet'); + } + } + for (const declaration of packageValue.manifest.public_actions) { + const module = moduleManifests.get(declaration.module_id) as { collections?: { key?: string }[] } | undefined; + if (!module?.collections?.some((collection) => collection.key === declaration.collection_key)) { + throw new Error('Public claim must select a declared included Module collection'); + } + } + } if ( packageValue.manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V1 || packageValue.manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 @@ -1867,7 +1963,7 @@ function verifyV1ResourceBindings( export async function buildDeftAppPackage(input: { manifest: DeftAppManifestInput; - artifacts: DeftAppPackageArtifactV0[]; + artifacts: (DeftAppPackageArtifactV0 | z.infer)[]; }): Promise<{ package: DeftAppPackage; json: string; digest: AppDigest }> { const manifest = parseDeftAppManifest(input.manifest); const packageInput = { @@ -1875,7 +1971,9 @@ export async function buildDeftAppPackage(input: { manifest_digest: await digestAppManifest(manifest), artifacts: [...input.artifacts].sort((left, right) => left.path.localeCompare(right.path)), }; - const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 + const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 + ? DeftAppPackageV4Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V4, ...packageInput }) + : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 ? DeftAppPackageV3Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V3, ...packageInput }) : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V2 ? DeftAppPackageV2Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V2, ...packageInput }) @@ -1901,7 +1999,9 @@ export async function verifyDeftAppPackageJson( // As with manifest parsing, direct dispatch keeps invalid-v0 issue shapes // stable while allowing the explicitly versioned v1 and v2 formats. const packageFormat = recordWithString(raw, 'package_format'); - const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V3 + const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V4 + ? DeftAppPackageV4Schema.parse(raw) + : packageFormat === DEFT_APP_PACKAGE_FORMAT_V3 ? DeftAppPackageV3Schema.parse(raw) : packageFormat === DEFT_APP_PACKAGE_FORMAT_V2 ? DeftAppPackageV2Schema.parse(raw) diff --git a/packages/app-kit/src/installed-authoring.ts b/packages/app-kit/src/installed-authoring.ts new file mode 100644 index 00000000..acfac40a --- /dev/null +++ b/packages/app-kit/src/installed-authoring.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { DeftExperienceReferenceSchema } from './experience.js'; +import { RuntimeAuthoringShape, RuntimeAuthoringSchema } from './runtime-authoring.js'; + +const key = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/) + .refine((value) => !['constructor', 'prototype'].includes(value)); +export const InstalledExperienceSchema = DeftExperienceReferenceSchema.extend({ + key, label: z.string().min(1).max(80).regex(/^[^\u0000-\u001f\u007f<>]+$/), +}); +export const PublicActionDeclarationSchema = z.strictObject({ + key, action_key: key, module_id: z.string().min(1).max(128), + collection_key: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/), + input_mapping: z.record(key, z.enum(['claim.resource_id', 'claim.claim_id'])) + .refine((value) => Object.keys(value).length > 0 && Object.keys(value).length <= 32), +}); +export const InstalledAuthoringShape = { + ...RuntimeAuthoringShape, + experiences: z.array(InstalledExperienceSchema).max(1), + public_actions: z.array(PublicActionDeclarationSchema).max(8), +}; +export const InstalledAuthoringSchema = z.strictObject(InstalledAuthoringShape).superRefine((value, ctx) => { + const runtime = RuntimeAuthoringSchema.safeParse({ runtime_requirements: value.runtime_requirements, + private_capabilities: value.private_capabilities, runtime_actions: value.runtime_actions }); + if (!runtime.success) for (const issue of runtime.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + if (new Set(value.public_actions.map((item) => item.key)).size !== value.public_actions.length) { + ctx.addIssue({ code: 'custom', path: ['public_actions'], message: 'Public action keys must be unique' }); + } + for (const [index, declaration] of value.public_actions.entries()) { + const action = value.runtime_actions.find((item) => item.key === declaration.action_key); + const capability = value.private_capabilities.find((item) => item.key === action?.capability_key); + if (!capability || capability.input_schema.required.some((field) => !Object.hasOwn(declaration.input_mapping, field)) + || Object.keys(declaration.input_mapping).some((field) => { + const schema = capability.input_schema.properties[field]; + return !schema || schema.type !== 'string' || schema.maxLength < 36; + })) ctx.addIssue({ code: 'custom', path: ['public_actions', index], + message: 'Public input must map declared string fields from canonical claim identifiers and cover every required field' }); + } +}); +export const InstalledRequestedAuthoritySchema = z.strictObject({ + requirements: InstalledAuthoringSchema, + classification: z.strictObject({ authority_state: z.literal('requested_only'), executable: z.literal(false), + provider_access: z.literal(false), review_required: z.literal(true) }), +}); diff --git a/packages/app-kit/test/cli.test.ts b/packages/app-kit/test/cli.test.ts index e737f9eb..4972f6f8 100644 --- a/packages/app-kit/test/cli.test.ts +++ b/packages/app-kit/test/cli.test.ts @@ -53,7 +53,7 @@ test('external authoring loop initializes and builds deterministically without c } const invalidTemplate = run(await mkdtemp(resolve(tmpdir(), 'deft-app-kit-invalid-')), 'init', '--template', 'unknown'); assert.notEqual(invalidTemplate.status, 0); - assert.equal(invalidTemplate.stderr.trim(), 'Usage: deft app init [--template declarative|connected|connected-automation|runtime]'); + assert.equal(invalidTemplate.stderr.trim(), 'Usage: deft app init [--template declarative|connected|connected-automation|runtime|installed]'); const checked = run(project, 'check'); assert.equal(checked.status, 0, checked.stderr); @@ -149,7 +149,7 @@ test('connected template emits a Module v2 dependency App and requested authorit single_use_install: true, compatibility: { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.5', '0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, @@ -184,7 +184,7 @@ test('connected template emits a Module v2 dependency App and requested authorit assert.equal(diagnosed.status, 0, diagnosed.stderr); assert.equal( diagnosed.stdout.trim(), - `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.4; App Protocol v1; ` + `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.5; App Protocol v1; ` + `package format deft.app.package.v1; install mode stage_only; host ${hostUrl}`, ); assert.doesNotMatch(diagnosed.stdout, /registry|signature|signed|trusted|verified/i); @@ -413,7 +413,7 @@ test('Protocol v2 check, build, requested-authority, and doctor paths stay stage single_use_install: true, compatibility: { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.5', '0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, @@ -431,7 +431,7 @@ test('Protocol v2 check, build, requested-authority, and doctor paths stay stage assert.equal(diagnosed.status, 0, diagnosed.stderr); assert.equal( diagnosed.stdout.trim(), - `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.4; App Protocol v2; ` + `Compatible App Kit package @deft/app-kit version 0.1.0-alpha.5; App Protocol v2; ` + `package format deft.app.package.v2; install mode stage_only; host ${hostUrl}\n` + 'Bounded automation contracts ready; run `deft app simulate-automation --fixture ` before staging.', ); diff --git a/packages/app-kit/test/developer-contract.test.ts b/packages/app-kit/test/developer-contract.test.ts index 69455e71..58d2ff50 100644 --- a/packages/app-kit/test/developer-contract.test.ts +++ b/packages/app-kit/test/developer-contract.test.ts @@ -44,12 +44,13 @@ test('freezes one additive App Kit and protocol-flow compatibility contract', as assert.equal(DEFT_APP_KIT_VERSION, packageJson.version); assert.deepEqual(DEFT_APP_DEVELOPER_COMPATIBILITY, { schema: 'deft.app_developer.compatibility.v1', - app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, + app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.5', '0.1.0-alpha.4', '0.1.0-alpha.3', '0.1.0-alpha.2', '0.1.0-alpha.1'] }, protocol_flows: { '0': { package_format: 'deft.app.package.v0', install_mode: 'stage_and_activate' }, '1': { package_format: 'deft.app.package.v1', install_mode: 'stage_only' }, '2': { package_format: 'deft.app.package.v2', install_mode: 'stage_only' }, '3': { package_format: 'deft.app.package.v3', install_mode: 'stage_only' }, + '4': { package_format: 'deft.app.package.v4', install_mode: 'stage_only' }, }, }); assert.equal(Object.isFrozen(DEFT_APP_DEVELOPER_COMPATIBILITY), true); @@ -78,7 +79,7 @@ test('freezes one additive App Kit and protocol-flow compatibility contract', as ...DEFT_APP_DEVELOPER_COMPATIBILITY, app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.0'] }, }, '1'), - /does not support @deft\/app-kit 0\.1\.0-alpha\.4/, + /does not support @deft\/app-kit 0\.1\.0-alpha\.5/, ); assert.throws( () => parseDeftAppDeveloperCompatibility({ @@ -93,7 +94,7 @@ test('candidate Kit refuses a host advertising only the preceding authoring cont assert.throws(() => resolveDeftAppDeveloperProtocolFlow({ ...DEFT_APP_DEVELOPER_COMPATIBILITY, app_kit: { package: '@deft/app-kit', versions: ['0.1.0-alpha.2', '0.1.0-alpha.1'] }, - }, '1'), /does not support @deft\/app-kit 0\.1\.0-alpha\.4/); + }, '1'), /does not support @deft\/app-kit 0\.1\.0-alpha\.5/); }); test('checks a connected package against only the public host and provider contracts', async () => { diff --git a/packages/app-kit/test/installed-authoring.test.ts b/packages/app-kit/test/installed-authoring.test.ts new file mode 100644 index 00000000..5fa08e68 --- /dev/null +++ b/packages/app-kit/test/installed-authoring.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildDeftAppPackage, verifyDeftAppPackageJson, prepareModuleArtifact, prepareDeftExperienceArtifact, + parseDeftAppManifest, diffDeftAppRequestedAuthority, buildDeftAppRequestedAuthorityReport } from '../dist/index.js'; + +async function fixture() { + const module = await prepareModuleArtifact({ path: 'modules/parcels/deft.module.json', manifest: { + schema_version: '1', id: 'community.example.parcels', slug: 'parcels', version: '1.0.0', name: 'Parcels', + collections: [{ key: 'parcels', name: 'Parcels', singular_name: 'Parcel', + fields: [{ key: 'name', label: 'Name', type: 'text', required: true }], + views: [{ key: 'all', name: 'All', type: 'table', fields: ['name'] }], + search: { title_field: 'name', subtitle_fields: [], fields: ['name'] } }], + navigation: { default_collection: 'parcels', default_view: 'all' }, + } }); + const experience = await prepareDeftExperienceArtifact('experiences/main.json', { + schema_version: 'deft.experience_bundle.v1', worker_source: 'self.onmessage=()=>{};', + entry_view: 'main', resource_keys: [], action_keys: ['create_label'], + }); + const object = { type: 'object' as const, properties: { shipment_id: { type: 'string' as const, maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false as const }; + const manifest = { schema_version: '4' as const, id: 'community.example.shipping', version: '1.0.0', name: 'Shipping', + license: 'AGPL-3.0-only', compatibility: { app_protocol: '4' as const }, + modules: [{ module_id: 'community.example.parcels', version: '1.0.0', manifest_path: module.path, manifest_digest: module.digest }], + navigation: [], runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' as const }], + private_capabilities: [{ key: 'label', version: '1' as const, input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'create_label', label: 'Create label', capability_key: 'label', runtime_requirement_key: 'carrier' }], + experiences: [{ key: 'main', label: 'Shipping', artifact_path: experience.path, artifact_digest: experience.digest, + bridge_version: 'deft.experience_bridge.v1' as const, renderer_version: 'deft.trusted_renderer.v1' as const }], + public_actions: [{ key: 'claim_label', action_key: 'create_label', module_id: 'community.example.parcels', collection_key: 'parcels', + input_mapping: { shipment_id: 'claim.resource_id' as const } }], + }; + return { manifest, artifacts: [module, experience] }; +} + +test('installed v4 combines exact Module, Experience and public mapping without granting authority', async () => { + const input = await fixture(); + const built = await buildDeftAppPackage(input); + assert.equal(built.package.package_format, 'deft.app.package.v4'); + assert.deepEqual(await verifyDeftAppPackageJson(built.json), built); + assert.equal((await buildDeftAppPackage({ ...input, artifacts: [...input.artifacts].reverse() })).json, built.json); + const report = buildDeftAppRequestedAuthorityReport(input.manifest); + assert.equal(report.schema, 'deft.app.requested_authority.v4'); + assert.equal(report.requested_authority.classification.executable, false); + const changed = structuredClone(input.manifest); changed.experiences[0]!.label = 'Changed'; + assert.deepEqual((await diffDeftAppRequestedAuthority({ prior: input.manifest, proposed: changed })).changed_atoms, ['experiences']); + assert.throws(() => parseDeftAppManifest({ ...input.manifest, schema_version: '3', compatibility: { app_protocol: '3' } })); +}); + +test('installed v4 rejects orphan artifacts, undeclared actions and invalid canonical claim mappings', async () => { + const input = await fixture(); + await assert.rejects(() => buildDeftAppPackage({ ...input, artifacts: input.artifacts.slice(0, 1) }), /exactly/); + for (const patch of [{ input_mapping: { shipment_id: 'request.body' } }, { input_mapping: {} }, + { action_key: 'missing' }, { module_id: 'missing' }, { input_mapping: { extra: 'claim.claim_id' } }]) { + assert.throws(() => parseDeftAppManifest({ ...input.manifest, public_actions: [{ ...input.manifest.public_actions[0], ...patch }] })); + } + const invalid = structuredClone(input.manifest); invalid.public_actions[0]!.collection_key = 'missing'; + await assert.rejects(() => buildDeftAppPackage({ ...input, manifest: invalid }), /included Module collection/); + const tampered = await prepareDeftExperienceArtifact('experiences/main.json', { + schema_version: 'deft.experience_bundle.v1', worker_source: 'self.onmessage=()=>{};', entry_view: 'main', + resource_keys: [], action_keys: ['undeclared'] }); + const manifest = structuredClone(input.manifest); manifest.experiences[0]!.artifact_digest = tampered.digest; + await assert.rejects(() => buildDeftAppPackage({ manifest, artifacts: [input.artifacts[0]!, tampered] }), /declared Runtime actions/); +}); diff --git a/packages/app-kit/test/packed-external.test.ts b/packages/app-kit/test/packed-external.test.ts index c4e2ec58..4019ba1b 100644 --- a/packages/app-kit/test/packed-external.test.ts +++ b/packages/app-kit/test/packed-external.test.ts @@ -57,7 +57,7 @@ test('packed App Kit builds Contacts, connected Campaigns, and scheduled Campaig const installedRoot = await realpath(resolve(consumer, 'node_modules', '@deft', 'app-kit')); assert.equal(installedRoot.startsWith(await realpath(consumer)), true); const installedPackage = JSON.parse(await readFile(resolve(installedRoot, 'package.json'), 'utf8')) as any; - assert.equal(installedPackage.version, '0.1.0-alpha.4'); + assert.equal(installedPackage.version, '0.1.0-alpha.5'); assert.equal(installedPackage.bin.deft, './dist/cli.js'); const installedFiles = await readdir(installedRoot, { recursive: true }); assert.equal(installedFiles.some((entry) => /^src(?:[\\/]|$)/.test(entry)), false); diff --git a/packages/app-kit/test/protocol-v2.test.ts b/packages/app-kit/test/protocol-v2.test.ts index d2e32c1f..975c154a 100644 --- a/packages/app-kit/test/protocol-v2.test.ts +++ b/packages/app-kit/test/protocol-v2.test.ts @@ -125,7 +125,7 @@ describe('App Protocol v2 bounded automation request contract', () => { assert.deepEqual(getDeftAppManifestJsonSchema('2'), schema); assert.deepEqual(getDeftAppManifestJsonSchema('1'), getDeftAppManifestV1JsonSchema()); assert.deepEqual(getDeftAppManifestJsonSchema('0'), getDeftAppManifestV0JsonSchema()); - assert.throws(() => getDeftAppManifestJsonSchema('4'), /schema v4 is not supported/); + assert.throws(() => getDeftAppManifestJsonSchema('5'), /schema v5 is not supported/); }); test('accepts only one bounded daily trigger declaration over a resolved action', async () => { diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index 6b5844c9..d04fe303 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -146,6 +146,9 @@ async function main() { '0.3.0-preview.31-app-runtime-channel.sql', '0.3.0-preview.32-app-public-claims.sql', '0.3.0-preview.33-app-runtime-authoring.sql', + '0.3.0-preview.34-app-installed-authoring.sql', + '0.3.0-preview.35-app-public-runtime.sql', + '0.3.0-preview.36-app-experience-sessions.sql', ]) { await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); console.log(`[apply-extras] reconciled ${platformFile}`); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 97abb6bd..6e6ed138 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -675,7 +675,7 @@ export const appRuns = pgTable('app_runs', { contract_version: text('contract_version').$type<'deft.app_run.v1'>().notNull(), origin_kind: text('origin_kind').$type<'core' | 'legacy_connector' | 'app'>().notNull(), initiating_actor_type: text('initiating_actor_type') - .$type<'human' | 'agent_employee' | 'system' | 'automation'>() + .$type<'human' | 'agent_employee' | 'system' | 'automation' | 'app_public'>() .notNull(), initiating_actor_id: text('initiating_actor_id').notNull(), execution_actor_type: text('execution_actor_type') @@ -690,6 +690,8 @@ export const appRuns = pgTable('app_runs', { origin_app_version_id: text('origin_app_version_id'), origin_app_binding_key: text('origin_app_binding_key'), origin_runtime_binding_id: text('origin_runtime_binding_id'), + origin_public_endpoint_id: text('origin_public_endpoint_id'), + origin_public_ingress_id: text('origin_public_ingress_id'), origin_app_grant_snapshot_id: text('origin_app_grant_snapshot_id'), origin_app_automation_definition_id: text('origin_app_automation_definition_id'), origin_app_automation_fire_id: text('origin_app_automation_fire_id'), @@ -828,6 +830,11 @@ export const appRuns = pgTable('app_runs', { foreignColumns: [appAutomationDefinitions.org_id, appAutomationDefinitions.id], name: 'app_runs_automation_definition_fk', }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.origin_public_endpoint_id, t.origin_public_ingress_id], + foreignColumns: [appPublicIngress.org_id, appPublicIngress.endpoint_id, appPublicIngress.id], + name: 'app_runs_public_ingress_fk', + }).onDelete('restrict'), foreignKey({ columns: [ t.org_id, @@ -850,6 +857,9 @@ export const appRuns = pgTable('app_runs', { uniqueIndex('app_runs_automation_fire_unique') .on(t.org_id, t.origin_app_automation_definition_id, t.origin_app_automation_fire_id) .where(sql`${t.origin_app_automation_fire_id} IS NOT NULL`), + uniqueIndex('app_runs_public_ingress_unique') + .on(t.org_id, t.origin_public_endpoint_id, t.origin_public_ingress_id) + .where(sql`${t.origin_public_ingress_id} IS NOT NULL`), index('app_runs_idempotency_lookup_idx').on( t.org_id, t.initiating_actor_type, @@ -883,6 +893,8 @@ export const appRuns = pgTable('app_runs', { AND ${t.provider_kind} = 'mcp' AND ${t.origin_app_binding_key} IS NOT NULL AND ${t.origin_runtime_binding_id} IS NULL + AND ${t.origin_public_endpoint_id} IS NULL + AND ${t.origin_public_ingress_id} IS NULL AND ${t.origin_app_grant_snapshot_id} IS NOT NULL AND ${t.risk_class} = 'external_write' AND ${t.review_requirement} = 'always' @@ -894,6 +906,7 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL AND ${t.initiating_actor_type} <> 'automation' + AND ${t.initiating_actor_type} <> 'app_public' AND ${t.execution_actor_type} <> 'automation' ) OR ( ${t.review_scope} = 'approved_automation_definition' @@ -914,8 +927,18 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_runtime_binding_id} IS NOT NULL AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL - AND ${t.initiating_actor_type} <> 'automation' - AND ${t.execution_actor_type} <> 'automation' + AND ( + (${t.initiating_actor_type} = 'app_public' + AND ${t.execution_actor_type} = 'human' + AND ${t.initiating_actor_id} = ${t.origin_public_ingress_id} + AND ${t.origin_public_endpoint_id} IS NOT NULL + AND ${t.origin_public_ingress_id} IS NOT NULL) + OR (${t.initiating_actor_type} <> 'automation' + AND ${t.initiating_actor_type} <> 'app_public' + AND ${t.execution_actor_type} <> 'automation' + AND ${t.origin_public_endpoint_id} IS NULL + AND ${t.origin_public_ingress_id} IS NULL) + ) AND ${t.review_scope} = 'per_invocation' ) OR ( ${t.origin_kind} <> 'app' @@ -927,7 +950,10 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_app_grant_snapshot_id} IS NULL AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL + AND ${t.origin_public_endpoint_id} IS NULL + AND ${t.origin_public_ingress_id} IS NULL AND ${t.initiating_actor_type} <> 'automation' + AND ${t.initiating_actor_type} <> 'app_public' AND ${t.execution_actor_type} <> 'automation' ) `), @@ -938,7 +964,7 @@ export const appRuns = pgTable('app_runs', { ) `), check('app_runs_actor_type_check', sql` - ${t.initiating_actor_type} IN ('human', 'agent_employee', 'system', 'automation') + ${t.initiating_actor_type} IN ('human', 'agent_employee', 'system', 'automation', 'app_public') AND ${t.execution_actor_type} IN ('human', 'agent_employee', 'system', 'automation') `), check('app_runs_provider_kind_check', sql`${t.provider_kind} IN ('mcp', 'app_runtime')`), @@ -1171,7 +1197,7 @@ export const appRunEvents = pgTable('app_run_events', { )`), check('app_run_events_actor_shape_check', sql` (${t.actor_type} IS NULL AND ${t.actor_id} IS NULL) - OR (${t.actor_type} IN ('human', 'agent_employee', 'system', 'automation') AND ${t.actor_id} IS NOT NULL) + OR (${t.actor_type} IN ('human', 'agent_employee', 'system', 'automation', 'app_public') AND ${t.actor_id} IS NOT NULL) `), check('app_run_events_payload_check', sql`jsonb_typeof(${t.payload}) = 'object' AND octet_length(${t.payload}::text) <= 32768`), ]); @@ -1685,7 +1711,7 @@ export const appVersions = pgTable('app_versions', { uniqueIndex('app_versions_one_active_unique') .on(t.org_id, t.installation_id) .where(sql`${t.state} = 'active'`), - check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2', '3')`), + check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2', '3', '4')`), check('app_versions_connected_request_check', sql` ${t.protocol_version} = '0' OR ${t.requested_grant_snapshot_id} IS NOT NULL `), @@ -4667,6 +4693,48 @@ export const webSessions = pgTable('web_sessions', { created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), }, (t) => [index('web_sessions_user_org_idx').on(t.user_id, t.org_id)]); +// A host-issued, short-lived proof that a human may render one reviewed App +// experience. The token itself is never stored here. +export const appExperienceSessions = pgTable('app_experience_sessions', { + ...id(), + ...orgId(), + user_id: text('user_id').notNull(), + web_session_id: text('web_session_id').notNull(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + grant_snapshot_kind: text('grant_snapshot_kind').$type<'effective'>().default('effective').notNull(), + experience_key: text('experience_key').notNull(), + artifact_digest: text('artifact_digest').notNull(), + lifecycle_epoch: integer('lifecycle_epoch').notNull(), + grant_epoch: integer('grant_epoch').notNull(), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + expires_at: timestamp('expires_at', { withTimezone: true }).notNull(), + revoked_at: timestamp('revoked_at', { withTimezone: true }), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_experience_sessions_member_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.web_session_id], foreignColumns: [webSessions.id], + name: 'app_experience_sessions_web_session_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id], + foreignColumns: [appVersions.org_id, appVersions.installation_id, appVersions.id], + name: 'app_experience_sessions_version_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, + t.grant_snapshot_id, t.grant_snapshot_kind], + foreignColumns: [appGrantSnapshots.org_id, appGrantSnapshots.app_installation_id, + appGrantSnapshots.app_version_id, appGrantSnapshots.id, appGrantSnapshots.snapshot_kind], + name: 'app_experience_sessions_grant_fk' }).onDelete('restrict'), + index('app_experience_sessions_web_app_idx').on(t.org_id, t.web_session_id, + t.app_installation_id, t.expires_at), + index('app_experience_sessions_expires_idx').on(t.expires_at), + check('app_experience_sessions_key_check', sql`${t.experience_key} ~ '^[a-z][a-z0-9_]{0,47}$'`), + check('app_experience_sessions_digest_check', sql`${t.artifact_digest} ~ '^sha256:[a-f0-9]{64}$'`), + check('app_experience_sessions_epoch_check', sql`${t.lifecycle_epoch} >= 0 AND ${t.grant_epoch} >= 0`), + check('app_experience_sessions_kind_check', sql`${t.grant_snapshot_kind} = 'effective'`), + check('app_experience_sessions_expiry_check', sql`${t.expires_at} > ${t.created_at}`), +]); + // ═══ REVOKED TOKENS ═══ // Server-side refresh token revocation (Option B — stateless JWTs, hash-based blacklist). // Logout inserts the sha256 hash; /refresh rejects any token whose hash is present. @@ -4693,6 +4761,11 @@ export const appPublicEndpoints = pgTable('app_public_endpoints', { installation_grant_epoch: integer('installation_grant_epoch').notNull(), module_installation_id: text('module_installation_id').notNull(), collection_key: text('collection_key').notNull(), + public_action_key: text('public_action_key'), + runtime_binding_id: text('runtime_binding_id'), + approver_user_id: text('approver_user_id'), + input_mapping: jsonb('input_mapping').$type | null>(), + mapping_digest: text('mapping_digest'), state: text('state').$type<'disabled' | 'enabled'>().default('disabled').notNull(), endpoint_epoch: integer('endpoint_epoch').default(1).notNull(), review_digest: text('review_digest').notNull(), @@ -4718,6 +4791,12 @@ export const appPublicEndpoints = pgTable('app_public_endpoints', { foreignColumns: [moduleInstallations.org_id, moduleInstallations.id], name: 'app_public_endpoints_module_fk', }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.runtime_binding_id], + foreignColumns: [appRuntimeBindings.org_id, appRuntimeBindings.id], + name: 'app_public_endpoints_runtime_binding_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.approver_user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_public_endpoints_approver_fk' }).onDelete('restrict'), unique('app_public_endpoints_org_id_unique').on(t.org_id, t.id), uniqueIndex('app_public_endpoints_slug_digest_unique').on(t.slug_digest), index('app_public_endpoints_org_installation_idx').on(t.org_id, t.app_installation_id, t.state), @@ -4729,6 +4808,15 @@ export const appPublicEndpoints = pgTable('app_public_endpoints', { check('app_public_endpoints_collection_check', sql`${t.collection_key} ~ '^[a-z][a-z0-9_]{0,63}$'`), check('app_public_endpoints_label_check', sql`octet_length(${t.public_label}) BETWEEN 1 AND 200`), check('app_public_endpoints_body_limit_check', sql`${t.max_body_bytes} BETWEEN 128 AND 8192`), + check('app_public_endpoints_action_shape_check', sql` + (${t.public_action_key} IS NULL AND ${t.runtime_binding_id} IS NULL + AND ${t.approver_user_id} IS NULL AND ${t.input_mapping} IS NULL AND ${t.mapping_digest} IS NULL) + OR (${t.public_action_key} IS NOT NULL AND ${t.public_action_key} ~ '^[a-z][a-z0-9_]{0,47}$' + AND ${t.runtime_binding_id} IS NOT NULL AND ${t.approver_user_id} IS NOT NULL + AND ${t.input_mapping} IS NOT NULL AND jsonb_typeof(${t.input_mapping}) = 'object' + AND octet_length(${t.input_mapping}::text) <= 4096 + AND ${t.mapping_digest} IS NOT NULL AND ${t.mapping_digest} ~ '^sha256:[a-f0-9]{64}$') + `), ]); // A receipt is retained even for a losing claim. No raw request body, cookie, @@ -4741,7 +4829,7 @@ export const appPublicIngress = pgTable('app_public_ingress', { request_key_digest: text('request_key_digest').notNull(), input_digest: text('input_digest').notNull(), state: text('state').$type<'processing' | 'confirmed' | 'conflict'>().notNull(), - follow_up_state: text('follow_up_state').$type<'pending' | 'unsupported'>().default('pending').notNull(), + follow_up_state: text('follow_up_state').$type<'pending' | 'unsupported' | 'run_created'>().default('pending').notNull(), follow_up_code: text('follow_up_code').$type<'APP_HANDLER_UNAVAILABLE' | 'ENDPOINT_REVOKED'>(), handled_at: timestamp('handled_at'), created_at: timestamp('created_at').defaultNow().notNull(), @@ -4760,7 +4848,9 @@ export const appPublicIngress = pgTable('app_public_ingress', { check('app_public_ingress_state_check', sql`${t.state} IN ('processing', 'confirmed', 'conflict')`), check('app_public_ingress_follow_up_check', sql`(${t.follow_up_state} = 'pending' AND ${t.follow_up_code} IS NULL AND ${t.handled_at} IS NULL) OR (${t.follow_up_state} = 'unsupported' AND ${t.follow_up_code} IS NOT NULL - AND ${t.follow_up_code} IN ('APP_HANDLER_UNAVAILABLE', 'ENDPOINT_REVOKED') AND ${t.handled_at} IS NOT NULL)`), + AND ${t.follow_up_code} IN ('APP_HANDLER_UNAVAILABLE', 'ENDPOINT_REVOKED') AND ${t.handled_at} IS NOT NULL) + OR (${t.follow_up_state} = 'run_created' AND ${t.follow_up_code} IS NULL + AND ${t.handled_at} IS NOT NULL)`), ]); // The uniqueness key omits endpoint identity: two public endpoints cannot diff --git a/packages/db/upgrades/0.3.0-preview.34-app-installed-authoring.sql b/packages/db/upgrades/0.3.0-preview.34-app-installed-authoring.sql new file mode 100644 index 00000000..966712cb --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.34-app-installed-authoring.sql @@ -0,0 +1,103 @@ +-- Additive protocol. Rollback keeps v4 rows inert; do not downgrade the constraint with v4 data present. +ALTER TABLE app_versions DROP CONSTRAINT IF EXISTS app_versions_protocol_supported_check; +ALTER TABLE app_versions ADD CONSTRAINT app_versions_protocol_supported_check CHECK (protocol_version IN ('0', '1', '2', '3', '4')); + +CREATE OR REPLACE FUNCTION assert_app_installation_grant_coherence( + checked_org_id text, + checked_installation_id text +) RETURNS void AS $$ +DECLARE + installation app_installations%ROWTYPE; + version_protocol text; + version_state text; +BEGIN + SELECT * INTO installation FROM app_installations + WHERE org_id = checked_org_id AND id = checked_installation_id; + IF NOT FOUND THEN RETURN; END IF; + + IF installation.active_version_id IS NULL THEN + IF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_GRANT_POINTER_WITHOUT_VERSION' USING ERRCODE = '23514'; + END IF; + RETURN; + END IF; + + SELECT protocol_version, state INTO version_protocol, version_state + FROM app_versions + WHERE org_id = checked_org_id + AND installation_id = checked_installation_id + AND id = installation.active_version_id; + IF NOT FOUND OR version_state <> 'active' THEN + RAISE EXCEPTION 'APP_ACTIVE_VERSION_INVALID' USING ERRCODE = '23514'; + END IF; + + IF installation.state = 'active' AND version_protocol IN ('1', '2', '3', '4') THEN + IF installation.active_grant_snapshot_id IS NULL + OR installation.active_grant_snapshot_kind <> 'effective' + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_REQUIRED' USING ERRCODE = '23514'; + END IF; + ELSIF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_NOT_ALLOWED' USING ERRCODE = '23514'; + END IF; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION enforce_app_grant_snapshot_lineage() RETURNS trigger AS $$ +BEGIN + IF NEW.snapshot_kind = 'requested' THEN + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND requested_grant_snapshot_id = NEW.id + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_POINTER_MISMATCH' USING ERRCODE = '23514'; + END IF; + ELSE + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND protocol_version IN ('1', '2', '3', '4') + ) THEN + RAISE EXCEPTION 'APP_GRANT_EFFECTIVE_PROTOCOL_UNSUPPORTED' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND app_version_id = NEW.app_version_id + AND id = NEW.requested_snapshot_id + AND snapshot_kind = 'requested' + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM org_members + WHERE org_id = NEW.org_id + AND user_id = NEW.reviewed_by_actor_id + AND is_active = true + AND role IN ('owner', 'admin') + ) THEN + RAISE EXCEPTION 'APP_GRANT_REVIEWER_NOT_AUTHORIZED' USING ERRCODE = '23514'; + END IF; + IF NEW.supersedes_snapshot_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND id = NEW.supersedes_snapshot_id + AND snapshot_kind = 'effective' + ) THEN + RAISE EXCEPTION 'APP_GRANT_SUPERSEDES_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/db/upgrades/0.3.0-preview.35-app-public-runtime.sql b/packages/db/upgrades/0.3.0-preview.35-app-public-runtime.sql new file mode 100644 index 00000000..f2cba9f9 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.35-app-public-runtime.sql @@ -0,0 +1,153 @@ +-- Additive public Runtime lineage. Existing unsupported ingress receipts remain +-- terminal and retain their original shape; this migration does not replay them. +ALTER TABLE app_public_endpoints + ADD COLUMN IF NOT EXISTS public_action_key text, + ADD COLUMN IF NOT EXISTS runtime_binding_id text, + ADD COLUMN IF NOT EXISTS approver_user_id text, + ADD COLUMN IF NOT EXISTS input_mapping jsonb, + ADD COLUMN IF NOT EXISTS mapping_digest text; + +ALTER TABLE app_public_endpoints + DROP CONSTRAINT IF EXISTS app_public_endpoints_runtime_binding_fk, + DROP CONSTRAINT IF EXISTS app_public_endpoints_approver_fk, + DROP CONSTRAINT IF EXISTS app_public_endpoints_action_shape_check; +ALTER TABLE app_public_endpoints + ADD CONSTRAINT app_public_endpoints_runtime_binding_fk + FOREIGN KEY (org_id, runtime_binding_id) + REFERENCES app_runtime_bindings(org_id, id) ON DELETE RESTRICT, + ADD CONSTRAINT app_public_endpoints_approver_fk + FOREIGN KEY (org_id, approver_user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + ADD CONSTRAINT app_public_endpoints_action_shape_check CHECK ( + (public_action_key IS NULL AND runtime_binding_id IS NULL + AND approver_user_id IS NULL AND input_mapping IS NULL AND mapping_digest IS NULL) + OR (public_action_key IS NOT NULL + AND public_action_key ~ '^[a-z][a-z0-9_]{0,47}$' + AND runtime_binding_id IS NOT NULL AND approver_user_id IS NOT NULL + AND input_mapping IS NOT NULL AND jsonb_typeof(input_mapping) = 'object' + AND octet_length(input_mapping::text) <= 4096 + AND mapping_digest IS NOT NULL AND mapping_digest ~ '^sha256:[a-f0-9]{64}$') + ); + +ALTER TABLE app_public_ingress + DROP CONSTRAINT IF EXISTS app_public_ingress_follow_up_check; +ALTER TABLE app_public_ingress + ADD CONSTRAINT app_public_ingress_follow_up_check CHECK ( + (follow_up_state = 'pending' AND follow_up_code IS NULL + AND handled_at IS NULL) + OR (follow_up_state = 'unsupported' AND follow_up_code IS NOT NULL + AND follow_up_code IN ('APP_HANDLER_UNAVAILABLE', 'ENDPOINT_REVOKED') + AND handled_at IS NOT NULL) + OR (follow_up_state = 'run_created' AND follow_up_code IS NULL + AND handled_at IS NOT NULL) + ); + +ALTER TABLE app_runs + ADD COLUMN IF NOT EXISTS origin_public_endpoint_id text, + ADD COLUMN IF NOT EXISTS origin_public_ingress_id text; +ALTER TABLE app_runs + DROP CONSTRAINT IF EXISTS app_runs_public_ingress_fk; +ALTER TABLE app_runs + ADD CONSTRAINT app_runs_public_ingress_fk + FOREIGN KEY (org_id, origin_public_endpoint_id, origin_public_ingress_id) + REFERENCES app_public_ingress(org_id, endpoint_id, id) ON DELETE RESTRICT; +CREATE UNIQUE INDEX IF NOT EXISTS app_runs_public_ingress_unique + ON app_runs(org_id, origin_public_endpoint_id, origin_public_ingress_id) + WHERE origin_public_ingress_id IS NOT NULL; +ALTER TABLE app_runs + DROP CONSTRAINT IF EXISTS app_runs_app_origin_coherence_check; +ALTER TABLE app_runs + ADD CONSTRAINT app_runs_app_origin_coherence_check CHECK ( + ( + origin_kind = 'app' AND origin_app_installation_id IS NOT NULL + AND origin_app_version_id IS NOT NULL AND provider_kind = 'mcp' + AND origin_app_binding_key IS NOT NULL AND origin_runtime_binding_id IS NULL + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL + AND origin_app_grant_snapshot_id IS NOT NULL + AND risk_class = 'external_write' AND review_requirement = 'always' + AND retry_class = 'idempotent_with_key' AND retention_class = 'standard' + AND ((review_scope = 'per_invocation' + AND origin_app_automation_definition_id IS NULL + AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation') + OR (review_scope = 'approved_automation_definition' + AND origin_app_automation_definition_id IS NOT NULL + AND origin_app_automation_fire_id IS NOT NULL + AND initiating_actor_type = 'human' AND execution_actor_type = 'automation' + AND execution_actor_id = origin_app_automation_definition_id)) + ) OR ( + origin_kind = 'app' AND provider_kind = 'app_runtime' + AND origin_app_installation_id IS NOT NULL AND origin_app_version_id IS NOT NULL + AND origin_app_grant_snapshot_id IS NOT NULL AND origin_app_binding_key IS NULL + AND origin_runtime_binding_id IS NOT NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND ((initiating_actor_type = 'app_public' AND execution_actor_type = 'human' + AND initiating_actor_id = origin_public_ingress_id + AND origin_public_endpoint_id IS NOT NULL AND origin_public_ingress_id IS NOT NULL) + OR (initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation' + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL)) + AND review_scope = 'per_invocation' + ) OR ( + origin_kind <> 'app' AND provider_kind = 'mcp' + AND origin_app_installation_id IS NULL AND origin_app_version_id IS NULL + AND origin_app_binding_key IS NULL AND origin_runtime_binding_id IS NULL + AND origin_app_grant_snapshot_id IS NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL + AND initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation' + ) + ); +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_actor_type_check; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_actor_type_check CHECK ( + initiating_actor_type IN ('human', 'agent_employee', 'system', 'automation', 'app_public') + AND execution_actor_type IN ('human', 'agent_employee', 'system', 'automation') +); +ALTER TABLE app_run_events DROP CONSTRAINT IF EXISTS app_run_events_actor_shape_check; +ALTER TABLE app_run_events ADD CONSTRAINT app_run_events_actor_shape_check CHECK ( + (actor_type IS NULL AND actor_id IS NULL) + OR (actor_type IN ('human', 'agent_employee', 'system', 'automation', 'app_public') + AND actor_id IS NOT NULL) +); + +-- A public mapping may only be revised while disabled, with a new reviewed +-- epoch. Legacy unmapped endpoints retain their original state transitions. +CREATE OR REPLACE FUNCTION enforce_app_public_endpoint_mapping() RETURNS trigger AS $$ +BEGIN + IF (NEW.public_action_key, NEW.runtime_binding_id, NEW.approver_user_id, + NEW.input_mapping, NEW.mapping_digest) IS DISTINCT FROM + (OLD.public_action_key, OLD.runtime_binding_id, OLD.approver_user_id, + OLD.input_mapping, OLD.mapping_digest) THEN + IF OLD.state <> 'disabled' OR NEW.state <> 'disabled' + OR NEW.endpoint_epoch <> OLD.endpoint_epoch + 1 + OR NEW.review_digest = OLD.review_digest + OR NEW.reviewed_at <= OLD.reviewed_at THEN + RAISE EXCEPTION 'APP_PUBLIC_MAPPING_REVIEW_REQUIRED' USING ERRCODE = '55000'; + END IF; + END IF; + IF (OLD.public_action_key IS NOT NULL OR NEW.public_action_key IS NOT NULL) + AND NEW.state IS DISTINCT FROM OLD.state + AND NEW.endpoint_epoch <> OLD.endpoint_epoch + 1 THEN + RAISE EXCEPTION 'APP_PUBLIC_ENDPOINT_EPOCH_REQUIRED' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_public_endpoint_mapping_trigger ON app_public_endpoints; +CREATE TRIGGER app_public_endpoint_mapping_trigger BEFORE UPDATE ON app_public_endpoints + FOR EACH ROW EXECUTE FUNCTION enforce_app_public_endpoint_mapping(); + +-- Existing Run transition machinery remains unchanged; only the two new +-- public origin pins are immutable once a Run has been inserted. +CREATE OR REPLACE FUNCTION enforce_app_run_public_identity() RETURNS trigger AS $$ +BEGIN + IF (NEW.origin_public_endpoint_id, NEW.origin_public_ingress_id) IS DISTINCT FROM + (OLD.origin_public_endpoint_id, OLD.origin_public_ingress_id) THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_run_public_identity_trigger ON app_runs; +CREATE TRIGGER app_run_public_identity_trigger BEFORE UPDATE ON app_runs + FOR EACH ROW EXECUTE FUNCTION enforce_app_run_public_identity(); diff --git a/packages/db/upgrades/0.3.0-preview.36-app-experience-sessions.sql b/packages/db/upgrades/0.3.0-preview.36-app-experience-sessions.sql new file mode 100644 index 00000000..66d1d415 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.36-app-experience-sessions.sql @@ -0,0 +1,46 @@ +-- Short-lived host-owned browser sessions for immutable App Experiences. +-- An opaque session ID is a locator, never standalone authorization: every +-- request must also pass the current web SID, member, App and grant checks. +CREATE TABLE IF NOT EXISTS app_experience_sessions ( + id text PRIMARY KEY, + org_id text NOT NULL, + user_id text NOT NULL, + web_session_id text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + grant_snapshot_kind text NOT NULL DEFAULT 'effective', + experience_key text NOT NULL, + artifact_digest text NOT NULL, + lifecycle_epoch integer NOT NULL, + grant_epoch integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + CONSTRAINT app_experience_sessions_member_fk FOREIGN KEY (org_id, user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + CONSTRAINT app_experience_sessions_web_session_fk FOREIGN KEY (web_session_id) + REFERENCES web_sessions(id) ON DELETE RESTRICT, + CONSTRAINT app_experience_sessions_version_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id) + REFERENCES app_versions(org_id, installation_id, id) ON DELETE RESTRICT, + CONSTRAINT app_experience_sessions_grant_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, grant_snapshot_kind) + REFERENCES app_grant_snapshots + (org_id, app_installation_id, app_version_id, id, snapshot_kind) ON DELETE RESTRICT, + CONSTRAINT app_experience_sessions_key_check CHECK + (experience_key ~ '^[a-z][a-z0-9_]{0,47}$'), + CONSTRAINT app_experience_sessions_digest_check CHECK + (artifact_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_experience_sessions_epoch_check CHECK + (lifecycle_epoch >= 0 AND grant_epoch >= 0), + CONSTRAINT app_experience_sessions_kind_check CHECK + (grant_snapshot_kind = 'effective'), + CONSTRAINT app_experience_sessions_expiry_check CHECK + (expires_at > created_at) +); + +CREATE INDEX IF NOT EXISTS app_experience_sessions_web_app_idx + ON app_experience_sessions(org_id, web_session_id, app_installation_id, expires_at); +CREATE INDEX IF NOT EXISTS app_experience_sessions_expires_idx + ON app_experience_sessions(expires_at); diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index fc5c69de..3e5282b5 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -187,6 +187,21 @@ export const upgradeManifest = { file: '0.3.0-preview.33-app-runtime-authoring.sql', description: 'Permit explicitly reviewed Runtime App protocol v3 with effective grant coherence', }, + { + version: '0.3.0-preview.34', + file: '0.3.0-preview.34-app-installed-authoring.sql', + description: 'Permit additive installed Runtime App protocol v4 with effective grant coherence', + }, + { + version: '0.3.0-preview.35', + file: '0.3.0-preview.35-app-public-runtime.sql', + description: 'Bind reviewed public ingress principals to one governed Runtime Run', + }, + { + version: '0.3.0-preview.36', + file: '0.3.0-preview.36-app-experience-sessions.sql', + description: 'Pin installed Experience sessions to authenticated web and App authority', + }, ] satisfies UpgradeMigration[], } as const; diff --git a/packages/shared/src/app-runs.ts b/packages/shared/src/app-runs.ts index 783250eb..94d64b30 100644 --- a/packages/shared/src/app-runs.ts +++ b/packages/shared/src/app-runs.ts @@ -186,6 +186,8 @@ export const AppRunActorSchema = z.discriminatedUnion('actor_type', [ user_id: ExactIdentitySchema.optional(), }).strict(), z.object({ actor_type: z.literal('system'), system_id: ExactIdentitySchema }).strict(), + z.object({ actor_type: z.literal('app_public'), endpoint_id: ExactIdentitySchema, + ingress_id: ExactIdentitySchema }).strict(), z.object({ actor_type: z.literal('automation'), automation_id: ExactIdentitySchema, @@ -214,6 +216,15 @@ export const AppRunOriginSchema = z.union([ runtime_binding_id: ExactIdentitySchema, grant_snapshot_id: ExactIdentitySchema, }).strict(), + z.object({ + origin_kind: z.literal('app'), + installation_id: ExactIdentitySchema, + app_version_id: ExactIdentitySchema, + runtime_binding_id: ExactIdentitySchema, + grant_snapshot_id: ExactIdentitySchema, + public_endpoint_id: ExactIdentitySchema, + public_ingress_id: ExactIdentitySchema, + }).strict(), ]); export type AppRunOrigin = z.infer; @@ -315,6 +326,9 @@ export const AppRunAuthorityRefSchema = z.object({ 'app_binding', 'app_runtime_registration', 'app_runtime_binding', + 'app_public_endpoint', + 'app_public_ingress', + 'app_public_claim', 'app_dependency', 'app_automation_request', 'app_automation_definition', diff --git a/scripts/gate-g/README.md b/scripts/gate-g/README.md index 136e40d4..11aecef4 100644 --- a/scripts/gate-g/README.md +++ b/scripts/gate-g/README.md @@ -5,7 +5,7 @@ These fixtures exercise proposed boundaries. They do not implement or certify th - `experiences/`: loopback browser egress and bounded interaction experiment. - `runtime/`: separate-process recovery experiment with independent synthetic host/provider ledgers. - `public/`: transaction/claim experiment on an explicitly assigned disposable PostgreSQL database. -- `required-tests.json`: reviewed inventory of 72 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, eleven Runtime/public foundation cases, and four reviewed Runtime journey/concurrency cases. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. +- `required-tests.json`: reviewed inventory of 74 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, eleven Runtime/public foundation cases, four reviewed Runtime journey/concurrency cases, four installed-consumer cases, and one production automation process/restore case. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. - `verify-upgrade.mjs`: read-only retained-data fingerprints and schema snapshots for the assigned disposable PostgreSQL cluster. Capture a tracked predecessor before candidate upgrades, compare retained columns afterward, and compare candidate fresh/upgrade schemas separately. ## Capture and check test execution diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 905bbc67..9af363e9 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -5,6 +5,14 @@ { "description": "All current App Kit baseline and candidate Runtime/Experience leaf cases; offline packed consumers use pinned cached dependencies.", "cases": [ + { + "name": "installed v4 combines exact Module, Experience and public mapping without granting authority", + "file": "packages/app-kit/test/installed-authoring.test.ts" + }, + { + "name": "installed v4 rejects orphan artifacts, undeclared actions and invalid canonical claim mappings", + "file": "packages/app-kit/test/installed-authoring.test.ts" + }, { "name": "builds and verifies byte-identical deterministic packages", "file": "packages/app-kit/test/app-kit.test.ts" @@ -617,6 +625,38 @@ ], "id": "runtime-author-journey", "description": "Reviewed v3 lifecycle, actual packed Runtime Kit, authenticated HTTP review and separate SDK worker with signed receipt on the assigned disposable PostgreSQL database." + }, + { + "id": "installed-author-journey", + "description": "Protocol v4 atomic Module activation, human web-session Experience submission, and packed public claim through approval to a separate Runtime effect on the assigned disposable PostgreSQL database.", + "cases": [ + { + "name": "anonymous public route mounts only when Apps and explicit ingress opt-in are both enabled", + "file": "apps/api/test/app-public-route-flags.test.ts" + }, + { + "name": "v4 activation rolls back earlier included Modules when a later Module conflicts", + "file": "apps/api/test/app-installed-review-db.test.ts" + }, + { + "name": "installed Experience session pins human web SID, App, grant and bounded active count", + "file": "apps/api/test/app-experience-db.test.ts" + }, + { + "name": "packed v4 public claim creates a distinct principal Run, approved Runtime effect and signed receipt", + "file": "apps/api/test/app-public-runtime-http-db.test.ts" + } + ] + }, + { + "id": "automation-process-restore", + "description": "Production automation scanner, queues and workers across real process kills, durable provider ledger and restored database/keyrings; uses its own guarded source/restore profile.", + "cases": [ + { + "name": "Track A production automation survives process kills and DB/keyring restore", + "file": "apps/api/test/track-a-restore-process-db.test.ts" + } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" diff --git a/scripts/gate-g/verify-upgrade.mjs b/scripts/gate-g/verify-upgrade.mjs index c94d39c2..43e4270d 100644 --- a/scripts/gate-g/verify-upgrade.mjs +++ b/scripts/gate-g/verify-upgrade.mjs @@ -28,7 +28,7 @@ function stable(value) { await client.connect(); try { if (mode === 'schema') { - const selected = ['app_installations', 'app_versions', 'app_grant_snapshots', 'app_runs', 'app_run_attempts', 'capability_provider_snapshots']; + const selected = ['app_installations', 'app_versions', 'app_grant_snapshots', 'app_runs', 'app_run_attempts', 'app_run_events', 'app_experience_sessions', 'capability_provider_snapshots']; const discovered = await client.query("SELECT tablename FROM pg_tables WHERE schemaname='public' AND (tablename LIKE 'app_runtime_%' OR tablename LIKE 'app_public_%' OR tablename='app_canonical_claims') ORDER BY tablename"); selected.push(...discovered.rows.map((row) => row.tablename)); const columns = await client.query("SELECT table_name,column_name,data_type,is_nullable,column_default FROM information_schema.columns WHERE table_schema='public' AND table_name=ANY($1) ORDER BY table_name,column_name", [selected]); diff --git a/scripts/runtime-packed-author.test.mts b/scripts/runtime-packed-author.test.mts index fec6788d..b56a52e9 100644 --- a/scripts/runtime-packed-author.test.mts +++ b/scripts/runtime-packed-author.test.mts @@ -35,5 +35,16 @@ test('outside author consumes packed Runtime Kit without workspace imports and b await writeFile(join(project, 'verify.mjs'), "import {verifyDeftAppPackageJson,parseRuntimeObjectInput,createAppRuntimeClient} from '@deft/app-kit'; import {readFile} from 'node:fs/promises'; if(typeof createAppRuntimeClient!=='function') throw Error('runtime transport export'); const p=await verifyDeftAppPackageJson(await readFile('.deft/app.deftapp.json','utf8')); if(p.package.manifest.schema_version!=='3') throw Error('protocol'); parseRuntimeObjectInput(p.package.manifest.private_capabilities[0].input_schema,{shipment_id:'synthetic-1'}); console.log(p.digest);\n"); assert.match(run(['verify.mjs'], project), /sha256:[a-f0-9]{64}/); if (process.env.DEFT_RUNTIME_AUTHOR_PACKAGE) await writeFile(process.env.DEFT_RUNTIME_AUTHOR_PACKAGE, artifact); + const installedProject = join(project, 'installed-app'); + await mkdir(installedProject); + run([cli, 'app', 'init', '--template', 'installed'], installedProject); + run([cli, 'app', 'check'], installedProject); + run([cli, 'app', 'build'], installedProject); + const installedArtifact = await readFile(join(installedProject, '.deft/app.deftapp.json'), 'utf8'); + run([cli, 'app', 'build'], installedProject); + assert.equal(await readFile(join(installedProject, '.deft/app.deftapp.json'), 'utf8'), installedArtifact); + await writeFile(join(installedProject, 'verify.mjs'), "import {verifyDeftAppPackageJson} from '@deft/app-kit'; import {readFile} from 'node:fs/promises'; const p=await verifyDeftAppPackageJson(await readFile('.deft/app.deftapp.json','utf8')); if(p.package.manifest.schema_version!=='4'||p.package.artifacts.length!==2||p.package.manifest.public_actions.length!==1||p.package.manifest.experiences.length!==1) throw Error('installed contract'); console.log(p.digest);\n"); + assert.match(run(['verify.mjs'], installedProject), /sha256:[a-f0-9]{64}/); + if (process.env.DEFT_INSTALLED_AUTHOR_PACKAGE) await writeFile(process.env.DEFT_INSTALLED_AUTHOR_PACKAGE, installedArtifact); console.log(`Independent author artifact: ${join(project, '.deft/app.deftapp.json')}`); }); From f74ff6843b4b92cd5b1528a4e1949df014b7d336 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:00:29 +0530 Subject: [PATCH 004/161] fix native resource privacy and add strict v2 references --- apps/api/src/lib/agent-context.ts | 141 +++----------- apps/api/src/lib/calendar-event-visibility.ts | 45 +++++ apps/api/src/lib/mcp-tools/events.ts | 9 +- apps/api/src/lib/native-wiki-owner.ts | 159 +++++++++++++++ apps/api/src/lib/wiki-visibility.ts | 12 +- .../test/calendar-event-visibility-db.test.ts | 127 ++++++++++++ apps/api/test/mcp-events.test.ts | 110 ++++++----- apps/api/test/native-wiki-owner-db.test.ts | 184 ++++++++++++++++++ packages/shared/package.json | 3 +- packages/shared/src/index.ts | 1 + packages/shared/src/resources-v2.ts | 119 +++++++++++ packages/shared/test/resources-v2.test.ts | 110 +++++++++++ scripts/gate-g/required-tests.json | 12 ++ 13 files changed, 860 insertions(+), 172 deletions(-) create mode 100644 apps/api/src/lib/calendar-event-visibility.ts create mode 100644 apps/api/src/lib/native-wiki-owner.ts create mode 100644 apps/api/test/calendar-event-visibility-db.test.ts create mode 100644 apps/api/test/native-wiki-owner-db.test.ts create mode 100644 packages/shared/src/resources-v2.ts create mode 100644 packages/shared/test/resources-v2.test.ts diff --git a/apps/api/src/lib/agent-context.ts b/apps/api/src/lib/agent-context.ts index 42a67eb1..93116832 100644 --- a/apps/api/src/lib/agent-context.ts +++ b/apps/api/src/lib/agent-context.ts @@ -59,6 +59,8 @@ import { executeAppActionOperation, } from './app-action-operations.js'; import { buildNativeAppActionActor } from './agent-app-action-actor.js'; +import { resolveNativeWikiReader, readNativeWiki, searchNativeWiki } from './native-wiki-owner.js'; +import { liveHumanCalendarEventCondition, liveEmployeeCalendarEventCondition } from './calendar-event-visibility.js'; type Citation = { type: string; id: string; title: string; url?: string }; @@ -583,7 +585,9 @@ export async function executeToolCall( eq(events.event_type, 'calendar_event'), gte(events.timestamp, dayStart), lt(events.timestamp, dayEnd), - eq(events.org_id, orgId), + agentEmployeeId + ? liveEmployeeCalendarEventCondition(orgId, agentEmployeeId) + : liveHumanCalendarEventCondition(orgId, _userId), ]; if (params.query) { @@ -2051,132 +2055,41 @@ export async function executeToolCall( // ─── Wiki Tools ─── case 'wiki_search': { - // Block 0.6 — semantic wiki search. Routes through retrieveContext - // which runs hybrid FTS (search_vector @@ plainto_tsquery) + pgvector - // cosine (embedding <=> queryVector) weighted 0.4 / 0.6 * confidence. - // Falls back to FTS-only when OPENAI_API_KEY is missing or the - // pgvector <=> operator is unavailable. - const { query, type: pageType, scope: pageScope, limit: maxResults = 5 } = params; - const { retrieveContext } = await import('./retrieve-context.js'); - const hits = await retrieveContext({ - query, - org_id: orgId, - types: ['wiki'], - limit: Math.min(maxResults, 10), - }); - - // Fetch the full wiki_pages row + linked pages for each hit so the - // tool output keeps the shape callers expect (title/slug/summary/ - // type/scope/confidence/updated_at + linked_pages[]). - const hitIds = hits.map((h) => h.source_id); - const pages = - hitIds.length > 0 - ? await db - .select({ - id: wikiPages.id, - title: wikiPages.title, - slug: wikiPages.slug, - summary: wikiPages.summary, - type: wikiPages.type, - scope: wikiPages.scope, - confidence: wikiPages.confidence, - updated_at: wikiPages.updated_at, - }) - .from(wikiPages) - .where( - and( - eq(wikiPages.org_id, orgId), - eq(wikiPages.is_deleted, false), - inArray(wikiPages.id, hitIds), - ...(pageType ? [eq(wikiPages.type, pageType)] : []), - ...(pageScope ? [eq(wikiPages.scope, pageScope)] : []), - ), - ) - : []; - - // Preserve retrieveContext's ranking order. - const byId = new Map(pages.map((p) => [p.id, p])); - const ordered = hitIds - .map((id) => byId.get(id)) - .filter((p): p is NonNullable => Boolean(p)); - - const enriched = await Promise.all( - ordered.map(async (page) => { - const links = await db - .select({ title: wikiPages.title, slug: wikiPages.slug }) - .from(wikiLinks) - .innerJoin(wikiPages, eq(wikiLinks.target_page_id, wikiPages.id)) - .where(eq(wikiLinks.source_page_id, page.id)) - .limit(5); - return { ...page, linked_pages: links }; - }), - ); - - const citations: Citation[] = ordered.map((p) => ({ + const reader = await resolveNativeWikiReader({ orgId, userId: _userId, + agentEmployeeId }); + const pages = reader ? await searchNativeWiki(reader, params) : []; + const citations: Citation[] = pages.map((p) => ({ type: 'wiki', id: p.id, title: p.title, })); - - return { result: { pages: enriched, count: enriched.length }, citations }; + return { result: { pages, count: pages.length }, citations }; } case 'wiki_read': { const { slug } = params; - - const [page] = await db.select() - .from(wikiPages) - .where(and(eq(wikiPages.org_id, orgId), eq(wikiPages.slug, slug), eq(wikiPages.is_deleted, false))) - .limit(1); - - if (!page) { + const reader = await resolveNativeWikiReader({ orgId, userId: _userId, + agentEmployeeId }); + const read = reader && typeof slug === 'string' ? await readNativeWiki(reader, slug) : null; + if (!read) { return { result: { error: `Wiki page "${slug}" not found` }, citations: [] }; } - - // Get linked pages - const linkedPages = await db.select({ - slug: wikiPages.slug, - title: wikiPages.title, - type: wikiPages.type, - summary: wikiPages.summary, - }) - .from(wikiLinks) - .innerJoin(wikiPages, eq(wikiLinks.target_page_id, wikiPages.id)) - .where(eq(wikiLinks.source_page_id, page.id)); - - // Get backlinks - const backlinks = await db.select({ - slug: wikiPages.slug, - title: wikiPages.title, - type: wikiPages.type, - }) - .from(wikiLinks) - .innerJoin(wikiPages, eq(wikiLinks.source_page_id, wikiPages.id)) - .where(eq(wikiLinks.target_page_id, page.id)); - - // Get citations - const citations = await db.select() - .from(wikiCitations) - .where(eq(wikiCitations.page_id, page.id)) - .orderBy(desc(wikiCitations.created_at)) - .limit(10); - return { result: { - title: page.title, - slug: page.slug, - type: page.type, - scope: page.scope, - content: page.content, - summary: page.summary, - confidence: page.confidence, - version: page.version, - updated_at: page.updated_at, - linked_pages: linkedPages, - backlinks, - citations, + title: read.page.title, + slug: read.page.slug, + type: read.page.type, + scope: read.page.scope, + content: read.page.content, + summary: read.page.summary, + confidence: read.page.confidence, + version: read.page.version, + updated_at: read.page.updated_at, + linked_pages: read.linked_pages, + backlinks: read.backlinks, + citations: read.citations, }, - citations: [{ type: 'wiki', id: page.id, title: page.title }], + citations: [{ type: 'wiki', id: read.page.id, title: read.page.title }], }; } diff --git a/apps/api/src/lib/calendar-event-visibility.ts b/apps/api/src/lib/calendar-event-visibility.ts new file mode 100644 index 00000000..b81619f9 --- /dev/null +++ b/apps/api/src/lib/calendar-event-visibility.ts @@ -0,0 +1,45 @@ +import { and, eq, or, sql, type SQL } from 'drizzle-orm'; +import { agentEmployees, connectedAccounts, events, orgMembers } from '@deft/db/schema'; +import { canonicalDeftyEmployeeCondition } from './defty-identity.js'; + +/** Events belong to their direct user or to the owner of the connected account. + * The connected account must be in the event's organization as well. */ +function ownerCondition(viewerUserId: string | typeof agentEmployees.user_id): SQL { + return sql`(${events.user_id} = ${viewerUserId} OR EXISTS ( + SELECT 1 FROM ${connectedAccounts} + WHERE ${connectedAccounts.id} = ${events.connected_account_id} + AND ${connectedAccounts.org_id} = ${events.org_id} + AND ${connectedAccounts.user_id} = ${viewerUserId} + ))`; +} + +/** Use for a host-authenticated human or native Defty caller. A stale or + * removed organization membership cannot read private calendar content. */ +export function liveHumanCalendarEventCondition(orgId: string, userId: string): SQL { + return and( + eq(events.org_id, orgId), + ownerCondition(userId), + sql`EXISTS (SELECT 1 FROM ${orgMembers} + WHERE ${orgMembers.org_id} = ${orgId} + AND ${orgMembers.user_id} = ${userId} + AND ${orgMembers.is_active} = true)`, + )!; +} + +/** Employee credentials identify only the current employee's own shadow + * member. A triggering user's id is never a delegated calendar credential. */ +export function liveEmployeeCalendarEventCondition(orgId: string, employeeId: string): SQL { + return and( + eq(events.org_id, orgId), + sql`EXISTS (SELECT 1 FROM ${agentEmployees} + INNER JOIN ${orgMembers} + ON ${orgMembers.org_id} = ${agentEmployees.org_id} + AND ${orgMembers.user_id} = ${agentEmployees.user_id} + WHERE ${agentEmployees.id} = ${employeeId} + AND ${agentEmployees.org_id} = ${orgId} + AND ${agentEmployees.is_active} = true + AND ${orgMembers.is_active} = true + AND ${or(eq(agentEmployees.is_deleted, false), canonicalDeftyEmployeeCondition())} + AND ${ownerCondition(agentEmployees.user_id)})`, + )!; +} diff --git a/apps/api/src/lib/mcp-tools/events.ts b/apps/api/src/lib/mcp-tools/events.ts index 69ac8b60..54a6f751 100644 --- a/apps/api/src/lib/mcp-tools/events.ts +++ b/apps/api/src/lib/mcp-tools/events.ts @@ -3,15 +3,15 @@ * * Queries the `events` table (the unified schema where native events, * calendar reminders, imported ICS feeds, and connected-tool events land). - * Lets an agent read the event stream for its org without needing every user - * to connect a separate MCP server per provider. + * Reads only events owned by the authenticated employee's live member user, + * including that user's connected accounts. * * Filtering: * - `type` / `types` narrows by event_type (e.g. 'calendar_event') * - `source` narrows by source enum (e.g. 'ics', 'google_calendar') * - `since` / `until`— ISO8601 window on the event.timestamp field * - * Scoping is strict: every query is filtered by `ctx.org_id`. There is no + * Scoping is strict: every query is filtered by current owner and org. There is no * `is_deleted` column on `events`, so no soft-delete filter. Results are * capped at 200 rows and default to 50. */ @@ -19,6 +19,7 @@ import { and, eq, gte, lte, inArray, desc } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; import { db } from '../db.js'; import { events } from '@deft/db/schema'; +import { liveEmployeeCalendarEventCondition } from '../calendar-event-visibility.js'; import type { ToolContext, ToolResult } from './types.js'; import { errorResult, textResult } from './types.js'; @@ -56,7 +57,7 @@ export async function eventsQuery( const limit = Math.min(Math.max(1, args.limit ?? 50), 200); try { - const conditions: SQL[] = [eq(events.org_id, ctx.org_id)]; + const conditions: SQL[] = [liveEmployeeCalendarEventCondition(ctx.org_id, ctx.employee_id)]; // Type filter: prefer `types` (list) if present, else fall back to `type`. if (Array.isArray(args.types) && args.types.length > 0) { diff --git a/apps/api/src/lib/native-wiki-owner.ts b/apps/api/src/lib/native-wiki-owner.ts new file mode 100644 index 00000000..fb90e521 --- /dev/null +++ b/apps/api/src/lib/native-wiki-owner.ts @@ -0,0 +1,159 @@ +import { and, desc, eq, inArray, ne, or, type SQL } from 'drizzle-orm'; +import { + agentEmployees, messages, orgMembers, spaceMembers, spaces, tasks, users, + wikiCitations, wikiLinks, wikiPages, +} from '@deft/db/schema'; +import { db } from './db.js'; +import { visibleWikiPageCondition } from './wiki-visibility.js'; +import { visibleTaskCondition } from './task-visibility.js'; +import { canonicalDeftyEmployeeCondition } from './defty-identity.js'; +import { employeeProjectAccessAllows, loadEmployeeProjectAccess } from './mcp-tools/employee-project-access.js'; +import { retrieveContext } from './retrieve-context.js'; + +/** Native agent identity is supplied by the host runner, never tool input. */ +export type NativeWikiCaller = Readonly<{ + orgId: string; + userId: string; + agentEmployeeId?: string; +}>; + +export type NativeWikiReader = Readonly<{ + orgId: string; + subjectUserId: string; + agentEmployeeId?: string; +}>; + +export async function resolveNativeWikiReader(caller: NativeWikiCaller): Promise { + if (!caller.orgId || (!caller.userId && !caller.agentEmployeeId)) return null; + if (caller.agentEmployeeId) { + const [employee] = await db.select({ user_id: agentEmployees.user_id }) + .from(agentEmployees) + .innerJoin(orgMembers, and(eq(orgMembers.org_id, agentEmployees.org_id), + eq(orgMembers.user_id, agentEmployees.user_id))) + .where(and(eq(agentEmployees.id, caller.agentEmployeeId), + eq(agentEmployees.org_id, caller.orgId), eq(agentEmployees.is_active, true), + eq(orgMembers.is_active, true), + or(eq(agentEmployees.is_deleted, false), canonicalDeftyEmployeeCondition()))) + .limit(1); + return employee?.user_id ? { orgId: caller.orgId, subjectUserId: employee.user_id, + agentEmployeeId: caller.agentEmployeeId } : null; + } + const [member] = await db.select({ id: orgMembers.id }) + .from(orgMembers) + .innerJoin(users, eq(users.id, orgMembers.user_id)) + .where(and(eq(orgMembers.org_id, caller.orgId), eq(orgMembers.user_id, caller.userId), + eq(orgMembers.is_active, true), eq(users.is_agent, false))) + .limit(1); + return member ? { orgId: caller.orgId, subjectUserId: caller.userId } : null; +} + +function visiblePage(reader: NativeWikiReader): SQL | undefined { + const humanVisibility = visibleWikiPageCondition(reader.subjectUserId, reader.orgId); + return reader.agentEmployeeId + ? or(humanVisibility, and(eq(wikiPages.agent_employee_id, reader.agentEmployeeId), + ne(wikiPages.scope, 'org'))) + : humanVisibility; +} + +async function linkedPages(reader: NativeWikiReader, pageId: string, + direction: 'out' | 'in', limit: number) { + const source = direction === 'out' ? wikiLinks.source_page_id : wikiLinks.target_page_id; + const target = direction === 'out' ? wikiLinks.target_page_id : wikiLinks.source_page_id; + const rows = await db.select({ + slug: wikiPages.slug, title: wikiPages.title, type: wikiPages.type, + summary: wikiPages.summary, context: wikiLinks.context, + }).from(wikiLinks) + .innerJoin(wikiPages, eq(target, wikiPages.id)) + .where(and(eq(source, pageId), eq(wikiLinks.org_id, reader.orgId), + eq(wikiPages.org_id, reader.orgId), eq(wikiPages.is_deleted, false), visiblePage(reader))) + .limit(limit); + return rows; +} + +async function citationVisible(reader: NativeWikiReader, citation: typeof wikiCitations.$inferSelect): Promise { + if (citation.org_id && citation.org_id !== reader.orgId) return false; + if (citation.source_type === 'message') { + const [row] = await db.select({ id: messages.id }).from(messages) + .innerJoin(spaces, and(eq(spaces.id, messages.space_id), eq(spaces.org_id, reader.orgId))) + .innerJoin(spaceMembers, and(eq(spaceMembers.space_id, messages.space_id), + eq(spaceMembers.user_id, reader.subjectUserId))) + .where(and(eq(messages.id, citation.source_id), eq(messages.org_id, reader.orgId), + eq(messages.is_deleted, false))) + .limit(1); + return Boolean(row); + } + if (citation.source_type === 'task') { + const [row] = await db.select({ project_id: tasks.project_id }).from(tasks) + .where(and(eq(tasks.id, citation.source_id), eq(tasks.org_id, reader.orgId), + eq(tasks.is_deleted, false), visibleTaskCondition(reader.subjectUserId))) + .limit(1); + if (!row) return false; + if (!reader.agentEmployeeId) return true; + return employeeProjectAccessAllows(await loadEmployeeProjectAccess({ + org_id: reader.orgId, employee_id: reader.agentEmployeeId, + }), row.project_id); + } + // Unknown source types have no reviewed owner authorization path. + return false; +} + +export async function readNativeWiki(reader: NativeWikiReader, slug: string) { + if (!slug) return null; + const [page] = await db.select().from(wikiPages) + .where(and(eq(wikiPages.org_id, reader.orgId), eq(wikiPages.slug, slug), + eq(wikiPages.is_deleted, false), visiblePage(reader))) + .limit(1); + if (!page) return null; + const candidates = await db.select().from(wikiCitations) + .where(eq(wikiCitations.page_id, page.id)) + .orderBy(desc(wikiCitations.created_at)).limit(10); + // Every linked target and citation source is authorized independently. + const [currentLinks, currentBacklinks] = await Promise.all([ + linkedPages(reader, page.id, 'out', 100), linkedPages(reader, page.id, 'in', 100), + ]); + const checks = await Promise.all(candidates.map((citation) => citationVisible(reader, citation))); + // A removal during adjunct reads must not let an old page body escape. + const [stillVisible] = await db.select().from(wikiPages) + .where(and(eq(wikiPages.id, page.id), eq(wikiPages.org_id, reader.orgId), + eq(wikiPages.is_deleted, false), visiblePage(reader))).limit(1); + if (!stillVisible) return null; + if (!await resolveNativeWikiReader({ orgId: reader.orgId, + userId: reader.subjectUserId, agentEmployeeId: reader.agentEmployeeId })) return null; + return { page: stillVisible, + linked_pages: currentLinks.map(({ context: _context, ...link }) => link), + backlinks: currentBacklinks.map(({ summary: _summary, context: _context, ...link }) => link), + citations: candidates.filter((_, index) => checks[index]) }; +} + +export async function searchNativeWiki(reader: NativeWikiReader, input: { + query?: unknown; type?: unknown; scope?: unknown; limit?: unknown; +}, timing?: { afterCandidates?: () => Promise }) { + if (typeof input.query !== 'string' || input.query.trim().length < 2) return []; + const limit = typeof input.limit === 'number' && Number.isInteger(input.limit) + ? Math.max(1, Math.min(input.limit, 10)) : 5; + const hits = await retrieveContext({ query: input.query, org_id: reader.orgId, + ...(reader.agentEmployeeId ? { agent_employee_id: reader.agentEmployeeId } + : { user_id: reader.subjectUserId }), types: ['wiki'], limit }); + const ids = hits.map((hit) => hit.source_id); + if (ids.length === 0) return []; + // Search index results are candidate IDs only. The test seam pauses between + // candidate selection and current owner resolution; tool callers cannot set it. + await timing?.afterCandidates?.(); + const rows = await db.select({ + id: wikiPages.id, title: wikiPages.title, slug: wikiPages.slug, + summary: wikiPages.summary, type: wikiPages.type, scope: wikiPages.scope, + confidence: wikiPages.confidence, updated_at: wikiPages.updated_at, + }).from(wikiPages).where(and(eq(wikiPages.org_id, reader.orgId), + eq(wikiPages.is_deleted, false), inArray(wikiPages.id, ids), visiblePage(reader), + ...(typeof input.type === 'string' ? [eq(wikiPages.type, input.type as typeof wikiPages.$inferSelect.type)] : []), + ...(typeof input.scope === 'string' ? [eq(wikiPages.scope, input.scope as typeof wikiPages.$inferSelect.scope)] : []))); + const byId = new Map(rows.map((row) => [row.id, row])); + const ordered = ids.map((id) => byId.get(id)).filter((row): row is NonNullable => Boolean(row)); + const enriched = await Promise.all(ordered.map(async (page) => ({ + ...page, linked_pages: (await linkedPages(reader, page.id, 'out', 5)) + .map(({ slug, title }) => ({ slug, title })), + }))); + if (!await resolveNativeWikiReader({ orgId: reader.orgId, userId: reader.subjectUserId, + agentEmployeeId: reader.agentEmployeeId })) return []; + return enriched; +} diff --git a/apps/api/src/lib/wiki-visibility.ts b/apps/api/src/lib/wiki-visibility.ts index 4a1bcb6c..12c933fc 100644 --- a/apps/api/src/lib/wiki-visibility.ts +++ b/apps/api/src/lib/wiki-visibility.ts @@ -1,11 +1,17 @@ import { and, eq, or, sql } from 'drizzle-orm'; -import { spaceMembers, wikiPages } from '@deft/db/schema'; +import { spaceMembers, spaces, wikiPages } from '@deft/db/schema'; -export function visibleWikiPageCondition(userId: string) { +export function visibleWikiPageCondition(userId: string, orgId?: string) { return or( eq(wikiPages.scope, 'org'), eq(wikiPages.user_id, userId), - sql`exists ( + orgId ? sql`exists ( + select 1 from ${spaces} + inner join ${spaceMembers} on ${spaceMembers.space_id} = ${spaces.id} + where ${spaces.id} = ${wikiPages.space_id} + and ${spaces.org_id} = ${orgId} + and ${spaceMembers.user_id} = ${userId} + )` : sql`exists ( select 1 from ${spaceMembers} where ${spaceMembers.space_id} = ${wikiPages.space_id} and ${spaceMembers.user_id} = ${userId} diff --git a/apps/api/test/calendar-event-visibility-db.test.ts b/apps/api/test/calendar-event-visibility-db.test.ts new file mode 100644 index 00000000..9de5368e --- /dev/null +++ b/apps/api/test/calendar-event-visibility-db.test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { and, eq, inArray } from 'drizzle-orm'; +import { agentEmployees, connectedAccounts, events, orgMembers, orgs, users } from '@deft/db/schema'; +import { db } from '../src/lib/db.js'; +import { executeToolCall } from '../src/lib/agent-context.js'; +import { eventsQuery } from '../src/lib/mcp-tools/events.js'; +import { humanCalendarList } from '../src/lib/mcp-tools/human.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c03b_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); +const eventTime = new Date('2037-06-15T12:00:00.000Z'); +const range = { from: '2037-06-14T00:00:00.000Z', until: '2037-06-17T00:00:00.000Z' }; + +function rowIds(result: { content: Array<{ text: string }>; isError?: boolean }): Set { + assert.equal(result.isError, false); + return new Set((JSON.parse(result.content[0]!.text) as Array<{ id: string }>).map(row => row.id)); +} + +test('calendar events enforce live owner custody for human, employee MCP and native agent reads', + { skip: !safe }, async () => { + const marker = randomUUID(); + const orgId = randomUUID(); + const foreignOrgId = randomUUID(); + const ownerId = randomUUID(); + const peerId = randomUUID(); + const employeeUserId = randomUUID(); + const employeeId = randomUUID(); + const accountId = randomUUID(); + const ids = { owner: randomUUID(), peerNative: randomUUID(), peerIcs: randomUUID(), + peerConnected: randomUUID(), employee: randomUUID(), foreign: randomUUID() }; + const names = { owner: `${marker}-owner`, peerNative: `${marker}-peer-native`, + peerIcs: `${marker}-peer-ics`, peerConnected: `${marker}-peer-connected`, + employee: `${marker}-employee`, foreign: `${marker}-foreign` }; + try { + await db.insert(orgs).values([ + { id: orgId, name: 'Calendar visibility fixture', slug: `${marker}-org` }, + { id: foreignOrgId, name: 'Foreign calendar fixture', slug: `${marker}-foreign-org` }, + ]); + await db.insert(users).values([ + { id: ownerId, email: `${marker}-owner@example.test`, name: 'Owner' }, + { id: peerId, email: `${marker}-peer@example.test`, name: 'Peer' }, + { id: employeeUserId, email: `${marker}-employee@example.test`, name: 'Employee' }, + ]); + await db.insert(orgMembers).values([ownerId, peerId, employeeUserId].map(userId => ({ + id: randomUUID(), org_id: orgId, user_id: userId, role: 'member' as const, + }))); + await db.insert(agentEmployees).values({ id: employeeId, org_id: orgId, + user_id: employeeUserId, name: 'Calendar test employee', slug: `${marker}-agent`, + role: 'custom', system_prompt: 'Synthetic calendar ACL test', created_by: ownerId }); + await db.insert(connectedAccounts).values({ id: accountId, org_id: orgId, + user_id: peerId, provider: 'google_calendar', + provider_account_id: `${marker}-account`, access_token_encrypted: 'synthetic-test-only' }); + const event = (id: string, eventOrgId: string, source: 'native' | 'ics' | 'google_calendar', + title: string, userId: string | null, connectedId: string | null = null) => ({ + id, org_id: eventOrgId, source, event_type: 'calendar_event', title, + body: 'Synthetic private content', timestamp: eventTime, + metadata: { start: eventTime.toISOString(), end: new Date(eventTime.getTime() + 3600_000).toISOString() }, + user_id: userId, connected_account_id: connectedId, + }); + await db.insert(events).values([ + event(ids.owner, orgId, 'native', names.owner, ownerId), + event(ids.peerNative, orgId, 'native', names.peerNative, peerId), + event(ids.peerIcs, orgId, 'ics', names.peerIcs, peerId), + event(ids.peerConnected, orgId, 'google_calendar', names.peerConnected, null, accountId), + event(ids.employee, orgId, 'native', names.employee, employeeUserId), + event(ids.foreign, foreignOrgId, 'native', names.foreign, null), + ]); + + const owner = rowIds(await humanCalendarList(range, { org_id: orgId, user_id: ownerId, + role: 'member', scopes: ['read:calendar'] })); + const unscoped = await humanCalendarList(range, { org_id: orgId, user_id: ownerId, + role: 'member', scopes: [] }); + assert.equal(unscoped.isError, true); + const peer = rowIds(await humanCalendarList(range, { org_id: orgId, user_id: peerId, + role: 'member', scopes: ['read:calendar'] })); + assert.equal(owner.has(ids.owner), true); + for (const id of [ids.peerNative, ids.peerIcs, ids.peerConnected, ids.employee]) { + assert.equal(owner.has(id), false); + } + for (const id of [ids.peerNative, ids.peerIcs, ids.peerConnected]) assert.equal(peer.has(id), true); + assert.equal(peer.has(ids.owner), false); + + const employeeContext = { org_id: orgId, employee_id: employeeId, + employee_slug: `${marker}-agent`, trust_level: 'standard' as const }; + const employee = rowIds(await eventsQuery({ caller_employee_slug: 'forged-other-employee', + type: 'calendar_event', limit: 200 }, employeeContext)); + assert.equal(employee.has(ids.employee), true); + for (const id of [ids.owner, ids.peerNative, ids.peerIcs, ids.peerConnected, ids.foreign]) { + assert.equal(employee.has(id), false); + } + const employeeNative = await executeToolCall('check_calendar', { date: '2037-06-15' }, + orgId, ownerId, undefined, employeeId); + const employeeTitles = new Set((employeeNative.result as Array<{ title: string }>).map(row => row.title)); + assert.equal(employeeTitles.has(names.employee), true); + for (const name of [names.owner, names.peerNative, names.peerIcs, names.peerConnected, names.foreign]) { + assert.equal(employeeTitles.has(name), false); + } + const ownerNative = await executeToolCall('check_calendar', { date: '2037-06-15' }, orgId, ownerId); + const ownerTitles = new Set((ownerNative.result as Array<{ title: string }>).map(row => row.title)); + assert.equal(ownerTitles.has(names.owner), true); + assert.equal(ownerTitles.has(names.peerNative), false); + + await db.update(agentEmployees).set({ is_active: false }).where(eq(agentEmployees.id, employeeId)); + assert.equal(rowIds(await eventsQuery({ caller_employee_slug: `${marker}-agent` }, employeeContext)) + .has(ids.employee), false); + await db.update(agentEmployees).set({ is_active: true }).where(eq(agentEmployees.id, employeeId)); + await db.update(orgMembers).set({ is_active: false }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, employeeUserId))); + assert.equal(rowIds(await eventsQuery({ caller_employee_slug: `${marker}-agent` }, employeeContext)) + .has(ids.employee), false); + await db.update(orgMembers).set({ is_active: false }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + const revokedNative = await executeToolCall('check_calendar', { date: '2037-06-15' }, orgId, ownerId); + assert.equal((revokedNative.result as Array<{ title: string }>).some(row => row.title === names.owner), false); + } finally { + await db.delete(events).where(inArray(events.id, Object.values(ids))); + await db.delete(connectedAccounts).where(eq(connectedAccounts.id, accountId)); + await db.delete(agentEmployees).where(eq(agentEmployees.id, employeeId)); + await db.delete(orgMembers).where(eq(orgMembers.org_id, orgId)); + await db.delete(users).where(inArray(users.id, [ownerId, peerId, employeeUserId])); + await db.delete(orgs).where(inArray(orgs.id, [orgId, foreignOrgId])); + } + }); diff --git a/apps/api/test/mcp-events.test.ts b/apps/api/test/mcp-events.test.ts index f63ba81b..c8ae260a 100644 --- a/apps/api/test/mcp-events.test.ts +++ b/apps/api/test/mcp-events.test.ts @@ -4,7 +4,7 @@ * Run: pnpm --filter @deft/api test -- mcp-events * * Covers: - * 1. events_query returns rows scoped to the caller's org + * 1. events_query returns rows scoped to the live employee owner and org * 2. events_query filters by event_type (type param) * 3. events_query filters by since/until time range * 4. events_query with invalid caller_employee_slug returns 403 @@ -14,28 +14,33 @@ */ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; import pg from 'pg'; import { Hono } from 'hono'; +import { safeTestDatabaseUrl } from './fixtures/safe-test-database.js'; -const DATABASE_URL = - process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/deft'; -const ORG_ID = '1d7d869a-5e68-48d5-832e-11d8f3bb1dd6'; // Maneek seed org -const TEST_EMPLOYEE_ID = 'test-mcp-phase6-events-employee'; -const TEST_EMPLOYEE_SLUG = 'mcp-phase6-events-test'; -const TEST_USER_ID = 'test-mcp-phase6-events-user'; +const DATABASE_URL = safeTestDatabaseUrl(); +const canRun = Boolean(DATABASE_URL); +const marker = randomUUID(); +const ORG_ID = randomUUID(); +const TEST_EMPLOYEE_ID = randomUUID(); +const TEST_EMPLOYEE_SLUG = `mcp-phase6-events-${marker}`; +const TEST_USER_ID = randomUUID(); // Seeded events we insert in before() and delete in after(). We use a // synthetic org id for the "other org" row to prove scoping. -const OTHER_ORG_ID = 'phase6-events-other-org'; -const OTHER_EVENT_ID = 'test-mcp-phase6-other-event'; -const PR_EVENT_ID = 'test-mcp-phase6-pr-event'; -const CAL_EVENT_ID = 'test-mcp-phase6-cal-event'; -const OLD_EVENT_ID = 'test-mcp-phase6-old-event'; +const OTHER_ORG_ID = randomUUID(); +const OTHER_EVENT_ID = randomUUID(); +const UNOWNED_EVENT_ID = randomUUID(); +const PR_EVENT_ID = randomUUID(); +const CAL_EVENT_ID = randomUUID(); +const OLD_EVENT_ID = randomUUID(); let RAW_TOKEN: string | null = null; let testApp: Hono | null = null; async function withClient(fn: (c: pg.Client) => Promise): Promise { + if (!DATABASE_URL) throw new Error('Explicit disposable test database required'); const c = new pg.Client({ connectionString: DATABASE_URL }); await c.connect(); try { @@ -47,12 +52,14 @@ async function withClient(fn: (c: pg.Client) => Promise): Promise { async function seedFixtures() { await withClient(async (c) => { + await c.query(`INSERT INTO orgs (id,name,slug,timezone) VALUES ($1,$2,$3,'UTC')`, + [ORG_ID, 'Phase6 events owner fixture', `phase6-events-${marker}`]); // Shadow user for the test employee await c.query( `INSERT INTO users (id, email, name, is_agent) VALUES ($1, $2, $3, true) ON CONFLICT (id) DO NOTHING`, - [TEST_USER_ID, 'mcp-phase6-events@test.local', 'MCP Phase 6 Events User'], + [TEST_USER_ID, `mcp-phase6-events-${marker}@test.local`, 'MCP Phase 6 Events User'], ); // Test employee @@ -72,28 +79,19 @@ async function seedFixtures() { TEST_EMPLOYEE_SLUG, ], ); + await c.query( + `INSERT INTO org_members (id, org_id, user_id, role, is_active) + VALUES ($1, $2, $3, 'member', true) + ON CONFLICT (org_id, user_id) DO UPDATE SET is_active = true`, + [randomUUID(), ORG_ID, TEST_USER_ID], + ); // Make sure the "other org" row exists (FK on events.org_id references orgs) await c.query( `INSERT INTO orgs (id, name, slug, timezone) - VALUES ($1, 'Phase6 Other Org', 'phase6-other-org', 'UTC') + VALUES ($1, 'Phase6 Other Org', $2, 'UTC') ON CONFLICT (id) DO NOTHING`, - [OTHER_ORG_ID], - ); - - // Clean any stale events from prior runs (by id OR external_id) - await c.query( - `DELETE FROM events WHERE id = ANY($1::text[]) - OR external_id = ANY($2::text[])`, - [ - [PR_EVENT_ID, CAL_EVENT_ID, OLD_EVENT_ID, OTHER_EVENT_ID], - [ - 'phase6-pr-merged-external', - 'phase6-cal-external-v1', - 'phase6-pr-opened-old', - 'phase6-other-org-external', - ], - ], + [OTHER_ORG_ID, `phase6-events-other-${marker}`], ); // Seed events. NOTE: the events.timestamp column is `timestamp without @@ -116,42 +114,50 @@ async function seedFixtures() { await c.query( `INSERT INTO events (id, org_id, source, event_type, external_id, title, body, url, - actor, timestamp, metadata) - VALUES ($1, $2, 'github', 'pr_merged', 'phase6-pr-merged-external', + actor, timestamp, metadata, user_id) + VALUES ($1, $2, 'github', 'pr_merged', $5, 'Phase6 PR merged', 'body', 'https://example.com/pr/1', - 'tester', $3, '{}'::jsonb)`, - [PR_EVENT_ID, ORG_ID, recentPrTs], + 'tester', $3, '{}'::jsonb, $4)`, + [PR_EVENT_ID, ORG_ID, recentPrTs, TEST_USER_ID, `${marker}-pr-merged`], ); await c.query( `INSERT INTO events (id, org_id, source, event_type, external_id, title, body, url, - actor, timestamp, metadata) + actor, timestamp, metadata, user_id) VALUES ($1, $2, 'google_calendar', 'calendar_event', - 'phase6-cal-external-v1', + $5, 'Phase6 upcoming meeting', 'body', 'https://example.com/cal/1', - 'tester', $3, '{}'::jsonb)`, - [CAL_EVENT_ID, ORG_ID, upcomingCalTs], + 'tester', $3, '{}'::jsonb, $4)`, + [CAL_EVENT_ID, ORG_ID, upcomingCalTs, TEST_USER_ID, `${marker}-calendar`], ); await c.query( `INSERT INTO events (id, org_id, source, event_type, external_id, title, body, url, - actor, timestamp, metadata) - VALUES ($1, $2, 'github', 'pr_opened', 'phase6-pr-opened-old', + actor, timestamp, metadata, user_id) + VALUES ($1, $2, 'github', 'pr_opened', $5, 'Phase6 old pr', 'body', 'https://example.com/pr/0', - 'tester', $3, '{}'::jsonb)`, - [OLD_EVENT_ID, ORG_ID, oldEventTs], + 'tester', $3, '{}'::jsonb, $4)`, + [OLD_EVENT_ID, ORG_ID, oldEventTs, TEST_USER_ID, `${marker}-pr-old`], + ); + + await c.query( + `INSERT INTO events + (id, org_id, source, event_type, external_id, title, body, timestamp, metadata) + VALUES ($1, $2, 'native', 'calendar_event', $4, + 'Phase6 unowned private event', 'private', $3, '{}'::jsonb)`, + [UNOWNED_EVENT_ID, ORG_ID, upcomingCalTs, `${marker}-unowned`], ); await c.query( `INSERT INTO events (id, org_id, source, event_type, external_id, title, body, url, actor, timestamp, metadata) - VALUES ($1, $2, 'github', 'pr_merged', 'phase6-other-org-external', + VALUES ($1, $2, 'github', 'pr_merged', $4, 'Phase6 other org PR', 'body', 'https://example.com/pr/x', 'tester', $3, '{}'::jsonb)`, - [OTHER_EVENT_ID, OTHER_ORG_ID, otherOrgTs], + [OTHER_EVENT_ID, OTHER_ORG_ID, otherOrgTs, `${marker}-other-org`], ); }); } @@ -160,15 +166,17 @@ async function teardownFixtures() { await withClient(async (c) => { await c.query( `DELETE FROM events WHERE id = ANY($1::text[])`, - [[PR_EVENT_ID, CAL_EVENT_ID, OLD_EVENT_ID, OTHER_EVENT_ID]], + [[PR_EVENT_ID, CAL_EVENT_ID, OLD_EVENT_ID, OTHER_EVENT_ID, UNOWNED_EVENT_ID]], ); + await c.query(`DELETE FROM org_members WHERE org_id = $1 AND user_id = $2`, [ORG_ID, TEST_USER_ID]); await c.query(`DELETE FROM agent_employees WHERE id = $1`, [TEST_EMPLOYEE_ID]); await c.query(`DELETE FROM users WHERE id = $1`, [TEST_USER_ID]); - await c.query(`DELETE FROM orgs WHERE id = $1`, [OTHER_ORG_ID]); + await c.query(`DELETE FROM orgs WHERE id = ANY($1::text[])`, [[ORG_ID, OTHER_ORG_ID]]); }); } before(async () => { + if (!canRun) return; await seedFixtures(); const tokenModule = await import('../src/lib/mcp-token.js'); const routeModule = await import('../src/routes/mcp-server-v1.js'); @@ -178,6 +186,7 @@ before(async () => { }); after(async () => { + if (!canRun) return; await teardownFixtures(); }); @@ -213,7 +222,7 @@ function parseContent(body: any): any { // ───────────────────────────────────────────────────────────────────────────── -test('1. events_query returns rows scoped to the caller org (no cross-org leak)', async () => { +test('1. events_query returns only live employee-owned rows in the caller org', { skip: !canRun }, async () => { const { status, body } = await mcpCall( 'events_query', { caller_employee_slug: TEST_EMPLOYEE_SLUG, limit: 50 }, @@ -229,9 +238,10 @@ test('1. events_query returns rows scoped to the caller org (no cross-org leak)' assert.ok(ids.has(CAL_EVENT_ID), 'Calendar event in results'); // Must NOT contain the other-org event assert.ok(!ids.has(OTHER_EVENT_ID), 'Other org event must not leak in'); + assert.ok(!ids.has(UNOWNED_EVENT_ID), 'Same-org event without an owner must not leak in'); }); -test('2. events_query filters by type (event_type)', async () => { +test('2. events_query filters by type (event_type)', { skip: !canRun }, async () => { const { status, body } = await mcpCall( 'events_query', { @@ -254,7 +264,7 @@ test('2. events_query filters by type (event_type)', async () => { assert.ok(!ids.has(CAL_EVENT_ID), 'Calendar event filtered out'); }); -test('3. events_query filters by since/until time range', async () => { +test('3. events_query filters by since/until time range', { skip: !canRun }, async () => { // Scope by source=github so we only match the 2 github events we seeded // (recent PR merged + 5-day-old PR opened). The seed org may contain // unrelated events from other test fixtures, so narrowing keeps the @@ -281,7 +291,7 @@ test('3. events_query filters by since/until time range', async () => { assert.ok(!ids.has(OLD_EVENT_ID), '5-day-old event filtered out by since'); }); -test('4. events_query ignores model-authored caller_employee_slug and uses the bearer identity', async () => { +test('4. events_query ignores model-authored caller_employee_slug and uses the bearer identity', { skip: !canRun }, async () => { const { status, body } = await mcpCall( 'events_query', { caller_employee_slug: 'nobody-on-this-phase6-gateway' }, diff --git a/apps/api/test/native-wiki-owner-db.test.ts b/apps/api/test/native-wiki-owner-db.test.ts new file mode 100644 index 00000000..3d11a9b9 --- /dev/null +++ b/apps/api/test/native-wiki-owner-db.test.ts @@ -0,0 +1,184 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import { db, closeDb } from '../src/lib/db.js'; +import { executeToolCall } from '../src/lib/agent-context.js'; +import { humanFetch, type HumanToolContext } from '../src/lib/mcp-tools/human.js'; +import { resolveNativeWikiReader, searchNativeWiki } from '../src/lib/native-wiki-owner.js'; +import { + agentEmployees, messages, orgMembers, orgs, spaceMembers, spaces, users, + wikiCitations, wikiLinks, wikiPages, +} from '@deft/db/schema'; + +const url = process.env.DATABASE_URL ?? ''; +const safeDatabase = url === process.env.DEFT_TEST_DATABASE_URL + && /^postgresql:\/\/gate_g_test@127\.0\.0\.1:55435\/gate_g_phase5_test_[a-z0-9_]+$/.test(url); + +test('native wiki owner uses current human/employee authority for pages, links and citations', + { skip: !safeDatabase }, async () => { + const orgId = randomUUID(); + const ownerId = randomUUID(); + const otherId = randomUUID(); + const shadowId = randomUUID(); + const employeeId = randomUUID(); + const spaceId = randomUUID(); + const messageId = randomUUID(); + const sharedId = randomUUID(); + const ownerPageId = randomUUID(); + const employeePageId = randomUUID(); + const sharedSlug = `shared-${randomUUID()}`; + const ownerSlug = `owner-${randomUUID()}`; + const employeeSlug = `employee-${randomUUID()}`; + const ownerTerm = `privateowner${randomUUID().replaceAll('-', '')}`; + const employeeTerm = `privateemployee${randomUUID().replaceAll('-', '')}`; + const personalContext = (userId: string): HumanToolContext => ({ + org_id: orgId, user_id: userId, role: 'member', scopes: ['read:wiki'], + }); + + try { + await db.insert(orgs).values({ id: orgId, name: 'Native wiki owner test', slug: `wiki-${orgId}` }); + await db.insert(users).values([ + { id: ownerId, name: 'Owner', email: `owner-${ownerId}@example.test` }, + { id: otherId, name: 'Other', email: `other-${otherId}@example.test` }, + { id: shadowId, name: 'Employee shadow', email: `shadow-${shadowId}@example.test`, is_agent: true }, + ]); + await db.insert(orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: otherId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: shadowId, role: 'member', is_active: true }, + ]); + await db.insert(agentEmployees).values({ id: employeeId, org_id: orgId, + user_id: shadowId, name: 'Test employee', slug: `test-${employeeId}`, + role: 'custom', system_prompt: 'Fixture', created_by: ownerId, + is_active: true, is_deleted: false }); + await db.insert(spaces).values({ id: spaceId, org_id: orgId, name: 'Private source', type: 'private' }); + await db.insert(spaceMembers).values({ id: randomUUID(), space_id: spaceId, user_id: ownerId }); + await db.insert(messages).values({ id: messageId, org_id: orgId, space_id: spaceId, + user_id: ownerId, content: 'Private cited source' }); + await db.insert(wikiPages).values([ + { id: sharedId, org_id: orgId, scope: 'org', type: 'concept', + title: 'Shared wiki', slug: sharedSlug, content: 'Shared body' }, + { id: ownerPageId, org_id: orgId, scope: 'user', user_id: ownerId, + type: 'fact', title: 'Owner private wiki', slug: ownerSlug, content: ownerTerm }, + { id: employeePageId, org_id: orgId, scope: 'user', user_id: shadowId, + agent_employee_id: employeeId, type: 'fact', title: 'Employee private wiki', + slug: employeeSlug, content: employeeTerm }, + ]); + await db.insert(wikiLinks).values([ + { id: randomUUID(), org_id: orgId, source_page_id: sharedId, target_page_id: ownerPageId }, + { id: randomUUID(), org_id: orgId, source_page_id: sharedId, target_page_id: employeePageId }, + ]); + await db.insert(wikiCitations).values({ id: randomUUID(), org_id: orgId, page_id: sharedId, + source_type: 'message', source_id: messageId, source_space_id: spaceId, + excerpt: 'Private cited source' }); + + const otherRead = await executeToolCall('wiki_read', { slug: ownerSlug }, orgId, otherId); + assert.match(otherRead.result.error, /not found/); + assert.deepEqual(otherRead.citations, []); + const otherSearch = await executeToolCall('wiki_search', { query: ownerTerm }, orgId, otherId); + assert.ok(!otherSearch.result.pages.some((page: { id: string }) => page.id === ownerPageId)); + assert.ok(!otherSearch.citations.some((citation) => citation.id === ownerPageId)); + + const ownerRead = await executeToolCall('wiki_read', { slug: ownerSlug }, orgId, ownerId); + assert.equal(ownerRead.result.content, ownerTerm); + assert.equal(ownerRead.citations[0]?.id, ownerPageId); + const ownerSearch = await executeToolCall('wiki_search', { query: ownerTerm }, orgId, ownerId); + assert.ok(ownerSearch.result.pages.some((page: { id: string }) => page.id === ownerPageId)); + const ownerReader = await resolveNativeWikiReader({ orgId, userId: ownerId }); + assert.ok(ownerReader); + const revokedDuringSearch = await searchNativeWiki(ownerReader, { query: ownerTerm }, { + afterCandidates: async () => { + await db.update(wikiPages).set({ user_id: otherId }).where(eq(wikiPages.id, ownerPageId)); + }, + }); + assert.ok(!revokedDuringSearch.some((page) => page.id === ownerPageId), + 'owner removal after candidate ranking must drop the page and title'); + await db.update(wikiPages).set({ user_id: ownerId }).where(eq(wikiPages.id, ownerPageId)); + + const sharedOther = await executeToolCall('wiki_read', { slug: sharedSlug }, orgId, otherId); + assert.equal(sharedOther.result.content, 'Shared body'); + assert.deepEqual(sharedOther.result.linked_pages, []); + assert.deepEqual(sharedOther.result.citations, []); + const sharedOwner = await executeToolCall('wiki_read', { slug: sharedSlug }, orgId, ownerId); + assert.deepEqual(sharedOwner.result.linked_pages.map((page: { slug: string }) => page.slug), [ownerSlug]); + assert.equal(sharedOwner.result.citations.length, 1); + const linkRevokedDuringSearch = await searchNativeWiki(ownerReader, { query: 'Shared body' }, { + afterCandidates: async () => { + await db.update(wikiPages).set({ user_id: otherId }).where(eq(wikiPages.id, ownerPageId)); + }, + }); + const currentShared = linkRevokedDuringSearch.find((page) => page.id === sharedId); + assert.ok(currentShared); + assert.deepEqual(currentShared.linked_pages, [], + 'target removal after candidate ranking must drop its linked title'); + await db.update(wikiPages).set({ user_id: ownerId }).where(eq(wikiPages.id, ownerPageId)); + await db.delete(spaceMembers).where(and(eq(spaceMembers.space_id, spaceId), + eq(spaceMembers.user_id, ownerId))); + const afterSourceAccessLoss = await executeToolCall('wiki_read', { slug: sharedSlug }, orgId, ownerId); + assert.deepEqual(afterSourceAccessLoss.result.citations, []); + await db.update(wikiPages).set({ user_id: otherId }).where(eq(wikiPages.id, ownerPageId)); + const afterLinkAccessLoss = await executeToolCall('wiki_read', { slug: sharedSlug }, orgId, ownerId); + assert.deepEqual(afterLinkAccessLoss.result.linked_pages, []); + await db.update(wikiPages).set({ user_id: ownerId }).where(eq(wikiPages.id, ownerPageId)); + + // Employee authority is the live shadow identity, not the triggering human owner. + const employeeOwnerRead = await executeToolCall('wiki_read', { slug: ownerSlug }, + orgId, ownerId, undefined, employeeId); + assert.match(employeeOwnerRead.result.error, /not found/); + const employeeOwnRead = await executeToolCall('wiki_read', { slug: employeeSlug }, + orgId, ownerId, undefined, employeeId); + assert.equal(employeeOwnRead.result.content, employeeTerm); + const autonomousEmployeeRead = await executeToolCall('wiki_read', { slug: employeeSlug }, + orgId, '', undefined, employeeId); + assert.equal(autonomousEmployeeRead.result.content, employeeTerm); + const employeeOwnSearch = await executeToolCall('wiki_search', { query: employeeTerm }, + orgId, ownerId, undefined, employeeId); + assert.ok(employeeOwnSearch.result.pages.some((page: { id: string }) => page.id === employeePageId)); + const sharedEmployee = await executeToolCall('wiki_read', { slug: sharedSlug }, + orgId, ownerId, undefined, employeeId); + assert.deepEqual(sharedEmployee.result.linked_pages.map((page: { slug: string }) => page.slug), [employeeSlug]); + assert.deepEqual(sharedEmployee.result.citations, []); + await db.update(orgMembers).set({ is_active: false }) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, shadowId))); + const removedShadow = await executeToolCall('wiki_read', { slug: employeeSlug }, + orgId, ownerId, undefined, employeeId); + assert.ok(removedShadow.result.error); + await db.update(orgMembers).set({ is_active: true }) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, shadowId))); + + const missingHuman = await executeToolCall('wiki_read', { slug: sharedSlug }, orgId, ''); + assert.match(missingHuman.result.error, /not found/); + await db.update(orgMembers).set({ is_active: false }) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + const inactiveHuman = await executeToolCall('wiki_read', { slug: ownerSlug }, orgId, ownerId); + assert.match(inactiveHuman.result.error, /not found/); + await db.update(orgMembers).set({ is_active: true }) + .where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + await db.update(agentEmployees).set({ is_active: false }).where(eq(agentEmployees.id, employeeId)); + const inactiveEmployee = await executeToolCall('wiki_read', { slug: employeeSlug }, + orgId, ownerId, undefined, employeeId); + assert.ok(inactiveEmployee.result.error); + + // Personal MCP is a separate scoped human path; verify parity rather than claiming it uses this owner. + const personalDenied = await humanFetch({ id: `wiki:${ownerSlug}` }, personalContext(otherId)); + assert.equal(personalDenied.isError, true); + const personalAllowed = await humanFetch({ id: `wiki:${ownerSlug}` }, personalContext(ownerId)); + assert.equal(personalAllowed.isError, false); + assert.match(personalAllowed.content[0]!.text, new RegExp(ownerTerm)); + } finally { + await db.delete(wikiCitations).where(eq(wikiCitations.page_id, sharedId)); + await db.delete(wikiLinks).where(eq(wikiLinks.source_page_id, sharedId)); + await db.delete(wikiPages).where(eq(wikiPages.org_id, orgId)); + await db.delete(messages).where(eq(messages.id, messageId)); + await db.delete(spaceMembers).where(eq(spaceMembers.space_id, spaceId)); + await db.delete(spaces).where(eq(spaces.id, spaceId)); + await db.delete(agentEmployees).where(eq(agentEmployees.id, employeeId)); + await db.delete(orgMembers).where(eq(orgMembers.org_id, orgId)); + await db.delete(users).where(eq(users.id, ownerId)); + await db.delete(users).where(eq(users.id, otherId)); + await db.delete(users).where(eq(users.id, shadowId)); + await db.delete(orgs).where(eq(orgs.id, orgId)); + await closeDb(); + } + }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 56ac89ce..49503d98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,13 +5,14 @@ "license": "AGPL-3.0-only", "type": "module", "scripts": { - "test": "tsx --test test/modules.test.ts test/capabilities.test.ts test/app-runs.test.ts test/resources.test.ts test/app-platform-authority.test.ts", + "test": "tsx --test test/modules.test.ts test/capabilities.test.ts test/app-runs.test.ts test/resources.test.ts test/resources-v2.test.ts test/app-platform-authority.test.ts", "typecheck": "tsc --noEmit" }, "exports": { ".": "./src/index.ts", "./modules": "./src/modules.ts", "./resources": "./src/resources.ts", + "./resources-v2": "./src/resources-v2.ts", "./schemas": "./src/schemas.ts", "./rich-text": "./src/rich-text.ts" }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ac2f29e5..177c2142 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,4 +6,5 @@ export * from './app-runs'; export * from './capabilities'; export * from './modules'; export * from './resources'; +export * from './resources-v2'; export * from './app-platform-authority'; diff --git a/packages/shared/src/resources-v2.ts b/packages/shared/src/resources-v2.ts new file mode 100644 index 00000000..d46a9672 --- /dev/null +++ b/packages/shared/src/resources-v2.ts @@ -0,0 +1,119 @@ +import { z } from 'zod'; +import { + RESOURCE_LIMITS, + ResourceOpaqueIdSchema, + ResourceProviderInstanceIdSchema, + ResourceRefV1Schema, + ResourceTypeSchema, +} from './resources.js'; + +/** Additive resource identities. V1 remains closed to Module and core Task. */ +export const RESOURCE_V2_CONTRACT_VERSIONS = Object.freeze({ + ref: 'deft.resource_ref.v2', + safe_projection: 'deft.resource_safe_projection.v2', + resolve: 'deft.resource_resolve.v2', +} as const); + +function coreRef( + providerInstanceId: TInstance, + resourceType: TType, +) { + return z.strictObject({ + schema_version: z.literal(RESOURCE_V2_CONTRACT_VERSIONS.ref), + provider: z.strictObject({ + kind: z.literal('core'), + provider_instance_id: z.literal(providerInstanceId), + }), + resource_type: z.literal(resourceType), + resource_id: ResourceOpaqueIdSchema, + }); +} + +export const MessageResourceRefV2Schema = coreRef('messages', 'message'); +export const WikiPageResourceRefV2Schema = coreRef('wiki_pages', 'wiki_page'); +export const NoteResourceRefV2Schema = coreRef('notes', 'note'); +export const FileResourceRefV2Schema = coreRef('files', 'file'); +export const CalendarEventResourceRefV2Schema = coreRef('calendar_events', 'calendar_event'); +export const PersonResourceRefV2Schema = coreRef('people', 'person'); +export const TeamResourceRefV2Schema = coreRef('teams', 'team'); + +/** The instance id is an opaque host-issued locator, never a provider URL. */ +export const AppRuntimeResourceRefV2Schema = z.strictObject({ + schema_version: z.literal(RESOURCE_V2_CONTRACT_VERSIONS.ref), + provider: z.strictObject({ + kind: z.literal('app_runtime'), + provider_instance_id: ResourceProviderInstanceIdSchema, + }), + resource_type: ResourceTypeSchema, + resource_id: ResourceOpaqueIdSchema, +}); + +export const ResourceRefV2Schema = z.union([ + MessageResourceRefV2Schema, + WikiPageResourceRefV2Schema, + NoteResourceRefV2Schema, + FileResourceRefV2Schema, + CalendarEventResourceRefV2Schema, + PersonResourceRefV2Schema, + TeamResourceRefV2Schema, + AppRuntimeResourceRefV2Schema, +]); +export type ResourceRefV2 = z.infer; + +/** Version chooses the parser; a v2 provider never acquires v1 semantics. */ +export const AnyResourceRefSchema = z.union([ResourceRefV1Schema, ResourceRefV2Schema]); +export type AnyResourceRef = z.infer; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u; +const OPAQUE_REVISION = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +const SafeLabelSchema = z.string().min(1).max(RESOURCE_LIMITS.label_chars) + .refine((value) => value === value.trim() && !CONTROL_CHARACTERS.test(value), + 'Resource label must be trimmed and contain no control characters'); + +const HostRelativeHrefSchema = z.string().max(RESOURCE_LIMITS.href_chars) + .refine((value) => value.startsWith('/') && !value.startsWith('//') + && !value.includes('\\') && !CONTROL_CHARACTERS.test(value), + 'Resource href must be a host-relative path without backslashes or control characters'); + +const RevisionSchema = z.string().min(1).max(RESOURCE_LIMITS.revision_chars) + .regex(OPAQUE_REVISION, 'Resource revision contains unsupported characters'); + +/** This safe display projection is host-authored after live authorization. */ +export const ResourceSafeProjectionV2Schema = z.strictObject({ + schema_version: z.literal(RESOURCE_V2_CONTRACT_VERSIONS.safe_projection), + ref: ResourceRefV2Schema, + label: SafeLabelSchema, + href: HostRelativeHrefSchema.optional(), + revision: RevisionSchema.optional(), + updated_at: z.iso.datetime({ offset: true }).optional(), +}); +export type ResourceSafeProjectionV2 = z.infer; + +const ResolveBase = { + schema_version: z.literal(RESOURCE_V2_CONTRACT_VERSIONS.resolve), + ref: ResourceRefV2Schema, +}; + +function sameRef(left: ResourceRefV2, right: ResourceRefV2): boolean { + return left.schema_version === right.schema_version + && left.provider.kind === right.provider.kind + && left.provider.provider_instance_id === right.provider.provider_instance_id + && left.resource_type === right.resource_type + && left.resource_id === right.resource_id; +} + +/** Unavailable states carry no stale label, snippet, body, or navigation. */ +export const ResourceResolveResultV2Schema = z.discriminatedUnion('state', [ + z.strictObject({ ...ResolveBase, state: z.literal('available'), + resource: ResourceSafeProjectionV2Schema }), + z.strictObject({ ...ResolveBase, state: z.literal('unavailable') }), + z.strictObject({ ...ResolveBase, state: z.literal('tombstoned') }), + z.strictObject({ ...ResolveBase, state: z.literal('stale') }), +]).superRefine((result, ctx) => { + if (result.state === 'available' && !sameRef(result.ref, result.resource.ref)) { + ctx.addIssue({ code: 'custom', path: ['resource', 'ref'], + message: 'Projection reference must match resolved reference' }); + } +}); +export type ResourceResolveResultV2 = z.infer; diff --git a/packages/shared/test/resources-v2.test.ts b/packages/shared/test/resources-v2.test.ts new file mode 100644 index 00000000..90932257 --- /dev/null +++ b/packages/shared/test/resources-v2.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { RESOURCE_CONTRACT_VERSIONS, ResourceRefV1Schema } from '../src/resources.js'; +import { + AnyResourceRefSchema, + RESOURCE_V2_CONTRACT_VERSIONS, + ResourceRefV2Schema, + ResourceResolveResultV2Schema, + ResourceSafeProjectionV2Schema, +} from '../src/resources-v2.js'; + +const corePairs = [ + ['messages', 'message'], ['wiki_pages', 'wiki_page'], ['notes', 'note'], + ['files', 'file'], ['calendar_events', 'calendar_event'], + ['people', 'person'], ['teams', 'team'], +] as const; +const coreRef = (instance: string, type: string) => ({ + schema_version: RESOURCE_V2_CONTRACT_VERSIONS.ref, + provider: { kind: 'core', provider_instance_id: instance }, + resource_type: type, resource_id: 'item_1', +}); +const runtimeRef = { + schema_version: RESOURCE_V2_CONTRACT_VERSIONS.ref, + provider: { kind: 'app_runtime', provider_instance_id: 'registered_runtime_1' }, + resource_type: 'mail_thread', resource_id: 'thread_1', +}; +const v1Task = { + schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'core', provider_instance_id: 'tasks' }, + resource_type: 'task', resource_id: 'task_1', +}; + +describe('ResourceRef v2', () => { + test('accepts exactly seven native owner/type pairs and opaque runtime refs', () => { + for (const [instance, type] of corePairs) { + const ref = coreRef(instance, type); + assert.deepEqual(ResourceRefV2Schema.parse(ref), ref); + assert.deepEqual(AnyResourceRefSchema.parse(ref), ref); + } + assert.deepEqual(ResourceRefV2Schema.parse(runtimeRef), runtimeRef); + assert.deepEqual(AnyResourceRefSchema.parse(v1Task), v1Task); + }); + + test('does not reinterpret v1 or widen its unsupported-provider behavior', () => { + assert.equal(ResourceRefV1Schema.safeParse(coreRef('messages', 'message')).success, false); + assert.equal(ResourceRefV1Schema.safeParse(runtimeRef).success, false); + assert.equal(ResourceRefV2Schema.safeParse(v1Task).success, false); + for (const value of [ + coreRef('tasks', 'task'), coreRef('messages', 'wiki_page'), + coreRef('wiki_pages', 'message'), coreRef('other', 'message'), + { ...runtimeRef, provider: { kind: 'runtime', provider_instance_id: 'registered_runtime_1' } }, + ]) assert.equal(AnyResourceRefSchema.safeParse(value).success, false); + }); + + test('rejects authority, URL, extra fields, and malformed identities in references', () => { + for (const value of [ + { ...runtimeRef, org_id: 'other' }, + { ...runtimeRef, actor_id: 'user_1' }, + { ...runtimeRef, href: '/mail/thread_1' }, + { ...runtimeRef, provider: { ...runtimeRef.provider, credential: 'secret' } }, + { ...runtimeRef, provider: { ...runtimeRef.provider, + provider_instance_id: 'https://provider.example' } }, + { ...runtimeRef, resource_type: 'MailThread' }, + { ...runtimeRef, resource_id: 'thread/1' }, + { ...runtimeRef, resource_id: ' thread_1' }, + { ...runtimeRef, resource_id: 'x'.repeat(257) }, + ]) assert.equal(ResourceRefV2Schema.safeParse(value).success, false); + }); +}); + +describe('ResourceResolveResult v2', () => { + const projection = { + schema_version: RESOURCE_V2_CONTRACT_VERSIONS.safe_projection, + ref: runtimeRef, + label: 'A private thread', href: '/apps/mail/thread_1', + revision: 'rev:3', updated_at: '2026-09-24T13:00:00.000Z', + }; + const available = { schema_version: RESOURCE_V2_CONTRACT_VERSIONS.resolve, + state: 'available', ref: runtimeRef, resource: projection }; + + test('accepts only a bounded host-safe projection matching the requested ref', () => { + assert.deepEqual(ResourceSafeProjectionV2Schema.parse(projection), projection); + assert.deepEqual(ResourceResolveResultV2Schema.parse(available), available); + assert.equal(ResourceResolveResultV2Schema.safeParse({ ...available, + resource: { ...projection, ref: { ...runtimeRef, resource_id: 'other' } } }).success, false); + for (const resource of [ + { ...projection, body: 'private' }, + { ...projection, label: 'x'.repeat(201) }, + { ...projection, label: ' padded ' }, + { ...projection, href: 'https://provider.example/thread' }, + { ...projection, href: '//provider.example/thread' }, + { ...projection, href: '/\\provider.example/thread' }, + { ...projection, href: '/bad\npath' }, + { ...projection, updated_at: 'yesterday' }, + { ...projection, revision: 'rev/3' }, + ]) assert.equal(ResourceSafeProjectionV2Schema.safeParse(resource).success, false); + }); + + test('unavailable, tombstoned and stale states carry no private display data', () => { + for (const state of ['unavailable', 'tombstoned', 'stale'] as const) { + const result = { schema_version: RESOURCE_V2_CONTRACT_VERSIONS.resolve, + state, ref: runtimeRef }; + assert.deepEqual(ResourceResolveResultV2Schema.parse(result), result); + for (const extra of [{ label: 'old subject' }, { body: 'private' }, + { snippet: 'private' }, { resource: projection }, { href: '/apps/mail/thread_1' }]) { + assert.equal(ResourceResolveResultV2Schema.safeParse({ ...result, ...extra }).success, false); + } + } + }); +}); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 9af363e9..9781cf80 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -657,6 +657,18 @@ "file": "apps/api/test/track-a-restore-process-db.test.ts" } ] + }, + { + "id": "native-owner-foundation", + "description": "Disposable PostgreSQL privacy regressions plus real employee MCP bearer calls; every selected native owner case must execute.", + "cases": [ + { "file": "apps/api/test/native-wiki-owner-db.test.ts", "name": "native wiki owner uses current human/employee authority for pages, links and citations" }, + { "file": "apps/api/test/calendar-event-visibility-db.test.ts", "name": "calendar events enforce live owner custody for human, employee MCP and native agent reads" }, + { "file": "apps/api/test/mcp-events.test.ts", "name": "1. events_query returns only live employee-owned rows in the caller org" }, + { "file": "apps/api/test/mcp-events.test.ts", "name": "2. events_query filters by type (event_type)" }, + { "file": "apps/api/test/mcp-events.test.ts", "name": "3. events_query filters by since/until time range" }, + { "file": "apps/api/test/mcp-events.test.ts", "name": "4. events_query ignores model-authored caller_employee_slug and uses the bearer identity" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From e1ae2d09d4f185902193091485055189b30c83f8 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:14:54 +0530 Subject: [PATCH 005/161] add live-authorized native resource display reads --- apps/api/src/index.ts | 2 + apps/api/src/lib/mcp-tools/team-context.ts | 5 +- apps/api/src/lib/member-visibility.ts | 21 ++ .../lib/native-calendar-file-projections.ts | 80 ++++++++ .../api/src/lib/native-content-projections.ts | 85 ++++++++ .../src/lib/native-directory-projections.ts | 69 +++++++ apps/api/src/lib/native-resource-service.ts | 90 ++++++++ apps/api/src/lib/native-resource-types.ts | 15 ++ apps/api/src/routes/members.ts | 19 +- apps/api/src/routes/resources.ts | 37 ++++ ...ative-calendar-file-projections-db.test.ts | 193 ++++++++++++++++++ .../native-content-projections-db.test.ts | 134 ++++++++++++ .../native-directory-projections-db.test.ts | 121 +++++++++++ .../test/native-resource-service-db.test.ts | 147 +++++++++++++ scripts/gate-g/required-tests.json | 10 + 15 files changed, 1009 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/lib/member-visibility.ts create mode 100644 apps/api/src/lib/native-calendar-file-projections.ts create mode 100644 apps/api/src/lib/native-content-projections.ts create mode 100644 apps/api/src/lib/native-directory-projections.ts create mode 100644 apps/api/src/lib/native-resource-service.ts create mode 100644 apps/api/src/lib/native-resource-types.ts create mode 100644 apps/api/src/routes/resources.ts create mode 100644 apps/api/test/native-calendar-file-projections-db.test.ts create mode 100644 apps/api/test/native-content-projections-db.test.ts create mode 100644 apps/api/test/native-directory-projections-db.test.ts create mode 100644 apps/api/test/native-resource-service-db.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1b5c8ff0..0a828f1e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -69,6 +69,7 @@ import { skillsRoutes } from './routes/skills.js'; import { taskTemplateRoutes } from './routes/task-templates.js'; import { workIntentRoutes } from './routes/work-intents.js'; import { moduleRoutes } from './routes/modules.js'; +import { resourceRoutes } from './routes/resources.js'; import { appRoutes } from './routes/apps.js'; import { appActionRoutes } from './routes/app-actions.js'; import { appRunRoutes } from './routes/app-runs.js'; @@ -261,6 +262,7 @@ app.route('/api/task-templates', taskTemplateRoutes); app.route('/api/work-intents', workIntentRoutes); app.route('/api/modules', moduleRoutes); if (APPS_ENABLED) { + app.route('/api/resources', resourceRoutes); app.route('/api/apps/public', appPublicManagementRoutes); app.route('/api/apps/runtime', appRuntimeManagementRoutes); app.route('/api/app-runtime-review', appRuntimeReviewRoutes); diff --git a/apps/api/src/lib/mcp-tools/team-context.ts b/apps/api/src/lib/mcp-tools/team-context.ts index 20291a38..6f67f3a0 100644 --- a/apps/api/src/lib/mcp-tools/team-context.ts +++ b/apps/api/src/lib/mcp-tools/team-context.ts @@ -79,7 +79,10 @@ function normalizeHandle(value: string): string { .replace(/-{2,}/g, '-'); } -function canSeeTeam(access: TeamAccessContext, row: Omit): boolean { +export function canSeeTeam( + access: TeamAccessContext, + row: Pick, +): boolean { if (isAdmin(access)) return true; if (row.visibility === 'org') return true; if (access.user_id && row.lead_user_id === access.user_id) return true; diff --git a/apps/api/src/lib/member-visibility.ts b/apps/api/src/lib/member-visibility.ts new file mode 100644 index 00000000..7bfeb1a1 --- /dev/null +++ b/apps/api/src/lib/member-visibility.ts @@ -0,0 +1,21 @@ +import { sql } from 'drizzle-orm'; +import { agentEmployees, users } from '@deft/db/schema'; +import { DEFTY_EMAIL } from './ensure-defty-membership.js'; + +/** Keep member list and resource projections aligned on live agent visibility. */ +export function visibleLiveMemberForOrg(orgIdRef: unknown) { + return sql` + ( + ${users.kind} <> 'agent' + OR ${users.email} = ${DEFTY_EMAIL} + OR EXISTS ( + SELECT 1 + FROM ${agentEmployees} + WHERE ${agentEmployees.user_id} = ${users.id} + AND ${agentEmployees.org_id} = ${orgIdRef} + AND ${agentEmployees.is_active} = true + AND ${agentEmployees.is_deleted} = false + ) + ) + `; +} diff --git a/apps/api/src/lib/native-calendar-file-projections.ts b/apps/api/src/lib/native-calendar-file-projections.ts new file mode 100644 index 00000000..72f096a6 --- /dev/null +++ b/apps/api/src/lib/native-calendar-file-projections.ts @@ -0,0 +1,80 @@ +import { and, eq } from 'drizzle-orm'; +import { events, messageAttachments, messages, projects, + spaces, taskAttachments, tasks } from '@deft/db/schema'; +import { getVisibleAttachment } from './attachment-access.js'; +import { liveHumanCalendarEventCondition } from './calendar-event-visibility.js'; +import { db } from './db.js'; +import type { NativeResourceDisplay, NativeResourceSubject } from './native-resource-types.js'; + +/** Legacy File parent columns have single-ID references; typed links only pin + * the Message/Task org, not the parent Space/Project org. Check the complete + * parent chain before projecting even a label. The existing owner helper still + * decides the viewer's current membership/Task visibility. */ +async function parentChainInOrganization( + subject: NativeResourceSubject, + file: NonNullable>>, +): Promise { + const [messageLinks, taskLinks] = await Promise.all([ + db.select({ message_id: messageAttachments.message_id }).from(messageAttachments) + .where(and(eq(messageAttachments.org_id, subject.org_id), + eq(messageAttachments.file_id, file.id))).limit(2), + db.select({ task_id: taskAttachments.task_id }).from(taskAttachments) + .where(and(eq(taskAttachments.org_id, subject.org_id), + eq(taskAttachments.file_id, file.id))).limit(2), + ]); + if (messageLinks.length + taskLinks.length > 1) return false; + const messageId = messageLinks[0]?.message_id + ?? (taskLinks.length === 0 && !file.task_id ? file.message_id : null); + const taskId = taskLinks[0]?.task_id + ?? (messageLinks.length === 0 && !file.message_id ? file.task_id : null); + if (messageId) { + const [parent] = await db.select({ id: messages.id }).from(messages) + .innerJoin(spaces, and(eq(spaces.id, messages.space_id), eq(spaces.org_id, messages.org_id))) + .where(and(eq(messages.id, messageId), eq(messages.org_id, subject.org_id), + eq(messages.is_deleted, false))) + .limit(1); + return Boolean(parent); + } + if (taskId) { + const [parent] = await db.select({ id: tasks.id }).from(tasks) + .innerJoin(projects, and(eq(projects.id, tasks.project_id), eq(projects.org_id, tasks.org_id))) + .where(and(eq(tasks.id, taskId), eq(tasks.org_id, subject.org_id), + eq(tasks.is_deleted, false), eq(projects.is_deleted, false))) + .limit(1); + return Boolean(parent); + } + return messageLinks.length === 0 && taskLinks.length === 0 + && !file.message_id && !file.task_id; +} + +/** Native Calendar remains the owner. A generic event is not a calendar item. */ +export async function resolveNativeCalendarDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [event] = await db.select({ title: events.title, updated_at: events.updated_at }) + .from(events) + .where(and( + eq(events.id, id), + eq(events.event_type, 'calendar_event'), + liveHumanCalendarEventCondition(subject.org_id, subject.user_id), + )) + .limit(1); + if (!event) return null; + return { + label: event.title || 'Calendar event', + updated_at: event.updated_at.toISOString(), + }; +} + +/** Attachment visibility belongs to its current Message/Task parent, or to + * the uploader while staged. Never project bytes, storage keys or derivatives. */ +export async function resolveNativeFileDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const file = await getVisibleAttachment(id, subject.org_id, subject.user_id); + if (!file || file.processing_status === 'blocked' + || !(await parentChainInOrganization(subject, file))) return null; + return { label: file.filename, updated_at: file.updated_at.toISOString() }; +} diff --git a/apps/api/src/lib/native-content-projections.ts b/apps/api/src/lib/native-content-projections.ts new file mode 100644 index 00000000..d6b20d76 --- /dev/null +++ b/apps/api/src/lib/native-content-projections.ts @@ -0,0 +1,85 @@ +import { and, eq, isNull, or } from 'drizzle-orm'; +import { messages, notes, spaceMembers, spaces, wikiPages } from '@deft/db/schema'; +import { db } from './db.js'; +import { visibleNoteCondition } from './note-visibility.js'; +import { visibleWikiPageCondition } from './wiki-visibility.js'; +import type { NativeResourceDisplay, NativeResourceSubject } from './native-resource-types.js'; + +function isoDate(value: Date | null): string | undefined { + return value instanceof Date && Number.isFinite(value.getTime()) + ? value.toISOString() + : undefined; +} + +/** Exact-ID display only. The native message body and metadata are never selected. */ +export async function resolveNativeMessageDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [row] = await db.select({ updated_at: messages.updated_at }) + .from(messages) + .innerJoin(spaces, and( + eq(spaces.id, messages.space_id), + eq(spaces.org_id, subject.org_id), + )) + .innerJoin(spaceMembers, and( + eq(spaceMembers.space_id, spaces.id), + eq(spaceMembers.user_id, subject.user_id), + )) + .where(and( + eq(messages.id, id), + eq(messages.org_id, subject.org_id), + eq(messages.is_deleted, false), + )) + .limit(1); + if (!row) return null; + return { label: 'Message', updated_at: isoDate(row.updated_at) }; +} + +/** No wiki body, summary, links, or citations cross this display boundary. */ +export async function resolveNativeWikiDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [row] = await db.select({ + title: wikiPages.title, + updated_at: wikiPages.updated_at, + version: wikiPages.version, + }) + .from(wikiPages) + .leftJoin(spaces, eq(spaces.id, wikiPages.space_id)) + .where(and( + eq(wikiPages.id, id), + eq(wikiPages.org_id, subject.org_id), + eq(wikiPages.is_deleted, false), + or(isNull(wikiPages.space_id), eq(spaces.org_id, subject.org_id)), + visibleWikiPageCondition(subject.user_id, subject.org_id), + )) + .limit(1); + if (!row) return null; + return { label: row.title, revision: String(row.version), updated_at: isoDate(row.updated_at) }; +} + +/** Preserves explicit note shares and requires any attached space to remain in this org. */ +export async function resolveNativeNoteDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [row] = await db.select({ + title: notes.title, + updated_at: notes.updated_at, + version: notes.version, + }) + .from(notes) + .leftJoin(spaces, eq(spaces.id, notes.visibility_space_id)) + .where(and( + eq(notes.id, id), + eq(notes.org_id, subject.org_id), + eq(notes.is_deleted, false), + or(isNull(notes.visibility_space_id), eq(spaces.org_id, subject.org_id)), + visibleNoteCondition(subject.user_id), + )) + .limit(1); + if (!row) return null; + return { label: row.title, revision: String(row.version), updated_at: isoDate(row.updated_at) }; +} diff --git a/apps/api/src/lib/native-directory-projections.ts b/apps/api/src/lib/native-directory-projections.ts new file mode 100644 index 00000000..a817e638 --- /dev/null +++ b/apps/api/src/lib/native-directory-projections.ts @@ -0,0 +1,69 @@ +import { and, eq } from 'drizzle-orm'; +import { orgMembers, teamMembers, teams, users } from '@deft/db/schema'; +import { db } from './db.js'; +import { canSeeTeam } from './mcp-tools/team-context.js'; +import { visibleLiveMemberForOrg } from './member-visibility.js'; +import type { NativeResourceDisplay, NativeResourceSubject } from './native-resource-types.js'; + +/** The directory owner exposes current members only. Its richer profile fields + * never enter the generic resource projection. */ +export async function resolveNativePersonDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [viewer] = await db.select({ id: orgMembers.id }) + .from(orgMembers) + .where(and( + eq(orgMembers.org_id, subject.org_id), + eq(orgMembers.user_id, subject.user_id), + eq(orgMembers.is_active, true), + )) + .limit(1); + if (!viewer) return null; + + const [person] = await db.select({ name: users.name, updated_at: users.updated_at }) + .from(users) + .innerJoin(orgMembers, eq(orgMembers.user_id, users.id)) + .where(and( + eq(users.id, id), + eq(orgMembers.org_id, subject.org_id), + eq(orgMembers.is_active, true), + visibleLiveMemberForOrg(orgMembers.org_id), + )) + .limit(1); + return person ? { label: person.name, updated_at: person.updated_at.toISOString() } : null; +} + +/** Uses the existing team owner ACL with the viewer's current database role and + * team membership; the host-supplied role is deliberately not trusted. */ +export async function resolveNativeTeamDisplay( + subject: NativeResourceSubject, + id: string, +): Promise { + const [team] = await db.select({ + name: teams.name, + updated_at: teams.updated_at, + visibility: teams.visibility, + lead_user_id: teams.lead_user_id, + current_user_role: teamMembers.role, + org_role: orgMembers.role, + }) + .from(teams) + .innerJoin(orgMembers, and( + eq(orgMembers.org_id, teams.org_id), + eq(orgMembers.user_id, subject.user_id), + eq(orgMembers.is_active, true), + )) + .leftJoin(teamMembers, and( + eq(teamMembers.org_id, teams.org_id), + eq(teamMembers.team_id, teams.id), + eq(teamMembers.user_id, subject.user_id), + )) + .where(and(eq(teams.org_id, subject.org_id), eq(teams.id, id))) + .limit(1); + if (!team || !canSeeTeam( + { org_id: subject.org_id, user_id: subject.user_id, role: team.org_role }, + team, + )) return null; + return { label: team.name, updated_at: team.updated_at.toISOString() }; +} diff --git a/apps/api/src/lib/native-resource-service.ts b/apps/api/src/lib/native-resource-service.ts new file mode 100644 index 00000000..7c34b092 --- /dev/null +++ b/apps/api/src/lib/native-resource-service.ts @@ -0,0 +1,90 @@ +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { orgMembers, users, webSessions } from '@deft/db/schema'; +import { ResourceHostOrganizationIdSchema, ResourceOpaqueIdSchema, RESOURCE_LIMITS } from '@deft/shared/resources'; +import { ResourceRefV2Schema, ResourceResolveResultV2Schema, RESOURCE_V2_CONTRACT_VERSIONS, + type ResourceRefV2, type ResourceResolveResultV2 } from '@deft/shared/resources-v2'; +import { db } from './db.js'; +import { ResourceAuthorizationError } from './resource-authorization.js'; +import type { NativeResourceDisplay, NativeResourceSubject } from './native-resource-types.js'; +import { resolveNativeMessageDisplay, resolveNativeWikiDisplay, resolveNativeNoteDisplay } from './native-content-projections.js'; +import { resolveNativeCalendarDisplay, resolveNativeFileDisplay } from './native-calendar-file-projections.js'; +import { resolveNativePersonDisplay, resolveNativeTeamDisplay } from './native-directory-projections.js'; + +const callerSchema = z.strictObject({ org_id: ResourceHostOrganizationIdSchema, + user_id: ResourceOpaqueIdSchema, sid: z.string().uuid() }); +export type NativeResourceWebCaller = z.infer; + +function denied() { + return new ResourceAuthorizationError('Resource access denied', 'RESOURCE_ACCESS_DENIED', 403); +} + +async function liveSubject(caller: NativeResourceWebCaller): Promise { + const [row] = await db.select({ role: orgMembers.role, expires_at: webSessions.expires_at, + revoked_at: webSessions.revoked_at }).from(webSessions) + .innerJoin(orgMembers, and(eq(orgMembers.org_id, webSessions.org_id), + eq(orgMembers.user_id, webSessions.user_id), eq(orgMembers.is_active, true))) + .innerJoin(users, and(eq(users.id, webSessions.user_id), eq(users.kind, 'human'), + eq(users.is_agent, false))) + .where(and(eq(webSessions.id, caller.sid), eq(webSessions.org_id, caller.org_id), + eq(webSessions.user_id, caller.user_id))).limit(1); + if (!row || row.revoked_at || row.expires_at <= new Date()) throw denied(); + return { org_id: caller.org_id, user_id: caller.user_id, role: row.role }; +} + +function safeLabel(value: string): string { + const normalized = value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim(); + let label = ''; + for (const character of normalized) { + if (label.length + character.length > RESOURCE_LIMITS.label_chars) break; + label += character; + } + return label.trim() || 'Resource'; +} + +async function nativeDisplay(subject: NativeResourceSubject, ref: ResourceRefV2): Promise { + if (ref.provider.kind !== 'core') return null; + // Code-owned slots only: package strings never select an executable adapter. + switch (ref.provider.provider_instance_id) { + case 'messages': return resolveNativeMessageDisplay(subject, ref.resource_id); + case 'wiki_pages': return resolveNativeWikiDisplay(subject, ref.resource_id); + case 'notes': return resolveNativeNoteDisplay(subject, ref.resource_id); + case 'files': return resolveNativeFileDisplay(subject, ref.resource_id); + case 'calendar_events': return resolveNativeCalendarDisplay(subject, ref.resource_id); + case 'people': return resolveNativePersonDisplay(subject, ref.resource_id); + case 'teams': return resolveNativeTeamDisplay(subject, ref.resource_id); + } +} + +/** Web reads do not confer an Experience grant or a Runtime viewer credential. */ +export class NativeResourceService { + async resolve(callerValue: NativeResourceWebCaller, refValue: unknown): Promise { + const caller = callerSchema.safeParse(callerValue); + if (!caller.success) throw denied(); + const parsed = ResourceRefV2Schema.safeParse(refValue); + if (!parsed.success) { + throw new ResourceAuthorizationError('Resource reference is invalid', 'RESOURCE_REF_INVALID', 400); + } + const ref = parsed.data; + try { + const subject = await liveSubject(caller.data); + const display = await nativeDisplay(subject, ref); + const current = await liveSubject(caller.data); + // A role change during a private-team read cannot retain the old role's result. + if (current.role !== subject.role) throw denied(); + const base = { schema_version: RESOURCE_V2_CONTRACT_VERSIONS.resolve, ref }; + if (!display) return ResourceResolveResultV2Schema.parse({ ...base, state: 'unavailable' }); + return ResourceResolveResultV2Schema.parse({ ...base, state: 'available', resource: { + schema_version: RESOURCE_V2_CONTRACT_VERSIONS.safe_projection, ref, + label: safeLabel(display.label), + ...(display.revision === undefined ? {} : { revision: display.revision }), + ...(display.updated_at === undefined ? {} : { updated_at: display.updated_at }), + } }); + } catch (error) { + if (error instanceof ResourceAuthorizationError) throw error; + throw new ResourceAuthorizationError('Resource provider failed safely', 'RESOURCE_PROVIDER_FAILURE', 500); + } + } +} + +export const nativeResourceService = new NativeResourceService(); diff --git a/apps/api/src/lib/native-resource-types.ts b/apps/api/src/lib/native-resource-types.ts new file mode 100644 index 00000000..a2694249 --- /dev/null +++ b/apps/api/src/lib/native-resource-types.ts @@ -0,0 +1,15 @@ +import type { OrgRole } from './org-membership.js'; + +/** Resolved by the host from a live web session and current membership. */ +export type NativeResourceSubject = Readonly<{ + org_id: string; + user_id: string; + role: OrgRole; +}>; + +/** Owner-authored display data only; no body, provider URL or storage handle. */ +export type NativeResourceDisplay = Readonly<{ + label: string; + revision?: string; + updated_at?: string; +}>; diff --git a/apps/api/src/routes/members.ts b/apps/api/src/routes/members.ts index 9641104e..0912575a 100644 --- a/apps/api/src/routes/members.ts +++ b/apps/api/src/routes/members.ts @@ -28,7 +28,7 @@ import { webSessions, } from '@deft/db/schema'; import { env } from '../lib/env.js'; -import { DEFTY_EMAIL } from '../lib/ensure-defty-membership.js'; +import { visibleLiveMemberForOrg } from '../lib/member-visibility.js'; import { OrgMembershipError, requireOrgAdminOrOwner } from '../lib/org-membership.js'; import { evictActiveHuddleParticipants } from '../socket.js'; import { emitWebSessionRevocations } from '../lib/web-sessions.js'; @@ -57,23 +57,6 @@ function adminForbidden(c: Context, err: unknown) { return c.json({ error: 'Only admins can perform this action', code: 'FORBIDDEN' }, 403); } -function visibleLiveMemberForOrg(orgIdRef: unknown) { - return sql` - ( - ${users.kind} <> 'agent' - OR ${users.email} = ${DEFTY_EMAIL} - OR EXISTS ( - SELECT 1 - FROM ${agentEmployees} - WHERE ${agentEmployees.user_id} = ${users.id} - AND ${agentEmployees.org_id} = ${orgIdRef} - AND ${agentEmployees.is_active} = true - AND ${agentEmployees.is_deleted} = false - ) - ) - `; -} - // GET /api/members — list all members of current org type InviteClaims = { purpose?: string; diff --git a/apps/api/src/routes/resources.ts b/apps/api/src/routes/resources.ts new file mode 100644 index 00000000..246d8179 --- /dev/null +++ b/apps/api/src/routes/resources.ts @@ -0,0 +1,37 @@ +import { Hono } from 'hono'; +import { nativeResourceService } from '../lib/native-resource-service.js'; +import { ResourceAuthorizationError } from '../lib/resource-authorization.js'; + +const MAX_REF_CHARS = 2_048; + +/** Mounted only behind existing web authentication and the Apps feature gate. */ +export const resourceRoutes = new Hono(); +resourceRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + await next(); +}); +resourceRoutes.get('/resolve', async (c) => { + const queries = c.req.queries(); + const value = queries.ref?.[0]; + if (Object.keys(queries).length !== 1 || queries.ref?.length !== 1 + || !value || value.length > MAX_REF_CHARS) { + return c.json({ error: 'Resource reference is invalid', code: 'RESOURCE_REF_INVALID' }, 400); + } + let ref: unknown; + try { ref = JSON.parse(value); } + catch { return c.json({ error: 'Resource reference is invalid', code: 'RESOURCE_REF_INVALID' }, 400); } + const user = c.get('user'); + if (!user?.id || !user.org_id || !user.sid) { + return c.json({ error: 'Resource access denied', code: 'RESOURCE_ACCESS_DENIED' }, 403); + } + try { + return c.json(await nativeResourceService.resolve( + { org_id: user.org_id, user_id: user.id, sid: user.sid }, ref)); + } catch (error) { + if (error instanceof ResourceAuthorizationError) { + return c.json({ error: error.message, code: error.code }, error.status); + } + return c.json({ error: 'Resource provider failed safely', code: 'RESOURCE_PROVIDER_FAILURE' }, 500); + } +}); diff --git a/apps/api/test/native-calendar-file-projections-db.test.ts b/apps/api/test/native-calendar-file-projections-db.test.ts new file mode 100644 index 00000000..02ef6b64 --- /dev/null +++ b/apps/api/test/native-calendar-file-projections-db.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { and, eq, inArray } from 'drizzle-orm'; +import { connectedAccounts, events, files, messageAttachments, messages, + orgMembers, orgs, projects, spaceMembers, spaces, taskAttachments, tasks, users } from '@deft/db/schema'; +import { db } from '../src/lib/db.js'; +import { getVisibleAttachment } from '../src/lib/attachment-access.js'; +import { resolveNativeCalendarDisplay, resolveNativeFileDisplay } from + '../src/lib/native-calendar-file-projections.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_c0(?:3b|4)_(?:public|root)(?:_v[0-9]+)?$/.test(new URL(target).pathname); + +test('native Calendar and File display uses current event owner and attachment parent ACLs', + { skip: !safe }, async () => { + const marker = randomUUID(); + const orgId = randomUUID(); + const foreignOrgId = randomUUID(); + const ownerId = randomUUID(); + const peerId = randomUUID(); + const accountId = randomUUID(); + const spaceId = randomUUID(); + const foreignSpaceId = randomUUID(); + const messageId = randomUUID(); + const malformedMessageId = randomUUID(); + const projectId = randomUUID(); + const foreignProjectId = randomUUID(); + const taskId = randomUUID(); + const malformedTaskId = randomUUID(); + const attachedId = randomUUID(); + const stagedId = randomUUID(); + const taskFileId = randomUUID(); + const malformedMessageFileId = randomUUID(); + const malformedTaskFileId = randomUUID(); + const legacyBadId = randomUUID(); + const ids = { native: randomUUID(), ics: randomUUID(), connected: randomUUID(), + peer: randomUUID(), nonCalendar: randomUUID(), foreign: randomUUID() }; + const owner = { org_id: orgId, user_id: ownerId, role: 'member' as const }; + const peer = { org_id: orgId, user_id: peerId, role: 'member' as const }; + const foreign = { org_id: foreignOrgId, user_id: peerId, role: 'member' as const }; + const when = new Date('2037-06-15T12:00:00.000Z'); + try { + await db.insert(orgs).values([ + { id: orgId, name: 'Native display owner fixture', slug: `${marker}-org` }, + { id: foreignOrgId, name: 'Foreign display fixture', slug: `${marker}-foreign` }, + ]); + await db.insert(users).values([ + { id: ownerId, email: `${marker}-owner@example.test`, name: 'Owner' }, + { id: peerId, email: `${marker}-peer@example.test`, name: 'Peer' }, + ]); + await db.insert(orgMembers).values([ownerId, peerId].map(userId => ({ + id: randomUUID(), org_id: orgId, user_id: userId, role: 'member' as const, + }))); + await db.insert(connectedAccounts).values({ id: accountId, org_id: orgId, + user_id: ownerId, provider: 'google_calendar', + provider_account_id: `${marker}-account`, access_token_encrypted: 'synthetic-test-only' }); + const event = (id: string, eventOrg: string, source: 'native' | 'ics' | 'google_calendar' | 'github', + eventType: string, title: string, userId: string | null, connectedId: string | null = null) => ({ + id, org_id: eventOrg, source, event_type: eventType, title, timestamp: when, + metadata: { synthetic: true }, user_id: userId, connected_account_id: connectedId, + }); + await db.insert(events).values([ + event(ids.native, orgId, 'native', 'calendar_event', 'Native owner event', ownerId), + event(ids.ics, orgId, 'ics', 'calendar_event', 'ICS owner event', ownerId), + event(ids.connected, orgId, 'google_calendar', 'calendar_event', 'Connected owner event', null, accountId), + event(ids.peer, orgId, 'native', 'calendar_event', 'Private peer event', peerId), + event(ids.nonCalendar, orgId, 'github', 'pr_opened', 'Owner noncalendar event', ownerId), + event(ids.foreign, foreignOrgId, 'native', 'calendar_event', 'Foreign event', null), + ]); + await db.insert(spaces).values([ + { id: spaceId, org_id: orgId, name: 'Private fixture space', type: 'private', created_by: ownerId }, + { id: foreignSpaceId, org_id: foreignOrgId, name: 'Malformed foreign parent', + type: 'private', created_by: ownerId }, + ]); + await db.insert(spaceMembers).values([ + { id: randomUUID(), space_id: spaceId, user_id: ownerId }, + { id: randomUUID(), space_id: foreignSpaceId, user_id: ownerId }, + ]); + await db.insert(messages).values([ + { id: messageId, org_id: orgId, space_id: spaceId, user_id: ownerId, + content: 'Private fixture parent' }, + { id: malformedMessageId, org_id: orgId, space_id: foreignSpaceId, + user_id: ownerId, content: 'Malformed cross-org parent' }, + ]); + await db.insert(projects).values([ + { id: projectId, org_id: orgId, name: 'Task parent', prefix: 'OWN', lead_id: ownerId }, + { id: foreignProjectId, org_id: foreignOrgId, name: 'Foreign Task parent', + prefix: 'FRN', lead_id: ownerId }, + ]); + await db.insert(tasks).values([ + { id: taskId, org_id: orgId, project_id: projectId, number: 1, + title: 'Visible parent task', created_by: ownerId }, + { id: malformedTaskId, org_id: orgId, project_id: foreignProjectId, number: 1, + title: 'Malformed cross-org task', created_by: ownerId }, + ]); + await db.insert(files).values([ + { id: attachedId, org_id: orgId, uploaded_by: peerId, + filename: 'private-attachment.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${attachedId}`, processing_status: 'ready' }, + { id: stagedId, org_id: orgId, uploaded_by: ownerId, + filename: 'staged-owner.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${stagedId}`, processing_status: 'ready' }, + { id: taskFileId, org_id: orgId, uploaded_by: ownerId, + filename: 'task-attachment.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${taskFileId}`, processing_status: 'ready' }, + { id: malformedMessageFileId, org_id: orgId, uploaded_by: ownerId, + filename: 'wrong-space.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${malformedMessageFileId}`, processing_status: 'ready' }, + { id: malformedTaskFileId, org_id: orgId, uploaded_by: ownerId, + filename: 'wrong-project.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${malformedTaskFileId}`, processing_status: 'ready' }, + { id: legacyBadId, org_id: orgId, uploaded_by: ownerId, + filename: 'legacy-wrong-space.txt', mime_type: 'text/plain', size_bytes: 10, + storage_key: `synthetic-${legacyBadId}`, processing_status: 'ready', + message_id: malformedMessageId }, + ]); + await db.insert(messageAttachments).values([ + { org_id: orgId, message_id: messageId, file_id: attachedId, position: 0 }, + { org_id: orgId, message_id: malformedMessageId, file_id: malformedMessageFileId, position: 0 }, + ]); + await db.insert(taskAttachments).values([ + { org_id: orgId, task_id: taskId, file_id: taskFileId, position: 0 }, + { org_id: orgId, task_id: malformedTaskId, file_id: malformedTaskFileId, position: 0 }, + ]); + + for (const id of [ids.native, ids.ics, ids.connected]) { + const display = await resolveNativeCalendarDisplay(owner, id); + assert.ok(display?.label); + assert.match(display.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + assert.deepEqual(Object.keys(display).sort(), ['label', 'updated_at']); + } + for (const id of [ids.peer, ids.nonCalendar, ids.foreign]) { + assert.equal(await resolveNativeCalendarDisplay(owner, id), null); + } + assert.equal(await resolveNativeCalendarDisplay(foreign, ids.native), null); + assert.equal((await resolveNativeCalendarDisplay(peer, ids.peer))?.label, 'Private peer event'); + + assert.equal((await resolveNativeFileDisplay(owner, attachedId))?.label, 'private-attachment.txt'); + assert.equal(await resolveNativeFileDisplay(peer, attachedId), null); + assert.equal(await resolveNativeFileDisplay(foreign, attachedId), null); + assert.equal((await resolveNativeFileDisplay(owner, stagedId))?.label, 'staged-owner.txt'); + assert.equal(await resolveNativeFileDisplay(peer, stagedId), null); + assert.equal((await resolveNativeFileDisplay(owner, taskFileId))?.label, 'task-attachment.txt'); + // Existing direct owner helper does not validate Space/Project org on + // these malformed parent rows; the App-facing leaf must fail closed. + assert.ok(await getVisibleAttachment(malformedMessageFileId, orgId, ownerId)); + assert.ok(await getVisibleAttachment(malformedTaskFileId, orgId, ownerId)); + assert.ok(await getVisibleAttachment(legacyBadId, orgId, ownerId)); + assert.equal(await resolveNativeFileDisplay(owner, malformedMessageFileId), null); + assert.equal(await resolveNativeFileDisplay(owner, malformedTaskFileId), null); + assert.equal(await resolveNativeFileDisplay(owner, legacyBadId), null); + const fileDisplay = await resolveNativeFileDisplay(owner, attachedId); + assert.deepEqual(Object.keys(fileDisplay ?? {}).sort(), ['label', 'updated_at']); + assert.equal(JSON.stringify(fileDisplay).includes('synthetic-'), false); + await db.insert(taskAttachments).values({ org_id: orgId, task_id: taskId, + file_id: attachedId, position: 1 }); + assert.equal(await resolveNativeFileDisplay(owner, attachedId), null); + await db.delete(taskAttachments).where(and(eq(taskAttachments.org_id, orgId), + eq(taskAttachments.file_id, attachedId))); + await db.insert(spaceMembers).values({ id: randomUUID(), space_id: spaceId, user_id: peerId }); + assert.equal((await resolveNativeFileDisplay(peer, attachedId))?.label, 'private-attachment.txt'); + await db.delete(spaceMembers).where(and(eq(spaceMembers.space_id, spaceId), eq(spaceMembers.user_id, peerId))); + assert.equal(await resolveNativeFileDisplay(peer, attachedId), null); + await db.update(files).set({ processing_status: 'blocked' }).where(eq(files.id, attachedId)); + assert.equal(await resolveNativeFileDisplay(owner, attachedId), null); + + await db.update(connectedAccounts).set({ user_id: peerId }).where(eq(connectedAccounts.id, accountId)); + assert.equal(await resolveNativeCalendarDisplay(owner, ids.connected), null); + assert.equal((await resolveNativeCalendarDisplay(peer, ids.connected))?.label, 'Connected owner event'); + await db.update(orgMembers).set({ is_active: false }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + assert.equal(await resolveNativeCalendarDisplay(owner, ids.native), null); + } finally { + const fileIds = [attachedId, stagedId, taskFileId, malformedMessageFileId, + malformedTaskFileId, legacyBadId]; + await db.delete(messageAttachments).where(inArray(messageAttachments.file_id, fileIds)); + await db.delete(taskAttachments).where(inArray(taskAttachments.file_id, fileIds)); + await db.delete(files).where(inArray(files.id, fileIds)); + await db.delete(messages).where(inArray(messages.id, [messageId, malformedMessageId])); + await db.delete(tasks).where(inArray(tasks.id, [taskId, malformedTaskId])); + await db.delete(projects).where(inArray(projects.id, [projectId, foreignProjectId])); + await db.delete(spaceMembers).where(inArray(spaceMembers.space_id, [spaceId, foreignSpaceId])); + await db.delete(spaces).where(inArray(spaces.id, [spaceId, foreignSpaceId])); + await db.delete(events).where(inArray(events.id, Object.values(ids))); + await db.delete(connectedAccounts).where(eq(connectedAccounts.id, accountId)); + await db.delete(orgMembers).where(eq(orgMembers.org_id, orgId)); + await db.delete(users).where(inArray(users.id, [ownerId, peerId])); + await db.delete(orgs).where(inArray(orgs.id, [orgId, foreignOrgId])); + } + }); diff --git a/apps/api/test/native-content-projections-db.test.ts b/apps/api/test/native-content-projections-db.test.ts new file mode 100644 index 00000000..5f52f4bb --- /dev/null +++ b/apps/api/test/native-content-projections-db.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import pg from 'pg'; +import { closeDb } from '../src/lib/db.js'; +import { + resolveNativeMessageDisplay, + resolveNativeNoteDisplay, + resolveNativeWikiDisplay, +} from '../src/lib/native-content-projections.js'; +import type { NativeResourceSubject } from '../src/lib/native-resource-types.js'; + +const databaseUrl = process.env.DATABASE_URL ?? ''; +const canRun = databaseUrl === process.env.DEFT_TEST_DATABASE_URL + && /^postgresql:\/\/gate_g_test@127\.0\.0\.1:55435\/gate_g_phase5_test_[a-z0-9_]+$/.test(databaseUrl); + +after(closeDb); + +test('native display projections keep exact owner, share, space, and tombstone boundaries', + { skip: !canRun }, async () => { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + const org = randomUUID(), foreignOrg = randomUUID(); + const owner = randomUUID(), peer = randomUUID(), outsider = randomUUID(); + const space = randomUUID(), foreignSpace = randomUUID(); + const message = randomUUID(), wrongParentMessage = randomUUID(), deadMessage = randomUUID(); + const privatePage = randomUUID(), spacePage = randomUUID(), orgPage = randomUUID(); + const wrongParentPage = randomUUID(), deadPage = randomUUID(); + const privateNote = randomUUID(), sharedNote = randomUUID(), spaceNote = randomUUID(); + const wrongParentNote = randomUUID(), deadNote = randomUUID(); + const ownerSubject: NativeResourceSubject = { org_id: org, user_id: owner, role: 'member' }; + const peerSubject: NativeResourceSubject = { org_id: org, user_id: peer, role: 'member' }; + const outsiderSubject: NativeResourceSubject = { org_id: foreignOrg, user_id: outsider, role: 'member' }; + try { + for (const id of [org, foreignOrg]) { + await client.query('INSERT INTO orgs (id,name,slug) VALUES ($1,$2,$3)', + [id, 'Native display fixture', `display-${id}`]); + } + for (const id of [owner, peer, outsider]) { + await client.query('INSERT INTO users (id,name,email) VALUES ($1,$2,$3)', + [id, 'Display fixture', `${id}@example.test`]); + } + for (const [orgId, userId] of [[org, owner], [org, peer], [foreignOrg, outsider]]) { + await client.query("INSERT INTO org_members (id,org_id,user_id,role) VALUES ($1,$2,$3,'member')", + [randomUUID(), orgId, userId]); + } + await client.query("INSERT INTO spaces (id,org_id,name,type) VALUES ($1,$3,'Private','private'),($2,$4,'Other org','private')", + [space, foreignSpace, org, foreignOrg]); + await client.query('INSERT INTO space_members (id,space_id,user_id) VALUES ($1,$2,$3),($4,$5,$3)', + [randomUUID(), space, owner, randomUUID(), foreignSpace]); + for (const [id, spaceId, deleted] of [ + [message, space, false], [wrongParentMessage, foreignSpace, false], [deadMessage, space, true], + ] as const) { + await client.query('INSERT INTO messages (id,org_id,space_id,user_id,content,is_deleted) VALUES ($1,$2,$3,$4,$5,$6)', + [id, org, spaceId, owner, 'secret message body', deleted]); + } + const pageRows = [ + [privatePage, 'user', null, owner, false, 'Private page'], + [spacePage, 'space', space, null, false, 'Space page'], + [orgPage, 'org', null, null, false, 'Org page'], + [wrongParentPage, 'space', foreignSpace, null, false, 'Wrong parent'], + [deadPage, 'org', null, null, true, 'Deleted page'], + ] as const; + for (const [id, scope, spaceId, userId, deleted, title] of pageRows) { + await client.query(`INSERT INTO wiki_pages + (id,org_id,scope,space_id,user_id,type,title,slug,content,is_deleted) + VALUES ($1,$2,$3,$4,$5,'fact',$6,$7,'secret wiki body',$8)`, + [id, org, scope, spaceId, userId, title, `display-${id}`, deleted]); + } + const noteRows = [ + [privateNote, 'private', null, false, 'Private note'], + [sharedNote, 'private', null, false, 'Shared note'], + [spaceNote, 'space', space, false, 'Space note'], + [wrongParentNote, 'space', foreignSpace, false, 'Wrong parent note'], + [deadNote, 'org', null, true, 'Deleted note'], + ] as const; + for (const [id, visibility, spaceId, deleted, title] of noteRows) { + await client.query(`INSERT INTO notes + (id,org_id,user_id,title,content,visibility,visibility_space_id,is_deleted) + VALUES ($1,$2,$3,$4,'secret note body',$5,$6,$7)`, + [id, org, owner, title, visibility, spaceId, deleted]); + } + + assert.equal((await resolveNativeMessageDisplay(ownerSubject, message))?.label, 'Message'); + assert.equal(await resolveNativeMessageDisplay(peerSubject, message), null); + assert.equal(await resolveNativeMessageDisplay(outsiderSubject, message), null); + assert.equal(await resolveNativeMessageDisplay(ownerSubject, wrongParentMessage), null); + assert.equal(await resolveNativeMessageDisplay(ownerSubject, deadMessage), null); + assert.equal((await resolveNativeWikiDisplay(ownerSubject, privatePage))?.label, 'Private page'); + assert.equal(await resolveNativeWikiDisplay(peerSubject, privatePage), null); + assert.equal((await resolveNativeWikiDisplay(peerSubject, orgPage))?.label, 'Org page'); + assert.equal((await resolveNativeWikiDisplay(ownerSubject, spacePage))?.label, 'Space page'); + assert.equal(await resolveNativeWikiDisplay(peerSubject, spacePage), null); + assert.equal(await resolveNativeWikiDisplay(ownerSubject, wrongParentPage), null); + assert.equal(await resolveNativeWikiDisplay(ownerSubject, deadPage), null); + assert.equal(await resolveNativeWikiDisplay(outsiderSubject, orgPage), null); + assert.equal((await resolveNativeNoteDisplay(ownerSubject, privateNote))?.label, 'Private note'); + assert.equal(await resolveNativeNoteDisplay(peerSubject, privateNote), null); + assert.equal(await resolveNativeNoteDisplay(peerSubject, sharedNote), null); + assert.equal((await resolveNativeNoteDisplay(ownerSubject, spaceNote))?.label, 'Space note'); + assert.equal(await resolveNativeNoteDisplay(peerSubject, spaceNote), null); + assert.equal(await resolveNativeNoteDisplay(ownerSubject, wrongParentNote), null); + assert.equal(await resolveNativeNoteDisplay(ownerSubject, deadNote), null); + assert.equal(await resolveNativeNoteDisplay(outsiderSubject, privateNote), null); + + await client.query('INSERT INTO note_shares (id,note_id,shared_with_user_id) VALUES ($1,$2,$3)', + [randomUUID(), sharedNote, peer]); + assert.equal((await resolveNativeNoteDisplay(peerSubject, sharedNote))?.label, 'Shared note'); + await client.query('DELETE FROM note_shares WHERE note_id=$1', [sharedNote]); + assert.equal(await resolveNativeNoteDisplay(peerSubject, sharedNote), null); + await client.query('INSERT INTO space_members (id,space_id,user_id) VALUES ($1,$2,$3)', + [randomUUID(), space, peer]); + assert.equal((await resolveNativeMessageDisplay(peerSubject, message))?.label, 'Message'); + assert.equal((await resolveNativeWikiDisplay(peerSubject, spacePage))?.label, 'Space page'); + assert.equal((await resolveNativeNoteDisplay(peerSubject, spaceNote))?.label, 'Space note'); + await client.query('DELETE FROM space_members WHERE space_id=$1 AND user_id=$2', [space, peer]); + assert.equal(await resolveNativeMessageDisplay(peerSubject, message), null); + assert.equal(await resolveNativeWikiDisplay(peerSubject, spacePage), null); + assert.equal(await resolveNativeNoteDisplay(peerSubject, spaceNote), null); + assert.ok(!JSON.stringify(await resolveNativeWikiDisplay(ownerSubject, privatePage)).includes('secret')); + assert.ok(!JSON.stringify(await resolveNativeNoteDisplay(ownerSubject, privateNote)).includes('secret')); + } finally { + await client.query('DELETE FROM note_shares WHERE note_id = ANY($1)', [[privateNote, sharedNote, spaceNote, wrongParentNote, deadNote]]); + await client.query('DELETE FROM notes WHERE id = ANY($1)', [[privateNote, sharedNote, spaceNote, wrongParentNote, deadNote]]); + await client.query('DELETE FROM wiki_pages WHERE id = ANY($1)', [[privatePage, spacePage, orgPage, wrongParentPage, deadPage]]); + await client.query('DELETE FROM messages WHERE id = ANY($1)', [[message, wrongParentMessage, deadMessage]]); + await client.query('DELETE FROM space_members WHERE space_id = ANY($1)', [[space, foreignSpace]]); + await client.query('DELETE FROM spaces WHERE id = ANY($1)', [[space, foreignSpace]]); + await client.query('DELETE FROM org_members WHERE org_id = ANY($1)', [[org, foreignOrg]]); + await client.query('DELETE FROM orgs WHERE id = ANY($1)', [[org, foreignOrg]]); + await client.query('DELETE FROM users WHERE id = ANY($1)', [[owner, peer, outsider]]); + await client.end(); + } + }); diff --git a/apps/api/test/native-directory-projections-db.test.ts b/apps/api/test/native-directory-projections-db.test.ts new file mode 100644 index 00000000..9290dfb8 --- /dev/null +++ b/apps/api/test/native-directory-projections-db.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { and, eq, inArray } from 'drizzle-orm'; +import { agentEmployees, orgMembers, orgs, teamMembers, teams, users } from '@deft/db/schema'; +import { db } from '../src/lib/db.js'; +import { + resolveNativePersonDisplay, + resolveNativeTeamDisplay, +} from '../src/lib/native-directory-projections.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const parsed = target ? new URL(target) : null; +const safe = target === process.env.DATABASE_URL && parsed !== null + && (parsed.protocol === 'postgres:' || parsed.protocol === 'postgresql:') + && parsed.hostname === '127.0.0.1' && parsed.port === '55435' + && ['/gate_g_phase5_test_c04_directory', '/gate_g_phase5_test_c03b_root_v2'].includes(parsed.pathname) + && !parsed.search && !parsed.hash; + +test('native person and team displays follow live directory and private-team visibility', + { skip: !safe }, async () => { + const marker = randomUUID(); + const orgId = randomUUID(); + const foreignOrgId = randomUUID(); + const viewerId = randomUUID(); + const leadId = randomUUID(); + const personId = randomUUID(); + const foreignId = randomUUID(); + const hiddenAgentId = randomUUID(); + const employeeId = randomUUID(); + const privateTeamId = randomUUID(); + const orgTeamId = randomUUID(); + const viewer = { org_id: orgId, user_id: viewerId, role: 'member' as const }; + const staleAdmin = { ...viewer, role: 'admin' as const }; + const foreignViewer = { org_id: foreignOrgId, user_id: foreignId, role: 'member' as const }; + const usersToRemove = [viewerId, leadId, personId, foreignId, hiddenAgentId]; + try { + await db.insert(orgs).values([ + { id: orgId, name: 'Directory projection fixture', slug: `${marker}-directory` }, + { id: foreignOrgId, name: 'Foreign directory fixture', slug: `${marker}-foreign` }, + ]); + await db.insert(users).values([ + { id: viewerId, name: 'Viewer', email: `${marker}-viewer@example.test` }, + { id: leadId, name: 'Team Lead', email: `${marker}-lead@example.test` }, + { id: personId, name: 'Visible Person', email: `${marker}-person@example.test` }, + { id: foreignId, name: 'Foreign Person', email: `${marker}-foreign@example.test` }, + { id: hiddenAgentId, name: 'Retired Agent', kind: 'agent', + email: `${marker}-agent@example.test` }, + ]); + await db.insert(orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: viewerId, role: 'member' }, + { id: randomUUID(), org_id: orgId, user_id: leadId, role: 'member' }, + { id: randomUUID(), org_id: orgId, user_id: personId, role: 'member' }, + { id: randomUUID(), org_id: orgId, user_id: hiddenAgentId, role: 'member' }, + { id: randomUUID(), org_id: foreignOrgId, user_id: foreignId, role: 'member' }, + ]); + await db.insert(teams).values([ + { id: privateTeamId, org_id: orgId, name: 'Private Team', + handle: `${marker}-private`, visibility: 'private', lead_user_id: leadId }, + { id: orgTeamId, org_id: orgId, name: 'Org Team', + handle: `${marker}-org`, visibility: 'org' }, + ]); + + const person = await resolveNativePersonDisplay(viewer, personId); + assert.equal(person?.label, 'Visible Person'); + assert.deepEqual(Object.keys(person ?? {}).sort(), ['label', 'updated_at']); + assert.match(person?.updated_at ?? '', /^\d{4}-\d{2}-\d{2}T/); + assert.equal(JSON.stringify(person).includes('@example.test'), false); + assert.equal(await resolveNativePersonDisplay(viewer, foreignId), null); + assert.equal(await resolveNativePersonDisplay(foreignViewer, personId), null); + assert.equal(await resolveNativePersonDisplay(viewer, hiddenAgentId), null); + await db.insert(agentEmployees).values({ id: employeeId, org_id: orgId, + user_id: hiddenAgentId, name: 'Directory Agent', slug: `${marker}-agent`, + role: 'custom', system_prompt: 'synthetic test only', created_by: viewerId }); + assert.equal((await resolveNativePersonDisplay(viewer, hiddenAgentId))?.label, 'Retired Agent'); + await db.update(agentEmployees).set({ is_active: false }).where(eq(agentEmployees.id, employeeId)); + assert.equal(await resolveNativePersonDisplay(viewer, hiddenAgentId), null); + assert.equal((await resolveNativePersonDisplay(viewer, viewerId))?.label, 'Viewer'); + assert.equal((await resolveNativeTeamDisplay(viewer, orgTeamId))?.label, 'Org Team'); + assert.equal(await resolveNativeTeamDisplay(viewer, privateTeamId), null); + assert.equal(await resolveNativeTeamDisplay(foreignViewer, privateTeamId), null); + + const lead = { ...viewer, user_id: leadId }; + assert.equal((await resolveNativeTeamDisplay(lead, privateTeamId))?.label, 'Private Team'); + await db.insert(teamMembers).values({ id: randomUUID(), org_id: orgId, + team_id: privateTeamId, user_id: viewerId, role: 'member' }); + const memberDisplay = await resolveNativeTeamDisplay(viewer, privateTeamId); + assert.equal(memberDisplay?.label, 'Private Team'); + assert.deepEqual(Object.keys(memberDisplay ?? {}).sort(), ['label', 'updated_at']); + await db.delete(teamMembers).where(and( + eq(teamMembers.team_id, privateTeamId), eq(teamMembers.user_id, viewerId))); + assert.equal(await resolveNativeTeamDisplay(viewer, privateTeamId), null); + + await db.update(teams).set({ lead_user_id: viewerId }).where(eq(teams.id, privateTeamId)); + assert.equal((await resolveNativeTeamDisplay(viewer, privateTeamId))?.label, 'Private Team'); + await db.update(teams).set({ lead_user_id: leadId }).where(eq(teams.id, privateTeamId)); + assert.equal(await resolveNativeTeamDisplay(viewer, privateTeamId), null); + + await db.update(orgMembers).set({ role: 'admin' }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, viewerId))); + assert.equal((await resolveNativeTeamDisplay(viewer, privateTeamId))?.label, 'Private Team'); + await db.update(orgMembers).set({ role: 'member' }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, viewerId))); + assert.equal(await resolveNativeTeamDisplay(staleAdmin, privateTeamId), null); + + await db.update(orgMembers).set({ is_active: false }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, personId))); + assert.equal(await resolveNativePersonDisplay(viewer, personId), null); + await db.update(orgMembers).set({ is_active: false }).where(and( + eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, viewerId))); + assert.equal(await resolveNativePersonDisplay(viewer, leadId), null); + assert.equal(await resolveNativeTeamDisplay(viewer, orgTeamId), null); + } finally { + await db.delete(agentEmployees).where(eq(agentEmployees.id, employeeId)); + await db.delete(teamMembers).where(eq(teamMembers.org_id, orgId)); + await db.delete(teams).where(eq(teams.org_id, orgId)); + await db.delete(orgMembers).where(inArray(orgMembers.org_id, [orgId, foreignOrgId])); + await db.delete(users).where(inArray(users.id, usersToRemove)); + await db.delete(orgs).where(inArray(orgs.id, [orgId, foreignOrgId])); + } + }); diff --git a/apps/api/test/native-resource-service-db.test.ts b/apps/api/test/native-resource-service-db.test.ts new file mode 100644 index 00000000..3d3c0ae7 --- /dev/null +++ b/apps/api/test/native-resource-service-db.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { and, eq, inArray } from 'drizzle-orm'; +import { Hono } from 'hono'; +import { orgMembers, orgs, teams, users, webSessions, wikiPages } from '@deft/db/schema'; +import { ResourceResolveResultV2Schema } from '@deft/shared/resources-v2'; +import { db } from '../src/lib/db.js'; +import { NativeResourceService } from '../src/lib/native-resource-service.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; +import { authMiddleware } from '../src/middleware/auth.js'; +import { resourceRoutes } from '../src/routes/resources.js'; +import { safeTestDatabaseUrl } from './fixtures/safe-test-database.js'; + +const safe = Boolean(safeTestDatabaseUrl()); +const service = new NativeResourceService(); +const ref = (provider: string, type: string, id: string) => ({ + schema_version: 'deft.resource_ref.v2', + provider: { kind: 'core', provider_instance_id: provider }, resource_type: type, resource_id: id, +}); +const sid = (token: string): string => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).sid; + +test('native resource web resolver binds current session and owner without widening v1 or Runtime access', + { skip: !safe }, async () => { + const orgId = randomUUID(); const foreignOrg = randomUUID(); + const ownerId = randomUUID(); const peerId = randomUUID(); + const pageId = randomUUID(); const teamId = randomUUID(); + const ownerEmail = `${ownerId}@example.test`; const peerEmail = `${peerId}@example.test`; + const pageRef = ref('wiki_pages', 'wiki_page', pageId); + const app = new Hono(); + app.use('/api/*', authMiddleware); app.route('/api/resources', resourceRoutes); + try { + await db.insert(orgs).values([orgId, foreignOrg].map(id => ({ id, name: 'Native resource fixture', slug: id }))); + await db.insert(users).values([ + { id: ownerId, name: 'Owner', email: ownerEmail }, + { id: peerId, name: 'Peer', email: peerEmail }, + ]); + await db.insert(orgMembers).values([ownerId, peerId].map(userId => ({ + id: randomUUID(), org_id: orgId, user_id: userId, role: 'member' as const, + }))); + await db.insert(wikiPages).values({ id: pageId, org_id: orgId, user_id: ownerId, + scope: 'user', type: 'fact', slug: pageId, title: ` \n${'😀'.repeat(130)}\t `, + content: 'PRIVATE BODY MUST NEVER BE PROJECTED' }); + await db.insert(teams).values({ id: teamId, org_id: orgId, name: 'Private team', + handle: teamId, visibility: 'private', lead_user_id: ownerId }); + const ownerToken = (await createWebSession({ id: ownerId, org_id: orgId, email: ownerEmail })).accessToken; + const peerToken = (await createWebSession({ id: peerId, org_id: orgId, email: peerEmail })).accessToken; + const caller = { org_id: orgId, user_id: ownerId, sid: sid(ownerToken) }; + const peer = { org_id: orgId, user_id: peerId, sid: sid(peerToken) }; + const request = (value: unknown, token: string | undefined = ownerToken, suffix = '') => app.request( + `/api/resources/resolve?ref=${encodeURIComponent(JSON.stringify(value))}${suffix}`, + { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + + const response = await request(pageRef); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const available = ResourceResolveResultV2Schema.parse(await response.json()); + assert.equal(available.state, 'available'); + if (available.state !== 'available') throw new Error('Expected authorized projection'); + assert.equal(available.resource.label, '😀'.repeat(100)); + assert.equal(JSON.stringify(available).includes('PRIVATE BODY'), false); + assert.equal('href' in available.resource, false); + const unavailable = await service.resolve(peer, pageRef); + assert.deepEqual(unavailable, { schema_version: 'deft.resource_resolve.v2', ref: pageRef, state: 'unavailable' }); + assert.equal((await service.resolve(caller, ref('wiki_pages', 'wiki_page', randomUUID()))).state, 'unavailable'); + await assert.rejects(service.resolve({ ...caller, user_id: peerId }, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + await assert.rejects(service.resolve({ ...caller, org_id: foreignOrg }, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + await assert.rejects(service.resolve({ ...caller, sid: randomUUID() }, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + assert.equal((await request({ ...pageRef, org_id: orgId })).status, 400); + assert.equal((await request(ref('wiki_pages', 'message', pageId))).status, 400); + assert.equal((await request({ ...pageRef, schema_version: 'deft.resource_ref.v1' })).status, 400); + assert.equal((await request(pageRef, ownerToken, '&actor_id=forged')).status, 400); + assert.equal((await request(pageRef, ownerToken, '&ref={}')).status, 400); + assert.equal((await request('x'.repeat(2_049))).status, 400); + const anonymous = await app.request(`/api/resources/resolve?ref=${encodeURIComponent(JSON.stringify(pageRef))}`); + assert.equal(anonymous.status, 401); + + const runtimeRef = { ...pageRef, provider: { kind: 'app_runtime', provider_instance_id: 'operator_binding' }, + resource_type: 'mail_thread' }; + assert.deepEqual(await service.resolve(caller, runtimeRef), { + schema_version: 'deft.resource_resolve.v2', ref: runtimeRef, state: 'unavailable', + }); + + // Current DB role, rather than a cached caller/token role, controls private teams. + const teamRef = ref('teams', 'team', teamId); + assert.equal((await service.resolve(peer, teamRef)).state, 'unavailable'); + await db.update(orgMembers).set({ role: 'admin' }).where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, peerId))); + assert.equal((await service.resolve(peer, teamRef)).state, 'available'); + await db.update(orgMembers).set({ role: 'member' }).where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, peerId))); + assert.equal((await service.resolve(peer, teamRef)).state, 'unavailable'); + + // Exercise the real production mount in separate processes for both feature states. + const script = `import { app } from './src/index.ts'; + import { serve } from '@hono/node-server'; + import { closeDb } from './src/lib/db.ts'; + const server = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }); + await new Promise(resolve => server.on('listening', resolve)); + const url = 'http://127.0.0.1:' + server.address().port + process.env.NATIVE_TEST_PATH; + const anonymous = await fetch(url); + await anonymous.arrayBuffer(); + const response = await fetch(url, { + headers: { Authorization: 'Bearer ' + process.env.NATIVE_TEST_TOKEN } + }); + const body = await response.text(); + console.log('NATIVE_RESULT:' + JSON.stringify({ status: response.status, + anonymous: anonymous.status, leakedBody: body.includes('PRIVATE BODY'), + cache: response.headers.get('cache-control') })); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await closeDb();`; + for (const enabled of [false, true]) { + const output = execFileSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', script], { + cwd: fileURLToPath(new URL('../', import.meta.url)), encoding: 'utf8', timeout: 60_000, + env: { ...process.env, DEFT_APPS_ENABLED: String(enabled), DEFT_APP_RUNS_ENABLED: 'false', + DEFT_APP_RUN_APP_ORIGIN_ENABLED: 'false', DEFT_APP_AUTOMATIONS_ENABLED: 'false', + DEFT_APP_RUN_LEGACY_MCP_CUTOVER_ENABLED: 'false', DEFT_APP_RUNTIME_CHANNEL_ENABLED: 'false', + DEFT_APP_PUBLIC_INGRESS_ENABLED: 'false', NATIVE_TEST_TOKEN: ownerToken, + NATIVE_TEST_PATH: `/api/resources/resolve?ref=${encodeURIComponent(JSON.stringify(pageRef))}` }, + }); + const line = output.split(/\r?\n/).find(value => value.startsWith('NATIVE_RESULT:')); + assert.ok(line); + const result = JSON.parse(line.slice('NATIVE_RESULT:'.length)); + assert.equal(result.status, enabled ? 200 : 404); + assert.equal(result.anonymous, 401); + assert.equal(result.leakedBody, false); + if (enabled) assert.equal(result.cache, 'no-store'); + } + + await db.update(orgMembers).set({ is_active: false }).where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + await assert.rejects(service.resolve(caller, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + await db.update(orgMembers).set({ is_active: true }).where(and(eq(orgMembers.org_id, orgId), eq(orgMembers.user_id, ownerId))); + await db.update(webSessions).set({ expires_at: new Date(0) }).where(eq(webSessions.id, caller.sid)); + await assert.rejects(service.resolve(caller, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + await db.update(webSessions).set({ expires_at: new Date(Date.now() + 60_000), revoked_at: new Date() }) + .where(eq(webSessions.id, caller.sid)); + await assert.rejects(service.resolve(caller, pageRef), { code: 'RESOURCE_ACCESS_DENIED' }); + assert.equal((await request(pageRef)).status, 401); + } finally { + await db.delete(webSessions).where(eq(webSessions.org_id, orgId)); + await db.delete(wikiPages).where(eq(wikiPages.org_id, orgId)); + await db.delete(teams).where(eq(teams.org_id, orgId)); + await db.delete(orgMembers).where(eq(orgMembers.org_id, orgId)); + await db.delete(users).where(inArray(users.id, [ownerId, peerId])); + await db.delete(orgs).where(inArray(orgs.id, [orgId, foreignOrg])); + } + }); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 9781cf80..9e362887 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -669,6 +669,16 @@ { "file": "apps/api/test/mcp-events.test.ts", "name": "3. events_query filters by since/until time range" }, { "file": "apps/api/test/mcp-events.test.ts", "name": "4. events_query ignores model-authored caller_employee_slug and uses the bearer identity" } ] + }, + { + "id": "native-resource-web", + "description": "Seven native display owner adapters plus live web-session v2 dispatch and real loopback HTTP feature-gate proof on synthetic PostgreSQL.", + "cases": [ + { "file": "apps/api/test/native-content-projections-db.test.ts", "name": "native display projections keep exact owner, share, space, and tombstone boundaries" }, + { "file": "apps/api/test/native-calendar-file-projections-db.test.ts", "name": "native Calendar and File display uses current event owner and attachment parent ACLs" }, + { "file": "apps/api/test/native-directory-projections-db.test.ts", "name": "native person and team displays follow live directory and private-team visibility" }, + { "file": "apps/api/test/native-resource-service-db.test.ts", "name": "native resource web resolver binds current session and owner without widening v1 or Runtime access" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From e61a2e6f82420a80681d3f010bb24356658e611e Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:30:01 +0530 Subject: [PATCH 006/161] Add bounded candidate resource sync page contracts --- packages/app-kit/src/resource-sync.ts | 153 ++++++++++++++++++ packages/app-kit/test/resource-sync.test.ts | 166 ++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 packages/app-kit/src/resource-sync.ts create mode 100644 packages/app-kit/test/resource-sync.test.ts diff --git a/packages/app-kit/src/resource-sync.ts b/packages/app-kit/src/resource-sync.ts new file mode 100644 index 00000000..ba32abde --- /dev/null +++ b/packages/app-kit/src/resource-sync.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; +import { parseRuntimeObjectInput, RuntimeObjectSchema } from './runtime-authoring.js'; + +/** Pure candidate contracts. A host must separately review the descriptor and + * pin an App/version/grant/owner/Runtime binding before admitting a sync Run. */ +export const RESOURCE_SYNC_VERSIONS = Object.freeze({ + descriptor: 'deft.app_sync_descriptor.v1', + request: 'deft.app_sync_request.v1', + page: 'deft.app_sync_page.v1', +} as const); + +export const RESOURCE_SYNC_LIMITS = Object.freeze({ + items_per_page: 100, + page_bytes: 512 * 1024, + cursor_bytes: 2_048, + record_fields: 32, + label_chars: 200, + resource_type_chars: 64, + resource_id_chars: 256, + revision_chars: 128, +} as const); + +const controls = /[\u0000-\u001f\u007f]/u; +const opaque = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u; +const key = z.string().min(1).max(48).regex(/^[a-z][a-z0-9_]*$/u) + .refine((value) => !['constructor', 'prototype', '__proto__'].includes(value)); +function exactIdentity(max: number, pattern = opaque) { + return z.string().min(1).max(max) + .refine((value) => value === value.trim() && !controls.test(value)) + .regex(pattern); +} +function utf8Bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} +function validUnicode(value: string): boolean { + return new TextDecoder('utf-8', { fatal: true }).decode(new TextEncoder().encode(value)) === value; +} + +const resourceType = exactIdentity(RESOURCE_SYNC_LIMITS.resource_type_chars, + /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u); +const resourceId = exactIdentity(RESOURCE_SYNC_LIMITS.resource_id_chars); +const revision = exactIdentity(RESOURCE_SYNC_LIMITS.revision_chars); +const cursor = z.string().min(1) + .refine((value) => value.length <= RESOURCE_SYNC_LIMITS.cursor_bytes + && !controls.test(value) && validUnicode(value) + && utf8Bytes(value) <= RESOURCE_SYNC_LIMITS.cursor_bytes, + 'Cursor must be valid UTF-8 without controls and at most 2,048 bytes'); + +export const SyncDescriptorV1Schema = z.strictObject({ + schema_version: z.literal(RESOURCE_SYNC_VERSIONS.descriptor), + key, + runtime_requirement_key: key, + resource_type: resourceType, + requested_visibility: z.literal('user_private'), + record_schema: RuntimeObjectSchema, + label_field: key, +}).superRefine((descriptor, ctx) => { + const field = descriptor.record_schema.properties[descriptor.label_field]; + if (!field || field.type !== 'string' || field.maxLength > RESOURCE_SYNC_LIMITS.label_chars + || !descriptor.record_schema.required.includes(descriptor.label_field)) { + ctx.addIssue({ code: 'custom', path: ['label_field'], + message: 'Label field must be a required bounded string of at most 200 characters' }); + } +}); +export type SyncDescriptorV1 = z.infer; + +export const SyncRequestV1Schema = z.strictObject({ + schema_version: z.literal(RESOURCE_SYNC_VERSIONS.request), + cursor: cursor.nullable(), + max_items: z.number().int().min(1).max(RESOURCE_SYNC_LIMITS.items_per_page), +}); +export type SyncRequestV1 = z.infer; + +const scalar = z.union([z.string(), z.number().finite(), z.boolean()]); +const data = z.record(key, scalar).refine( + (value) => Object.keys(value).length <= RESOURCE_SYNC_LIMITS.record_fields, + 'Record has too many scalar fields', +); +const upsert = z.strictObject({ id: resourceId, revision, data }); +const tombstone = z.strictObject({ id: resourceId, revision }); +export const SyncPageV1Schema = z.strictObject({ + schema_version: z.literal(RESOURCE_SYNC_VERSIONS.page), + upserts: z.array(upsert).max(RESOURCE_SYNC_LIMITS.items_per_page), + tombstones: z.array(tombstone).max(RESOURCE_SYNC_LIMITS.items_per_page), + next_cursor: cursor.nullable(), + has_more: z.boolean(), +}).superRefine((page, ctx) => { + if (page.upserts.length + page.tombstones.length > RESOURCE_SYNC_LIMITS.items_per_page) { + ctx.addIssue({ code: 'custom', path: ['upserts'], message: 'Sync page has too many items' }); + } + const seen = new Set(); + for (const [kind, rows] of [['upserts', page.upserts], ['tombstones', page.tombstones]] as const) { + for (const [index, row] of rows.entries()) { + if (seen.has(row.id)) ctx.addIssue({ code: 'custom', path: [kind, index, 'id'], + message: 'Sync page resource IDs must be unique across all items' }); + seen.add(row.id); + } + } +}); +export type SyncPageV1 = z.infer; + +/** Stable serialized bytes for the page ceiling and a future host digest. + * This checks page shape; callers still need parseSyncPage's reviewed schema. */ +export function canonicalSyncPageJson(pageValue: unknown): string { + const page = SyncPageV1Schema.parse(pageValue); + const canonical = (value: unknown): string => { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + return `{${Object.keys(value).sort().map((name) => + `${JSON.stringify(name)}:${canonical((value as Record)[name])}`).join(',')}}`; + }; + return canonical(page); +} + +export function parseSyncDescriptor(value: unknown): SyncDescriptorV1 { + return SyncDescriptorV1Schema.parse(value); +} +export function parseSyncRequest(value: unknown): SyncRequestV1 { + return SyncRequestV1Schema.parse(value); +} + +/** Page shape is provider input; this parser needs the reviewed descriptor and + * exact host-created starting request. Neither can be nominated by the page. */ +export function parseSyncPage( + descriptorValue: unknown, + requestValue: unknown, + pageValue: unknown, +): SyncPageV1 { + const descriptor = parseSyncDescriptor(descriptorValue); + const request = parseSyncRequest(requestValue); + const page = SyncPageV1Schema.parse(pageValue); + if (page.upserts.length + page.tombstones.length > request.max_items) { + throw new TypeError('Sync page exceeds the requested item limit'); + } + if (page.has_more && (page.next_cursor === null || page.next_cursor === request.cursor)) { + throw new TypeError('Sync page must advance its cursor when more pages remain'); + } + for (const row of page.upserts) { + row.data = parseRuntimeObjectInput(descriptor.record_schema, row.data); + if (Object.values(row.data).some((value) => typeof value === 'string' && !validUnicode(value))) { + throw new TypeError('Sync record contains malformed Unicode'); + } + const label = row.data[descriptor.label_field]; + if (typeof label !== 'string' || !validUnicode(label) + || label.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().length === 0) { + throw new TypeError('Sync record label normalizes to empty'); + } + } + if (utf8Bytes(canonicalSyncPageJson(page)) > RESOURCE_SYNC_LIMITS.page_bytes) { + throw new TypeError('Sync page exceeds 512 KiB'); + } + return page; +} diff --git a/packages/app-kit/test/resource-sync.test.ts b/packages/app-kit/test/resource-sync.test.ts new file mode 100644 index 00000000..fc194a4f --- /dev/null +++ b/packages/app-kit/test/resource-sync.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + RESOURCE_SYNC_LIMITS, + canonicalSyncPageJson, + parseSyncDescriptor, + parseSyncPage, + parseSyncRequest, +} from '../src/resource-sync.js'; + +const descriptor = { + schema_version: 'deft.app_sync_descriptor.v1', + key: 'mailbox', runtime_requirement_key: 'mail_runtime', + resource_type: 'email_message', requested_visibility: 'user_private', + record_schema: { type: 'object', properties: { + subject: { type: 'string', maxLength: 200 }, + body: { type: 'string', maxLength: 16_384 }, + owner_id: { type: 'string', maxLength: 120 }, + url: { type: 'string', maxLength: 120 }, + }, required: ['subject'], additionalProperties: false }, + label_field: 'subject', +} as const; +const request = { schema_version: 'deft.app_sync_request.v1', cursor: 'cursor-1', max_items: 100 } as const; +const page = { schema_version: 'deft.app_sync_page.v1', + upserts: [{ id: 'message-1', revision: 'rev:1', + data: { subject: 'Hello', body: 'Private body', owner_id: 'inert-business-value', + url: 'https://provider.example.invalid/item' } }], + tombstones: [{ id: 'message-2', revision: 'rev:2' }], + next_cursor: 'cursor-2', has_more: true } as const; + +test('golden v1 page preserves only declared scalar data and stable canonical bytes', () => { + assert.deepEqual(parseSyncDescriptor(descriptor).record_schema.required, ['subject']); + assert.equal(parseSyncRequest(request).cursor, 'cursor-1'); + const parsed = parseSyncPage(descriptor, request, page); + assert.deepEqual(parsed, page); + assert.equal(canonicalSyncPageJson(parsed), + '{"has_more":true,"next_cursor":"cursor-2","schema_version":"deft.app_sync_page.v1",' + + '"tombstones":[{"id":"message-2","revision":"rev:2"}],' + + '"upserts":[{"data":{"body":"Private body","owner_id":"inert-business-value",' + + '"subject":"Hello","url":"https://provider.example.invalid/item"},' + + '"id":"message-1","revision":"rev:1"}]}'); + assert.equal(canonicalSyncPageJson({ ...parsed, upserts: [{ ...parsed.upserts[0]!, + data: { url: parsed.upserts[0]!.data.url!, subject: 'Hello', + owner_id: 'inert-business-value', body: 'Private body' } }] }), canonicalSyncPageJson(parsed)); +}); + +test('descriptor and request admit no authority, URL, owner, or executable envelope fields', () => { + for (const extra of [{ org_id: 'other' }, { owner_id: 'other' }, { url: 'https://x' }, + { acl: ['anyone'] }, { endpoint: 'https://x' }, { filter: { any: true } }]) { + assert.throws(() => parseSyncDescriptor({ ...descriptor, ...extra })); + assert.throws(() => parseSyncRequest({ ...request, ...extra })); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, ...extra })); + } + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ ...page.upserts[0], owner_id: 'other' }] })); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + tombstones: [{ ...page.tombstones[0], url: '/private' }] })); + assert.equal(parseSyncPage(descriptor, request, page).upserts[0]!.data.url, + 'https://provider.example.invalid/item'); +}); + +test('descriptor requires a declared, required, bounded string label and closed scalar fields', () => { + for (const changed of [ + { ...descriptor, label_field: 'body' }, + { ...descriptor, label_field: 'missing' }, + { ...descriptor, record_schema: { ...descriptor.record_schema, + properties: { ...descriptor.record_schema.properties, + subject: { type: 'string', maxLength: 201 } } } }, + { ...descriptor, record_schema: { ...descriptor.record_schema, + properties: { ...descriptor.record_schema.properties, + subject: { type: 'boolean' } } } }, + { ...descriptor, record_schema: { ...descriptor.record_schema, + additionalProperties: true } }, + { ...descriptor, record_schema: { ...descriptor.record_schema, + properties: { subject: { type: 'string', maxLength: 200 }, + ...Object.fromEntries(Array.from({ length: 32 }, (_, i) => [`f${i}`, { type: 'boolean' }])) } } }, + ]) assert.throws(() => parseSyncDescriptor(changed)); + const exact32 = { ...descriptor, record_schema: { ...descriptor.record_schema, + properties: { subject: { type: 'string' as const, maxLength: 200 }, + ...Object.fromEntries(Array.from({ length: 31 }, (_, i) => [`f${i}`, { type: 'boolean' }])) } } }; + assert.doesNotThrow(() => parseSyncDescriptor(exact32)); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', data: { subject: 'Hello', extra: true } }] })); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', data: { subject: 4 } }] })); + for (const subject of ['', ' \t ', '\u0000\n']) { + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', data: { subject } }] })); + } + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', data: { subject: 'x'.repeat(201) } }] })); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', + data: { subject: 'Valid label', body: '\ud800' } }] }), /malformed Unicode/); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + upserts: [{ id: 'message-1', revision: 'rev:1', + data: { subject: 'Valid\ud800 label' } }] }), /malformed Unicode/); +}); + +test('cursor progression and UTF-8 byte bounds are exact', () => { + assert.equal(parseSyncRequest({ ...request, cursor: '🙂'.repeat(512) }).cursor, + '🙂'.repeat(512)); + assert.throws(() => parseSyncRequest({ ...request, cursor: '🙂'.repeat(513) })); + assert.throws(() => parseSyncRequest({ ...request, cursor: 'x'.repeat(1_000_000) })); + assert.throws(() => parseSyncRequest({ ...request, cursor: '\ud800' })); + assert.throws(() => parseSyncRequest({ ...request, cursor: 'x\n' })); + assert.equal(parseSyncPage(descriptor, request, + { ...page, next_cursor: '🙂'.repeat(512) }).next_cursor, '🙂'.repeat(512)); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, next_cursor: '🙂'.repeat(513) })); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, next_cursor: 'cursor-1' })); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, next_cursor: null })); + assert.doesNotThrow(() => parseSyncPage(descriptor, request, + { ...page, upserts: [], tombstones: [], next_cursor: 'cursor-2' })); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, upserts: [], tombstones: [], next_cursor: 'cursor-1' })); + assert.doesNotThrow(() => parseSyncPage(descriptor, request, + { ...page, has_more: false, next_cursor: null })); +}); + +test('item count, duplicate IDs, exact resource identities and version fields are closed', () => { + const tombstones = Array.from({ length: 100 }, (_, i) => ({ id: `item-${i}`, revision: 'r:1' })); + assert.equal(parseSyncPage(descriptor, request, + { ...page, upserts: [], tombstones }).tombstones.length, 100); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, tombstones })); + assert.throws(() => parseSyncPage(descriptor, { ...request, max_items: 1 }, page)); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, tombstones: [{ id: 'message-1', revision: 'r:2' }] })); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, tombstones: [page.tombstones[0], page.tombstones[0]] })); + for (const badId of ['', ' spaced ', 'x'.repeat(257), 'not/a/path', 'x\n']) { + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + tombstones: [{ id: badId, revision: 'r:2' }] })); + } + assert.doesNotThrow(() => parseSyncPage(descriptor, request, { ...page, + tombstones: [{ id: 'x'.repeat(256), revision: 'r'.repeat(128) }] })); + assert.throws(() => parseSyncPage(descriptor, request, { ...page, + tombstones: [{ id: 'x', revision: 'r'.repeat(129) }] })); + assert.throws(() => parseSyncDescriptor({ ...descriptor, resource_type: 'x'.repeat(65) })); + assert.throws(() => parseSyncDescriptor({ ...descriptor, resource_type: 'Email/URL' })); + assert.throws(() => parseSyncRequest({ ...request, schema_version: 'deft.app_runtime_channel.v1' })); + assert.throws(() => parseSyncPage(descriptor, request, + { ...page, schema_version: 'deft.app_runtime_channel.v1' })); + assert.throws(() => canonicalSyncPageJson({ ...page, owner_id: 'not a page field' })); +}); + +test('canonical full-page 512 KiB ceiling accepts the boundary and rejects one byte more', () => { + const large = { ...page, upserts: Array.from({ length: 100 }, (_, i) => ({ + id: `item-${i}`, revision: 'r:1', data: { subject: 'Label', body: 'x'.repeat(4_000) }, + })), tombstones: [] }; + const deficit = RESOURCE_SYNC_LIMITS.page_bytes - Buffer.byteLength(canonicalSyncPageJson(large)); + assert.ok(deficit > 0 && deficit < 100 * 16_384); + const perItem = Math.floor(deficit / 100); + const remainder = deficit % 100; + for (const [index, row] of large.upserts.entries()) { + row.data.body += 'x'.repeat(perItem + (index === 0 ? remainder : 0)); + } + assert.equal(Buffer.byteLength(canonicalSyncPageJson(large)), RESOURCE_SYNC_LIMITS.page_bytes); + assert.doesNotThrow(() => parseSyncPage(descriptor, request, large)); + large.upserts[0]!.data.body += 'x'; + assert.equal(Buffer.byteLength(canonicalSyncPageJson(large)), RESOURCE_SYNC_LIMITS.page_bytes + 1); + assert.throws(() => parseSyncPage(descriptor, request, large), /512 KiB/); +}); From 186e2c9a637b1de8b05da18e2d0111f8c2950c67 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:48:24 +0530 Subject: [PATCH 007/161] Add experimental sync transport and encrypted owner primitives --- .../api/src/lib/app-resource-sync-contract.ts | 47 +++ apps/api/src/lib/app-resource-sync-secrets.ts | 118 +++++++ .../test/app-resource-sync-contract.test.ts | 89 ++++++ .../test/app-resource-sync-secrets.test.ts | 101 ++++++ packages/app-kit/package.json | 4 + .../app-kit/src/experimental/resource-sync.ts | 3 + packages/app-kit/src/resource-sync-client.ts | 297 ++++++++++++++++++ packages/app-kit/src/runtime-client.ts | 3 +- .../app-kit/test/resource-sync-client.test.ts | 199 ++++++++++++ .../app-kit/test/resource-sync-packed.test.ts | 77 +++++ packages/app-kit/test/runtime-client.test.ts | 18 ++ scripts/gate-g/required-tests.json | 13 + 12 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/app-resource-sync-contract.ts create mode 100644 apps/api/src/lib/app-resource-sync-secrets.ts create mode 100644 apps/api/test/app-resource-sync-contract.test.ts create mode 100644 apps/api/test/app-resource-sync-secrets.test.ts create mode 100644 packages/app-kit/src/experimental/resource-sync.ts create mode 100644 packages/app-kit/src/resource-sync-client.ts create mode 100644 packages/app-kit/test/resource-sync-client.test.ts create mode 100644 packages/app-kit/test/resource-sync-packed.test.ts diff --git a/apps/api/src/lib/app-resource-sync-contract.ts b/apps/api/src/lib/app-resource-sync-contract.ts new file mode 100644 index 00000000..59c4389b --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-contract.ts @@ -0,0 +1,47 @@ +import { + APP_RESOURCE_SYNC_AUDIENCE, + APP_RESOURCE_SYNC_CHANNEL_VERSION, + ResourceSyncClaimRequestSchema, + ResourceSyncStartRequestSchema, + ResourceSyncHeartbeatRequestSchema, + ResourceSyncResultRequestSchema, + parseSyncDescriptor, + parseSyncPage, + parseSyncRequest, + type SyncDescriptorV1, + type SyncRequestV1, +} from '@deft/app-kit/experimental/resource-sync'; +import { + APP_RUN_CONTRACT_VERSIONS, AppRunRetainedProviderResultSchema, + assertAppRunOutputWithinBudget, +} from '@deft/shared'; + +/** Candidate v2 only. The route/issuer must establish the sync session and + * immutable reviewed binding before using these parsers; none issue authority. */ +export { APP_RESOURCE_SYNC_AUDIENCE, APP_RESOURCE_SYNC_CHANNEL_VERSION }; +export const AppResourceSyncClaimRequestSchema = ResourceSyncClaimRequestSchema; +export const AppResourceSyncStartRequestSchema = ResourceSyncStartRequestSchema; +export const AppResourceSyncHeartbeatRequestSchema = ResourceSyncHeartbeatRequestSchema; +export const AppResourceSyncResultRequestSchema = ResourceSyncResultRequestSchema; + +export type ResourceSyncReviewedResultPin = Readonly<{ + descriptor: SyncDescriptorV1; + starting_request: SyncRequestV1; +}>; + +/** A callback cannot nominate the descriptor, cursor, owner, or binding. The + * caller supplies host-loaded, reviewed pins from the locked Run/checkpoint. */ +export function parseAppResourceSyncResult(value: unknown, pin: ResourceSyncReviewedResultPin) { + const result = ResourceSyncResultRequestSchema.parse(value); + if (result.status !== 'returned' || !result.provider_succeeded) return result; + const descriptor = parseSyncDescriptor(pin.descriptor); + const startingRequest = parseSyncRequest(pin.starting_request); + const page = parseSyncPage(descriptor, startingRequest, result.page); + const retained = AppRunRetainedProviderResultSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.provider_result, + provider_succeeded: true, + output: page, + }); + assertAppRunOutputWithinBudget(retained); + return { ...result, page }; +} diff --git a/apps/api/src/lib/app-resource-sync-secrets.ts b/apps/api/src/lib/app-resource-sync-secrets.ts new file mode 100644 index 00000000..efcf89b4 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-secrets.ts @@ -0,0 +1,118 @@ +import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto'; +import { z } from 'zod'; +import { assertCapabilityJsonWithinBudget, canonicalCapabilityJson, type CapabilityJsonValue } from '@deft/shared'; +import { type AppRunKeyProvider, AppRunKeyVersionUnavailableError } from './app-run-keyrings.js'; +import { AppRunSecretEnvelopeSchema, type AppRunSecretEnvelope } from './app-run-secrets.js'; + +// Candidate owner primitive; no channel or store is activated by this module. +// Callers derive every context field from locked host rows, never provider data. +const identity = z.string().uuid(); +const sequence = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const base = { org_id: identity, resource_binding_id: identity, checkpoint_id: identity }; +const locatorContext = z.strictObject(base); +const secretContext = z.discriminatedUnion('payload_kind', [ + z.strictObject({ ...base, payload_kind: z.literal('cursor'), + generation: sequence.refine((value) => value > 0), cursor_sequence: sequence }), + z.strictObject({ ...base, payload_kind: z.literal('projection'), + generation: sequence.refine((value) => value > 0), projection_id: identity, + slot: z.enum(['provider_id', 'record']) }), +]); +export type AppResourceSyncSecretContext = z.infer; +export type AppResourceSyncLocatorContext = z.infer; +export type AppResourceSyncFingerprint = Readonly<{ key_version: string; fingerprint: string }>; +const providerId = z.string().min(1).max(256).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u); +const cursor = z.string().min(1).max(2_048).refine((value) => + !/[\u0000-\u001f\u007f]/u.test(value) && Buffer.byteLength(value, 'utf8') <= 2_048 + && Buffer.from(value, 'utf8').toString('utf8') === value).nullable(); + +function aad(context: AppResourceSyncSecretContext): Buffer { + return Buffer.from(canonicalCapabilityJson(['deft.resource_sync.secret_aad.v1', context])); +} +function validatePayload(value: unknown, context: AppResourceSyncSecretContext): asserts value is CapabilityJsonValue { + if (context.payload_kind === 'cursor') cursor.parse(value); + if (context.payload_kind === 'projection' && context.slot === 'provider_id') providerId.parse(value); + // Cursor JSON escapes can exceed its raw UTF-8 length; both ceilings apply. + assertCapabilityJsonWithinBudget(value, context.payload_kind === 'cursor' ? 16_384 : 524_288); +} + +/** Reuses the retained keyring, with domains disjoint from Run secrets and + * fingerprints. Database owners must include these references in key-retirement + * checks and hold their checkpoint lock while locating, applying or rekeying. */ +export class AppResourceSyncSecretService { + constructor(private readonly keys: AppRunKeyProvider) {} + + sealJson(value: unknown, rawContext: AppResourceSyncSecretContext): AppRunSecretEnvelope { + const context = secretContext.parse(rawContext); + validatePayload(value, context); + const plaintext = Buffer.from(canonicalCapabilityJson(value)); + const key = this.keys.current('run_encryption'); + const nonce = randomBytes(12); + try { + const cipher = createCipheriv('aes-256-gcm', key.key, nonce); + cipher.setAAD(aad(context)); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + try { + return Object.freeze(AppRunSecretEnvelopeSchema.parse({ + schema_version: 'deft.secret.v1', algorithm: 'aes-256-gcm', key_version: key.key_id, + nonce_b64: nonce.toString('base64'), ciphertext_b64: ciphertext.toString('base64'), + auth_tag_b64: cipher.getAuthTag().toString('base64'), + })); + } finally { ciphertext.fill(0); } + } finally { plaintext.fill(0); key.key.fill(0); nonce.fill(0); } + } + + openJson(value: unknown, rawContext: AppResourceSyncSecretContext): CapabilityJsonValue { + const context = secretContext.parse(rawContext); + const envelope = AppRunSecretEnvelopeSchema.parse(value); + const key = this.keys.read('run_encryption', envelope.key_version); + if (!key) throw new AppRunKeyVersionUnavailableError(); + const ciphertext = Buffer.from(envelope.ciphertext_b64, 'base64'); + let plaintext: Buffer | undefined; + let partial: Buffer | undefined; + try { + const decipher = createDecipheriv('aes-256-gcm', key.key, + Buffer.from(envelope.nonce_b64, 'base64')); + decipher.setAAD(aad(context)); + decipher.setAuthTag(Buffer.from(envelope.auth_tag_b64, 'base64')); + partial = decipher.update(ciphertext); + plaintext = Buffer.concat([partial, decipher.final()]); + const parsed: unknown = JSON.parse(plaintext.toString('utf8')); + validatePayload(parsed, context); + return parsed; + } finally { key.key.fill(0); ciphertext.fill(0); plaintext?.fill(0); partial?.fill(0); } + } + + locator(providerResourceId: string, rawContext: AppResourceSyncLocatorContext): AppResourceSyncFingerprint { + return this.fingerprint('locator', [locatorContext.parse(rawContext), providerId.parse(providerResourceId)]); + } + + /** Pass all distinct locator key versions currently retained by this checkpoint. + * Missing keys deny lookup even when no candidate matched, preventing a new + * UUID from silently duplicating a row written under a lost key. */ + locatorCandidates(providerResourceId: string, rawContext: AppResourceSyncLocatorContext, + requiredKeyVersions: readonly string[]): readonly AppResourceSyncFingerprint[] { + const value = [locatorContext.parse(rawContext), providerId.parse(providerResourceId)]; + const keyIds = this.keys.keyIds('fingerprint'); + if (requiredKeyVersions.some((id) => !keyIds.includes(id))) throw new AppRunKeyVersionUnavailableError(); + return Object.freeze(keyIds.map((id) => this.fingerprint('locator', value, id))); + } + + cursorFingerprint(value: string | null, + rawContext: Extract, + keyVersion?: string): AppResourceSyncFingerprint { + const context = secretContext.parse(rawContext); + if (context.payload_kind !== 'cursor') throw new TypeError('Cursor context required'); + return this.fingerprint('cursor', [context, cursor.parse(value)], keyVersion); + } + + private fingerprint(purpose: 'locator' | 'cursor', value: CapabilityJsonValue, + keyVersion?: string): AppResourceSyncFingerprint { + const key = keyVersion === undefined ? this.keys.current('fingerprint') : this.keys.read('fingerprint', keyVersion); + if (!key) throw new AppRunKeyVersionUnavailableError(); + try { + const fingerprint = createHmac('sha256', key.key) + .update(`deft.resource_sync.${purpose}.v1\0`).update(canonicalCapabilityJson(value)).digest('hex'); + return Object.freeze({ key_version: key.key_id, fingerprint: `hmac-sha256:${fingerprint}` }); + } finally { key.key.fill(0); } + } +} diff --git a/apps/api/test/app-resource-sync-contract.test.ts b/apps/api/test/app-resource-sync-contract.test.ts new file mode 100644 index 00000000..d1c2756a --- /dev/null +++ b/apps/api/test/app-resource-sync-contract.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + APP_RESOURCE_SYNC_AUDIENCE, APP_RESOURCE_SYNC_CHANNEL_VERSION, + ResourceSyncClaimRequestSchema, ResourceSyncResultRequestSchema, + parseSyncPage, +} from '@deft/app-kit/experimental/resource-sync'; +import { + AppResourceSyncClaimRequestSchema, AppResourceSyncResultRequestSchema, + parseAppResourceSyncResult, +} from '../src/lib/app-resource-sync-contract.js'; + +const v2 = { schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE }; +const session = { session_id: '00000000-0000-4000-8000-000000000001', + session_token: 's'.repeat(43) }; +const attempt = { ...v2, ...session, + run_id: '00000000-0000-4000-8000-000000000002', + attempt_id: '00000000-0000-4000-8000-000000000003', + claim_token: '00000000-0000-4000-8000-000000000004', sequence: 1 }; +const descriptor = { schema_version: 'deft.app_sync_descriptor.v1' as const, + key: 'inbox', runtime_requirement_key: 'mail', resource_type: 'email_message', + requested_visibility: 'user_private' as const, + record_schema: { type: 'object' as const, properties: { + subject: { type: 'string' as const, maxLength: 200 }, + body: { type: 'string' as const, maxLength: 16_384 }, + }, required: ['subject'], additionalProperties: false as const }, + label_field: 'subject' }; +const starting_request = { schema_version: 'deft.app_sync_request.v1' as const, + cursor: 'start', max_items: 2 }; +const page = { schema_version: 'deft.app_sync_page.v1' as const, + upserts: [{ id: 'msg-1', revision: 'r1', data: { subject: 'Hello', body: 'private' } }], + tombstones: [], next_cursor: 'next', has_more: true }; +const pin = { descriptor, starting_request }; + +test('API and Kit use the same closed v2 claim and result request schemas', () => { + const claim = { ...v2, ...session, max_claims: 1 }; + assert.deepEqual(AppResourceSyncClaimRequestSchema.parse(claim), + ResourceSyncClaimRequestSchema.parse(claim)); + const result = { ...attempt, status: 'returned', provider_succeeded: true, page }; + assert.deepEqual(AppResourceSyncResultRequestSchema.parse(result), + ResourceSyncResultRequestSchema.parse(result)); + for (const changed of [ + { ...result, schema_version: 'deft.app_runtime_channel.v1' }, + { ...result, audience: 'app_runtime' }, + { ...result, resource_binding_id: '00000000-0000-4000-8000-000000000005' }, + { ...result, descriptor }, + { ...result, starting_request }, + { ...result, page: { ...page, owner_id: 'attacker' } }, + ]) { + assert.equal(AppResourceSyncResultRequestSchema.safeParse(changed).success, + ResourceSyncResultRequestSchema.safeParse(changed).success); + assert.equal(AppResourceSyncResultRequestSchema.safeParse(changed).success, false); + } +}); + +test('API success parses page against host-reviewed descriptor and starting request', () => { + const result = { ...attempt, status: 'returned', provider_succeeded: true, page }; + assert.deepEqual(parseAppResourceSyncResult(result, pin), result); + assert.deepEqual(parseSyncPage(descriptor, starting_request, page), page); + assert.throws(() => parseAppResourceSyncResult(result, { + ...pin, starting_request: { ...starting_request, cursor: 'next' }, + })); + assert.throws(() => parseAppResourceSyncResult(result, { + ...pin, descriptor: { ...descriptor, + record_schema: { ...descriptor.record_schema, + properties: { subject: { type: 'string' as const, maxLength: 200 } } } }, + })); + assert.throws(() => parseAppResourceSyncResult({ ...result, + page: { ...page, upserts: [{ id: 'msg-1', revision: 'r1', + data: { subject: 'Hello', unknown: true } }] } }, pin)); +}); + +test('closed failure and indeterminate outcomes never carry a page', () => { + for (const result of [ + { ...attempt, status: 'returned', provider_succeeded: false, + error_code: 'APP_RUN_PROVIDER_ERROR' }, + { ...attempt, status: 'not_attempted', error_code: 'APP_RUN_PROVIDER_TIMEOUT' }, + { ...attempt, status: 'indeterminate' }, + ]) assert.deepEqual(parseAppResourceSyncResult(result, pin), result); + for (const result of [ + { ...attempt, status: 'returned', provider_succeeded: false, + error_code: 'APP_RUN_PROVIDER_ERROR', page }, + { ...attempt, status: 'not_attempted', error_code: 'APP_RUN_PROVIDER_TIMEOUT', page }, + { ...attempt, status: 'indeterminate', page }, + { ...attempt, status: 'returned', provider_succeeded: true, + page: { ...page, next_cursor: 'start' } }, + ]) assert.throws(() => parseAppResourceSyncResult(result, pin)); +}); diff --git a/apps/api/test/app-resource-sync-secrets.test.ts b/apps/api/test/app-resource-sync-secrets.test.ts new file mode 100644 index 00000000..91329131 --- /dev/null +++ b/apps/api/test/app-resource-sync-secrets.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { parseEnvironmentAppRunKeyrings, AppRunKeyVersionUnavailableError } from '../src/lib/app-run-keyrings.js'; +import { AppRunSecretService } from '../src/lib/app-run-secrets.js'; +import { AppResourceSyncSecretService } from '../src/lib/app-resource-sync-secrets.js'; + +const key = (byte: number) => Buffer.alloc(32, byte).toString('base64'); +function keyring(rotated = false, retainOld = true) { + return parseEnvironmentAppRunKeyrings(JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: rotated ? 'e2' : 'e1', keys: { + ...(retainOld ? { e1: key(1) } : {}), ...(rotated ? { e2: key(4) } : {}), + } }, + receipt_signing: { current: 's1', keys: { s1: key(2) } }, + fingerprint: { current: rotated ? 'f2' : 'f1', keys: { + ...(retainOld ? { f1: key(3) } : {}), ...(rotated ? { f2: key(5) } : {}), + } }, + })); +} +const base = { org_id: randomUUID(), resource_binding_id: randomUUID(), checkpoint_id: randomUUID() }; +const projection = { ...base, payload_kind: 'projection' as const, generation: 1, + projection_id: randomUUID(), slot: 'record' as const }; +const cursorContext = { ...base, payload_kind: 'cursor' as const, generation: 1, cursor_sequence: 0 }; + +test('sync encrypted records reject tenant, binding, checkpoint, generation and record substitution', (t) => { + const keys = keyring(); t.after(() => keys.destroy()); + const service = new AppResourceSyncSecretService(keys); + const value = { provider_id: 'mail-1', revision: 'r1', data: { subject: 'Private subject', body: 'Private body' } }; + const envelope = service.sealJson(value, projection); + assert.deepEqual(service.openJson(envelope, projection), value); + assert.notEqual(service.sealJson(value, projection).nonce_b64, envelope.nonce_b64); + assert.ok(!JSON.stringify(envelope).includes('Private')); + for (const field of ['org_id', 'resource_binding_id', 'checkpoint_id', 'projection_id'] as const) { + assert.throws(() => service.openJson(envelope, { ...projection, [field]: randomUUID() })); + } + assert.throws(() => service.openJson(envelope, { ...projection, generation: 2 })); + assert.throws(() => service.openJson(envelope, { ...projection, slot: 'provider_id' })); + const idContext = { ...projection, slot: 'provider_id' as const }; + const encryptedId = service.sealJson('mail-1', idContext); + assert.equal(service.openJson(encryptedId, idContext), 'mail-1'); + assert.throws(() => service.openJson(encryptedId, projection)); + assert.throws(() => service.sealJson({ provider_id: 'mail-1' }, idContext)); + assert.throws(() => service.openJson(envelope, cursorContext)); + const changed = Buffer.from(envelope.ciphertext_b64, 'base64'); changed[0] = changed[0]! ^ 1; + assert.throws(() => service.openJson({ ...envelope, ciphertext_b64: changed.toString('base64') }, projection)); + assert.throws(() => service.openJson({ ...envelope, owner_id: randomUUID() }, projection)); +}); + +test('sync cursor authentication includes sequence and enforces raw UTF-8 and JSON ceilings', (t) => { + const keys = keyring(); t.after(() => keys.destroy()); + const service = new AppResourceSyncSecretService(keys); + for (const value of [null, '🙂'.repeat(512), '"'.repeat(2048)]) { + const envelope = service.sealJson(value, cursorContext); + assert.equal(service.openJson(envelope, cursorContext), value); + assert.throws(() => service.openJson(envelope, { ...cursorContext, cursor_sequence: 1 })); + } + for (const value of ['', '🙂'.repeat(513), '\ud800', 'line\n', { cursor: 'nested' }]) { + assert.throws(() => service.sealJson(value, cursorContext)); + } + assert.doesNotThrow(() => service.sealJson('x'.repeat(524286), projection)); + assert.throws(() => service.sealJson('x'.repeat(524287), projection)); +}); + +test('sync secrets and fingerprints are domain separated from existing Run data', (t) => { + const keys = keyring(); t.after(() => keys.destroy()); + const service = new AppResourceSyncSecretService(keys); + const runs = new AppRunSecretService(keys); + const runContext = { org_id: base.org_id, run_id: base.checkpoint_id, payload_kind: 'input' as const }; + assert.throws(() => runs.openJson(service.sealJson('cursor-1', cursorContext), runContext)); + assert.throws(() => service.openJson(runs.sealJson('cursor-1', runContext), cursorContext)); + const locator = service.locator('mail-1', base); + assert.notEqual(locator.fingerprint, runs.fingerprintJson('input', [base, 'mail-1']).fingerprint); + assert.notEqual(locator.fingerprint, service.cursorFingerprint('mail-1', cursorContext).fingerprint); + assert.notEqual(locator.fingerprint, service.locator('mail-1', { ...base, org_id: randomUUID() }).fingerprint); + assert.notEqual(locator.fingerprint, service.locator('mail-1', { ...base, resource_binding_id: randomUUID() }).fingerprint); + assert.notEqual(locator.fingerprint, service.locator('mail-1', { ...base, checkpoint_id: randomUUID() }).fingerprint); + assert.notEqual(service.cursorFingerprint(null, cursorContext).fingerprint, + service.cursorFingerprint(null, { ...cursorContext, cursor_sequence: 1 }).fingerprint); +}); + +test('sync retained-key rotation preserves locator discovery and refuses missing prior key versions', (t) => { + const oldKeys = keyring(); const newKeys = keyring(true); const retiredKeys = keyring(true, false); + t.after(() => { oldKeys.destroy(); newKeys.destroy(); retiredKeys.destroy(); }); + const oldService = new AppResourceSyncSecretService(oldKeys); + const next = new AppResourceSyncSecretService(newKeys); + const retired = new AppResourceSyncSecretService(retiredKeys); + const oldLocator = oldService.locator('mail-1', base); + const candidates = next.locatorCandidates('mail-1', base, [oldLocator.key_version]); + assert.equal(candidates.length, 2); + assert.deepEqual(candidates.find((item) => item.key_version === 'f1'), oldLocator); + assert.deepEqual(candidates.find((item) => item.key_version === 'f2'), next.locator('mail-1', base)); + assert.notEqual(next.locator('mail-1', base).fingerprint, next.locator('mail-2', base).fingerprint); + assert.deepEqual(next.cursorFingerprint('cursor-1', cursorContext, 'f1'), + oldService.cursorFingerprint('cursor-1', cursorContext)); + const sealed = oldService.sealJson({ data: 'original' }, projection); + assert.deepEqual(next.openJson(sealed, projection), { data: 'original' }); + assert.throws(() => retired.openJson(sealed, projection), AppRunKeyVersionUnavailableError); + assert.throws(() => retired.locatorCandidates('mail-1', base, ['f1']), AppRunKeyVersionUnavailableError); + assert.throws(() => next.locatorCandidates('mail-1', base, ['unknown']), AppRunKeyVersionUnavailableError); + assert.throws(() => retired.cursorFingerprint(null, cursorContext, 'f1'), AppRunKeyVersionUnavailableError); +}); diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 37004c4a..f6280db4 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -15,6 +15,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./experimental/resource-sync": { + "types": "./dist/experimental/resource-sync.d.ts", + "import": "./dist/experimental/resource-sync.js" } }, "bin": { diff --git a/packages/app-kit/src/experimental/resource-sync.ts b/packages/app-kit/src/experimental/resource-sync.ts new file mode 100644 index 00000000..90de09c0 --- /dev/null +++ b/packages/app-kit/src/experimental/resource-sync.ts @@ -0,0 +1,3 @@ +/** Candidate only: no v1 action or main App Kit manifest activation. */ +export * from '../resource-sync.js'; +export * from '../resource-sync-client.js'; diff --git a/packages/app-kit/src/resource-sync-client.ts b/packages/app-kit/src/resource-sync-client.ts new file mode 100644 index 00000000..aec86555 --- /dev/null +++ b/packages/app-kit/src/resource-sync-client.ts @@ -0,0 +1,297 @@ +import { z } from 'zod'; +import { + parseSyncPage, SyncDescriptorV1Schema, SyncPageV1Schema, SyncRequestV1Schema, + type SyncDescriptorV1, type SyncPageV1, type SyncRequestV1, +} from './resource-sync.js'; + +/** Candidate sync-only transport. V1 action sessions and SDK remain unchanged. */ +export const APP_RESOURCE_SYNC_CHANNEL_VERSION = 'deft.app_runtime_channel.v2' as const; +export const APP_RESOURCE_SYNC_AUDIENCE = 'app_resource_sync' as const; +const version = z.literal(APP_RESOURCE_SYNC_CHANNEL_VERSION); +const audience = z.literal(APP_RESOURCE_SYNC_AUDIENCE); +const uuid = z.uuid(); +const epoch = z.number().int().min(0).max(2_147_483_647); +const sequence = z.number().int().positive().max(2_147_483_647); +const token = z.string().min(32).max(512).regex(/^[A-Za-z0-9_-]+$/u); +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const instant = z.iso.datetime({ offset: true }); + +export const ResourceSyncSessionCredentialSchema = z.strictObject({ + session_id: uuid, session_token: token, +}); +export type ResourceSyncSessionCredential = z.infer; + +const requestBase = { schema_version: version, audience, session_id: uuid, session_token: token }; +const attemptRequest = { ...requestBase, run_id: uuid, attempt_id: uuid, + claim_token: uuid, sequence }; +export const ResourceSyncClaimRequestSchema = z.strictObject({ ...requestBase, max_claims: z.literal(1) }); +export const ResourceSyncStartRequestSchema = z.strictObject(attemptRequest); +export const ResourceSyncHeartbeatRequestSchema = z.strictObject(attemptRequest); + +const successOutcome = z.strictObject({ status: z.literal('returned'), + provider_succeeded: z.literal(true), page: SyncPageV1Schema }); +const failedOutcome = z.strictObject({ status: z.literal('returned'), + provider_succeeded: z.literal(false), error_code: z.literal('APP_RUN_PROVIDER_ERROR') }); +const unavailableOutcome = z.strictObject({ status: z.literal('not_attempted'), + error_code: z.enum(['APP_RUN_PROVIDER_UNAVAILABLE', 'APP_RUN_PROVIDER_TIMEOUT']) }); +const indeterminateOutcome = z.strictObject({ status: z.literal('indeterminate') }); +export const ResourceSyncOutcomeSchema = z.union([ + successOutcome, failedOutcome, unavailableOutcome, indeterminateOutcome, +]); +export const ResourceSyncResultRequestSchema = z.union([ + successOutcome.extend(attemptRequest), failedOutcome.extend(attemptRequest), + unavailableOutcome.extend(attemptRequest), indeterminateOutcome.extend(attemptRequest), +]); +export type ResourceSyncResultRequest = z.infer; + +export const ResourceSyncClaimSchema = z.strictObject({ + schema_version: version, audience, work_kind: z.literal('sync_page'), + org_id: uuid, app_installation_id: uuid, app_version_id: uuid, + grant_snapshot_id: uuid, lifecycle_epoch: epoch, grant_epoch: epoch, + runtime_registration_id: uuid, resource_binding_id: uuid, runtime_epoch: epoch, + session_id: uuid, session_epoch: epoch, + run_id: uuid, attempt_id: uuid, attempt_number: z.number().int().positive(), + claim_token: uuid, sequence, lease_expires_at: instant, + descriptor_digest: digest, +}); +export type ResourceSyncClaim = z.infer; +export const ResourceSyncClaimReplySchema = z.strictObject({ + schema_version: version, audience, claim: ResourceSyncClaimSchema.nullable(), +}); + +export const ResourceSyncStartSchema = z.strictObject({ + schema_version: version, audience, work_kind: z.literal('sync_page'), + resource_binding_id: uuid, run_id: uuid, attempt_id: uuid, sequence, + lease_expires_at: instant, descriptor_digest: digest, + descriptor: SyncDescriptorV1Schema, input: SyncRequestV1Schema, +}); +export type ResourceSyncStart = z.infer; +export const ResourceSyncStartReplySchema = z.strictObject({ + schema_version: version, audience, started: ResourceSyncStartSchema, +}); +export const ResourceSyncHeartbeatReplySchema = z.strictObject({ + schema_version: version, audience, work_kind: z.literal('sync_page'), + run_id: uuid, attempt_id: uuid, sequence, renewed: z.literal(true), + lease_expires_at: instant, +}); +export const ResourceSyncResultReplySchema = z.strictObject({ + schema_version: version, audience, work_kind: z.literal('sync_page'), + run_id: uuid, attempt_id: uuid, sequence, accepted: z.literal(true), +}); + +export const ResourceSyncErrorSchema = z.strictObject({ + code: z.enum([ + 'APP_RESOURCE_SYNC_DISABLED', 'APP_RESOURCE_SYNC_ACCESS_DENIED', + 'APP_RESOURCE_SYNC_INVALID_REQUEST', 'APP_RESOURCE_SYNC_TOO_LARGE', + 'APP_RESOURCE_SYNC_TIMEOUT', 'APP_RESOURCE_SYNC_FAILURE', + ]), + error: z.enum([ + 'Resource sync channel unavailable', 'Resource sync credential required', + 'Invalid resource sync request', 'Resource sync request too large', + 'Resource sync request timed out', 'Resource sync request failed', + ]), +}); + +/** Matches the host's sorted-key SHA-256 descriptor digest. It confers no + * authority; the host must compare the stored reviewed descriptor independently. */ +export async function digestResourceSyncDescriptor(value: unknown): Promise<`sha256:${string}`> { + const descriptor = SyncDescriptorV1Schema.parse(value); + const canonical = (item: unknown): string => { + if (item === null || typeof item !== 'object') return JSON.stringify(item); + if (Array.isArray(item)) return `[${item.map(canonical).join(',')}]`; + return `{${Object.keys(item).sort().map((key) => + `${JSON.stringify(key)}:${canonical((item as Record)[key])}`).join(',')}}`; + }; + const hash = await globalThis.crypto.subtle.digest('SHA-256', + new TextEncoder().encode(canonical(descriptor))); + return `sha256:${Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')}`; +} + +export async function parseResourceSyncStartForClaim( + claimValue: unknown, startedValue: unknown, +): Promise { + const claim = ResourceSyncClaimSchema.parse(claimValue); + const started = ResourceSyncStartSchema.parse(startedValue); + if (started.resource_binding_id !== claim.resource_binding_id + || started.run_id !== claim.run_id || started.attempt_id !== claim.attempt_id + || started.sequence !== claim.sequence + || started.descriptor_digest !== claim.descriptor_digest + || await digestResourceSyncDescriptor(started.descriptor) !== claim.descriptor_digest) { + throw new TypeError('Resource sync start does not match the reviewed claim'); + } + return started; +} + +export type ResourceSyncOutcome = z.infer; +export type ResourceSyncClientOptions = Readonly<{ + channel_url: string; + credential: ResourceSyncSessionCredential; + fetch?: typeof globalThis.fetch; + timeout_ms?: number; +}>; +export type ResourceSyncCallOptions = Readonly<{ signal?: AbortSignal }>; + +const MAX_TRANSPORT_BYTES = 1_100_000; +export class ResourceSyncClientError extends Error { + constructor(readonly status: number, readonly code: string) { + super(`Resource sync channel request failed: ${code}`); + this.name = 'ResourceSyncClientError'; + } +} + +export function createResourceSyncClient(options: ResourceSyncClientOptions) { + const url = new URL(options.channel_url); + if (url.username || url.password || url.search || url.hash + || (url.protocol !== 'https:' && !(url.protocol === 'http:' + && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) { + throw new TypeError('Resource sync channel URL must be HTTPS or loopback HTTP without credentials'); + } + const credential = ResourceSyncSessionCredentialSchema.parse(options.credential); + const fetcher = options.fetch ?? globalThis.fetch; + const timeout = options.timeout_ms ?? 15_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60_000) { + throw new TypeError('Invalid resource sync request deadline'); + } + + async function post(operation: 'claim' | 'start' | 'heartbeat' | 'result', + fields: Record, callOptions?: ResourceSyncCallOptions): Promise { + if (callOptions?.signal?.aborted) { + throw new ResourceSyncClientError(0, 'APP_RESOURCE_SYNC_ABORTED'); + } + const body = JSON.stringify({ ...fields, + schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE, + session_id: credential.session_id }); + if (new TextEncoder().encode(body).byteLength > MAX_TRANSPORT_BYTES) { + throw new ResourceSyncClientError(0, 'APP_RESOURCE_SYNC_TOO_LARGE'); + } + const controller = new AbortController(); + const signal = callOptions?.signal; + let timer: ReturnType | undefined; + let abortHandler: (() => void) | undefined; + const boundary = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new ResourceSyncClientError(0, 'APP_RESOURCE_SYNC_TIMEOUT')); + }, timeout); + abortHandler = () => { + controller.abort(); + reject(new ResourceSyncClientError(0, 'APP_RESOURCE_SYNC_ABORTED')); + }; + if (signal?.aborted) abortHandler(); + else signal?.addEventListener('abort', abortHandler, { once: true }); + }); + const bounded = (promise: Promise): Promise => Promise.race([promise, boundary]); + let reader: ReadableStreamDefaultReader | undefined; + try { + const destination = new URL(url); + destination.pathname = `${url.pathname.replace(/\/$/u, '')}/${operation}`; + const response = await bounded(fetcher(destination, { + method: 'POST', credentials: 'omit', redirect: 'error', signal: controller.signal, + headers: { authorization: `AppRuntime ${credential.session_token}`, + 'content-type': 'application/json' }, body, + })); + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (response.redirected || contentType !== 'application/json') { + throw new ResourceSyncClientError(response.status, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + } + const declared = response.headers.get('content-length'); + if (declared !== null && (!/^\d+$/u.test(declared) + || Number(declared) > MAX_TRANSPORT_BYTES)) { + throw new ResourceSyncClientError(response.status, 'APP_RESOURCE_SYNC_RESPONSE_TOO_LARGE'); + } + reader = response.body?.getReader(); + if (!reader) throw new ResourceSyncClientError(response.status, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const next = await bounded(reader.read()); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_TRANSPORT_BYTES) { + throw new ResourceSyncClientError(response.status, 'APP_RESOURCE_SYNC_RESPONSE_TOO_LARGE'); + } + chunks.push(next.value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + let payload: unknown; + try { payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); } + catch { throw new ResourceSyncClientError(response.status, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); } + if (!response.ok) { + const failure = ResourceSyncErrorSchema.safeParse(payload); + throw new ResourceSyncClientError(response.status, + failure.success ? failure.data.code : 'APP_RESOURCE_SYNC_FAILURE'); + } + return payload; + } finally { + if (timer) clearTimeout(timer); + if (signal && abortHandler) signal.removeEventListener('abort', abortHandler); + if (reader) void reader.cancel().catch(() => {}); + controller.abort(); + } + } + + function fieldsForClaim(value: unknown): Record { + const claim = ResourceSyncClaimSchema.parse(value); + if (claim.session_id !== credential.session_id) throw new TypeError('Resource sync session mismatch'); + return { run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + } + + return Object.freeze({ + async claim(callOptions?: ResourceSyncCallOptions): Promise { + const payload = ResourceSyncClaimReplySchema.safeParse( + await post('claim', { max_claims: 1 }, callOptions)); + if (!payload.success) throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + if (payload.data.claim && payload.data.claim.session_id !== credential.session_id) { + throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + } + return payload.data.claim; + }, + async start(claimValue: ResourceSyncClaim, + callOptions?: ResourceSyncCallOptions): Promise { + const fields = fieldsForClaim(claimValue); + const payload = ResourceSyncStartReplySchema.safeParse(await post('start', fields, callOptions)); + if (!payload.success) throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + try { return await parseResourceSyncStartForClaim(claimValue, payload.data.started); } + catch { throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); } + }, + async heartbeat(claimValue: ResourceSyncClaim, + callOptions?: ResourceSyncCallOptions): Promise { + const fields = fieldsForClaim(claimValue); + const payload = ResourceSyncHeartbeatReplySchema.safeParse( + await post('heartbeat', fields, callOptions)); + if (!payload.success || payload.data.run_id !== fields.run_id + || payload.data.attempt_id !== fields.attempt_id + || payload.data.sequence !== fields.sequence) { + throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + } + return payload.data.lease_expires_at; + }, + async result(claimValue: ResourceSyncClaim, startedValue: ResourceSyncStart, + outcomeValue: ResourceSyncOutcome, + callOptions?: ResourceSyncCallOptions): Promise { + const fields = fieldsForClaim(claimValue); + const started = await parseResourceSyncStartForClaim(claimValue, startedValue); + const parsedOutcome = ResourceSyncOutcomeSchema.parse(outcomeValue); + let outcome: ResourceSyncOutcome; + if (parsedOutcome.status === 'returned' && parsedOutcome.provider_succeeded) { + outcome = { status: 'returned', provider_succeeded: true, + page: parseSyncPage(started.descriptor, started.input, parsedOutcome.page) }; + } else { + outcome = parsedOutcome; + } + const payload = ResourceSyncResultReplySchema.safeParse( + await post('result', { ...fields, ...outcome }, callOptions)); + if (!payload.success || payload.data.run_id !== fields.run_id + || payload.data.attempt_id !== fields.attempt_id + || payload.data.sequence !== fields.sequence) { + throw new ResourceSyncClientError(200, 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + } + }, + }); +} + +export type { SyncDescriptorV1, SyncPageV1, SyncRequestV1 }; diff --git a/packages/app-kit/src/runtime-client.ts b/packages/app-kit/src/runtime-client.ts index d228dc03..033be4ac 100644 --- a/packages/app-kit/src/runtime-client.ts +++ b/packages/app-kit/src/runtime-client.ts @@ -66,7 +66,8 @@ export function createAppRuntimeClient(options: AppRuntimeClientOptions) { } async function post(operation: 'claim' | 'start' | 'heartbeat' | 'result', body: Record): Promise { - const url = new URL(`${base.pathname.replace(/\/$/, '')}/${operation}`, base); + const url = new URL(base); + url.pathname = `${base.pathname.replace(/\/$/, '')}/${operation}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { diff --git a/packages/app-kit/test/resource-sync-client.test.ts b/packages/app-kit/test/resource-sync-client.test.ts new file mode 100644 index 00000000..ef71be02 --- /dev/null +++ b/packages/app-kit/test/resource-sync-client.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; +import { + APP_RESOURCE_SYNC_AUDIENCE, APP_RESOURCE_SYNC_CHANNEL_VERSION, + ResourceSyncClaimSchema, ResourceSyncClientError, + createResourceSyncClient, digestResourceSyncDescriptor, +} from '../src/resource-sync-client.js'; + +const ids = { + org: '00000000-0000-4000-8000-000000000001', + install: '00000000-0000-4000-8000-000000000002', + version: '00000000-0000-4000-8000-000000000003', + grant: '00000000-0000-4000-8000-000000000004', + registration: '00000000-0000-4000-8000-000000000005', + binding: '00000000-0000-4000-8000-000000000006', + session: '00000000-0000-4000-8000-000000000007', + run: '00000000-0000-4000-8000-000000000008', + attempt: '00000000-0000-4000-8000-000000000009', + claim: '00000000-0000-4000-8000-00000000000a', +}; +const credential = { session_id: ids.session, session_token: 's'.repeat(43) }; +const descriptor = { + schema_version: 'deft.app_sync_descriptor.v1' as const, + key: 'inbox', runtime_requirement_key: 'mail', resource_type: 'email_message', + requested_visibility: 'user_private' as const, + record_schema: { type: 'object' as const, properties: { + subject: { type: 'string' as const, maxLength: 200 }, + }, required: ['subject'], additionalProperties: false as const }, + label_field: 'subject', +}; +const input = { schema_version: 'deft.app_sync_request.v1' as const, + cursor: 'start', max_items: 100 }; +const page = { schema_version: 'deft.app_sync_page.v1' as const, + upserts: [{ id: 'item-1', revision: 'r1', data: { subject: 'Hello' } }], + tombstones: [], next_cursor: 'next', has_more: true }; +const outer = { schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE }; +const expires = '2037-01-01T00:00:00.000Z'; + +async function fixture() { + const descriptor_digest = await digestResourceSyncDescriptor(descriptor); + const claim = { ...outer, work_kind: 'sync_page' as const, + org_id: ids.org, app_installation_id: ids.install, app_version_id: ids.version, + grant_snapshot_id: ids.grant, lifecycle_epoch: 2, grant_epoch: 3, + runtime_registration_id: ids.registration, resource_binding_id: ids.binding, + runtime_epoch: 4, session_id: ids.session, session_epoch: 5, + run_id: ids.run, attempt_id: ids.attempt, attempt_number: 1, + claim_token: ids.claim, sequence: 6, lease_expires_at: expires, + descriptor_digest }; + const started = { ...outer, work_kind: 'sync_page' as const, + resource_binding_id: ids.binding, run_id: ids.run, attempt_id: ids.attempt, + sequence: 6, lease_expires_at: expires, descriptor_digest, descriptor, input }; + return { claim, started }; +} + +test('v2 SDK validates every reply and sends a sync-only bearer transcript', async () => { + const { claim, started } = await fixture(); + const calls: Array<{ path: string; body: Record; init: RequestInit }> = []; + const fetcher: typeof fetch = async (url, init) => { + const path = new URL(String(url)).pathname; + const body = JSON.parse(String(init?.body)) as Record; + calls.push({ path, body, init: init! }); + if (path.endsWith('/claim')) return Response.json({ ...outer, claim }); + if (path.endsWith('/start')) return Response.json({ ...outer, started }); + if (path.endsWith('/heartbeat')) return Response.json({ ...outer, + work_kind: 'sync_page', run_id: ids.run, attempt_id: ids.attempt, + sequence: 6, renewed: true, lease_expires_at: expires }); + return Response.json({ ...outer, work_kind: 'sync_page', run_id: ids.run, + attempt_id: ids.attempt, sequence: 6, accepted: true }); + }; + const client = createResourceSyncClient({ + channel_url: 'http://127.0.0.1:4321/api/app-runtime/channel', credential, fetch: fetcher, + }); + const claimed = await client.claim(); + assert.deepEqual(claimed, ResourceSyncClaimSchema.parse(claim)); + const opened = await client.start(claimed!); + assert.deepEqual(opened.input, input); + assert.equal(await client.heartbeat(claimed!), expires); + await client.result(claimed!, opened, { status: 'returned', provider_succeeded: true, page }); + assert.deepEqual(calls.map((item) => item.path), ['/api/app-runtime/channel/claim', + '/api/app-runtime/channel/start', '/api/app-runtime/channel/heartbeat', + '/api/app-runtime/channel/result']); + for (const { body, init } of calls) { + assert.equal(body.schema_version, APP_RESOURCE_SYNC_CHANNEL_VERSION); + assert.equal(body.audience, APP_RESOURCE_SYNC_AUDIENCE); + assert.equal(body.session_id, credential.session_id); + assert.equal(Object.hasOwn(body, 'session_token'), false); + assert.equal((init.headers as Record).authorization, + `AppRuntime ${credential.session_token}`); + assert.equal(init.credentials, 'omit'); + assert.equal(init.redirect, 'error'); + } + assert.equal((calls[3]!.body.page as typeof page).upserts[0]!.data.subject, 'Hello'); + assert.equal(Object.hasOwn(calls[3]!.body, 'descriptor'), false); + assert.equal(Object.hasOwn(calls[3]!.body, 'resource_binding_id'), false); +}); + +test('descriptor digest is canonical and start must match all claim correlation pins', async () => { + const { claim, started } = await fixture(); + const canonical = '{"key":"inbox","label_field":"subject","record_schema":' + + '{"additionalProperties":false,"properties":{"subject":{"maxLength":200,"type":"string"}},' + + '"required":["subject"],"type":"object"},"requested_visibility":"user_private",' + + '"resource_type":"email_message","runtime_requirement_key":"mail",' + + '"schema_version":"deft.app_sync_descriptor.v1"}'; + assert.equal(claim.descriptor_digest, + `sha256:${createHash('sha256').update(canonical).digest('hex')}`); + for (const changed of [ + { ...started, run_id: ids.attempt }, + { ...started, attempt_id: ids.run }, + { ...started, resource_binding_id: ids.run }, + { ...started, sequence: 7 }, + { ...started, descriptor_digest: `sha256:${'f'.repeat(64)}` }, + { ...started, descriptor: { ...descriptor, resource_type: 'email_thread' } }, + ]) { + const client = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => Response.json({ ...outer, started: changed }) }); + await assert.rejects(() => client.start(claim), (error: unknown) => + error instanceof ResourceSyncClientError && error.code === 'APP_RESOURCE_SYNC_INVALID_RESPONSE'); + } +}); + +test('SDK rejects wrong audience, malformed replies, replay substitution and invalid pages', async () => { + const { claim, started } = await fixture(); + for (const payload of [ + { ...outer, claim: { ...claim, audience: 'app_runtime' } }, + { ...outer, claim: { ...claim, resource_binding_id: 'not-a-uuid' } }, + { ...outer, claim: { ...claim, extra: true } }, + { ...outer, extra: true, claim }, + ]) { + const client = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => Response.json(payload) }); + await assert.rejects(() => client.claim(), ResourceSyncClientError); + } + const fake = (operation: string): typeof fetch => async () => Response.json(operation === 'heartbeat' + ? { ...outer, work_kind: 'sync_page', run_id: ids.run, attempt_id: ids.attempt, + sequence: 7, renewed: true, lease_expires_at: expires } + : { ...outer, work_kind: 'sync_page', run_id: ids.run, attempt_id: ids.attempt, + sequence: 7, accepted: true }); + await assert.rejects(() => createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: fake('heartbeat') }).heartbeat(claim), ResourceSyncClientError); + await assert.rejects(() => createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: fake('result') }).result(claim, started, + { status: 'returned', provider_succeeded: true, page }), ResourceSyncClientError); + const noSend: typeof fetch = async () => { throw new Error('must not send'); }; + const client = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: noSend }); + await assert.rejects(() => client.result(claim, started, { status: 'returned', + provider_succeeded: true, page: { ...page, next_cursor: 'start' } }), /advance/); + await assert.rejects(() => client.result(claim, started, + { status: 'returned', provider_succeeded: false, + error_code: 'APP_RUN_PROVIDER_ERROR', page } as never)); +}); + +test('transport guards URL, AbortSignal, deadline, response size and generic errors', async () => { + for (const channel_url of ['http://example.test/channel', + 'https://user:password@example.test/channel', 'https://example.test/channel?token=x']) { + assert.throws(() => createResourceSyncClient({ channel_url, credential })); + } + let destination = ''; + const sameOrigin = createResourceSyncClient({ + channel_url: 'https://trusted.example//other.example/path', credential, + fetch: async (url) => { + destination = String(url); + return Response.json({ ...outer, claim: null }); + }, + }); + assert.equal(await sameOrigin.claim(), null); + assert.equal(new URL(destination).origin, 'https://trusted.example'); + assert.equal(new URL(destination).pathname, '//other.example/path/claim'); + const aborted = new AbortController(); aborted.abort(); + let calls = 0; + const abortClient = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => { calls++; return Response.json({ ...outer, claim: null }); } }); + await assert.rejects(() => abortClient.claim({ signal: aborted.signal }), (error: unknown) => + error instanceof ResourceSyncClientError && error.code === 'APP_RESOURCE_SYNC_ABORTED'); + assert.equal(calls, 0); + const slow = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, timeout_ms: 10, fetch: async () => new Promise(() => {}) }); + await assert.rejects(() => slow.claim(), (error: unknown) => + error instanceof ResourceSyncClientError && error.code === 'APP_RESOURCE_SYNC_TIMEOUT'); + const oversized = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => Response.json({ ...outer, claim: null }, + { headers: { 'content-length': '1100001' } }) }); + await assert.rejects(() => oversized.claim(), (error: unknown) => + error instanceof ResourceSyncClientError && error.code === 'APP_RESOURCE_SYNC_RESPONSE_TOO_LARGE'); + const denied = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => Response.json({ code: 'APP_RESOURCE_SYNC_ACCESS_DENIED', + error: 'Resource sync credential required' }, { status: 403 }) }); + await assert.rejects(() => denied.claim(), (error: unknown) => + error instanceof ResourceSyncClientError && error.status === 403 + && error.code === 'APP_RESOURCE_SYNC_ACCESS_DENIED'); + const rawProvider = createResourceSyncClient({ channel_url: 'https://example.test/channel', + credential, fetch: async () => Response.json({ code: 'PROVIDER_SECRET', + error: 'raw provider internal message' }, { status: 500 }) }); + await assert.rejects(() => rawProvider.claim(), (error: unknown) => + error instanceof ResourceSyncClientError && error.code === 'APP_RESOURCE_SYNC_FAILURE' + && !error.message.includes('raw provider')); +}); diff --git a/packages/app-kit/test/resource-sync-packed.test.ts b/packages/app-kit/test/resource-sync-packed.test.ts new file mode 100644 index 00000000..c09cd953 --- /dev/null +++ b/packages/app-kit/test/resource-sync-packed.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const packageRoot = resolve(import.meta.dirname, '..'); +const repositoryRoot = resolve(packageRoot, '..', '..'); +function runPnpm(args: string[]) { + assert.ok(process.env.npm_execpath, 'Run packed App Kit test through pnpm'); + const result = spawnSync(process.execPath, [process.env.npm_execpath, ...args], { + cwd: repositoryRoot, encoding: 'utf8', timeout: 120_000, + }); + assert.equal(result.status, 0, + [result.error?.message, result.stdout, result.stderr].filter(Boolean).join('\n')); + return result; +} + +test('packed experimental sync subpath resolves from an offline external consumer', + { timeout: 180_000 }, async () => { + const temporaryRoot = await mkdtemp(resolve(tmpdir(), 'deft-sync-packed-')); + assert.equal(temporaryRoot.startsWith(tmpdir()), true); + try { + const artifacts = resolve(temporaryRoot, 'artifacts'); + const consumer = resolve(temporaryRoot, 'consumer'); + await mkdir(artifacts, { recursive: true }); + await mkdir(consumer, { recursive: true }); + runPnpm(['--dir', packageRoot, 'pack', '--pack-destination', artifacts, '--json']); + const archives = (await readdir(artifacts)).filter((entry) => entry.endsWith('.tgz')); + assert.equal(archives.length, 1); + await writeFile(resolve(consumer, 'package.json'), JSON.stringify({ + name: 'deft-sync-external-consumer', version: '1.0.0', private: true, + type: 'module', dependencies: { + '@deft/app-kit': `file:${resolve(artifacts, archives[0]!).replace(/\\/gu, '/')}`, + }, + }), 'utf8'); + runPnpm(['--dir', consumer, 'install', '--ignore-workspace', '--offline']); + const installed = await realpath(resolve(consumer, 'node_modules', '@deft', 'app-kit')); + assert.equal(installed.startsWith(await realpath(consumer)), true); + const metadata = JSON.parse(await readFile(resolve(installed, 'package.json'), 'utf8')) as { + exports?: Record; + }; + assert.ok(metadata.exports?.['./experimental/resource-sync']); + const script = resolve(consumer, 'smoke.mjs'); + await writeFile(script, ` +import { parseSyncDescriptor, parseSyncPage, createResourceSyncClient, + APP_RESOURCE_SYNC_CHANNEL_VERSION } from '@deft/app-kit/experimental/resource-sync'; +import { parseDeftAppManifest } from '@deft/app-kit'; +const descriptor = { schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'mail', resource_type: 'email_message', + requested_visibility: 'user_private', record_schema: { type: 'object', + properties: { subject: { type: 'string', maxLength: 200 } }, + required: ['subject'], additionalProperties: false }, label_field: 'subject' }; +const request = { schema_version: 'deft.app_sync_request.v1', cursor: null, max_items: 1 }; +const page = { schema_version: 'deft.app_sync_page.v1', upserts: [ + { id: 'item-1', revision: 'r1', data: { subject: 'Hello' } }], + tombstones: [], next_cursor: 'next', has_more: true }; +if (parseSyncDescriptor(descriptor).key !== 'inbox' + || parseSyncPage(descriptor, request, page).upserts.length !== 1 + || typeof createResourceSyncClient !== 'function' + || typeof parseDeftAppManifest !== 'function' + || APP_RESOURCE_SYNC_CHANNEL_VERSION !== 'deft.app_runtime_channel.v2') { + throw new Error('Packed sync contract missing'); +} +console.log('PACKED_SYNC_OK'); +`, 'utf8'); + const run = spawnSync(process.execPath, [script], { + cwd: consumer, encoding: 'utf8', timeout: 30_000, + }); + assert.equal(run.status, 0, + [run.error?.message, run.stdout, run.stderr].filter(Boolean).join('\n')); + assert.match(run.stdout, /PACKED_SYNC_OK/); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + }); diff --git a/packages/app-kit/test/runtime-client.test.ts b/packages/app-kit/test/runtime-client.test.ts index a99d065f..e26c4fde 100644 --- a/packages/app-kit/test/runtime-client.test.ts +++ b/packages/app-kit/test/runtime-client.test.ts @@ -9,6 +9,24 @@ const claim = { sequence: 1, operation_name: 'send_notice', lease_expires_at: new Date().toISOString(), }; +test('Runtime client preserves the configured origin when its path starts with two slashes', async () => { + let calls = 0; + const client = createAppRuntimeClient({ + channel_url: 'https://trusted.example//other.example/runtime', credential, + fetch: async (target, init) => { + calls += 1; + const url = new URL(String(target)); + assert.equal(url.origin, 'https://trusted.example'); + assert.equal(url.pathname, '//other.example/runtime/claim'); + assert.equal((init?.headers as Record).authorization, + `AppRuntime ${credential.session_token}`); + return Response.json({ claim: null }); + }, + }); + assert.equal(await client.claim(), null); + assert.equal(calls, 1); +}); + test('Runtime client scopes the bearer to the channel and never retries an effect', async () => { const calls: Array<{ path: string; body: Record; credentials: RequestCredentials | undefined }> = []; const fetcher: typeof fetch = async (url, init) => { diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 9e362887..3c902f82 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -679,6 +679,19 @@ { "file": "apps/api/test/native-directory-projections-db.test.ts", "name": "native person and team displays follow live directory and private-team visibility" }, { "file": "apps/api/test/native-resource-service-db.test.ts", "name": "native resource web resolver binds current session and owner without widening v1 or Runtime access" } ] + }, + { + "id": "resource-sync-contract-foundation", + "description": "Candidate v2 host/Kit parser agreement and encrypted sync owner primitives; no database sync, live authority, or resource execution claim.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-contract.test.ts", "name": "API and Kit use the same closed v2 claim and result request schemas" }, + { "file": "apps/api/test/app-resource-sync-contract.test.ts", "name": "API success parses page against host-reviewed descriptor and starting request" }, + { "file": "apps/api/test/app-resource-sync-contract.test.ts", "name": "closed failure and indeterminate outcomes never carry a page" }, + { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync encrypted records reject tenant, binding, checkpoint, generation and record substitution" }, + { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync cursor authentication includes sequence and enforces raw UTF-8 and JSON ceilings" }, + { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync secrets and fingerprints are domain separated from existing Run data" }, + { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync retained-key rotation preserves locator discovery and refuses missing prior key versions" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From a65452d0d2ac8ef535a0762ef170df86f010eeef Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:09:44 +0530 Subject: [PATCH 008/161] Make automation operator state reflect current authority --- .../lib/app-automation-management-service.ts | 249 ++++++++++- ...-automation-operator-acceptance-db.test.ts | 394 ++++++++++++++++++ .../apps/app-automation-management.tsx | 2 + .../apps/connected-app-management.tsx | 2 +- apps/web/src/lib/app-automations.test.ts | 7 +- apps/web/src/lib/app-automations.ts | 4 +- scripts/gate-g/required-tests.json | 9 + 7 files changed, 644 insertions(+), 23 deletions(-) create mode 100644 apps/api/test/app-automation-operator-acceptance-db.test.ts diff --git a/apps/api/src/lib/app-automation-management-service.ts b/apps/api/src/lib/app-automation-management-service.ts index 5a62da8f..d86d0876 100644 --- a/apps/api/src/lib/app-automation-management-service.ts +++ b/apps/api/src/lib/app-automation-management-service.ts @@ -1,6 +1,7 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { AppDigestSchema } from '@deft/app-kit'; -import { appAutomationFires, appRuns, moduleRecords } from '@deft/db/schema'; +import { appActionBindings, appAutomationFires, appGrantSnapshots, appInstallations, appRuns, appVersions, capabilityProviderSnapshots, mcpConnections, mcpToolOverrides, moduleInstallations, moduleRecords, orgMembers, resourceRelationEdges, resourceRelationSets } from '@deft/db/schema'; +import { ResourceRefV1Schema, canonicalCapabilityJson } from '@deft/shared'; import type { ModuleActor } from '@deft/shared/modules'; import { and, count, desc, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; @@ -21,6 +22,7 @@ import { db } from './db.js'; import { APP_AUTOMATIONS_ENABLED } from './env.js'; import { AppError } from './app-errors.js'; import { digestAppGrantValue } from './app-grant-service.js'; +import { isMcpToolEnabled } from './mcp-tool-identity.js'; const KeySchema = z.string().regex(/^[a-z][a-z0-9_]{0,47}$/) .refine((value) => !/^(deft|core|system)(_|$)/.test(value)); @@ -86,13 +88,236 @@ export function projectAppAutomationManagementEligibility( definition: Pick, now: Date, enabled: boolean, + currentAuthority = true, ) { if (!enabled) return { status: 'delivery_disabled' as const, reason: 'Scheduled delivery is disabled by the host kill switch.' }; if (definition.state !== 'active') return { status: definition.state, reason: `Definition is ${definition.state}.` }; if (now >= definition.valid_until) return { status: 'expired' as const, reason: 'The approved validity window ended; create a freshly reviewed definition.' }; + if (!currentAuthority) return { status: 'blocked' as const, reason: 'Pinned App authority or resources changed; create a freshly reviewed definition.' }; if (now < definition.valid_from) return { status: 'waiting' as const, reason: 'Waiting for the approved validity window to begin.' }; return { status: 'awaiting_delivery_check' as const, reason: 'Schedule time is eligible; pinned authority and resources are rechecked before delivery.' }; } + +export function nextManagedAppAutomationFire( + definition: Pick, + now: Date, + enabled: boolean, + currentAuthority: boolean, +): string | null { + if (!enabled || !currentAuthority || definition.state !== 'active' || now >= definition.valid_until) return null; + const eligibleAfter = definition.state_changed_at > definition.valid_from + ? definition.state_changed_at + : definition.valid_from; + const next = nextEligibleAppAutomationOccurrence({ + local_time: definition.local_time, + timezone: definition.timezone, + now, + eligible_after: eligibleAfter, + eligible_before: definition.valid_until, + }); + return next?.resolution.kind === 'resolved' ? next.resolution.resolved_at_utc.toISOString() : null; +} + +export function isCurrentAutomationModulePin( + definition: Pick, + side: 'placement' | 'selected', + organizationId: string, + record: Pick | undefined, + moduleInstallation: Pick | undefined, +): boolean { + const parsed = ResourceRefV1Schema.safeParse(side === 'placement' + ? definition.placement_resource_ref : definition.selected_resource_ref); + if (!parsed.success || parsed.data.provider.kind !== 'module') return false; + const ref = parsed.data; + if (!record || record.is_deleted || record.org_id !== organizationId + || !moduleInstallation || !moduleInstallation.is_enabled || moduleInstallation.is_deleted + || moduleInstallation.id !== ref.provider.provider_instance_id + || record.installation_id !== ref.provider.provider_instance_id + || record.collection_key !== ref.resource_type || record.id !== ref.resource_id) return false; + const revision = side === 'placement' ? definition.placement_resource_revision : definition.selected_resource_revision; + const digest = side === 'placement' ? definition.placement_content_digest : definition.selected_content_digest; + return String(record.revision) === revision + && `sha256:${createHash('sha256').update(canonicalCapabilityJson(record.data)).digest('hex')}` === digest; +} + +export function isCurrentAutomationConnector( + definition: Pick, + connection: Pick | undefined, + operationDisabled: boolean, +): boolean { + return Boolean(connection?.id === definition.mcp_connection_id && connection.is_active + && connection.app_run_authorization_version === definition.connector_authorization_version + && isMcpToolEnabled(connection.enabled_tools, connection.slug, definition.operation_name) + && !operationDisabled); +} + +/** A bounded, read-only known-block projection for the operator. It is not + * delivery authorization; delivery performs full locked preparation before claim. */ +async function currentAutomationAuthority( + organizationId: string, + definitions: readonly AppAutomationDefinitionRow[], +): Promise> { + const result = new Map(definitions.map((definition) => [definition.id, false])); + if (definitions.length === 0) return result; + const installationIds = [...new Set(definitions.map((row) => row.app_installation_id))]; + const versionIds = [...new Set(definitions.map((row) => row.app_version_id))]; + const grantIds = [...new Set(definitions.map((row) => row.grant_snapshot_id))]; + const bindingIds = [...new Set(definitions.map((row) => row.action_binding_id))]; + const connectionIds = [...new Set(definitions.map((row) => row.mcp_connection_id))]; + const providerIds = [...new Set(definitions.map((row) => row.provider_snapshot_id))]; + const approverIds = [...new Set(definitions.map((row) => row.approved_by_user_id))]; + const refs = new Map>(); + for (const definition of definitions) { + refs.set(`${definition.id}:placement`, ResourceRefV1Schema.safeParse(definition.placement_resource_ref)); + refs.set(`${definition.id}:selected`, ResourceRefV1Schema.safeParse(definition.selected_resource_ref)); + } + const resourceIds = [...new Set([...refs.values()].flatMap((ref) => ref.success ? [ref.data.resource_id] : []))]; + const placementIds = [...new Set(definitions.flatMap((definition) => { + const ref = refs.get(`${definition.id}:placement`); + return ref?.success ? [ref.data.resource_id] : []; + }))]; + const selectedIds = [...new Set(definitions.flatMap((definition) => { + const ref = refs.get(`${definition.id}:selected`); + return ref?.success ? [ref.data.resource_id] : []; + }))]; + const relationKeys = [...new Set(definitions.map((row) => row.selected_relation_key))]; + const operationNames = [...new Set(definitions.map((row) => row.operation_name))]; + const moduleIds = [...new Set([...refs.values()].flatMap((ref) => ref.success && ref.data.provider.kind === 'module' + ? [ref.data.provider.provider_instance_id] : []))]; + const [installations, versions, grants, bindings, connections, providers, approvers, records, relations, overrides, modules] = await Promise.all([ + db.select({ id: appInstallations.id, state: appInstallations.state, active_version_id: appInstallations.active_version_id, + active_grant_snapshot_id: appInstallations.active_grant_snapshot_id, + active_grant_snapshot_kind: appInstallations.active_grant_snapshot_kind, + lifecycle_epoch: appInstallations.lifecycle_epoch, grant_epoch: appInstallations.grant_epoch, + }).from(appInstallations).where(and(eq(appInstallations.org_id, organizationId), inArray(appInstallations.id, installationIds))), + db.select({ id: appVersions.id, installation_id: appVersions.installation_id, state: appVersions.state, + protocol_version: appVersions.protocol_version, manifest_digest: appVersions.manifest_digest, + package_digest: appVersions.package_digest, + }).from(appVersions).where(and(eq(appVersions.org_id, organizationId), inArray(appVersions.id, versionIds))), + db.select({ id: appGrantSnapshots.id, app_installation_id: appGrantSnapshots.app_installation_id, + app_version_id: appGrantSnapshots.app_version_id, snapshot_kind: appGrantSnapshots.snapshot_kind, + snapshot_digest: appGrantSnapshots.snapshot_digest, canonical_snapshot: appGrantSnapshots.canonical_snapshot, + }).from(appGrantSnapshots).where(and(eq(appGrantSnapshots.org_id, organizationId), inArray(appGrantSnapshots.id, grantIds))), + db.select({ id: appActionBindings.id, app_installation_id: appActionBindings.app_installation_id, + app_version_id: appActionBindings.app_version_id, grant_snapshot_id: appActionBindings.grant_snapshot_id, + action_key: appActionBindings.action_key, interface_identity: appActionBindings.interface_identity, + binding_digest: appActionBindings.binding_digest, canonical_binding: appActionBindings.canonical_binding, + provider_kind: appActionBindings.provider_kind, mcp_connection_id: appActionBindings.mcp_connection_id, + provider_snapshot_id: appActionBindings.provider_snapshot_id, operation_name: appActionBindings.operation_name, + operation_schema_digest: appActionBindings.operation_schema_digest, + connector_authorization_version: appActionBindings.connector_authorization_version, + }).from(appActionBindings).where(and(eq(appActionBindings.org_id, organizationId), inArray(appActionBindings.id, bindingIds))), + db.select({ id: mcpConnections.id, is_active: mcpConnections.is_active, + app_run_authorization_version: mcpConnections.app_run_authorization_version, + enabled_tools: mcpConnections.enabled_tools, slug: mcpConnections.slug, + }).from(mcpConnections).where(and(eq(mcpConnections.org_id, organizationId), inArray(mcpConnections.id, connectionIds))), + db.select({ id: capabilityProviderSnapshots.id, provider_kind: capabilityProviderSnapshots.provider_kind, + provider_instance_id: capabilityProviderSnapshots.provider_instance_id, + snapshot_digest: capabilityProviderSnapshots.snapshot_digest, + }).from(capabilityProviderSnapshots).where(and(eq(capabilityProviderSnapshots.org_id, organizationId), inArray(capabilityProviderSnapshots.id, providerIds))), + db.select({ user_id: orgMembers.user_id, is_active: orgMembers.is_active, role: orgMembers.role, + app_run_authorization_version: orgMembers.app_run_authorization_version, + }).from(orgMembers).where(and(eq(orgMembers.org_id, organizationId), inArray(orgMembers.user_id, approverIds))), + resourceIds.length ? db.select({ id: moduleRecords.id, org_id: moduleRecords.org_id, + installation_id: moduleRecords.installation_id, collection_key: moduleRecords.collection_key, + is_deleted: moduleRecords.is_deleted, revision: moduleRecords.revision, data: moduleRecords.data, + }).from(moduleRecords).where(and(eq(moduleRecords.org_id, organizationId), inArray(moduleRecords.id, resourceIds))) : Promise.resolve([]), + placementIds.length ? db.select({ set: resourceRelationSets, edge: resourceRelationEdges }).from(resourceRelationSets) + .innerJoin(resourceRelationEdges, and(eq(resourceRelationEdges.org_id, resourceRelationSets.org_id), eq(resourceRelationEdges.relation_set_id, resourceRelationSets.id))) + .where(and(eq(resourceRelationSets.org_id, organizationId), inArray(resourceRelationSets.source_resource_id, placementIds), + inArray(resourceRelationSets.relation_key, relationKeys), inArray(resourceRelationEdges.target_resource_id, selectedIds), + eq(resourceRelationEdges.is_deleted, false))) : Promise.resolve([]), + db.select({ mcp_connection_id: mcpToolOverrides.mcp_connection_id, tool_name: mcpToolOverrides.tool_name, + is_disabled: mcpToolOverrides.is_disabled, + }).from(mcpToolOverrides).where(and(eq(mcpToolOverrides.org_id, organizationId), + inArray(mcpToolOverrides.mcp_connection_id, connectionIds), inArray(mcpToolOverrides.tool_name, operationNames))), + moduleIds.length ? db.select({ id: moduleInstallations.id, is_enabled: moduleInstallations.is_enabled, + is_deleted: moduleInstallations.is_deleted, + }).from(moduleInstallations).where(and(eq(moduleInstallations.org_id, organizationId), inArray(moduleInstallations.id, moduleIds))) : Promise.resolve([]), + ]); + const byId = (rows: T[]) => new Map(rows.map((row) => [row.id, row])); + const installationById = byId(installations); + const versionById = byId(versions); + const grantById = byId(grants); + const bindingById = byId(bindings); + const connectionById = byId(connections); + const providerById = byId(providers); + const approverById = new Map(approvers.map((row) => [row.user_id, row])); + const recordById = byId(records); + const moduleById = byId(modules); + for (const definition of definitions) { + const installation = installationById.get(definition.app_installation_id); + const version = versionById.get(definition.app_version_id); + const grant = grantById.get(definition.grant_snapshot_id); + const binding = bindingById.get(definition.action_binding_id); + const connection = connectionById.get(definition.mcp_connection_id); + const provider = providerById.get(definition.provider_snapshot_id); + const approver = approverById.get(definition.approved_by_user_id); + const placement = refs.get(`${definition.id}:placement`); + const selected = refs.get(`${definition.id}:selected`); + const relationCurrent = placement?.success && selected?.success && relations.some(({ set, edge }) => + set.source_provider_kind === placement.data.provider.kind + && set.source_provider_instance_id === placement.data.provider.provider_instance_id + && set.source_resource_type === placement.data.resource_type + && set.source_resource_id === placement.data.resource_id + && set.relation_key === definition.selected_relation_key + && set.revision === definition.selected_relation_revision + && edge.target_provider_kind === selected.data.provider.kind + && edge.target_provider_instance_id === selected.data.provider.provider_instance_id + && edge.target_resource_type === selected.data.resource_type + && edge.target_resource_id === selected.data.resource_id + && !edge.is_deleted); + const current = Boolean(installation?.state === 'active' + && installation.active_version_id === definition.app_version_id + && installation.active_grant_snapshot_id === definition.grant_snapshot_id + && installation.active_grant_snapshot_kind === 'effective' + && installation.lifecycle_epoch === definition.installation_lifecycle_epoch + && installation.grant_epoch === definition.installation_grant_epoch + && version?.installation_id === definition.app_installation_id + && version.state === 'active' && version.protocol_version === '2' + && version.manifest_digest === definition.app_manifest_digest + && version.package_digest === definition.app_package_digest + && grant?.app_installation_id === definition.app_installation_id + && grant.app_version_id === definition.app_version_id + && grant.snapshot_kind === 'effective' + && grant.snapshot_digest === definition.grant_snapshot_digest + && digestAppGrantValue(grant.canonical_snapshot) === grant.snapshot_digest + && binding?.app_installation_id === definition.app_installation_id + && binding.app_version_id === definition.app_version_id + && binding.grant_snapshot_id === definition.grant_snapshot_id + && binding.action_key === definition.action_key + && binding.interface_identity === definition.interface_identity + && binding.binding_digest === definition.binding_digest + && digestAppGrantValue(binding.canonical_binding) === binding.binding_digest + && binding.provider_kind === definition.provider_kind + && binding.mcp_connection_id === definition.mcp_connection_id + && binding.provider_snapshot_id === definition.provider_snapshot_id + && binding.operation_name === definition.operation_name + && binding.operation_schema_digest === definition.operation_schema_digest + && binding.connector_authorization_version === definition.connector_authorization_version + && isCurrentAutomationConnector(definition, connection, + overrides.some((override) => override.mcp_connection_id === definition.mcp_connection_id + && override.tool_name === definition.operation_name && override.is_disabled)) + && provider?.provider_kind === definition.provider_kind + && provider.provider_instance_id === definition.mcp_connection_id + && provider.snapshot_digest === definition.provider_snapshot_digest + && approver?.is_active && (approver.role === 'owner' || approver.role === 'admin') + && approver.app_run_authorization_version === definition.approver_authorization_version + && isCurrentAutomationModulePin(definition, 'placement', organizationId, + placement?.success ? recordById.get(placement.data.resource_id) : undefined, + placement?.success ? moduleById.get(placement.data.provider.provider_instance_id) : undefined) + && isCurrentAutomationModulePin(definition, 'selected', organizationId, + selected?.success ? recordById.get(selected.data.resource_id) : undefined, + selected?.success ? moduleById.get(selected.data.provider.provider_instance_id) : undefined) + && relationCurrent); + result.set(definition.id, current); + } + return result; +} const AutomationActionInputSchema = AppBindingInvokeInputSchema.omit({ idempotency_key: true, user_inputs: true, @@ -297,6 +522,7 @@ export async function listManagedAppAutomations( const latestFires = new Map(); const fireCounts = new Map>(); const runs = new Map>(); + const currentAuthority = await currentAutomationAuthority(actor.org_id, page); if (definitionIds.length > 0) { const latestRows = await db.selectDistinctOn([appAutomationFires.definition_id]) @@ -350,28 +576,17 @@ export async function listManagedAppAutomations( const latest = latestFires.get(definition.id) ?? null; const run = latest?.app_run_id ? runs.get(latest.app_run_id) ?? null : null; const counts = fireCounts.get(definition.id) ?? {}; - const eligibleAfter = definition.state_changed_at > definition.valid_from - ? definition.state_changed_at - : definition.valid_from; - const next = definition.state === 'active' && APP_AUTOMATIONS_ENABLED - ? nextEligibleAppAutomationOccurrence({ - local_time: definition.local_time, - timezone: definition.timezone, - now, - eligible_after: eligibleAfter, - eligible_before: definition.valid_until, - }) - : null; const eligibility = projectAppAutomationManagementEligibility( definition, now, APP_AUTOMATIONS_ENABLED, + currentAuthority.get(definition.id) === true, ); + const next = nextManagedAppAutomationFire(definition, now, APP_AUTOMATIONS_ENABLED, + currentAuthority.get(definition.id) === true); return { ...projectDefinition(definition), - next_fire_at_utc: next?.resolution.kind === 'resolved' - ? next.resolution.resolved_at_utc.toISOString() - : null, + next_fire_at_utc: next, eligibility, fire_summary: { pending: counts.pending ?? 0, diff --git a/apps/api/test/app-automation-operator-acceptance-db.test.ts b/apps/api/test/app-automation-operator-acceptance-db.test.ts new file mode 100644 index 00000000..21927f15 --- /dev/null +++ b/apps/api/test/app-automation-operator-acceptance-db.test.ts @@ -0,0 +1,394 @@ +import './fixtures/app-run-enabled-env.js'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { after, test } from 'node:test'; +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import { and, eq } from 'drizzle-orm'; +import { appActionBindings, appAutomationDefinitions, appGrantSnapshots, appInstallations, appModuleBindings, appVersions, mcpConnections, moduleInstallations, moduleRecords, moduleVersions, orgMembers, orgs, users } from '@deft/db/schema'; +import { RESOURCE_CONTRACT_VERSIONS, ResourceRefV1Schema } from '@deft/shared'; +import { db, closeDb } from '../src/lib/db.js'; +import { createWebSession } from '../src/lib/web-sessions.js'; +import { authMiddleware } from '../src/middleware/auth.js'; +import { appRoutes } from '../src/routes/apps.js'; +import { appRunRoutes } from '../src/routes/app-runs.js'; +import { mcpConnectionRoutes } from '../src/routes/mcp-connections.js'; +import { expireAppAutomationDefinition } from '../src/lib/app-automation-definition-service.js'; +import { humanModuleActor } from '../src/lib/module-service.js'; +import { isCurrentAutomationConnector, isCurrentAutomationModulePin, nextManagedAppAutomationFire, projectAppAutomationManagementEligibility } from '../src/lib/app-automation-management-service.js'; +import { CapabilityService } from '../src/lib/capability-service.js'; +import { activateAppInstallation, stageAppPackage } from '../src/lib/app-service.js'; +import { activateConnectedAppInstallation, prepareConnectedAppReview } from '../src/lib/app-review-service.js'; +import { createModuleRecord, updateModuleRecord } from '../src/lib/module-service.js'; +import { replaceResourceRelation } from '../src/lib/resource-relation-service.js'; +import { createReviewedAppAutomationDefinition, prepareAppAutomationDefinitionReview } from '../src/lib/app-automation-definition-service.js'; +import { digestAppGrantValue } from '../src/lib/app-grant-service.js'; +import { buildPhase5DependencyAppPackage, buildTrackAAutomatedConnectedAppPackage } from './fixtures/phase5-connected-app-package.js'; + +const databaseUrl = process.env.DATABASE_URL ?? ''; +const safe = databaseUrl === process.env.DEFT_TEST_DATABASE_URL + && databaseUrl === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_phase5_test_c04_a01'; + +after(closeDb); + +type OperatorDefinition = { + id: string; + state: string; + eligibility: { status: string; reason: string }; + validity: { valid_from: string; valid_until: string }; + next_fire_at_utc: string | null; + latest_fire: null | { state: string; terminal_reason: string | null }; + latest_run: null | { id: string; state: string }; + retry: { eligible: boolean; reason: string }; +}; + +test('A01 controlled clock keeps a future next fire for live waiting authority and suppresses stale authority', () => { + const definition = { + state: 'active' as const, + local_time: '10:00', timezone: 'UTC', + valid_from: new Date('2030-01-02T00:00:00.000Z'), + valid_until: new Date('2030-01-04T00:00:00.000Z'), + state_changed_at: new Date('2030-01-01T00:00:00.000Z'), + }; + const now = new Date('2030-01-01T12:00:00.000Z'); + assert.equal(projectAppAutomationManagementEligibility(definition, now, true, true).status, 'waiting'); + assert.equal(projectAppAutomationManagementEligibility(definition, now, true, false).status, 'blocked'); + assert.equal(nextManagedAppAutomationFire(definition, now, true, true), '2030-01-02T10:00:00.000Z'); + assert.equal(nextManagedAppAutomationFire(definition, now, true, false), null); + assert.equal(nextManagedAppAutomationFire(definition, now, false, true), null); +}); + +/** Seed this dedicated disposable DB with the existing governed lifecycle fixture: + * `pnpm exec tsx --test --test-name-pattern='Protocol v2 review and automation lifecycle converge on one governed Run' test/apps-connected-grants-db.test.ts` + * It creates 101 approved definitions, real blocked fires, a Run and a signed receipt. */ +test('A01 operator HTTP pages 100+ governed definitions and exposes current results and receipts', + { skip: !safe }, async () => { + const [fixtureOrg] = await db.select({ id: orgs.id }).from(orgs) + .where(eq(orgs.name, 'Protocol v2 lifecycle')).limit(1); + assert.ok(fixtureOrg, 'run the named governed lifecycle fixture on this dedicated DB first'); + const [seed] = await db.select().from(appAutomationDefinitions) + .where(eq(appAutomationDefinitions.org_id, fixtureOrg.id)).limit(1); + assert.ok(seed); + const [installation] = await db.select().from(appInstallations) + .where(and(eq(appInstallations.org_id, fixtureOrg.id), + eq(appInstallations.id, seed.app_installation_id))).limit(1); + assert.equal(installation?.state, 'disabled', 'fixture ends with an App kill'); + const [owner] = await db.select({ id: users.id, email: users.email }).from(users) + .where(eq(users.id, seed.created_by_user_id)).limit(1); + assert.ok(owner); + const token = (await createWebSession({ id: owner.id, org_id: fixtureOrg.id, + email: owner.email })).accessToken; + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.route('/api/apps', appRoutes); + app.route('/api/app-runs', appRunRoutes); + const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }); + try { + if (!server.listening) await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const base = `http://127.0.0.1:${address.port}`; + const request = (path: string) => fetch(`${base}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const definitions: OperatorDefinition[] = []; + const pageSizes: number[] = []; + let cursor: string | null = null; + do { + const path = `/api/apps/${seed.app_installation_id}/automations?limit=50` + + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''); + const response = await request(path); + assert.equal(response.status, 200); + const body = await response.json() as { automations: { + definitions: OperatorDefinition[]; next_cursor: string | null; + kill_switch: { enabled: boolean }; + } }; + assert.equal(body.automations.kill_switch.enabled, true); + pageSizes.push(body.automations.definitions.length); + definitions.push(...body.automations.definitions); + cursor = body.automations.next_cursor; + } while (cursor); + assert.deepEqual(pageSizes, [50, 50, 7]); + assert.equal(definitions.length, 107); + assert.equal(new Set(definitions.map((item) => item.id)).size, 107); + assert.ok(definitions.some((item) => item.state === 'paused')); + assert.ok(definitions.every((item) => Date.parse(item.validity.valid_until) + > Date.parse(item.validity.valid_from))); + const blockedFire = definitions.find((item) => item.latest_fire?.state === 'dead_letter'); + assert.ok(blockedFire); + assert.equal(blockedFire.retry.eligible, false); + assert.match(blockedFire.retry.reason, /freshly reviewed/); + const skipped = definitions.find((item) => item.latest_fire?.terminal_reason === 'definition_ineligible'); + assert.ok(skipped, 'paused/App-disabled delivery stays visible to the operator'); + const completed = definitions.find((item) => item.latest_run?.state === 'succeeded'); + assert.ok(completed?.latest_run); + const runResponse = await request(`/api/app-runs/${completed.latest_run.id}`); + assert.equal(runResponse.status, 200); + await runResponse.json(); + const receiptResponse = await request(`/api/app-runs/${completed.latest_run.id}/receipts`); + assert.equal(receiptResponse.status, 200); + const receipt = await receiptResponse.json() as { run: { id: string }; receipts: unknown[] }; + assert.equal(receipt.run.id, completed.latest_run.id); + assert.equal(receipt.receipts.length, 1); + const invalidCursor = await request(`/api/apps/${seed.app_installation_id}/automations?cursor=bad`); + assert.equal(invalidCursor.status, 400); + await invalidCursor.json(); + + const expiredRow = await db.select().from(appAutomationDefinitions).where(and( + eq(appAutomationDefinitions.org_id, fixtureOrg.id), + eq(appAutomationDefinitions.state, 'expired'), + )).limit(1); + const paused = await db.select().from(appAutomationDefinitions).where(and( + eq(appAutomationDefinitions.org_id, fixtureOrg.id), + eq(appAutomationDefinitions.state, 'paused'), + )).limit(1); + const expirationTarget = expiredRow[0] ?? paused[0]; + assert.ok(expirationTarget); + if (!expiredRow[0]) { + await expireAppAutomationDefinition(humanModuleActor({ orgId: fixtureOrg.id, + userId: owner.id, role: 'owner', source: 'rest' }), { + definition_id: expirationTarget.id, expected_epoch: expirationTarget.definition_epoch, + }); + } + let expired: OperatorDefinition | undefined; + cursor = null; + do { + const response = await request(`/api/apps/${seed.app_installation_id}/automations?limit=50` + + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '')); + assert.equal(response.status, 200); + const body = await response.json() as { automations: { + definitions: OperatorDefinition[]; next_cursor: string | null; + } }; + expired = body.automations.definitions.find((item) => item.id === expirationTarget.id) ?? expired; + cursor = body.automations.next_cursor; + } while (cursor); + assert.equal(expired?.state, 'expired'); + assert.equal(expired.eligibility.status, 'expired'); + assert.equal(expired.next_fire_at_utc, null); + + const active = definitions.find((item) => item.state === 'active'); + assert.ok(active); + assert.equal(active.eligibility.status, 'blocked'); + assert.equal(active.next_fire_at_utc, null); + const pinned = await db.select().from(appAutomationDefinitions).where(eq(appAutomationDefinitions.id, active.id)).limit(1); + assert.ok(pinned[0]); + const placementRef = ResourceRefV1Schema.parse(pinned[0].placement_resource_ref); + assert.equal(placementRef.provider.kind, 'module'); + const [record] = await db.select().from(moduleRecords).where(eq(moduleRecords.id, placementRef.resource_id)).limit(1); + const [moduleInstallation] = await db.select().from(moduleInstallations) + .where(eq(moduleInstallations.id, placementRef.provider.provider_instance_id)).limit(1); + assert.ok(record && moduleInstallation); + const liveModule = { ...moduleInstallation, is_enabled: true, is_deleted: false }; + assert.equal(isCurrentAutomationModulePin(pinned[0], 'placement', fixtureOrg.id, record, liveModule), true); + assert.equal(isCurrentAutomationModulePin(pinned[0], 'placement', fixtureOrg.id, + { ...record, revision: record.revision + 1 }, liveModule), false); + assert.equal(isCurrentAutomationModulePin(pinned[0], 'placement', fixtureOrg.id, + record, { ...liveModule, is_enabled: false }), false); + const [connection] = await db.select().from(mcpConnections) + .where(eq(mcpConnections.id, pinned[0].mcp_connection_id)).limit(1); + assert.ok(connection); + assert.equal(isCurrentAutomationConnector(pinned[0], connection, false), true); + assert.equal(isCurrentAutomationConnector(pinned[0], { + ...connection, app_run_authorization_version: connection.app_run_authorization_version + 1, + }, false), false); + assert.equal(isCurrentAutomationConnector(pinned[0], connection, true), false); + console.log('A01_HTTP_RESULT', JSON.stringify({ pages: pageSizes, + definitions: definitions.length, blocked_fire: blockedFire.latest_fire?.state, + receipt_count: receipt.receipts.length, disabled_app_eligibility: active.eligibility.status, + disabled_app_next: active.next_fire_at_utc })); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }); + +test('A01 live reviewed definition has a next fire, then blocks changed resource and revoked connector authority', + { skip: !safe }, async () => { + const outboxRoot = await mkdtemp(resolve(tmpdir(), 'deft-a01-live-')); + const previous = { + selfHosted: process.env.DEFT_SELF_HOSTED, + unsafeStdio: process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO, + allowlist: process.env.MCP_STDIO_ALLOWED_COMMANDS, + }; + process.env.DEFT_SELF_HOSTED = 'true'; + process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; + process.env.MCP_STDIO_ALLOWED_COMMANDS = process.execPath; + try { + const orgId = randomUUID(); + const userId = randomUUID(); + const email = `a01-live-${randomUUID()}@example.test`; + await db.insert(orgs).values({ id: orgId, name: 'A01 live authority', slug: `a01-live-${randomUUID()}` }); + await db.insert(users).values({ id: userId, name: 'A01 live owner', email }); + await db.insert(orgMembers).values({ id: randomUUID(), org_id: orgId, user_id: userId, role: 'owner', is_active: true }); + const actor = humanModuleActor({ orgId, userId, role: 'owner', source: 'rest' }); + const dependency = await buildPhase5DependencyAppPackage(); + const dependencyInstallation = await stageAppPackage(actor, dependency.json); + await activateAppInstallation(actor, dependencyInstallation.id, dependencyInstallation.package_digest); + const built = await buildTrackAAutomatedConnectedAppPackage(); + const staged = await stageAppPackage(actor, built.json); + const [version] = await db.select().from(appVersions).where(eq(appVersions.id, staged.version_id)).limit(1); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(appGrantSnapshots) + .where(eq(appGrantSnapshots.id, version.requested_grant_snapshot_id)).limit(1); + assert.ok(requested); + const connectionId = randomUUID(); + const providerRoot = resolve(import.meta.dirname, '..', '..', '..', 'examples', 'app-platform-sandbox-email-provider'); + await db.insert(mcpConnections).values({ + id: connectionId, org_id: orgId, name: 'A01 synthetic mail', slug: `a01-mail-${randomUUID()}`, + server_url: null, transport: 'stdio', stdio_command: process.execPath, + stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', resolve(outboxRoot, 'effects.jsonl')], + auth_type: 'none', is_active: true, created_by: userId, + }); + const capability = new CapabilityService(); + const reviewRequest = { + app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch, + connector_selections: [{ connector_requirement_key: 'mail_provider', mcp_connection_id: connectionId }], + }; + const review = await prepareConnectedAppReview(actor, staged.id, reviewRequest, capability); + await activateConnectedAppInstallation(actor, staged.id, { + ...reviewRequest, expected_review_digest: review.review_digest, accept_host_policy: true, + }, capability); + const campaignBinding = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and(eq(appModuleBindings.org_id, orgId), eq(appModuleBindings.app_installation_id, staged.id))).limit(1); + const contactBinding = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and(eq(appModuleBindings.org_id, orgId), eq(appModuleBindings.app_installation_id, dependencyInstallation.id))).limit(1); + assert.ok(campaignBinding[0] && contactBinding[0]); + const contact = await createModuleRecord(actor, { + module_id: 'org.deft.reference.resource-contacts', collection_key: 'contacts', + data: { name: 'A01 contact', email: 'a01@example.test' }, relations: {}, + expected_manifest_digest: contactBinding[0].version.manifest_digest, + idempotency_key: `a01-contact-${randomUUID()}`, + }); + const campaign = await createModuleRecord(actor, { + module_id: 'org.deft.reference.resource-campaigns', collection_key: 'campaigns', + data: { name: 'A01 campaign', subject: 'A01 proof', body: 'One daily action.', status: 'ready' }, relations: {}, + expected_manifest_digest: campaignBinding[0].version.manifest_digest, + idempotency_key: `a01-campaign-${randomUUID()}`, + }); + assert.ok(contact.record && campaign.record); + const placementRef = { schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module' as const, provider_instance_id: campaignBinding[0].binding.module_installation_id }, + resource_type: 'campaigns', resource_id: campaign.record.id }; + const selectedRef = { schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module' as const, provider_instance_id: contactBinding[0].binding.module_installation_id }, + resource_type: 'contacts', resource_id: contact.record.id }; + await replaceResourceRelation(actor, { schema_version: RESOURCE_CONTRACT_VERSIONS.relation, + source: placementRef, relation_key: 'contacts', refs: [selectedRef], + expected_revision: 0, idempotency_key: `a01-relation-${randomUUID()}` }); + const [binding] = await db.select().from(appActionBindings).where(and( + eq(appActionBindings.org_id, orgId), eq(appActionBindings.app_installation_id, staged.id), + eq(appActionBindings.action_key, 'send_campaign_email'), + )).limit(1); + assert.ok(binding); + const clock = new Date(); + const scheduled = new Date(Math.floor(clock.getTime() / 60_000) * 60_000 + 10 * 60_000); + const definitionInput = { + app_installation_id: staged.id, app_version_id: version.id, action_binding_id: binding.id, + automation_request_key: 'daily_campaign_send', + placement: { resource_ref: placementRef, revision: String(campaign.record.revision), + content_digest: digestAppGrantValue(campaign.record.data) }, + selected: { resource_ref: selectedRef, revision: String(contact.record.revision), + content_digest: digestAppGrantValue(contact.record.data) }, + local_time: scheduled.toISOString().slice(11, 16), timezone: 'UTC', + validity_seconds: 30 * 24 * 60 * 60, max_org_runs_per_utc_day: 100, + max_pending_org_fires: 25, + } as const; + const definitionReview = await prepareAppAutomationDefinitionReview(actor, definitionInput); + const created = await createReviewedAppAutomationDefinition(actor, { + ...definitionInput, expected_review_digest: definitionReview.review_digest, + accept_code_owned_policy: true, + }, { now: () => clock }); + const campaignTwo = await createModuleRecord(actor, { + module_id: 'org.deft.reference.resource-campaigns', collection_key: 'campaigns', + data: { name: 'A01 second campaign', subject: 'Second A01 proof', body: 'Independent pin.', status: 'ready' }, + relations: {}, expected_manifest_digest: campaignBinding[0].version.manifest_digest, + idempotency_key: `a01-campaign-two-${randomUUID()}`, + }); + assert.ok(campaignTwo.record); + const placementTwo = { ...placementRef, resource_id: campaignTwo.record.id }; + await replaceResourceRelation(actor, { schema_version: RESOURCE_CONTRACT_VERSIONS.relation, + source: placementTwo, relation_key: 'contacts', refs: [selectedRef], + expected_revision: 0, idempotency_key: `a01-relation-two-${randomUUID()}` }); + const secondInput = { ...definitionInput, placement: { + resource_ref: placementTwo, revision: String(campaignTwo.record.revision), + content_digest: digestAppGrantValue(campaignTwo.record.data), + } }; + const secondReview = await prepareAppAutomationDefinitionReview(actor, secondInput); + const second = await createReviewedAppAutomationDefinition(actor, { + ...secondInput, expected_review_digest: secondReview.review_digest, + accept_code_owned_policy: true, + }, { now: () => clock }); + const token = (await createWebSession({ id: userId, org_id: orgId, email })).accessToken; + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.route('/api/apps', appRoutes); + app.route('/api/mcp-connections', mcpConnectionRoutes); + const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }); + try { + if (!server.listening) await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const url = `http://127.0.0.1:${address.port}/api/apps/${staged.id}/automations?limit=50`; + const read = async (definitionId: string) => { + const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + assert.equal(response.status, 200); + return (await response.json() as { automations: { definitions: OperatorDefinition[] } }).automations.definitions + .find((row) => row.id === definitionId); + }; + const eligible = await read(created.id); + assert.equal(eligible?.eligibility.status, 'awaiting_delivery_check'); + assert.equal(eligible.next_fire_at_utc, scheduled.toISOString()); + assert.equal((await read(second.id))?.eligibility.status, 'awaiting_delivery_check'); + const updated = await updateModuleRecord(actor, { record_id: campaign.record.id, + patch: { subject: 'Changed A01 proof' }, expected_revision: campaign.record.revision, + expected_manifest_digest: campaignBinding[0].version.manifest_digest, + idempotency_key: `a01-update-${randomUUID()}` }); + assert.ok(updated.record); + const changed = await read(created.id); + assert.equal(changed?.eligibility.status, 'blocked'); + assert.equal(changed.next_fire_at_utc, null); + assert.equal((await read(second.id))?.eligibility.status, 'awaiting_delivery_check'); + const addressUrl = `http://127.0.0.1:${address.port}`; + const revoke = await fetch(`${addressUrl}/api/mcp-connections/${connectionId}`, { + method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: false }), + }); + assert.equal(revoke.status, 200); + await revoke.json(); + const connectorRevoked = await read(second.id); + assert.equal(connectorRevoked?.eligibility.status, 'blocked'); + assert.equal(connectorRevoked.next_fire_at_utc, null); + console.log('A01_LIVE_RESULT', JSON.stringify({ eligible: eligible?.eligibility.status, + next: eligible?.next_fire_at_utc, changed_resource: changed?.eligibility.status, + revoked_connector: connectorRevoked.eligibility.status })); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + } finally { + if (previous.selfHosted === undefined) delete process.env.DEFT_SELF_HOSTED; + else process.env.DEFT_SELF_HOSTED = previous.selfHosted; + if (previous.unsafeStdio === undefined) delete process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO; + else process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = previous.unsafeStdio; + if (previous.allowlist === undefined) delete process.env.MCP_STDIO_ALLOWED_COMMANDS; + else process.env.MCP_STDIO_ALLOWED_COMMANDS = previous.allowlist; + if (resolve(outboxRoot) !== outboxRoot + || !outboxRoot.startsWith(resolve(tmpdir(), 'deft-a01-live-'))) { + throw new Error('Refusing to remove unexpected A01 temporary path'); + } + await rm(outboxRoot, { recursive: true, force: true }); + } + }); diff --git a/apps/web/src/components/apps/app-automation-management.tsx b/apps/web/src/components/apps/app-automation-management.tsx index d42bf142..1dd44b5b 100644 --- a/apps/web/src/components/apps/app-automation-management.tsx +++ b/apps/web/src/components/apps/app-automation-management.tsx @@ -56,6 +56,8 @@ function AutomationRow({ definition, runnerEnabled, busy, onTransition, onInspec
+ + diff --git a/apps/web/src/components/apps/connected-app-management.tsx b/apps/web/src/components/apps/connected-app-management.tsx index 2218d9ee..9c1e28e9 100644 --- a/apps/web/src/components/apps/connected-app-management.tsx +++ b/apps/web/src/components/apps/connected-app-management.tsx @@ -261,7 +261,7 @@ export function ConnectedAppManagement({ {health.issues.length > 0 &&
    {health.issues.map((issue) =>
  • {issue.message}
  • )}
}
} - {installedManifest?.compatibility.app_protocol === '2' && app.state === 'active' && } + {installedManifest?.compatibility.app_protocol === '2' && (app.state === 'active' || app.state === 'disabled') && }

Recent Runs

{grants.recent_runs.length === 0 ?

No App Runs yet.

:
    {grants.recent_runs.slice(0, 5).map((run) =>
  • )}
}
} diff --git a/apps/web/src/lib/app-automations.test.ts b/apps/web/src/lib/app-automations.test.ts index ee4d560b..a46f20f5 100644 --- a/apps/web/src/lib/app-automations.test.ts +++ b/apps/web/src/lib/app-automations.test.ts @@ -57,8 +57,8 @@ test('automation management normalization keeps schedule, health, and safe Run s schedule: { local_time: '09:30', timezone: 'Asia/Calcutta', catch_up_window_minutes: 15 }, validity: { valid_from: '2026-09-01T00:00:00.000Z', valid_until: '2026-10-01T00:00:00.000Z' }, budgets: { max_org_runs_per_utc_day: 100, max_pending_org_fires: 25 }, - next_fire_at_utc: '2026-09-02T04:00:00.000Z', - eligibility: { status: 'awaiting_delivery_check', reason: 'Schedule time is eligible; pinned authority and resources are rechecked before delivery.' }, + next_fire_at_utc: null, + eligibility: { status: 'blocked', reason: 'Pinned App authority or resources changed; create a freshly reviewed definition.' }, fire_summary: { pending: 0, claimed: 0, run_created: 1, skipped: 0, dead_letter: 0 }, latest_fire: { id: 'fire-1', logical_local_date: '2026-09-01', resolved_at_utc: '2026-09-01T04:00:00.000Z', @@ -71,7 +71,8 @@ test('automation management normalization keeps schedule, health, and safe Run s } }); assert.equal(management.killSwitchEnabled, true); assert.equal(management.nextCursor, 'older-page'); - assert.equal(management.definitions[0].eligibility.status, 'awaiting_delivery_check'); + assert.equal(management.definitions[0].eligibility.status, 'blocked'); + assert.equal(management.definitions[0].nextFireAtUtc, null); assert.equal(management.definitions[0].latestRun?.state, 'succeeded'); assert.equal('authorization_vector' in management.definitions[0], false); }); diff --git a/apps/web/src/lib/app-automations.ts b/apps/web/src/lib/app-automations.ts index f7a6c0c6..2e64ee8f 100644 --- a/apps/web/src/lib/app-automations.ts +++ b/apps/web/src/lib/app-automations.ts @@ -43,7 +43,7 @@ export type AppAutomationDefinition = { budgets: { maxOrgRunsPerUtcDay: number; maxPendingOrgFires: number }; nextFireAtUtc: string | null; eligibility: { - status: 'awaiting_delivery_check' | 'waiting' | 'delivery_disabled' | 'paused' | 'revoked' | 'expired'; + status: 'awaiting_delivery_check' | 'waiting' | 'delivery_disabled' | 'blocked' | 'paused' | 'revoked' | 'expired'; reason: string; }; fireSummary: { pending: number; claimed: number; runCreated: number; skipped: number; deadLetter: number }; @@ -195,7 +195,7 @@ export function normalizeAppAutomationManagement(value: unknown): AppAutomationM eligibility: { status: (() => { const status = stringValue(eligibility.status, 'App automation eligibility status'); - if (!['awaiting_delivery_check', 'waiting', 'delivery_disabled', 'paused', 'revoked', 'expired'].includes(status)) { + if (!['awaiting_delivery_check', 'waiting', 'delivery_disabled', 'blocked', 'paused', 'revoked', 'expired'].includes(status)) { throw new Error('Invalid App automation eligibility status.'); } return status as AppAutomationDefinition['eligibility']['status']; diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 3c902f82..c89182e8 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -692,6 +692,15 @@ { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync secrets and fingerprints are domain separated from existing Run data" }, { "file": "apps/api/test/app-resource-sync-secrets.test.ts", "name": "sync retained-key rotation preserves locator discovery and refuses missing prior key versions" } ] + }, + { + "id": "automation-operator-http", + "description": "A01 real owner-reviewed synthetic Apps: 107 definitions paginated with receipts, positive schedule time, and changed-resource/connector blocks. Browser evidence is recorded separately.", + "cases": [ + { "file": "apps/api/test/app-automation-operator-acceptance-db.test.ts", "name": "A01 controlled clock keeps a future next fire for live waiting authority and suppresses stale authority" }, + { "file": "apps/api/test/app-automation-operator-acceptance-db.test.ts", "name": "A01 operator HTTP pages 100+ governed definitions and exposes current results and receipts" }, + { "file": "apps/api/test/app-automation-operator-acceptance-db.test.ts", "name": "A01 live reviewed definition has a next fire, then blocks changed resource and revoked connector authority" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From 241771ba01db48004c6e90a36fd82581d3030ff2 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:22:31 +0530 Subject: [PATCH 009/161] Add dormant reviewed App resource sync schema --- .../test/app-resource-sync-schema-db.test.ts | 156 ++++ packages/db/scripts/apply-extras.ts | 1 + .../app-resource-sync-schema-fixture.ts | 174 ++++ packages/db/src/schema.ts | 415 +++++++++- .../0.3.0-preview.37-app-resource-sync.sql | 773 ++++++++++++++++++ packages/db/upgrades/manifest.ts | 5 + 6 files changed, 1515 insertions(+), 9 deletions(-) create mode 100644 apps/api/test/app-resource-sync-schema-db.test.ts create mode 100644 packages/db/scripts/fixtures/app-resource-sync-schema-fixture.ts create mode 100644 packages/db/upgrades/0.3.0-preview.37-app-resource-sync.sql diff --git a/apps/api/test/app-resource-sync-schema-db.test.ts b/apps/api/test/app-resource-sync-schema-db.test.ts new file mode 100644 index 00000000..547cfcab --- /dev/null +++ b/apps/api/test/app-resource-sync-schema-db.test.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; +import pg from 'pg'; +import { insertAppResourceSyncSchemaFixture } from '../../../packages/db/scripts/fixtures/app-resource-sync-schema-fixture.js'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const assignedDatabase = databaseUrl && databaseUrl === process.env.DATABASE_URL + && new URL(databaseUrl).hostname === '127.0.0.1' + && new URL(databaseUrl).port === '55435' + && /^\/gate_g_phase5_test_c04_s04_(?:fresh|upgrade)(?:_v\d+)?$/.test(new URL(databaseUrl).pathname); + +test('S04 v2 schema pins reviewed ancestry, intent CAS and encrypted capacity', { + skip: !assignedDatabase, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + const [{ db, closeDb }, schema, kit, appService, appReview, moduleService] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const suffix = randomUUID(); + const orgId = randomUUID(); + const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'S04 schema fixture', + slug: `s04-schema-${suffix}` }); + await db.insert(schema.users).values({ id: ownerId, + email: `s04-schema-${suffix}@example.test`, name: 'S04 owner' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), + org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }); + const actor = moduleService.humanModuleActor({ orgId, userId: ownerId, + role: 'owner', source: 'ui' }); + // The host v2 authoring/activation path is a later slice. A reviewed v3 + // App supplies real installation/version/effective-grant ancestors here; + // the fixture only probes the additive SQL shape, never live sync authority. + const pkg = (await kit.buildDeftAppPackage({ manifest: { + schema_version: '3', id: `community.example.s04.schema${suffix.replace(/-/g, '')}`, + version: '1.0.0', name: 'S04 schema', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '3' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'provider', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'deliver', version: '1', + input_schema: { type: 'object', properties: { id: { type: 'string', maxLength: 120 } }, + required: ['id'], additionalProperties: false }, + output_schema: { type: 'object', properties: { ok: { type: 'boolean' } }, + required: ['ok'], additionalProperties: false } }], + runtime_actions: [{ key: 'deliver', label: 'Deliver', capability_key: 'deliver', + runtime_requirement_key: 'provider' }], + }, artifacts: [] })).json; + const staged = await appService.stageAppPackage(actor, pkg); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const reviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch }; + const review = await appReview.prepareRuntimeAppReview(actor, staged.id, reviewInput); + const active = await appReview.activateRuntimeApp(actor, staged.id, { + ...reviewInput, expected_review_digest: review.review_digest, accept_host_policy: true }); + const ids = await insertAppResourceSyncSchemaFixture(client, { + org_id: orgId, app_installation_id: staged.id, + app_version_id: version.id, grant_snapshot_id: active.grant_snapshot_id, + owner_user_id: ownerId, + }, { with_projections: true, key_suffix: 'schema' }); + const lineage = { org_id: orgId, app_installation_id: staged.id, + app_version_id: version.id, grant_snapshot_id: active.grant_snapshot_id, + owner_user_id: ownerId }; + await assert.rejects(insertAppResourceSyncSchemaFixture(client, lineage, + { run_actor_id: randomUUID(), key_suffix: 'badactor' }), + /app_runs_origin_coherence_check|app_runs_actor_check|check constraint/i); + await assert.rejects(insertAppResourceSyncSchemaFixture(client, lineage, + { intent_descriptor_digest: 'sha256:' + '0'.repeat(64), + key_suffix: 'baddesc' }), /app_sync_intents_descriptor_fk/); + + async function rejectsSql(sql: string, values: unknown[], expected: RegExp) { + await client.query('BEGIN'); + try { + await assert.rejects(client.query(sql, values), (error: unknown) => { + assert.match(String(error), expected); + return true; + }); + } finally { await client.query('ROLLBACK'); } + } + const [checkpoint] = (await client.query(`SELECT * FROM app_sync_checkpoints + WHERE org_id=$1 AND id=$2`, [orgId, ids.checkpoint_id])).rows; + assert.equal(checkpoint.retained_record_count, 2); + assert.equal(Number(checkpoint.retained_bytes), 42, + 'live and tombstone ciphertext identity bytes count toward consent'); + assert.equal(checkpoint.cursor_sequence, 0); + await rejectsSql(`UPDATE app_resource_bindings SET owner_user_id=$3 + WHERE org_id=$1 AND id=$2`, [orgId, ids.binding_id, randomUUID()], + /APP_RESOURCE_BINDING_IMMUTABLE/); + await rejectsSql(`UPDATE app_resource_bindings SET reviewed_descriptor='{}'::jsonb + WHERE org_id=$1 AND id=$2`, [orgId, ids.binding_id], + /APP_RESOURCE_BINDING_IMMUTABLE|app_resource_bindings_descriptor_check/); + await rejectsSql(`UPDATE app_sync_checkpoints SET retained_bytes=0 + WHERE org_id=$1 AND id=$2`, [orgId, ids.checkpoint_id], + /APP_SYNC_COUNTER_DIRECT_WRITE/); + await rejectsSql(`UPDATE app_sync_checkpoints SET fresh_until=now()+interval '1 day' + WHERE org_id=$1 AND id=$2`, [orgId, ids.checkpoint_id], + /APP_SYNC_CURSOR_CAS_REQUIRED/); + await rejectsSql(`UPDATE app_sync_checkpoints SET cursor_sequence=1, + last_applied_run_id=$3,last_applied_page_digest=$4, + last_applied_at=now(),last_checked_at=now() + WHERE org_id=$1 AND id=$2`, [orgId, ids.checkpoint_id, randomUUID(), + 'sha256:' + createHash('sha256').update('wrong').digest('hex')], + /APP_SYNC_CURSOR_INTENT_MISMATCH|app_sync_checkpoints_last_intent_fk/); + await rejectsSql(`UPDATE app_resource_projections SET resource_id_hmac_key_version='forged' + WHERE org_id=$1 AND id=$2`, [orgId, ids.live_projection_id], + /APP_RESOURCE_PROJECTION_REKEY_REQUIRED/); + await rejectsSql(`UPDATE app_resource_projections SET body_bytes=0 + WHERE org_id=$1 AND id=$2`, [orgId, ids.live_projection_id], + /app_resource_projections_body_check|APP_RESOURCE_PROJECTION_REKEY_REQUIRED/); + await rejectsSql(`UPDATE app_sync_intents SET descriptor_digest=$3 + WHERE org_id=$1 AND run_id=$2`, [orgId, ids.run_id, + 'sha256:' + '0'.repeat(64)], /APP_SYNC_INTENT_APPEND_ONLY/); + await rejectsSql(`INSERT INTO app_sync_intents + (id,org_id,run_id,resource_binding_id,checkpoint_id,app_installation_id, + app_version_id,grant_snapshot_id,provider_snapshot_id,owner_user_id, + descriptor_digest,generation,expected_cursor_sequence, + expected_cursor_hmac_key_version,expected_cursor_hmac) + SELECT $3,org_id,run_id,resource_binding_id,checkpoint_id, + app_installation_id,app_version_id,grant_snapshot_id,provider_snapshot_id, + owner_user_id,$4,generation,expected_cursor_sequence, + expected_cursor_hmac_key_version,expected_cursor_hmac + FROM app_sync_intents WHERE org_id=$1 AND run_id=$2`, + [orgId, ids.run_id, randomUUID(), 'sha256:' + '0'.repeat(64)], + /app_sync_intents_descriptor_fk|app_sync_intents_org_run_unique/); + + const settled = await client.query(`UPDATE app_sync_checkpoints + SET cursor_sequence=1,last_applied_run_id=$3,last_applied_page_digest=$4, + last_applied_at=now(),last_checked_at=now(),fresh_until=now()+interval '1 hour' + WHERE org_id=$1 AND id=$2 RETURNING cursor_sequence,retained_record_count,retained_bytes`, + [orgId, ids.checkpoint_id, ids.run_id, + 'sha256:' + createHash('sha256').update('page').digest('hex')]); + assert.equal(settled.rows[0].cursor_sequence, 1); + assert.equal(settled.rows[0].retained_record_count, 2); + assert.equal(Number(settled.rows[0].retained_bytes), 42); + await rejectsSql(`UPDATE app_sync_checkpoints SET cursor_sequence=2, + last_applied_at=now(),last_checked_at=now() + WHERE org_id=$1 AND id=$2`, [orgId, ids.checkpoint_id], + /APP_SYNC_CURSOR_SETTLEMENT_INVALID|APP_SYNC_CURSOR_INTENT_MISMATCH/); + } finally { + await client.end(); + await closeDb(); + } +}); diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index d04fe303..d4b37df0 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -149,6 +149,7 @@ async function main() { '0.3.0-preview.34-app-installed-authoring.sql', '0.3.0-preview.35-app-public-runtime.sql', '0.3.0-preview.36-app-experience-sessions.sql', + '0.3.0-preview.37-app-resource-sync.sql', ]) { await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); console.log(`[apply-extras] reconciled ${platformFile}`); diff --git a/packages/db/scripts/fixtures/app-resource-sync-schema-fixture.ts b/packages/db/scripts/fixtures/app-resource-sync-schema-fixture.ts new file mode 100644 index 00000000..dfa4b814 --- /dev/null +++ b/packages/db/scripts/fixtures/app-resource-sync-schema-fixture.ts @@ -0,0 +1,174 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { Client } from 'pg'; + +/** SQL ancestry fixture only. The envelopes have valid stored shape but are + * intentionally not host-decryptable; use AppResourceSyncSecretService for + * live channel or crypto tests. The caller supplies a real installed App, + * effective grant, org and owner from its disposable fixture. */ +export async function insertAppResourceSyncSchemaFixture(client: Client, lineage: { + org_id: string; + app_installation_id: string; + app_version_id: string; + grant_snapshot_id: string; + owner_user_id: string; +}, options: { with_run_intent?: boolean; with_projections?: boolean; + projection_states?: readonly ('live' | 'tombstone')[]; + key_suffix?: string; + /** Adversarial SQL-shape tests only; the fixture transaction must reject. */ + run_actor_id?: string; intent_descriptor_digest?: string } = {}) { + const suffix = options.key_suffix ?? 'v1'; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/.test(suffix)) { + throw new TypeError('Invalid synthetic key suffix'); + } + const fingerprintKeyVersion = `fixture-fp-${suffix}`; + const encryptionKeyVersion = `fixture-enc-${suffix}`; + const ids = { + registration_id: randomUUID(), + provider_snapshot_id: randomUUID(), + binding_id: randomUUID(), + checkpoint_id: randomUUID(), + run_id: randomUUID(), + live_projection_id: randomUUID(), + tombstone_projection_id: randomUUID(), + }; + const digest = (value: string) => + 'sha256:' + createHash('sha256').update(value).digest('hex'); + const hmac = (value: string) => + 'hmac-sha256:' + createHash('sha256').update(value).digest('hex'); + const now = new Date(); + const descriptor = { + schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'provider', resource_type: 'message', + requested_visibility: 'user_private', + record_schema: { type: 'object', properties: { + label: { type: 'string', maxLength: 120 }, + }, required: ['label'], additionalProperties: false }, + label_field: 'label', + }; + const descriptorDigest = digest(JSON.stringify(descriptor)); + const ciphertext = Buffer.from('schema-fixture', 'utf8').toString('base64'); + const nonce = Buffer.alloc(12, 1).toString('base64'); + const tag = Buffer.alloc(16, 2).toString('base64'); + const cursorHmac = hmac('synthetic-null-cursor'); + await client.query('BEGIN'); + try { + await client.query(` + INSERT INTO app_runtime_registrations + (id,org_id,app_installation_id,app_version_id,grant_snapshot_id, + operator_user_id,contract_version) + VALUES ($1,$2,$3,$4,$5,$6,'deft.app_runtime_channel.v2')`, + [ids.registration_id, lineage.org_id, lineage.app_installation_id, + lineage.app_version_id, lineage.grant_snapshot_id, lineage.owner_user_id]); + await client.query(` + UPDATE app_runtime_registrations SET state='active',runtime_epoch=1, + reviewed_by_user_id=$3,reviewed_at=$4,updated_at=$4 + WHERE org_id=$1 AND id=$2`, + [lineage.org_id, ids.registration_id, lineage.owner_user_id, now]); + await client.query(` + INSERT INTO capability_provider_snapshots + (id,org_id,provider_kind,provider_instance_id,adapter_contract_version, + snapshot_digest,safe_snapshot,captured_at) + VALUES ($1,$2,'app_runtime',$3,'deft.app_runtime_channel.v2',$4,'{}'::jsonb,$5)`, + [ids.provider_snapshot_id, lineage.org_id, ids.registration_id, + digest(ids.registration_id), now]); + await client.query(` + INSERT INTO app_resource_bindings + (id,org_id,app_installation_id,app_version_id,grant_snapshot_id, + runtime_registration_id,provider_instance_id,provider_snapshot_id, + resource_key,resource_family,operation_name,interface_identity, + reviewed_descriptor,descriptor_digest,owner_user_id, + max_records_per_page,max_page_bytes,max_retained_records, + max_retained_bytes,min_interval_seconds) + VALUES ($1,$2,$3,$4,$5,$6,$6,$7,'inbox','message','sync_inbox',$8, + $9::jsonb,$10,$11,100,524288,100000,1073741824,60)`, + [ids.binding_id, lineage.org_id, lineage.app_installation_id, + lineage.app_version_id, lineage.grant_snapshot_id, ids.registration_id, + ids.provider_snapshot_id, + `deft.resource_sync.v2:${lineage.org_id.toLowerCase()}:${lineage.app_installation_id.toLowerCase()}:inbox`, + JSON.stringify(descriptor), descriptorDigest, lineage.owner_user_id]); + await client.query(` + UPDATE app_resource_bindings SET state='active',reviewed_by_user_id=$3, + reviewed_at=$4,consent_expires_at=$5,updated_at=$4 + WHERE org_id=$1 AND id=$2`, + [lineage.org_id, ids.binding_id, lineage.owner_user_id, now, + new Date(now.getTime() + 24 * 60 * 60 * 1000)]); + await client.query(` + INSERT INTO app_sync_checkpoints + (id,org_id,resource_binding_id,cursor_hmac_key_version,cursor_hmac) + VALUES ($1,$2,$3,$4,$5)`, + [ids.checkpoint_id, lineage.org_id, ids.binding_id, + fingerprintKeyVersion, cursorHmac]); + if (options.with_run_intent !== false) { + await client.query(` + INSERT INTO app_runs + (id,org_id,contract_version,origin_kind,initiating_actor_type, + initiating_actor_id,execution_actor_type,execution_actor_id, + provider_kind,provider_instance_id,operation_name,provider_snapshot_id, + origin_app_installation_id,origin_app_version_id, + origin_app_grant_snapshot_id,origin_resource_binding_id, + risk_class,review_requirement,review_scope,retry_class,retention_class, + idempotency_key_version,idempotency_fingerprint, + input_fingerprint_key_version,input_fingerprint, + authorization_snapshot,safe_preview,root_run_id, + input_expires_at,result_expires_at,idempotency_expires_at,attempt_limit) + VALUES + ($1,$2,'deft.app_run.v1','app','system',$15,'system',$15, + 'app_runtime',$4,'sync_inbox',$5,$6,$7,$8,$3, + 'internal_write','policy','reviewed_resource_sync','unsafe_or_unknown','standard', + $14,$9,$14,$10,'{}'::jsonb,'{}'::jsonb,$1, + $11,$12,$13,1)`, + [ids.run_id, lineage.org_id, ids.binding_id, ids.registration_id, + ids.provider_snapshot_id, lineage.app_installation_id, + lineage.app_version_id, lineage.grant_snapshot_id, + hmac(ids.run_id + ':idempotency'), hmac(ids.run_id + ':input'), + new Date(now.getTime() + 60_000), new Date(now.getTime() + 120_000), + new Date(now.getTime() + 180_000), fingerprintKeyVersion, + options.run_actor_id ?? ids.binding_id]); + await client.query(` + INSERT INTO app_sync_intents + (id,org_id,run_id,resource_binding_id,checkpoint_id, + app_installation_id,app_version_id,grant_snapshot_id, + provider_snapshot_id,owner_user_id,descriptor_digest, + generation,expected_cursor_sequence,expected_cursor_hmac_key_version, + expected_cursor_hmac) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,1,0,$13,$12)`, + [randomUUID(), lineage.org_id, ids.run_id, ids.binding_id, + ids.checkpoint_id, lineage.app_installation_id, lineage.app_version_id, + lineage.grant_snapshot_id, ids.provider_snapshot_id, + lineage.owner_user_id, options.intent_descriptor_digest ?? descriptorDigest, + cursorHmac, fingerprintKeyVersion]); + } + if (options.with_projections) { + const base = [lineage.org_id, ids.binding_id, ids.checkpoint_id, + fingerprintKeyVersion, encryptionKeyVersion, nonce, ciphertext, tag, 14, now]; + for (const item of [ + { id: ids.live_projection_id, locator: hmac('live'), state: 'live', + body: ['deft.secret.v1','aes-256-gcm',encryptionKeyVersion,nonce,ciphertext,tag,14], + tombstoned: null }, + { id: ids.tombstone_projection_id, locator: hmac('tombstone'), + state: 'tombstone', body: [null,null,null,null,null,null,0], tombstoned: now }, + ].filter((candidate) => (options.projection_states ?? ['live', 'tombstone']) + .includes(candidate.state as 'live' | 'tombstone'))) { + await client.query(` + INSERT INTO app_resource_projections + (id,org_id,resource_binding_id,checkpoint_id,generation, + resource_id_hmac_key_version,resource_id_hmac, + provider_id_envelope_version,provider_id_algorithm,provider_id_key_version, + provider_id_nonce_b64,provider_id_ciphertext_b64,provider_id_auth_tag_b64, + provider_id_bytes,body_envelope_version,body_algorithm,body_key_version, + body_nonce_b64,body_ciphertext_b64,body_auth_tag_b64,body_bytes, + state,applied_sequence,first_seen_at,last_seen_at,tombstoned_at) + VALUES ($1,$2,$3,$4,1,$5,$6,'deft.secret.v1','aes-256-gcm',$7, + $8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,1,$20,$20,$21)`, + [item.id, ...base.slice(0, 3), base[3], item.locator, + ...base.slice(4, 9), ...item.body, item.state, now, item.tombstoned]); + } + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + return { ...ids, descriptor, descriptor_digest: descriptorDigest, + cursor_hmac: cursorHmac }; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6e6ed138..25047881 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -690,6 +690,7 @@ export const appRuns = pgTable('app_runs', { origin_app_version_id: text('origin_app_version_id'), origin_app_binding_key: text('origin_app_binding_key'), origin_runtime_binding_id: text('origin_runtime_binding_id'), + origin_resource_binding_id: text('origin_resource_binding_id'), origin_public_endpoint_id: text('origin_public_endpoint_id'), origin_public_ingress_id: text('origin_public_ingress_id'), origin_app_grant_snapshot_id: text('origin_app_grant_snapshot_id'), @@ -711,7 +712,7 @@ export const appRuns = pgTable('app_runs', { .notNull(), review_requirement: text('review_requirement').$type<'policy' | 'always'>().notNull(), review_scope: text('review_scope') - .$type<'per_invocation' | 'immutable_batch' | 'approved_automation_definition' | 'forbidden_in_automation'>() + .$type<'per_invocation' | 'immutable_batch' | 'approved_automation_definition' | 'forbidden_in_automation' | 'reviewed_resource_sync'>() .notNull(), retry_class: text('retry_class').$type<'safe' | 'idempotent_with_key' | 'unsafe_or_unknown'>().notNull(), retention_class: text('retention_class').$type<'ephemeral' | 'standard' | 'extended'>().notNull(), @@ -746,6 +747,11 @@ export const appRuns = pgTable('app_runs', { ...timestamps(), }, (t) => [ unique('app_runs_org_id_id_unique').on(t.org_id, t.id), + unique('app_runs_resource_attempt_identity_unique').on(t.org_id, t.id, t.origin_resource_binding_id), + unique('app_runs_sync_intent_identity_unique').on(t.org_id, t.id, + t.origin_app_installation_id, t.origin_app_version_id, + t.origin_app_grant_snapshot_id, t.origin_resource_binding_id, + t.provider_snapshot_id), foreignKey({ columns: [t.org_id, t.provider_snapshot_id], foreignColumns: [capabilityProviderSnapshots.org_id, capabilityProviderSnapshots.id], @@ -825,6 +831,20 @@ export const appRuns = pgTable('app_runs', { appRuntimeBindings.retention_class], name: 'app_runs_runtime_binding_fk', }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.origin_app_installation_id, t.origin_app_version_id, + t.origin_app_grant_snapshot_id, t.origin_resource_binding_id, t.provider_kind, + t.provider_instance_id, t.operation_name, t.provider_snapshot_id, + t.risk_class, t.review_requirement, t.review_scope, t.retry_class, t.retention_class], + foreignColumns: [appResourceBindings.org_id, appResourceBindings.app_installation_id, + appResourceBindings.app_version_id, appResourceBindings.grant_snapshot_id, + appResourceBindings.id, appResourceBindings.provider_kind, + appResourceBindings.provider_instance_id, appResourceBindings.operation_name, + appResourceBindings.provider_snapshot_id, appResourceBindings.risk_class, + appResourceBindings.review_requirement, appResourceBindings.review_scope, + appResourceBindings.retry_class, appResourceBindings.retention_class], + name: 'app_runs_resource_binding_fk', + }).onDelete('restrict'), foreignKey({ columns: [t.org_id, t.origin_app_automation_definition_id], foreignColumns: [appAutomationDefinitions.org_id, appAutomationDefinitions.id], @@ -893,6 +913,7 @@ export const appRuns = pgTable('app_runs', { AND ${t.provider_kind} = 'mcp' AND ${t.origin_app_binding_key} IS NOT NULL AND ${t.origin_runtime_binding_id} IS NULL + AND ${t.origin_resource_binding_id} IS NULL AND ${t.origin_public_endpoint_id} IS NULL AND ${t.origin_public_ingress_id} IS NULL AND ${t.origin_app_grant_snapshot_id} IS NOT NULL @@ -925,6 +946,7 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_app_grant_snapshot_id} IS NOT NULL AND ${t.origin_app_binding_key} IS NULL AND ${t.origin_runtime_binding_id} IS NOT NULL + AND ${t.origin_resource_binding_id} IS NULL AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL AND ( @@ -940,6 +962,28 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_public_ingress_id} IS NULL) ) AND ${t.review_scope} = 'per_invocation' + ) OR ( + ${t.origin_kind} = 'app' + AND ${t.provider_kind} = 'app_runtime' + AND ${t.origin_app_installation_id} IS NOT NULL + AND ${t.origin_app_version_id} IS NOT NULL + AND ${t.origin_app_grant_snapshot_id} IS NOT NULL + AND ${t.origin_resource_binding_id} IS NOT NULL + AND ${t.origin_runtime_binding_id} IS NULL + AND ${t.origin_app_binding_key} IS NULL + AND ${t.origin_public_endpoint_id} IS NULL + AND ${t.origin_public_ingress_id} IS NULL + AND ${t.origin_app_automation_definition_id} IS NULL + AND ${t.origin_app_automation_fire_id} IS NULL + AND ${t.initiating_actor_type} = 'system' + AND ${t.execution_actor_type} = 'system' + AND ${t.initiating_actor_id} = ${t.origin_resource_binding_id} + AND ${t.execution_actor_id} = ${t.origin_resource_binding_id} + AND ${t.risk_class} = 'internal_write' + AND ${t.review_requirement} = 'policy' + AND ${t.review_scope} = 'reviewed_resource_sync' + AND ${t.retry_class} = 'unsafe_or_unknown' + AND ${t.retention_class} = 'standard' ) OR ( ${t.origin_kind} <> 'app' AND ${t.provider_kind} = 'mcp' @@ -947,6 +991,7 @@ export const appRuns = pgTable('app_runs', { AND ${t.origin_app_version_id} IS NULL AND ${t.origin_app_binding_key} IS NULL AND ${t.origin_runtime_binding_id} IS NULL + AND ${t.origin_resource_binding_id} IS NULL AND ${t.origin_app_grant_snapshot_id} IS NULL AND ${t.origin_app_automation_definition_id} IS NULL AND ${t.origin_app_automation_fire_id} IS NULL @@ -974,7 +1019,7 @@ export const appRuns = pgTable('app_runs', { )`), check('app_runs_risk_check', sql`${t.risk_class} IN ('read', 'internal_write', 'external_write', 'destructive', 'privileged')`), check('app_runs_review_requirement_check', sql`${t.review_requirement} IN ('policy', 'always')`), - check('app_runs_review_scope_check', sql`${t.review_scope} IN ('per_invocation', 'immutable_batch', 'approved_automation_definition', 'forbidden_in_automation')`), + check('app_runs_review_scope_check', sql`${t.review_scope} IN ('per_invocation', 'immutable_batch', 'approved_automation_definition', 'forbidden_in_automation', 'reviewed_resource_sync')`), check('app_runs_retry_class_check', sql`${t.retry_class} IN ('safe', 'idempotent_with_key', 'unsafe_or_unknown')`), check('app_runs_retention_class_check', sql`${t.retention_class} IN ('ephemeral', 'standard', 'extended')`), check('app_runs_fingerprint_check', sql` @@ -1054,6 +1099,7 @@ export const appRunAttempts = pgTable('app_run_attempts', { provider_idempotency_key_version: text('provider_idempotency_key_version'), provider_idempotency_fingerprint: text('provider_idempotency_fingerprint'), runtime_binding_id: text('runtime_binding_id'), + resource_binding_id: text('resource_binding_id'), runtime_session_id: text('runtime_session_id'), runtime_session_epoch: integer('runtime_session_epoch'), runtime_epoch: integer('runtime_epoch'), @@ -1076,6 +1122,19 @@ export const appRunAttempts = pgTable('app_run_attempts', { appRuntimeSessions.runtime_epoch], name: 'app_run_attempts_runtime_session_fk', }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.resource_binding_id, t.runtime_session_id, + t.runtime_session_epoch, t.runtime_epoch], + foreignColumns: [appRuntimeSessions.org_id, appRuntimeSessions.resource_binding_id, + appRuntimeSessions.id, appRuntimeSessions.session_epoch, + appRuntimeSessions.runtime_epoch], + name: 'app_run_attempts_resource_session_fk', + }).onDelete('restrict'), + foreignKey({ + columns: [t.org_id, t.run_id, t.resource_binding_id], + foreignColumns: [appRuns.org_id, appRuns.id, appRuns.origin_resource_binding_id], + name: 'app_run_attempts_resource_run_fk', + }).onDelete('restrict'), unique('app_run_attempts_org_run_id_unique').on(t.org_id, t.run_id, t.id), uniqueIndex('app_run_attempts_number_unique').on(t.org_id, t.run_id, t.attempt_number), uniqueIndex('app_run_attempts_one_active_unique') @@ -1108,10 +1167,17 @@ export const appRunAttempts = pgTable('app_run_attempts', { OR (jsonb_typeof(${t.safe_outcome}) = 'object' AND octet_length(${t.safe_outcome}::text) <= 32768) `), check('app_run_attempts_runtime_shape_check', sql` - (${t.runtime_binding_id} IS NULL AND ${t.runtime_session_id} IS NULL + (${t.runtime_binding_id} IS NULL AND ${t.resource_binding_id} IS NULL + AND ${t.runtime_session_id} IS NULL AND ${t.runtime_session_epoch} IS NULL AND ${t.runtime_epoch} IS NULL AND ${t.runtime_sequence} IS NULL AND ${t.runtime_result_hmac} IS NULL) - OR (${t.runtime_binding_id} IS NOT NULL AND ${t.runtime_session_id} IS NOT NULL + OR (${t.runtime_binding_id} IS NOT NULL AND ${t.resource_binding_id} IS NULL + AND ${t.runtime_session_id} IS NOT NULL + AND ${t.runtime_session_epoch} IS NOT NULL AND ${t.runtime_session_epoch} >= 0 + AND ${t.runtime_epoch} IS NOT NULL AND ${t.runtime_epoch} >= 0 + AND ${t.runtime_sequence} IS NOT NULL AND ${t.runtime_sequence} >= 1) + OR (${t.runtime_binding_id} IS NULL AND ${t.resource_binding_id} IS NOT NULL + AND ${t.runtime_session_id} IS NOT NULL AND ${t.runtime_session_epoch} IS NOT NULL AND ${t.runtime_session_epoch} >= 0 AND ${t.runtime_epoch} IS NOT NULL AND ${t.runtime_epoch} >= 0 AND ${t.runtime_sequence} IS NOT NULL AND ${t.runtime_sequence} >= 1) @@ -2142,8 +2208,11 @@ export const appRuntimeRegistrations = pgTable('app_runtime_registrations', { unique('app_runtime_registrations_org_id_id_unique').on(t.org_id, t.id), unique('app_runtime_registrations_ancestry_unique').on(t.org_id, t.app_installation_id, t.app_version_id, t.grant_snapshot_id, t.id), + unique('app_runtime_registrations_contract_ancestry_unique').on(t.org_id, + t.app_installation_id, t.app_version_id, t.grant_snapshot_id, t.id, t.contract_version), check('app_runtime_registrations_state_check', sql`${t.state} IN ('disabled','active','revoked')`), check('app_runtime_registrations_kind_check', sql`${t.grant_snapshot_kind} = 'effective'`), + check('app_runtime_registrations_contract_check', sql`${t.contract_version} IN ('deft.app_runtime_channel.v1', 'deft.app_runtime_channel.v2')`), check('app_runtime_registrations_epoch_check', sql`${t.runtime_epoch} >= 0`), check('app_runtime_registrations_review_check', sql` (${t.state} = 'disabled' AND ${t.reviewed_at} IS NULL AND ${t.reviewed_by_user_id} IS NULL) @@ -2158,6 +2227,8 @@ export const appRuntimeBindings = pgTable('app_runtime_bindings', { app_version_id: text('app_version_id').notNull(), grant_snapshot_id: text('grant_snapshot_id').notNull(), runtime_registration_id: text('runtime_registration_id').notNull(), + registration_contract_version: text('registration_contract_version') + .$type<'deft.app_runtime_channel.v1'>().default('deft.app_runtime_channel.v1').notNull(), action_key: text('action_key').notNull(), interface_identity: text('interface_identity').notNull(), provider_kind: text('provider_kind').$type<'app_runtime'>().default('app_runtime').notNull(), @@ -2174,10 +2245,11 @@ export const appRuntimeBindings = pgTable('app_runtime_bindings', { ...timestamps(), }, (t) => [ foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, - t.grant_snapshot_id, t.runtime_registration_id], + t.grant_snapshot_id, t.runtime_registration_id, t.registration_contract_version], foreignColumns: [appRuntimeRegistrations.org_id, appRuntimeRegistrations.app_installation_id, appRuntimeRegistrations.app_version_id, appRuntimeRegistrations.grant_snapshot_id, - appRuntimeRegistrations.id], name: 'app_runtime_bindings_registration_fk' }).onDelete('restrict'), + appRuntimeRegistrations.id, appRuntimeRegistrations.contract_version], + name: 'app_runtime_bindings_registration_fk' }).onDelete('restrict'), foreignKey({ columns: [t.org_id, t.provider_kind, t.provider_instance_id, t.provider_snapshot_id], foreignColumns: [capabilityProviderSnapshots.org_id, capabilityProviderSnapshots.provider_kind, capabilityProviderSnapshots.provider_instance_id, capabilityProviderSnapshots.id], @@ -2189,6 +2261,7 @@ export const appRuntimeBindings = pgTable('app_runtime_bindings', { t.risk_class, t.review_requirement, t.retry_class, t.retention_class), unique('app_runtime_bindings_registration_identity_unique').on(t.org_id, t.runtime_registration_id, t.id), check('app_runtime_bindings_kind_check', sql`${t.provider_kind} = 'app_runtime'`), + check('app_runtime_bindings_registration_contract_check', sql`${t.registration_contract_version} = 'deft.app_runtime_channel.v1'`), check('app_runtime_bindings_identity_check', sql` ${t.provider_instance_id} = ${t.runtime_registration_id} AND ${t.action_key} ~ '^[a-z][a-z0-9_]{0,47}$' @@ -2203,14 +2276,128 @@ export const appRuntimeBindings = pgTable('app_runtime_bindings', { `), ]); +// Host-reviewed resource sync authority is distinct from v1 action bindings. +// Cursor state and provider records live in separate tables below. +export const appResourceBindings = pgTable('app_resource_bindings', { + ...id(), + ...orgId(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + grant_snapshot_kind: text('grant_snapshot_kind').$type<'effective'>().default('effective').notNull(), + runtime_registration_id: text('runtime_registration_id').notNull(), + registration_contract_version: text('registration_contract_version') + .$type<'deft.app_runtime_channel.v2'>().default('deft.app_runtime_channel.v2').notNull(), + provider_kind: text('provider_kind').$type<'app_runtime'>().default('app_runtime').notNull(), + provider_instance_id: text('provider_instance_id').notNull(), + provider_snapshot_id: text('provider_snapshot_id').notNull(), + resource_key: text('resource_key').notNull(), + resource_family: text('resource_family').notNull(), + operation_name: text('operation_name').notNull(), + interface_identity: text('interface_identity').notNull(), + reviewed_descriptor: jsonb('reviewed_descriptor').$type>().notNull(), + descriptor_digest: text('descriptor_digest').notNull(), + owner_user_id: text('owner_user_id').notNull(), + owner_scope: text('owner_scope').$type<'private_user'>().default('private_user').notNull(), + risk_class: text('risk_class').$type<'internal_write'>().default('internal_write').notNull(), + review_requirement: text('review_requirement').$type<'policy'>().default('policy').notNull(), + review_scope: text('review_scope').$type<'reviewed_resource_sync'>().default('reviewed_resource_sync').notNull(), + retry_class: text('retry_class').$type<'unsafe_or_unknown'>().default('unsafe_or_unknown').notNull(), + retention_class: text('retention_class').$type<'standard'>().default('standard').notNull(), + max_records_per_page: integer('max_records_per_page').notNull(), + max_page_bytes: integer('max_page_bytes').notNull(), + max_retained_records: integer('max_retained_records').notNull(), + max_retained_bytes: integer('max_retained_bytes').notNull(), + min_interval_seconds: integer('min_interval_seconds').notNull(), + consent_expires_at: timestamp('consent_expires_at'), + state: text('state').$type<'disabled' | 'active' | 'revoked'>().default('disabled').notNull(), + reviewed_by_user_id: text('reviewed_by_user_id'), + reviewed_at: timestamp('reviewed_at'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, + t.grant_snapshot_id, t.grant_snapshot_kind], + foreignColumns: [appGrantSnapshots.org_id, appGrantSnapshots.app_installation_id, + appGrantSnapshots.app_version_id, appGrantSnapshots.id, appGrantSnapshots.snapshot_kind], + name: 'app_resource_bindings_grant_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.app_installation_id, t.app_version_id, + t.grant_snapshot_id, t.runtime_registration_id, t.registration_contract_version], + foreignColumns: [appRuntimeRegistrations.org_id, appRuntimeRegistrations.app_installation_id, + appRuntimeRegistrations.app_version_id, appRuntimeRegistrations.grant_snapshot_id, + appRuntimeRegistrations.id, appRuntimeRegistrations.contract_version], + name: 'app_resource_bindings_registration_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.provider_kind, t.provider_instance_id, t.provider_snapshot_id], + foreignColumns: [capabilityProviderSnapshots.org_id, capabilityProviderSnapshots.provider_kind, + capabilityProviderSnapshots.provider_instance_id, capabilityProviderSnapshots.id], + name: 'app_resource_bindings_provider_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.owner_user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_resource_bindings_owner_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.reviewed_by_user_id], + foreignColumns: [orgMembers.org_id, orgMembers.user_id], + name: 'app_resource_bindings_reviewer_fk' }).onDelete('restrict'), + unique('app_resource_bindings_org_id_id_unique').on(t.org_id, t.id), + unique('app_resource_bindings_registration_identity_unique').on(t.org_id, t.runtime_registration_id, t.id), + unique('app_resource_bindings_owner_identity_unique').on(t.org_id, t.id, t.owner_user_id), + unique('app_resource_bindings_descriptor_identity_unique').on(t.org_id, t.id, + t.owner_user_id, t.descriptor_digest), + unique('app_resource_bindings_run_identity_unique').on(t.org_id, t.app_installation_id, + t.app_version_id, t.grant_snapshot_id, t.id, t.provider_kind, + t.provider_instance_id, t.operation_name, t.provider_snapshot_id, + t.risk_class, t.review_requirement, t.review_scope, t.retry_class, t.retention_class), + index('app_resource_bindings_owner_idx').on(t.org_id, t.owner_user_id, t.state), + check('app_resource_bindings_identity_check', sql` + ${t.grant_snapshot_kind} = 'effective' + AND ${t.registration_contract_version} = 'deft.app_runtime_channel.v2' + AND ${t.provider_kind} = 'app_runtime' + AND ${t.provider_instance_id} = ${t.runtime_registration_id} + AND ${t.owner_scope} = 'private_user' + AND ${t.resource_key} ~ '^[a-z][a-z0-9_]{0,47}$' + AND ${t.resource_family} ~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + AND octet_length(${t.resource_family}) <= 64 + AND ${t.operation_name} = 'sync_' || ${t.resource_key} + AND ${t.interface_identity} = 'deft.resource_sync.v2:' || lower(${t.org_id}) || ':' || + lower(${t.app_installation_id}) || ':' || ${t.resource_key} + AND ${t.descriptor_digest} ~ '^sha256:[a-f0-9]{64}$' + `), + check('app_resource_bindings_descriptor_check', sql` + jsonb_typeof(${t.reviewed_descriptor}) = 'object' + AND coalesce(jsonb_typeof(${t.reviewed_descriptor}->'schema_version'), '') = 'string' + AND coalesce(${t.reviewed_descriptor}->>'schema_version', '') = 'deft.app_sync_descriptor.v1' + AND octet_length(${t.reviewed_descriptor}::text) <= 65536 + `), + check('app_resource_bindings_policy_check', sql` + ${t.risk_class} = 'internal_write' AND ${t.review_requirement} = 'policy' + AND ${t.review_scope} = 'reviewed_resource_sync' + AND ${t.retry_class} = 'unsafe_or_unknown' AND ${t.retention_class} = 'standard' + `), + check('app_resource_bindings_limits_check', sql` + ${t.max_records_per_page} BETWEEN 1 AND 100 + AND ${t.max_page_bytes} BETWEEN 1 AND 524288 + AND ${t.max_retained_records} BETWEEN 1 AND 100000 + AND ${t.max_retained_bytes} BETWEEN 1 AND 1073741824 + AND ${t.min_interval_seconds} BETWEEN 60 AND 86400 + `), + check('app_resource_bindings_review_check', sql` + (${t.state} = 'disabled' AND ${t.reviewed_by_user_id} IS NULL + AND ${t.reviewed_at} IS NULL AND ${t.consent_expires_at} IS NULL) + OR (${t.state} IN ('active','revoked') AND ${t.reviewed_by_user_id} IS NOT NULL + AND ${t.reviewed_by_user_id} = ${t.owner_user_id} + AND ${t.reviewed_at} IS NOT NULL AND ${t.consent_expires_at} IS NOT NULL + AND ${t.consent_expires_at} > ${t.reviewed_at} + AND ${t.consent_expires_at} <= ${t.reviewed_at} + interval '90 days') + `), +]); + export const appRuntimeSessions = pgTable('app_runtime_sessions', { ...id(), ...orgId(), runtime_registration_id: text('runtime_registration_id').notNull(), - runtime_binding_id: text('runtime_binding_id').notNull(), + runtime_binding_id: text('runtime_binding_id'), + resource_binding_id: text('resource_binding_id'), operator_user_id: text('operator_user_id').notNull(), token_hash: text('token_hash').notNull(), - audience: text('audience').$type<'app_runtime'>().default('app_runtime').notNull(), + audience: text('audience').$type<'app_runtime' | 'app_resource_sync'>().default('app_runtime').notNull(), session_epoch: integer('session_epoch').default(0).notNull(), runtime_epoch: integer('runtime_epoch').notNull(), lifecycle_epoch: integer('lifecycle_epoch').notNull(), @@ -2223,13 +2410,23 @@ export const appRuntimeSessions = pgTable('app_runtime_sessions', { foreignKey({ columns: [t.org_id, t.runtime_registration_id, t.runtime_binding_id], foreignColumns: [appRuntimeBindings.org_id, appRuntimeBindings.runtime_registration_id, appRuntimeBindings.id], name: 'app_runtime_sessions_binding_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.runtime_registration_id, t.resource_binding_id], + foreignColumns: [appResourceBindings.org_id, appResourceBindings.runtime_registration_id, + appResourceBindings.id], name: 'app_runtime_sessions_resource_binding_fk' }).onDelete('restrict'), foreignKey({ columns: [t.org_id, t.operator_user_id], foreignColumns: [orgMembers.org_id, orgMembers.user_id], name: 'app_runtime_sessions_operator_fk' }).onDelete('restrict'), unique('app_runtime_sessions_attempt_identity_unique').on(t.org_id, t.runtime_binding_id, t.id, t.session_epoch, t.runtime_epoch), + unique('app_runtime_sessions_resource_attempt_identity_unique').on(t.org_id, t.resource_binding_id, + t.id, t.session_epoch, t.runtime_epoch), unique('app_runtime_sessions_token_hash_unique').on(t.token_hash), - check('app_runtime_sessions_audience_check', sql`${t.audience} = 'app_runtime'`), + check('app_runtime_sessions_audience_check', sql` + (${t.audience} = 'app_runtime' AND ${t.runtime_binding_id} IS NOT NULL + AND ${t.resource_binding_id} IS NULL) + OR (${t.audience} = 'app_resource_sync' AND ${t.runtime_binding_id} IS NULL + AND ${t.resource_binding_id} IS NOT NULL) + `), check('app_runtime_sessions_epoch_check', sql`${t.session_epoch} >= 0 AND ${t.runtime_epoch} >= 0 AND ${t.lifecycle_epoch} >= 0 AND ${t.grant_epoch} >= 0 AND ${t.next_sequence} >= 1`), check('app_runtime_sessions_token_check', sql`${t.token_hash} ~ '^sha256:[a-f0-9]{64}$'`), ]); @@ -2239,6 +2436,206 @@ export const appRuntimeSessions = pgTable('app_runtime_sessions', { // Only their lifecycle state and epoch may change after creation. Fires are a // durable identity/claim ledger for the later scheduler cutover; this package // does not enqueue or execute them. +export const appSyncCheckpoints = pgTable('app_sync_checkpoints', { + ...id(), + ...orgId(), + resource_binding_id: text('resource_binding_id').notNull(), + generation: integer('generation').default(1).notNull(), + state: text('state').$type<'active' | 'paused'>().default('active').notNull(), + cursor_sequence: integer('cursor_sequence').default(0).notNull(), + cursor_hmac_key_version: text('cursor_hmac_key_version').notNull(), + cursor_hmac: text('cursor_hmac').notNull(), + cursor_state: text('cursor_state').$type<'empty' | 'value'>().default('empty').notNull(), + cursor_envelope_version: text('cursor_envelope_version'), + cursor_algorithm: text('cursor_algorithm'), + cursor_key_version: text('cursor_key_version'), + cursor_nonce_b64: text('cursor_nonce_b64'), + cursor_ciphertext_b64: text('cursor_ciphertext_b64'), + cursor_auth_tag_b64: text('cursor_auth_tag_b64'), + cursor_bytes: integer('cursor_bytes').default(0).notNull(), + retained_record_count: integer('retained_record_count').default(0).notNull(), + retained_bytes: integer('retained_bytes').default(0).notNull(), + last_applied_run_id: text('last_applied_run_id'), + last_applied_page_digest: text('last_applied_page_digest'), + last_applied_at: timestamp('last_applied_at'), + last_checked_at: timestamp('last_checked_at'), + fresh_until: timestamp('fresh_until'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.resource_binding_id], + foreignColumns: [appResourceBindings.org_id, appResourceBindings.id], + name: 'app_sync_checkpoints_binding_fk' }).onDelete('restrict'), + // The reverse last-applied-intent FK is installed by preview.37/apply-extras; + // declaring both directions here creates a Drizzle TypeScript initialization cycle. + unique('app_sync_checkpoints_org_id_id_unique').on(t.org_id, t.id), + unique('app_sync_checkpoints_binding_unique').on(t.org_id, t.resource_binding_id), + unique('app_sync_checkpoints_intent_identity_unique').on(t.org_id, t.id, t.resource_binding_id), + index('app_sync_checkpoints_freshness_idx').on(t.org_id, t.state, t.fresh_until), + check('app_sync_checkpoints_state_check', sql`${t.state} IN ('active','paused')`), + check('app_sync_checkpoints_counters_check', sql` + ${t.generation} >= 1 AND ${t.cursor_sequence} >= 0 + AND ${t.retained_record_count} BETWEEN 0 AND 100000 + AND ${t.retained_bytes} BETWEEN 0 AND 1073741824 + AND ${t.cursor_bytes} BETWEEN 0 AND 16384 + `), + check('app_sync_checkpoints_hmac_check', sql` + ${t.cursor_hmac_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.cursor_hmac} ~ '^hmac-sha256:[a-f0-9]{64}$' + `), + check('app_sync_checkpoints_cursor_envelope_check', sql` + (${t.cursor_state} = 'empty' AND ${t.cursor_bytes} = 0 + AND ${t.cursor_envelope_version} IS NULL AND ${t.cursor_algorithm} IS NULL + AND ${t.cursor_key_version} IS NULL AND ${t.cursor_nonce_b64} IS NULL + AND ${t.cursor_ciphertext_b64} IS NULL AND ${t.cursor_auth_tag_b64} IS NULL) + OR (${t.cursor_state} = 'value' AND ${t.cursor_bytes} BETWEEN 1 AND 16384 + AND ${t.cursor_envelope_version} IS NOT NULL AND ${t.cursor_algorithm} IS NOT NULL + AND ${t.cursor_key_version} IS NOT NULL AND ${t.cursor_nonce_b64} IS NOT NULL + AND ${t.cursor_ciphertext_b64} IS NOT NULL AND ${t.cursor_auth_tag_b64} IS NOT NULL + AND ${t.cursor_envelope_version} = 'deft.secret.v1' + AND ${t.cursor_algorithm} = 'aes-256-gcm' + AND ${t.cursor_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.cursor_nonce_b64} ~ '^[A-Za-z0-9+/]{16}$' + AND ${t.cursor_auth_tag_b64} ~ '^[A-Za-z0-9+/]{22}==$' + AND ${t.cursor_ciphertext_b64} ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND octet_length(decode(${t.cursor_ciphertext_b64}, 'base64')) = ${t.cursor_bytes}) + `), + check('app_sync_checkpoints_application_check', sql` + (${t.cursor_sequence} = 0 AND ${t.last_applied_run_id} IS NULL + AND ${t.last_applied_page_digest} IS NULL AND ${t.last_applied_at} IS NULL) + OR (${t.cursor_sequence} > 0 AND ${t.last_applied_run_id} IS NOT NULL + AND ${t.last_applied_page_digest} IS NOT NULL + AND ${t.last_applied_page_digest} ~ '^sha256:[a-f0-9]{64}$' + AND ${t.last_applied_at} IS NOT NULL) + `), +]); + +export const appSyncIntents = pgTable('app_sync_intents', { + ...id(), + ...orgId(), + run_id: text('run_id').notNull(), + resource_binding_id: text('resource_binding_id').notNull(), + checkpoint_id: text('checkpoint_id').notNull(), + app_installation_id: text('app_installation_id').notNull(), + app_version_id: text('app_version_id').notNull(), + grant_snapshot_id: text('grant_snapshot_id').notNull(), + provider_snapshot_id: text('provider_snapshot_id').notNull(), + owner_user_id: text('owner_user_id').notNull(), + descriptor_digest: text('descriptor_digest').notNull(), + generation: integer('generation').notNull(), + expected_cursor_sequence: integer('expected_cursor_sequence').notNull(), + expected_cursor_hmac_key_version: text('expected_cursor_hmac_key_version').notNull(), + expected_cursor_hmac: text('expected_cursor_hmac').notNull(), + created_at: timestamp('created_at').defaultNow().notNull(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.run_id, t.app_installation_id, + t.app_version_id, t.grant_snapshot_id, t.resource_binding_id, t.provider_snapshot_id], + foreignColumns: [appRuns.org_id, appRuns.id, appRuns.origin_app_installation_id, + appRuns.origin_app_version_id, appRuns.origin_app_grant_snapshot_id, + appRuns.origin_resource_binding_id, appRuns.provider_snapshot_id], + name: 'app_sync_intents_run_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.checkpoint_id, t.resource_binding_id], + foreignColumns: [appSyncCheckpoints.org_id, appSyncCheckpoints.id, + appSyncCheckpoints.resource_binding_id], + name: 'app_sync_intents_checkpoint_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.resource_binding_id, t.owner_user_id], + foreignColumns: [appResourceBindings.org_id, appResourceBindings.id, + appResourceBindings.owner_user_id], + name: 'app_sync_intents_owner_fk' }).onDelete('restrict'), + foreignKey({ columns: [t.org_id, t.resource_binding_id, t.owner_user_id, + t.descriptor_digest], + foreignColumns: [appResourceBindings.org_id, appResourceBindings.id, + appResourceBindings.owner_user_id, appResourceBindings.descriptor_digest], + name: 'app_sync_intents_descriptor_fk' }).onDelete('restrict'), + unique('app_sync_intents_org_run_unique').on(t.org_id, t.run_id), + unique('app_sync_intents_checkpoint_run_unique').on(t.org_id, t.run_id, + t.resource_binding_id, t.checkpoint_id), + index('app_sync_intents_binding_idx').on(t.org_id, t.resource_binding_id, t.created_at), + check('app_sync_intents_start_check', sql` + ${t.generation} >= 1 AND ${t.expected_cursor_sequence} >= 0 + AND ${t.expected_cursor_hmac_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.expected_cursor_hmac} ~ '^hmac-sha256:[a-f0-9]{64}$' + AND ${t.descriptor_digest} ~ '^sha256:[a-f0-9]{64}$' + `), +]); + +export const appResourceProjections = pgTable('app_resource_projections', { + ...id(), + ...orgId(), + resource_binding_id: text('resource_binding_id').notNull(), + checkpoint_id: text('checkpoint_id').notNull(), + generation: integer('generation').notNull(), + resource_id_hmac_key_version: text('resource_id_hmac_key_version').notNull(), + resource_id_hmac: text('resource_id_hmac').notNull(), + provider_id_envelope_version: text('provider_id_envelope_version').notNull(), + provider_id_algorithm: text('provider_id_algorithm').$type<'aes-256-gcm'>().notNull(), + provider_id_key_version: text('provider_id_key_version').notNull(), + provider_id_nonce_b64: text('provider_id_nonce_b64').notNull(), + provider_id_ciphertext_b64: text('provider_id_ciphertext_b64').notNull(), + provider_id_auth_tag_b64: text('provider_id_auth_tag_b64').notNull(), + provider_id_bytes: integer('provider_id_bytes').notNull(), + body_envelope_version: text('body_envelope_version'), + body_algorithm: text('body_algorithm').$type<'aes-256-gcm'>(), + body_key_version: text('body_key_version'), + body_nonce_b64: text('body_nonce_b64'), + body_ciphertext_b64: text('body_ciphertext_b64'), + body_auth_tag_b64: text('body_auth_tag_b64'), + body_bytes: integer('body_bytes').default(0).notNull(), + state: text('state').$type<'live' | 'tombstone'>().notNull(), + applied_sequence: integer('applied_sequence').notNull(), + first_seen_at: timestamp('first_seen_at').notNull(), + last_seen_at: timestamp('last_seen_at').notNull(), + source_updated_at: timestamp('source_updated_at'), + fresh_until: timestamp('fresh_until'), + tombstoned_at: timestamp('tombstoned_at'), + ...timestamps(), +}, (t) => [ + foreignKey({ columns: [t.org_id, t.checkpoint_id, t.resource_binding_id], + foreignColumns: [appSyncCheckpoints.org_id, appSyncCheckpoints.id, + appSyncCheckpoints.resource_binding_id], + name: 'app_resource_projections_checkpoint_fk' }).onDelete('restrict'), + unique('app_resource_projections_org_id_id_unique').on(t.org_id, t.id), + unique('app_resource_projections_locator_unique').on(t.org_id, t.checkpoint_id, + t.resource_id_hmac_key_version, t.resource_id_hmac), + index('app_resource_projections_binding_state_idx').on(t.org_id, t.resource_binding_id, + t.state, t.fresh_until), + check('app_resource_projections_lineage_check', sql` + ${t.generation} >= 1 AND ${t.applied_sequence} >= 1 + AND ${t.first_seen_at} <= ${t.last_seen_at} + AND ${t.resource_id_hmac_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.resource_id_hmac} ~ '^hmac-sha256:[a-f0-9]{64}$' + `), + check('app_resource_projections_provider_envelope_check', sql` + ${t.provider_id_envelope_version} = 'deft.secret.v1' + AND ${t.provider_id_algorithm} = 'aes-256-gcm' + AND ${t.provider_id_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.provider_id_nonce_b64} ~ '^[A-Za-z0-9+/]{16}$' + AND ${t.provider_id_auth_tag_b64} ~ '^[A-Za-z0-9+/]{22}==$' + AND ${t.provider_id_ciphertext_b64} ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND ${t.provider_id_bytes} BETWEEN 1 AND 512 + AND octet_length(decode(${t.provider_id_ciphertext_b64}, 'base64')) = ${t.provider_id_bytes} + `), + check('app_resource_projections_body_check', sql` + (${t.state} = 'tombstone' AND ${t.tombstoned_at} IS NOT NULL + AND ${t.body_bytes} = 0 AND ${t.body_envelope_version} IS NULL + AND ${t.body_algorithm} IS NULL AND ${t.body_key_version} IS NULL + AND ${t.body_nonce_b64} IS NULL AND ${t.body_ciphertext_b64} IS NULL + AND ${t.body_auth_tag_b64} IS NULL) + OR (${t.state} = 'live' AND ${t.tombstoned_at} IS NULL + AND ${t.body_envelope_version} IS NOT NULL AND ${t.body_algorithm} IS NOT NULL + AND ${t.body_key_version} IS NOT NULL AND ${t.body_nonce_b64} IS NOT NULL + AND ${t.body_ciphertext_b64} IS NOT NULL AND ${t.body_auth_tag_b64} IS NOT NULL + AND ${t.body_envelope_version} = 'deft.secret.v1' + AND ${t.body_algorithm} = 'aes-256-gcm' + AND ${t.body_key_version} = ${t.provider_id_key_version} + AND ${t.body_key_version} ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND ${t.body_nonce_b64} ~ '^[A-Za-z0-9+/]{16}$' + AND ${t.body_auth_tag_b64} ~ '^[A-Za-z0-9+/]{22}==$' + AND ${t.body_ciphertext_b64} ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND ${t.body_bytes} BETWEEN 1 AND 524288 + AND octet_length(decode(${t.body_ciphertext_b64}, 'base64')) = ${t.body_bytes}) + `), +]); + export const appAutomationDefinitions = pgTable('app_automation_definitions', { ...id(), ...orgId(), diff --git a/packages/db/upgrades/0.3.0-preview.37-app-resource-sync.sql b/packages/db/upgrades/0.3.0-preview.37-app-resource-sync.sql new file mode 100644 index 00000000..f129526a --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.37-app-resource-sync.sql @@ -0,0 +1,773 @@ +-- Forward-only, default-off host-owned resource sync ancestry. +-- Reconciles fresh Drizzle pushes and upgrades supported predecessor schemas. +ALTER TABLE app_runtime_registrations + DROP CONSTRAINT IF EXISTS app_runtime_registrations_contract_check; +ALTER TABLE app_runtime_registrations + ADD CONSTRAINT app_runtime_registrations_contract_check CHECK + (contract_version IN ('deft.app_runtime_channel.v1', 'deft.app_runtime_channel.v2')); +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'app_runtime_registrations_contract_ancestry_unique' + AND conrelid = 'app_runtime_registrations'::regclass) THEN + ALTER TABLE app_runtime_registrations + ADD CONSTRAINT app_runtime_registrations_contract_ancestry_unique UNIQUE + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, contract_version); + END IF; +END $$; + +-- Existing v1 action bindings remain v1, including when new v2 registrations exist. +ALTER TABLE app_runtime_bindings + ADD COLUMN IF NOT EXISTS registration_contract_version text NOT NULL DEFAULT 'deft.app_runtime_channel.v1'; +ALTER TABLE app_runtime_bindings + DROP CONSTRAINT IF EXISTS app_runtime_bindings_registration_contract_check; +ALTER TABLE app_runtime_bindings + ADD CONSTRAINT app_runtime_bindings_registration_contract_check CHECK + (registration_contract_version = 'deft.app_runtime_channel.v1'); +ALTER TABLE app_runtime_bindings DROP CONSTRAINT IF EXISTS app_runtime_bindings_registration_fk; +ALTER TABLE app_runtime_bindings + ADD CONSTRAINT app_runtime_bindings_registration_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, + runtime_registration_id, registration_contract_version) + REFERENCES app_runtime_registrations + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, contract_version) + ON DELETE RESTRICT; +CREATE OR REPLACE FUNCTION enforce_app_runtime_binding_v1_contract() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.registration_contract_version IS DISTINCT FROM OLD.registration_contract_version THEN + RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_binding_v1_contract_trigger ON app_runtime_bindings; +CREATE TRIGGER app_runtime_binding_v1_contract_trigger BEFORE UPDATE ON app_runtime_bindings + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_binding_v1_contract(); + +CREATE TABLE IF NOT EXISTS app_resource_bindings ( + id text PRIMARY KEY, + org_id text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + grant_snapshot_kind text NOT NULL DEFAULT 'effective', + runtime_registration_id text NOT NULL, + registration_contract_version text NOT NULL DEFAULT 'deft.app_runtime_channel.v2', + provider_kind text NOT NULL DEFAULT 'app_runtime', + provider_instance_id text NOT NULL, + provider_snapshot_id text NOT NULL, + resource_key text NOT NULL, + resource_family text NOT NULL, + operation_name text NOT NULL, + interface_identity text NOT NULL, + reviewed_descriptor jsonb NOT NULL, + descriptor_digest text NOT NULL, + owner_user_id text NOT NULL, + owner_scope text NOT NULL DEFAULT 'private_user', + risk_class text NOT NULL DEFAULT 'internal_write', + review_requirement text NOT NULL DEFAULT 'policy', + review_scope text NOT NULL DEFAULT 'reviewed_resource_sync', + retry_class text NOT NULL DEFAULT 'unsafe_or_unknown', + retention_class text NOT NULL DEFAULT 'standard', + max_records_per_page integer NOT NULL, + max_page_bytes integer NOT NULL, + max_retained_records integer NOT NULL, + max_retained_bytes integer NOT NULL, + min_interval_seconds integer NOT NULL, + consent_expires_at timestamp, + state text NOT NULL DEFAULT 'disabled', + reviewed_by_user_id text, + reviewed_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_resource_bindings_grant_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, grant_snapshot_kind) + REFERENCES app_grant_snapshots + (org_id, app_installation_id, app_version_id, id, snapshot_kind) ON DELETE RESTRICT, + CONSTRAINT app_resource_bindings_registration_fk FOREIGN KEY + (org_id, app_installation_id, app_version_id, grant_snapshot_id, + runtime_registration_id, registration_contract_version) + REFERENCES app_runtime_registrations + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, contract_version) + ON DELETE RESTRICT, + CONSTRAINT app_resource_bindings_provider_fk FOREIGN KEY + (org_id, provider_kind, provider_instance_id, provider_snapshot_id) + REFERENCES capability_provider_snapshots + (org_id, provider_kind, provider_instance_id, id) ON DELETE RESTRICT, + CONSTRAINT app_resource_bindings_owner_fk FOREIGN KEY (org_id, owner_user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + CONSTRAINT app_resource_bindings_reviewer_fk FOREIGN KEY (org_id, reviewed_by_user_id) + REFERENCES org_members(org_id, user_id) ON DELETE RESTRICT, + CONSTRAINT app_resource_bindings_org_id_id_unique UNIQUE (org_id, id), + CONSTRAINT app_resource_bindings_registration_identity_unique UNIQUE + (org_id, runtime_registration_id, id), + CONSTRAINT app_resource_bindings_owner_identity_unique UNIQUE (org_id, id, owner_user_id), + CONSTRAINT app_resource_bindings_descriptor_identity_unique UNIQUE + (org_id, id, owner_user_id, descriptor_digest), + CONSTRAINT app_resource_bindings_run_identity_unique UNIQUE + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, + provider_kind, provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, review_scope, retry_class, retention_class), + CONSTRAINT app_resource_bindings_identity_check CHECK ( + grant_snapshot_kind = 'effective' + AND registration_contract_version = 'deft.app_runtime_channel.v2' + AND provider_kind = 'app_runtime' AND provider_instance_id = runtime_registration_id + AND owner_scope = 'private_user' + AND resource_key ~ '^[a-z][a-z0-9_]{0,47}$' + AND resource_family ~ '^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$' + AND octet_length(resource_family) <= 64 + AND operation_name = 'sync_' || resource_key + AND interface_identity = 'deft.resource_sync.v2:' || lower(org_id) || ':' || + lower(app_installation_id) || ':' || resource_key + AND descriptor_digest ~ '^sha256:[a-f0-9]{64}$'), + CONSTRAINT app_resource_bindings_descriptor_check CHECK ( + jsonb_typeof(reviewed_descriptor) = 'object' + AND coalesce(jsonb_typeof(reviewed_descriptor->'schema_version'), '') = 'string' + AND coalesce(reviewed_descriptor->>'schema_version', '') = 'deft.app_sync_descriptor.v1' + AND octet_length(reviewed_descriptor::text) <= 65536), + CONSTRAINT app_resource_bindings_policy_check CHECK ( + risk_class = 'internal_write' AND review_requirement = 'policy' + AND review_scope = 'reviewed_resource_sync' + AND retry_class = 'unsafe_or_unknown' AND retention_class = 'standard'), + CONSTRAINT app_resource_bindings_limits_check CHECK ( + max_records_per_page BETWEEN 1 AND 100 AND max_page_bytes BETWEEN 1 AND 524288 + AND max_retained_records BETWEEN 1 AND 100000 + AND max_retained_bytes BETWEEN 1 AND 1073741824 + AND min_interval_seconds BETWEEN 60 AND 86400), + CONSTRAINT app_resource_bindings_review_check CHECK ( + (state = 'disabled' AND reviewed_by_user_id IS NULL + AND reviewed_at IS NULL AND consent_expires_at IS NULL) + OR (state IN ('active','revoked') AND reviewed_by_user_id IS NOT NULL + AND reviewed_by_user_id = owner_user_id AND reviewed_at IS NOT NULL + AND consent_expires_at IS NOT NULL AND consent_expires_at > reviewed_at + AND consent_expires_at <= reviewed_at + interval '90 days')) +); +CREATE INDEX IF NOT EXISTS app_resource_bindings_owner_idx + ON app_resource_bindings(org_id, owner_user_id, state); + +ALTER TABLE app_runs ADD COLUMN IF NOT EXISTS origin_resource_binding_id text; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'app_runs_resource_attempt_identity_unique' + AND conrelid = 'app_runs'::regclass) THEN + ALTER TABLE app_runs ADD CONSTRAINT app_runs_resource_attempt_identity_unique UNIQUE + (org_id, id, origin_resource_binding_id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'app_runs_sync_intent_identity_unique' + AND conrelid = 'app_runs'::regclass) THEN + ALTER TABLE app_runs ADD CONSTRAINT app_runs_sync_intent_identity_unique UNIQUE + (org_id, id, origin_app_installation_id, origin_app_version_id, + origin_app_grant_snapshot_id, origin_resource_binding_id, provider_snapshot_id); + END IF; +END $$; +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_resource_binding_fk; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_resource_binding_fk FOREIGN KEY + (org_id, origin_app_installation_id, origin_app_version_id, + origin_app_grant_snapshot_id, origin_resource_binding_id, provider_kind, + provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, review_scope, retry_class, retention_class) + REFERENCES app_resource_bindings + (org_id, app_installation_id, app_version_id, grant_snapshot_id, id, provider_kind, + provider_instance_id, operation_name, provider_snapshot_id, + risk_class, review_requirement, review_scope, retry_class, retention_class) + ON DELETE RESTRICT; +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_review_scope_check; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_review_scope_check CHECK ( + review_scope IN ('per_invocation','immutable_batch','approved_automation_definition', + 'forbidden_in_automation','reviewed_resource_sync')); +ALTER TABLE app_runs DROP CONSTRAINT IF EXISTS app_runs_app_origin_coherence_check; +ALTER TABLE app_runs ADD CONSTRAINT app_runs_app_origin_coherence_check CHECK ( + (origin_kind = 'app' AND origin_app_installation_id IS NOT NULL + AND origin_app_version_id IS NOT NULL AND provider_kind = 'mcp' + AND origin_app_binding_key IS NOT NULL AND origin_runtime_binding_id IS NULL + AND origin_resource_binding_id IS NULL + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL + AND origin_app_grant_snapshot_id IS NOT NULL + AND risk_class = 'external_write' AND review_requirement = 'always' + AND retry_class = 'idempotent_with_key' AND retention_class = 'standard' + AND ((review_scope = 'per_invocation' + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation') + OR (review_scope = 'approved_automation_definition' + AND origin_app_automation_definition_id IS NOT NULL + AND origin_app_automation_fire_id IS NOT NULL + AND initiating_actor_type = 'human' AND execution_actor_type = 'automation' + AND execution_actor_id = origin_app_automation_definition_id))) + OR (origin_kind = 'app' AND provider_kind = 'app_runtime' + AND origin_app_installation_id IS NOT NULL AND origin_app_version_id IS NOT NULL + AND origin_app_grant_snapshot_id IS NOT NULL AND origin_app_binding_key IS NULL + AND origin_runtime_binding_id IS NOT NULL AND origin_resource_binding_id IS NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND ((initiating_actor_type = 'app_public' AND execution_actor_type = 'human' + AND initiating_actor_id = origin_public_ingress_id + AND origin_public_endpoint_id IS NOT NULL AND origin_public_ingress_id IS NOT NULL) + OR (initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation' + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL)) + AND review_scope = 'per_invocation') + OR (origin_kind = 'app' AND provider_kind = 'app_runtime' + AND origin_app_installation_id IS NOT NULL AND origin_app_version_id IS NOT NULL + AND origin_app_grant_snapshot_id IS NOT NULL AND origin_resource_binding_id IS NOT NULL + AND origin_runtime_binding_id IS NULL AND origin_app_binding_key IS NULL + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND initiating_actor_type = 'system' AND execution_actor_type = 'system' + AND initiating_actor_id = origin_resource_binding_id + AND execution_actor_id = origin_resource_binding_id + AND risk_class = 'internal_write' AND review_requirement = 'policy' + AND review_scope = 'reviewed_resource_sync' AND retry_class = 'unsafe_or_unknown' + AND retention_class = 'standard') + OR (origin_kind <> 'app' AND provider_kind = 'mcp' + AND origin_app_installation_id IS NULL AND origin_app_version_id IS NULL + AND origin_app_binding_key IS NULL AND origin_runtime_binding_id IS NULL + AND origin_resource_binding_id IS NULL AND origin_app_grant_snapshot_id IS NULL + AND origin_app_automation_definition_id IS NULL AND origin_app_automation_fire_id IS NULL + AND origin_public_endpoint_id IS NULL AND origin_public_ingress_id IS NULL + AND initiating_actor_type <> 'automation' AND initiating_actor_type <> 'app_public' + AND execution_actor_type <> 'automation') +); +CREATE OR REPLACE FUNCTION enforce_app_run_resource_identity() RETURNS trigger AS $$ +BEGIN + IF NEW.origin_resource_binding_id IS DISTINCT FROM OLD.origin_resource_binding_id THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_run_resource_identity_trigger ON app_runs; +CREATE TRIGGER app_run_resource_identity_trigger BEFORE UPDATE ON app_runs + FOR EACH ROW EXECUTE FUNCTION enforce_app_run_resource_identity(); + +-- An existing Runtime session or attempt has exactly one v1 or v2 target. +ALTER TABLE app_runtime_sessions ALTER COLUMN runtime_binding_id DROP NOT NULL; +ALTER TABLE app_runtime_sessions ADD COLUMN IF NOT EXISTS resource_binding_id text; +ALTER TABLE app_runtime_sessions DROP CONSTRAINT IF EXISTS app_runtime_sessions_resource_binding_fk; +ALTER TABLE app_runtime_sessions ADD CONSTRAINT app_runtime_sessions_resource_binding_fk FOREIGN KEY + (org_id, runtime_registration_id, resource_binding_id) + REFERENCES app_resource_bindings(org_id, runtime_registration_id, id) ON DELETE RESTRICT; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'app_runtime_sessions_resource_attempt_identity_unique' + AND conrelid = 'app_runtime_sessions'::regclass) THEN + ALTER TABLE app_runtime_sessions + ADD CONSTRAINT app_runtime_sessions_resource_attempt_identity_unique UNIQUE + (org_id, resource_binding_id, id, session_epoch, runtime_epoch); + END IF; +END $$; +ALTER TABLE app_runtime_sessions DROP CONSTRAINT IF EXISTS app_runtime_sessions_audience_check; +ALTER TABLE app_runtime_sessions ADD CONSTRAINT app_runtime_sessions_audience_check CHECK ( + (audience = 'app_runtime' AND runtime_binding_id IS NOT NULL AND resource_binding_id IS NULL) + OR (audience = 'app_resource_sync' AND runtime_binding_id IS NULL + AND resource_binding_id IS NOT NULL)); +CREATE OR REPLACE FUNCTION enforce_app_runtime_session_resource_identity() RETURNS trigger AS $$ +BEGIN + IF NEW.resource_binding_id IS DISTINCT FROM OLD.resource_binding_id THEN + RAISE EXCEPTION 'APP_RUNTIME_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_session_resource_identity_trigger ON app_runtime_sessions; +CREATE TRIGGER app_runtime_session_resource_identity_trigger BEFORE UPDATE ON app_runtime_sessions + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_session_resource_identity(); + +ALTER TABLE app_run_attempts ADD COLUMN IF NOT EXISTS resource_binding_id text; +ALTER TABLE app_run_attempts DROP CONSTRAINT IF EXISTS app_run_attempts_resource_session_fk; +ALTER TABLE app_run_attempts ADD CONSTRAINT app_run_attempts_resource_session_fk FOREIGN KEY + (org_id, resource_binding_id, runtime_session_id, runtime_session_epoch, runtime_epoch) + REFERENCES app_runtime_sessions + (org_id, resource_binding_id, id, session_epoch, runtime_epoch) ON DELETE RESTRICT; +ALTER TABLE app_run_attempts DROP CONSTRAINT IF EXISTS app_run_attempts_resource_run_fk; +ALTER TABLE app_run_attempts ADD CONSTRAINT app_run_attempts_resource_run_fk FOREIGN KEY + (org_id, run_id, resource_binding_id) + REFERENCES app_runs(org_id, id, origin_resource_binding_id) ON DELETE RESTRICT; +ALTER TABLE app_run_attempts DROP CONSTRAINT IF EXISTS app_run_attempts_runtime_shape_check; +ALTER TABLE app_run_attempts ADD CONSTRAINT app_run_attempts_runtime_shape_check CHECK ( + (runtime_binding_id IS NULL AND resource_binding_id IS NULL AND runtime_session_id IS NULL + AND runtime_session_epoch IS NULL AND runtime_epoch IS NULL + AND runtime_sequence IS NULL AND runtime_result_hmac IS NULL) + OR (runtime_binding_id IS NOT NULL AND resource_binding_id IS NULL + AND runtime_session_id IS NOT NULL AND runtime_session_epoch IS NOT NULL + AND runtime_session_epoch >= 0 AND runtime_epoch IS NOT NULL AND runtime_epoch >= 0 + AND runtime_sequence IS NOT NULL AND runtime_sequence >= 1) + OR (runtime_binding_id IS NULL AND resource_binding_id IS NOT NULL + AND runtime_session_id IS NOT NULL AND runtime_session_epoch IS NOT NULL + AND runtime_session_epoch >= 0 AND runtime_epoch IS NOT NULL AND runtime_epoch >= 0 + AND runtime_sequence IS NOT NULL AND runtime_sequence >= 1)); +CREATE OR REPLACE FUNCTION enforce_app_runtime_attempt_resource_identity() RETURNS trigger AS $$ +BEGIN + IF OLD.runtime_session_id IS NOT NULL AND + NEW.resource_binding_id IS DISTINCT FROM OLD.resource_binding_id THEN + RAISE EXCEPTION 'APP_RUN_IMMUTABLE_FIELD' USING ERRCODE = '55000'; + END IF; + IF OLD.runtime_session_id IS NULL AND NEW.runtime_session_id IS NOT NULL + AND NEW.resource_binding_id IS NOT NULL + AND NOT (OLD.state = 'pending' AND NEW.state = 'claimed') THEN + RAISE EXCEPTION 'APP_RUN_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_runtime_attempt_resource_identity_trigger ON app_run_attempts; +CREATE TRIGGER app_runtime_attempt_resource_identity_trigger BEFORE UPDATE ON app_run_attempts + FOR EACH ROW EXECUTE FUNCTION enforce_app_runtime_attempt_resource_identity(); + +CREATE TABLE IF NOT EXISTS app_sync_checkpoints ( + id text PRIMARY KEY, + org_id text NOT NULL, + resource_binding_id text NOT NULL, + generation integer NOT NULL DEFAULT 1, + state text NOT NULL DEFAULT 'active', + cursor_sequence integer NOT NULL DEFAULT 0, + cursor_hmac_key_version text NOT NULL, + cursor_hmac text NOT NULL, + cursor_state text NOT NULL DEFAULT 'empty', + cursor_envelope_version text, + cursor_algorithm text, + cursor_key_version text, + cursor_nonce_b64 text, + cursor_ciphertext_b64 text, + cursor_auth_tag_b64 text, + cursor_bytes integer NOT NULL DEFAULT 0, + retained_record_count integer NOT NULL DEFAULT 0, + retained_bytes integer NOT NULL DEFAULT 0, + last_applied_run_id text, + last_applied_page_digest text, + last_applied_at timestamp, + last_checked_at timestamp, + fresh_until timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_sync_checkpoints_binding_fk FOREIGN KEY (org_id, resource_binding_id) + REFERENCES app_resource_bindings(org_id, id) ON DELETE RESTRICT, + CONSTRAINT app_sync_checkpoints_org_id_id_unique UNIQUE (org_id, id), + CONSTRAINT app_sync_checkpoints_binding_unique UNIQUE (org_id, resource_binding_id), + CONSTRAINT app_sync_checkpoints_intent_identity_unique UNIQUE (org_id, id, resource_binding_id), + CONSTRAINT app_sync_checkpoints_state_check CHECK (state IN ('active','paused')), + CONSTRAINT app_sync_checkpoints_counters_check CHECK ( + generation >= 1 AND cursor_sequence >= 0 + AND retained_record_count BETWEEN 0 AND 100000 + AND retained_bytes BETWEEN 0 AND 1073741824 + AND cursor_bytes BETWEEN 0 AND 16384), + CONSTRAINT app_sync_checkpoints_hmac_check CHECK ( + cursor_hmac_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND cursor_hmac ~ '^hmac-sha256:[a-f0-9]{64}$'), + CONSTRAINT app_sync_checkpoints_cursor_envelope_check CHECK ( + (cursor_state = 'empty' AND cursor_bytes = 0 + AND cursor_envelope_version IS NULL AND cursor_algorithm IS NULL + AND cursor_key_version IS NULL AND cursor_nonce_b64 IS NULL + AND cursor_ciphertext_b64 IS NULL AND cursor_auth_tag_b64 IS NULL) + OR (cursor_state = 'value' AND cursor_bytes BETWEEN 1 AND 16384 + AND cursor_envelope_version IS NOT NULL AND cursor_algorithm IS NOT NULL + AND cursor_key_version IS NOT NULL AND cursor_nonce_b64 IS NOT NULL + AND cursor_ciphertext_b64 IS NOT NULL AND cursor_auth_tag_b64 IS NOT NULL + AND cursor_envelope_version = 'deft.secret.v1' AND cursor_algorithm = 'aes-256-gcm' + AND cursor_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND cursor_nonce_b64 ~ '^[A-Za-z0-9+/]{16}$' + AND cursor_auth_tag_b64 ~ '^[A-Za-z0-9+/]{22}==$' + AND cursor_ciphertext_b64 ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND octet_length(decode(cursor_ciphertext_b64, 'base64')) = cursor_bytes)), + CONSTRAINT app_sync_checkpoints_application_check CHECK ( + (cursor_sequence = 0 AND last_applied_run_id IS NULL + AND last_applied_page_digest IS NULL AND last_applied_at IS NULL) + OR (cursor_sequence > 0 AND last_applied_run_id IS NOT NULL + AND last_applied_page_digest IS NOT NULL + AND last_applied_page_digest ~ '^sha256:[a-f0-9]{64}$' + AND last_applied_at IS NOT NULL)) +); +CREATE INDEX IF NOT EXISTS app_sync_checkpoints_freshness_idx + ON app_sync_checkpoints(org_id, state, fresh_until); + +CREATE TABLE IF NOT EXISTS app_sync_intents ( + id text PRIMARY KEY, + org_id text NOT NULL, + run_id text NOT NULL, + resource_binding_id text NOT NULL, + checkpoint_id text NOT NULL, + app_installation_id text NOT NULL, + app_version_id text NOT NULL, + grant_snapshot_id text NOT NULL, + provider_snapshot_id text NOT NULL, + owner_user_id text NOT NULL, + descriptor_digest text NOT NULL, + generation integer NOT NULL, + expected_cursor_sequence integer NOT NULL, + expected_cursor_hmac_key_version text NOT NULL, + expected_cursor_hmac text NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_sync_intents_run_fk FOREIGN KEY + (org_id, run_id, app_installation_id, app_version_id, grant_snapshot_id, + resource_binding_id, provider_snapshot_id) + REFERENCES app_runs + (org_id, id, origin_app_installation_id, origin_app_version_id, + origin_app_grant_snapshot_id, origin_resource_binding_id, provider_snapshot_id) + ON DELETE RESTRICT, + CONSTRAINT app_sync_intents_checkpoint_fk FOREIGN KEY + (org_id, checkpoint_id, resource_binding_id) + REFERENCES app_sync_checkpoints(org_id, id, resource_binding_id) ON DELETE RESTRICT, + CONSTRAINT app_sync_intents_owner_fk FOREIGN KEY + (org_id, resource_binding_id, owner_user_id) + REFERENCES app_resource_bindings(org_id, id, owner_user_id) ON DELETE RESTRICT, + CONSTRAINT app_sync_intents_descriptor_fk FOREIGN KEY + (org_id, resource_binding_id, owner_user_id, descriptor_digest) + REFERENCES app_resource_bindings(org_id, id, owner_user_id, descriptor_digest) + ON DELETE RESTRICT, + CONSTRAINT app_sync_intents_org_run_unique UNIQUE (org_id, run_id), + CONSTRAINT app_sync_intents_checkpoint_run_unique UNIQUE + (org_id, run_id, resource_binding_id, checkpoint_id), + CONSTRAINT app_sync_intents_start_check CHECK ( + generation >= 1 AND expected_cursor_sequence >= 0 + AND expected_cursor_hmac_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND expected_cursor_hmac ~ '^hmac-sha256:[a-f0-9]{64}$' + AND descriptor_digest ~ '^sha256:[a-f0-9]{64}$') +); +CREATE INDEX IF NOT EXISTS app_sync_intents_binding_idx + ON app_sync_intents(org_id, resource_binding_id, created_at); +ALTER TABLE app_sync_checkpoints DROP CONSTRAINT IF EXISTS app_sync_checkpoints_last_intent_fk; +ALTER TABLE app_sync_checkpoints ADD CONSTRAINT app_sync_checkpoints_last_intent_fk FOREIGN KEY + (org_id, last_applied_run_id, resource_binding_id, id) + REFERENCES app_sync_intents(org_id, run_id, resource_binding_id, checkpoint_id) + ON DELETE RESTRICT; + +CREATE TABLE IF NOT EXISTS app_resource_projections ( + id text PRIMARY KEY, + org_id text NOT NULL, + resource_binding_id text NOT NULL, + checkpoint_id text NOT NULL, + generation integer NOT NULL, + resource_id_hmac_key_version text NOT NULL, + resource_id_hmac text NOT NULL, + provider_id_envelope_version text NOT NULL, + provider_id_algorithm text NOT NULL, + provider_id_key_version text NOT NULL, + provider_id_nonce_b64 text NOT NULL, + provider_id_ciphertext_b64 text NOT NULL, + provider_id_auth_tag_b64 text NOT NULL, + provider_id_bytes integer NOT NULL, + body_envelope_version text, + body_algorithm text, + body_key_version text, + body_nonce_b64 text, + body_ciphertext_b64 text, + body_auth_tag_b64 text, + body_bytes integer NOT NULL DEFAULT 0, + state text NOT NULL, + applied_sequence integer NOT NULL, + first_seen_at timestamp NOT NULL, + last_seen_at timestamp NOT NULL, + source_updated_at timestamp, + fresh_until timestamp, + tombstoned_at timestamp, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT app_resource_projections_checkpoint_fk FOREIGN KEY + (org_id, checkpoint_id, resource_binding_id) + REFERENCES app_sync_checkpoints(org_id, id, resource_binding_id) ON DELETE RESTRICT, + CONSTRAINT app_resource_projections_org_id_id_unique UNIQUE (org_id, id), + CONSTRAINT app_resource_projections_locator_unique UNIQUE + (org_id, checkpoint_id, resource_id_hmac_key_version, resource_id_hmac), + CONSTRAINT app_resource_projections_lineage_check CHECK ( + generation >= 1 AND applied_sequence >= 1 AND first_seen_at <= last_seen_at + AND resource_id_hmac_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND resource_id_hmac ~ '^hmac-sha256:[a-f0-9]{64}$'), + CONSTRAINT app_resource_projections_provider_envelope_check CHECK ( + provider_id_envelope_version = 'deft.secret.v1' + AND provider_id_algorithm = 'aes-256-gcm' + AND provider_id_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND provider_id_nonce_b64 ~ '^[A-Za-z0-9+/]{16}$' + AND provider_id_auth_tag_b64 ~ '^[A-Za-z0-9+/]{22}==$' + AND provider_id_ciphertext_b64 ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND provider_id_bytes BETWEEN 1 AND 512 + AND octet_length(decode(provider_id_ciphertext_b64, 'base64')) = provider_id_bytes), + CONSTRAINT app_resource_projections_body_check CHECK ( + (state = 'tombstone' AND tombstoned_at IS NOT NULL AND body_bytes = 0 + AND body_envelope_version IS NULL AND body_algorithm IS NULL + AND body_key_version IS NULL AND body_nonce_b64 IS NULL + AND body_ciphertext_b64 IS NULL AND body_auth_tag_b64 IS NULL) + OR (state = 'live' AND tombstoned_at IS NULL + AND body_envelope_version IS NOT NULL AND body_algorithm IS NOT NULL + AND body_key_version IS NOT NULL AND body_nonce_b64 IS NOT NULL + AND body_ciphertext_b64 IS NOT NULL AND body_auth_tag_b64 IS NOT NULL + AND body_envelope_version = 'deft.secret.v1' + AND body_algorithm = 'aes-256-gcm' + AND body_key_version = provider_id_key_version + AND body_key_version ~ '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$' + AND body_nonce_b64 ~ '^[A-Za-z0-9+/]{16}$' + AND body_auth_tag_b64 ~ '^[A-Za-z0-9+/]{22}==$' + AND body_ciphertext_b64 ~ '^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$' + AND body_bytes BETWEEN 1 AND 524288 + AND octet_length(decode(body_ciphertext_b64, 'base64')) = body_bytes)) +); +CREATE INDEX IF NOT EXISTS app_resource_projections_binding_state_idx + ON app_resource_projections(org_id, resource_binding_id, state, fresh_until); + +-- Host review is monotonic and an old reviewed descriptor never changes in place. +CREATE OR REPLACE FUNCTION enforce_app_resource_binding() RETURNS trigger AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_APPEND_ONLY' USING ERRCODE = '55000'; + END IF; + IF TG_OP = 'INSERT' THEN + IF NEW.state <> 'disabled' OR NEW.reviewed_by_user_id IS NOT NULL + OR NEW.reviewed_at IS NOT NULL OR NEW.consent_expires_at IS NOT NULL THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_REVIEW_REQUIRED' USING ERRCODE = '55000'; + END IF; + END IF; + IF TG_OP = 'UPDATE' THEN + IF (NEW.id, NEW.org_id, NEW.app_installation_id, NEW.app_version_id, NEW.grant_snapshot_id, + NEW.grant_snapshot_kind, NEW.runtime_registration_id, NEW.registration_contract_version, + NEW.provider_kind, NEW.provider_instance_id, NEW.provider_snapshot_id, + NEW.resource_key, NEW.resource_family, NEW.operation_name, NEW.interface_identity, + NEW.reviewed_descriptor, NEW.descriptor_digest, NEW.owner_user_id, NEW.owner_scope, + NEW.risk_class, NEW.review_requirement, NEW.review_scope, NEW.retry_class, + NEW.retention_class, NEW.max_records_per_page, NEW.max_page_bytes, + NEW.max_retained_records, NEW.max_retained_bytes, NEW.min_interval_seconds, + NEW.created_at) IS DISTINCT FROM + (OLD.id, OLD.org_id, OLD.app_installation_id, OLD.app_version_id, OLD.grant_snapshot_id, + OLD.grant_snapshot_kind, OLD.runtime_registration_id, OLD.registration_contract_version, + OLD.provider_kind, OLD.provider_instance_id, OLD.provider_snapshot_id, + OLD.resource_key, OLD.resource_family, OLD.operation_name, OLD.interface_identity, + OLD.reviewed_descriptor, OLD.descriptor_digest, OLD.owner_user_id, OLD.owner_scope, + OLD.risk_class, OLD.review_requirement, OLD.review_scope, OLD.retry_class, + OLD.retention_class, OLD.max_records_per_page, OLD.max_page_bytes, + OLD.max_retained_records, OLD.max_retained_bytes, OLD.min_interval_seconds, + OLD.created_at) THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_IMMUTABLE' USING ERRCODE = '55000'; + END IF; + IF OLD.state = 'disabled' AND NEW.state = 'active' THEN + IF OLD.reviewed_by_user_id IS NOT NULL OR OLD.reviewed_at IS NOT NULL + OR OLD.consent_expires_at IS NOT NULL OR NEW.reviewed_by_user_id <> NEW.owner_user_id + OR NEW.reviewed_at IS NULL OR NEW.consent_expires_at IS NULL + OR NOT EXISTS (SELECT 1 FROM org_members WHERE org_id = NEW.org_id + AND user_id = NEW.owner_user_id AND is_active) THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_REVIEW_INVALID' USING ERRCODE = '55000'; + END IF; + ELSIF OLD.state = 'active' AND NEW.state = 'revoked' THEN + IF (NEW.reviewed_by_user_id, NEW.reviewed_at, NEW.consent_expires_at) + IS DISTINCT FROM (OLD.reviewed_by_user_id, OLD.reviewed_at, OLD.consent_expires_at) THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_IMMUTABLE' USING ERRCODE = '55000'; + END IF; + ELSIF NEW.state = OLD.state THEN + IF (NEW.reviewed_by_user_id, NEW.reviewed_at, NEW.consent_expires_at) + IS DISTINCT FROM (OLD.reviewed_by_user_id, OLD.reviewed_at, OLD.consent_expires_at) THEN + RAISE EXCEPTION 'APP_RESOURCE_BINDING_IMMUTABLE' USING ERRCODE = '55000'; + END IF; + ELSE + RAISE EXCEPTION 'APP_RESOURCE_BINDING_ILLEGAL_TRANSITION' USING ERRCODE = '55000'; + END IF; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_resource_binding_guard_trigger ON app_resource_bindings; +CREATE TRIGGER app_resource_binding_guard_trigger BEFORE INSERT OR UPDATE OR DELETE ON app_resource_bindings + FOR EACH ROW EXECUTE FUNCTION enforce_app_resource_binding(); + +CREATE OR REPLACE FUNCTION enforce_app_sync_intent_immutable() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'APP_SYNC_INTENT_APPEND_ONLY' USING ERRCODE = '55000'; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_sync_intent_immutable_trigger ON app_sync_intents; +CREATE TRIGGER app_sync_intent_immutable_trigger BEFORE UPDATE OR DELETE ON app_sync_intents + FOR EACH ROW EXECUTE FUNCTION enforce_app_sync_intent_immutable(); + +CREATE OR REPLACE FUNCTION enforce_app_sync_intent_start() RETURNS trigger AS $$ +DECLARE current_checkpoint record; current_run record; +BEGIN + SELECT state, origin_resource_binding_id INTO current_run FROM app_runs + WHERE org_id = NEW.org_id AND id = NEW.run_id FOR SHARE; + SELECT generation, cursor_sequence, cursor_hmac_key_version, cursor_hmac, state + INTO current_checkpoint FROM app_sync_checkpoints + WHERE org_id = NEW.org_id AND id = NEW.checkpoint_id + AND resource_binding_id = NEW.resource_binding_id FOR SHARE; + IF current_checkpoint IS NULL OR current_run IS NULL + OR current_checkpoint.state <> 'active' + OR current_run.state <> 'pending' + OR current_run.origin_resource_binding_id <> NEW.resource_binding_id + OR (current_checkpoint.generation, current_checkpoint.cursor_sequence, + current_checkpoint.cursor_hmac_key_version, current_checkpoint.cursor_hmac) + IS DISTINCT FROM + (NEW.generation, NEW.expected_cursor_sequence, + NEW.expected_cursor_hmac_key_version, NEW.expected_cursor_hmac) THEN + RAISE EXCEPTION 'APP_SYNC_INTENT_START_MISMATCH' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_sync_intent_start_trigger ON app_sync_intents; +CREATE TRIGGER app_sync_intent_start_trigger BEFORE INSERT ON app_sync_intents + FOR EACH ROW EXECUTE FUNCTION enforce_app_sync_intent_start(); + +-- Stored capacity is decoded ciphertext bytes, including encrypted provider +-- identifiers retained on tombstones, plus the current encrypted cursor. +-- It intentionally excludes base64/JSON/index overhead. Binding-specific +-- ceilings are enforced here; SQL CHECKs alone cannot compare parent rows. +CREATE OR REPLACE FUNCTION enforce_app_sync_checkpoint() RETURNS trigger AS $$ +DECLARE binding_limit record; matching_intent boolean; +BEGIN + SELECT max_retained_records, max_retained_bytes INTO binding_limit + FROM app_resource_bindings + WHERE org_id = NEW.org_id AND id = NEW.resource_binding_id; + IF NOT FOUND OR NEW.retained_record_count > binding_limit.max_retained_records + OR NEW.retained_bytes + NEW.cursor_bytes > binding_limit.max_retained_bytes THEN + RAISE EXCEPTION 'APP_SYNC_CAPACITY_EXCEEDED' USING ERRCODE = '23514'; + END IF; + IF TG_OP = 'INSERT' THEN + IF NEW.retained_record_count <> 0 OR NEW.retained_bytes <> 0 + OR NEW.generation <> 1 OR NEW.cursor_sequence <> 0 THEN + RAISE EXCEPTION 'APP_SYNC_CHECKPOINT_INITIAL_STATE' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + IF (NEW.id, NEW.org_id, NEW.resource_binding_id, NEW.created_at) IS DISTINCT FROM + (OLD.id, OLD.org_id, OLD.resource_binding_id, OLD.created_at) THEN + RAISE EXCEPTION 'APP_SYNC_CHECKPOINT_IMMUTABLE' USING ERRCODE = '55000'; + END IF; + -- A generation reset requires its own reviewed host transition. This slice + -- only permits page settlement in generation one. + IF NEW.generation <> OLD.generation THEN + RAISE EXCEPTION 'APP_SYNC_GENERATION_RESET_UNSUPPORTED' USING ERRCODE = '55000'; + END IF; + IF OLD.state = 'paused' AND NEW.state <> 'paused' THEN + RAISE EXCEPTION 'APP_SYNC_CHECKPOINT_RESUME_UNSUPPORTED' USING ERRCODE = '55000'; + END IF; + IF (NEW.retained_record_count, NEW.retained_bytes) IS DISTINCT FROM + (OLD.retained_record_count, OLD.retained_bytes) + AND pg_trigger_depth() < 2 THEN + RAISE EXCEPTION 'APP_SYNC_COUNTER_DIRECT_WRITE' USING ERRCODE = '55000'; + END IF; + IF NEW.cursor_sequence < OLD.cursor_sequence + OR NEW.cursor_sequence > OLD.cursor_sequence + 1 THEN + RAISE EXCEPTION 'APP_SYNC_CURSOR_SEQUENCE' USING ERRCODE = '55000'; + END IF; + IF NEW.cursor_sequence = OLD.cursor_sequence + AND (NEW.cursor_hmac_key_version, NEW.cursor_hmac, NEW.cursor_state, + NEW.cursor_envelope_version, NEW.cursor_algorithm, NEW.cursor_key_version, + NEW.cursor_nonce_b64, NEW.cursor_ciphertext_b64, NEW.cursor_auth_tag_b64, + NEW.cursor_bytes, NEW.last_applied_run_id, NEW.last_applied_page_digest, + NEW.last_applied_at, NEW.last_checked_at, NEW.fresh_until) IS DISTINCT FROM + (OLD.cursor_hmac_key_version, OLD.cursor_hmac, OLD.cursor_state, + OLD.cursor_envelope_version, OLD.cursor_algorithm, OLD.cursor_key_version, + OLD.cursor_nonce_b64, OLD.cursor_ciphertext_b64, OLD.cursor_auth_tag_b64, + OLD.cursor_bytes, OLD.last_applied_run_id, OLD.last_applied_page_digest, + OLD.last_applied_at, OLD.last_checked_at, OLD.fresh_until) THEN + RAISE EXCEPTION 'APP_SYNC_CURSOR_CAS_REQUIRED' USING ERRCODE = '55000'; + END IF; + IF NEW.cursor_sequence = OLD.cursor_sequence + 1 THEN + IF OLD.state <> 'active' OR NEW.state <> 'active' + OR NEW.last_applied_run_id IS NOT DISTINCT FROM OLD.last_applied_run_id + OR NEW.last_checked_at IS NULL + OR (NEW.fresh_until IS NOT NULL AND NEW.fresh_until < NEW.last_checked_at) THEN + RAISE EXCEPTION 'APP_SYNC_CURSOR_SETTLEMENT_INVALID' USING ERRCODE = '55000'; + END IF; + SELECT EXISTS ( + SELECT 1 FROM app_sync_intents i + WHERE i.org_id = OLD.org_id AND i.run_id = NEW.last_applied_run_id + AND i.resource_binding_id = OLD.resource_binding_id AND i.checkpoint_id = OLD.id + AND i.generation = OLD.generation + AND i.expected_cursor_sequence = OLD.cursor_sequence + AND i.expected_cursor_hmac_key_version = OLD.cursor_hmac_key_version + AND i.expected_cursor_hmac = OLD.cursor_hmac + ) INTO matching_intent; + IF NOT matching_intent THEN + RAISE EXCEPTION 'APP_SYNC_CURSOR_INTENT_MISMATCH' USING ERRCODE = '55000'; + END IF; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_sync_checkpoint_guard_trigger ON app_sync_checkpoints; +CREATE TRIGGER app_sync_checkpoint_guard_trigger + BEFORE INSERT OR UPDATE ON app_sync_checkpoints + FOR EACH ROW EXECUTE FUNCTION enforce_app_sync_checkpoint(); + +CREATE OR REPLACE FUNCTION enforce_app_resource_projection() RETURNS trigger AS $$ +DECLARE cp record; rekey_mode boolean; +BEGIN + IF TG_OP = 'DELETE' THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_RETAINED' USING ERRCODE = '55000'; + END IF; + -- All writers must acquire the checkpoint before projection rows. This + -- trigger acquires it for direct SQL too, and page settlement holds it first. + SELECT generation, cursor_sequence, state INTO cp FROM app_sync_checkpoints + WHERE org_id = NEW.org_id AND id = NEW.checkpoint_id + AND resource_binding_id = NEW.resource_binding_id FOR UPDATE; + IF cp IS NULL OR cp.state <> 'active' THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_CHECKPOINT_INVALID' USING ERRCODE = '55000'; + END IF; + rekey_mode := coalesce(current_setting('deft.app_resource_sync_rekey', true) = 'on', false); + IF TG_OP = 'INSERT' THEN + IF NEW.generation <> cp.generation OR NEW.applied_sequence <> cp.cursor_sequence + 1 THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_SEQUENCE' USING ERRCODE = '55000'; + END IF; + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + IF (NEW.id, NEW.org_id, NEW.resource_binding_id, NEW.checkpoint_id, NEW.created_at, + NEW.first_seen_at) IS DISTINCT FROM + (OLD.id, OLD.org_id, OLD.resource_binding_id, OLD.checkpoint_id, OLD.created_at, + OLD.first_seen_at) THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_IMMUTABLE' USING ERRCODE = '55000'; + END IF; + IF NEW.generation <> cp.generation OR NEW.generation < OLD.generation THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_STALE' USING ERRCODE = '55000'; + END IF; + IF NEW.applied_sequence = OLD.applied_sequence THEN + -- Rekey is a separate host-verified transaction under checkpoint lock. + -- The setting is an internal transaction marker, not a provider + -- credential; host code must decrypt and compare the old/new provider + -- ID and record plaintext. Both AES envelopes may be rewrapped. + IF NOT rekey_mode + OR (NEW.generation, NEW.state, NEW.last_seen_at, + NEW.source_updated_at, NEW.fresh_until, NEW.tombstoned_at) + IS DISTINCT FROM + (OLD.generation, OLD.state, OLD.last_seen_at, + OLD.source_updated_at, OLD.fresh_until, OLD.tombstoned_at) THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_REKEY_REQUIRED' USING ERRCODE = '55000'; + END IF; + ELSIF NEW.applied_sequence = cp.cursor_sequence + 1 + AND NEW.applied_sequence > OLD.applied_sequence THEN + IF (NEW.resource_id_hmac_key_version, NEW.resource_id_hmac, + NEW.provider_id_envelope_version, NEW.provider_id_algorithm, + NEW.provider_id_key_version, NEW.provider_id_nonce_b64, + NEW.provider_id_ciphertext_b64, NEW.provider_id_auth_tag_b64, + NEW.provider_id_bytes) IS DISTINCT FROM + (OLD.resource_id_hmac_key_version, OLD.resource_id_hmac, + OLD.provider_id_envelope_version, OLD.provider_id_algorithm, + OLD.provider_id_key_version, OLD.provider_id_nonce_b64, + OLD.provider_id_ciphertext_b64, OLD.provider_id_auth_tag_b64, + OLD.provider_id_bytes) AND NOT rekey_mode THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_IDENTITY_REKEY_REQUIRED' USING ERRCODE = '55000'; + END IF; + ELSE + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_SEQUENCE' USING ERRCODE = '55000'; + END IF; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_resource_projection_guard_trigger ON app_resource_projections; +CREATE TRIGGER app_resource_projection_guard_trigger + BEFORE INSERT OR UPDATE OR DELETE ON app_resource_projections + FOR EACH ROW EXECUTE FUNCTION enforce_app_resource_projection(); + +CREATE OR REPLACE FUNCTION account_app_resource_projection() RETURNS trigger AS $$ +DECLARE count_delta integer; bytes_delta integer; +BEGIN + IF TG_OP = 'INSERT' THEN + count_delta := 1; + bytes_delta := NEW.provider_id_bytes + NEW.body_bytes; + ELSE + count_delta := 0; + bytes_delta := NEW.provider_id_bytes + NEW.body_bytes + - OLD.provider_id_bytes - OLD.body_bytes; + END IF; + UPDATE app_sync_checkpoints AS cp SET + retained_record_count = cp.retained_record_count + count_delta, + retained_bytes = cp.retained_bytes + bytes_delta, + updated_at = now() + WHERE cp.org_id = NEW.org_id AND cp.id = NEW.checkpoint_id + AND cp.resource_binding_id = NEW.resource_binding_id + AND cp.generation = NEW.generation + AND cp.retained_record_count + count_delta >= 0 + AND cp.retained_bytes + bytes_delta >= 0; + IF NOT FOUND THEN + RAISE EXCEPTION 'APP_RESOURCE_PROJECTION_CHECKPOINT_STALE' USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_resource_projection_capacity_trigger ON app_resource_projections; +CREATE TRIGGER app_resource_projection_capacity_trigger + AFTER INSERT OR UPDATE ON app_resource_projections + FOR EACH ROW EXECUTE FUNCTION account_app_resource_projection(); diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index 3e5282b5..f9f7ffca 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -202,6 +202,11 @@ export const upgradeManifest = { file: '0.3.0-preview.36-app-experience-sessions.sql', description: 'Pin installed Experience sessions to authenticated web and App authority', }, + { + version: '0.3.0-preview.37', + file: '0.3.0-preview.37-app-resource-sync.sql', + description: 'Add dormant host-reviewed resource sync bindings, sessions, intent and encrypted projections', + }, ] satisfies UpgradeMigration[], } as const; From ff53bbbe1c15d5fb12b9e174c08ef9d1563a818c Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:42:43 +0530 Subject: [PATCH 010/161] Fence sync sessions and retain encrypted resource keys at startup --- .../lib/app-resource-sync-key-references.ts | 60 ++++ .../api/src/lib/app-run-live-authorization.ts | 6 + apps/api/src/lib/app-run-runtime.ts | 7 +- apps/api/src/lib/app-runtime-authority.ts | 4 +- apps/api/src/lib/app-runtime-channel.ts | 7 +- ...pp-resource-sync-key-references-db.test.ts | 290 ++++++++++++++++++ apps/api/test/fixtures/runtime-v3-package.ts | 2 +- packages/db/scripts/upgrade.test.ts | 15 +- scripts/gate-g/required-tests.json | 14 + 9 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-key-references.ts create mode 100644 apps/api/test/app-resource-sync-key-references-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-key-references.ts b/apps/api/src/lib/app-resource-sync-key-references.ts new file mode 100644 index 00000000..48ac6eba --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-key-references.ts @@ -0,0 +1,60 @@ +import { sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { APP_RUN_LIMITS } from '@deft/shared'; +import { db } from './db.js'; +import type { AppRunKeyReference } from './app-run-keyrings.js'; + +const ReferenceRowSchema = z.strictObject({ + purpose: z.enum(['run_encryption', 'fingerprint']), + key_id: z.string().min(1).max(APP_RUN_LIMITS.key_id_chars) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u), +}); + +/** + * Inventories all retained resource-sync key references, including revoked + * bindings and tombstones. This global scan is for bootstrap and explicit + * keyring retirement checks, not per-Run submission; + * locator discovery for one page still needs a checkpoint-locked inventory of + * every retained projection locator key version. + */ +export async function listAppResourceSyncKeyReferences( + orgId?: string, +): Promise { + const selectedOrgId = orgId === undefined ? null : z.string().uuid().parse(orgId); + const result = await db.execute(sql<{ purpose: string; key_id: string }>` + WITH selected_org AS (SELECT ${selectedOrgId}::text AS id), retained_refs AS ( + SELECT 'fingerprint'::text AS purpose, cp.cursor_hmac_key_version AS key_id + FROM app_sync_checkpoints cp CROSS JOIN selected_org scope + WHERE scope.id IS NULL OR cp.org_id = scope.id + UNION ALL + SELECT 'run_encryption'::text, cp.cursor_key_version + FROM app_sync_checkpoints cp CROSS JOIN selected_org scope + WHERE cp.cursor_state = 'value' AND cp.cursor_key_version IS NOT NULL + AND (scope.id IS NULL OR cp.org_id = scope.id) + UNION ALL + SELECT 'fingerprint'::text, projection.resource_id_hmac_key_version + FROM app_resource_projections projection CROSS JOIN selected_org scope + WHERE scope.id IS NULL OR projection.org_id = scope.id + UNION ALL + SELECT 'run_encryption'::text, projection.provider_id_key_version + FROM app_resource_projections projection CROSS JOIN selected_org scope + WHERE scope.id IS NULL OR projection.org_id = scope.id + UNION ALL + SELECT 'run_encryption'::text, projection.body_key_version + FROM app_resource_projections projection CROSS JOIN selected_org scope + WHERE projection.body_key_version IS NOT NULL + AND (scope.id IS NULL OR projection.org_id = scope.id) + UNION ALL + SELECT 'fingerprint'::text, intent.expected_cursor_hmac_key_version + FROM app_sync_intents intent + INNER JOIN app_runs run ON run.org_id = intent.org_id AND run.id = intent.run_id + CROSS JOIN selected_org scope + WHERE run.state IN ('pending', 'pending_approval', 'running', + 'waiting_external', 'unknown_outcome') + AND (scope.id IS NULL OR intent.org_id = scope.id) + ) + SELECT DISTINCT purpose, key_id FROM retained_refs ORDER BY purpose, key_id + `); + return Object.freeze(result.rows.map((row) => + Object.freeze(ReferenceRowSchema.parse(row)))); +} diff --git a/apps/api/src/lib/app-run-live-authorization.ts b/apps/api/src/lib/app-run-live-authorization.ts index c6c11498..4b925be1 100644 --- a/apps/api/src/lib/app-run-live-authorization.ts +++ b/apps/api/src/lib/app-run-live-authorization.ts @@ -800,6 +800,9 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize run: AppRunSafeView, internal: InternalRunAuthorization, ): Promise { + // Resource sync requires its own host-created intent and live consent. + // The existing action authorization paths cannot authorize that scope. + if (run.review_scope === 'reviewed_resource_sync') return false; if (run.provider_kind === 'app_runtime') { try { if (run.origin_kind !== 'app' || run.execution_actor_type !== 'human' @@ -921,6 +924,9 @@ export class PostgresAppRunLiveAuthorization implements AppRunExecutionAuthorize callerSurface: z.infer, storedAppRefs: readonly AuthorityRef[], ): Promise { + if (run.review_scope === 'reviewed_resource_sync') { + throw new Error('APP_RUN_AUTHORIZATION_STALE'); + } if ( !internal.origin_app_installation_id || !internal.origin_app_version_id diff --git a/apps/api/src/lib/app-run-runtime.ts b/apps/api/src/lib/app-run-runtime.ts index d2a582be..21b30870 100644 --- a/apps/api/src/lib/app-run-runtime.ts +++ b/apps/api/src/lib/app-run-runtime.ts @@ -3,7 +3,9 @@ import { PostgresAppRunApprovalResolver, postgresAppRunApprovalAdapter } from '. import { PostgresAppRunAttentionProjector } from './app-run-attention.js'; import { PostgresAppRunAuthorizer } from './app-run-authorization.js'; import { AppRunError } from './app-run-errors.js'; -import { parseEnvironmentAppRunKeyrings, type EnvironmentAppRunKeyProvider } from './app-run-keyrings.js'; +import { assertAppRunReferencedKeysAvailable, parseEnvironmentAppRunKeyrings, + type EnvironmentAppRunKeyProvider } from './app-run-keyrings.js'; +import { listAppResourceSyncKeyReferences } from './app-resource-sync-key-references.js'; import { PostgresAppRunLiveAuthorization } from './app-run-live-authorization.js'; import { AppRunOperationsService, @@ -100,6 +102,9 @@ async function createAppRunRuntime(): Promise { try { await service.assertReferencedKeysAvailable(); + // Resource projections survive individual Runs and revoked bindings. + // Inventory their keys once at bootstrap, outside the submission hot path. + assertAppRunReferencedKeysAvailable(keys, await listAppResourceSyncKeyReferences()); } catch (error) { keys.destroy(); throw error; diff --git a/apps/api/src/lib/app-runtime-authority.ts b/apps/api/src/lib/app-runtime-authority.ts index 686da98b..9969feac 100644 --- a/apps/api/src/lib/app-runtime-authority.ts +++ b/apps/api/src/lib/app-runtime-authority.ts @@ -129,7 +129,8 @@ export async function loadLiveRuntimeAuthority( eq(appRuntimeSessions.org_id, orgId), eq(appRuntimeSessions.id, sessionId), eq(appRuntimeSessions.token_hash, tokenHash), )).limit(1); - if (!locator) return null; + if (!locator || locator.audience !== 'app_runtime' + || !locator.runtime_binding_id || locator.resource_binding_id !== null) return null; // Match App lifecycle's membership -> installation lock order. A Run // locator is untrusted; its exact actor identity is reread at the boundary. const memberIds: string[] = []; @@ -177,6 +178,7 @@ export async function loadLiveRuntimeAuthority( )).limit(1); const checkedAt = now(); if (!session || session.audience !== 'app_runtime' || session.revoked_at + || !session.runtime_binding_id || session.resource_binding_id !== null || session.expires_at <= checkedAt || session.runtime_registration_id !== locator.runtime_registration_id || session.runtime_binding_id !== locator.runtime_binding_id || session.operator_user_id !== locator.operator_user_id) return null; diff --git a/apps/api/src/lib/app-runtime-channel.ts b/apps/api/src/lib/app-runtime-channel.ts index 5cc7536b..35116461 100644 --- a/apps/api/src/lib/app-runtime-channel.ts +++ b/apps/api/src/lib/app-runtime-channel.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, gt, isNotNull } from 'drizzle-orm'; +import { and, asc, eq, gt, isNotNull, isNull } from 'drizzle-orm'; import { appRunAttempts, appRuns, appRuntimeSessions } from '@deft/db/schema'; import { db } from './db.js'; import { isAppRuntimeChannelEnabled } from './env.js'; @@ -96,7 +96,10 @@ export class AppRuntimeChannel { token_hash: appRuntimeSessions.token_hash, }).from(appRuntimeSessions).where(and( eq(appRuntimeSessions.id, sessionId), eq(appRuntimeSessions.token_hash, tokenHash), + eq(appRuntimeSessions.audience, 'app_runtime'), + isNull(appRuntimeSessions.resource_binding_id), )).limit(1); - return session ?? null; + if (!session?.runtime_binding_id) return null; + return { ...session, runtime_binding_id: session.runtime_binding_id }; } } diff --git a/apps/api/test/app-resource-sync-key-references-db.test.ts b/apps/api/test/app-resource-sync-key-references-db.test.ts new file mode 100644 index 00000000..795c340b --- /dev/null +++ b/apps/api/test/app-resource-sync-key-references-db.test.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; +import pg from 'pg'; +import { runtimeV3PackageJson } from './fixtures/runtime-v3-package.js'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const safeDatabase = (() => { + if (!databaseUrl || databaseUrl !== process.env.DATABASE_URL) return false; + try { + const url = new URL(databaseUrl); + return ['postgres:', 'postgresql:'].includes(url.protocol) + && url.hostname === '127.0.0.1' && url.port === '55435' + && url.pathname === '/gate_g_phase5_test_s04_runtime_keyrefs' + && url.search === '' && url.hash === ''; + } catch { return false; } +})(); + +const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; +const hmac = (value: string) => `hmac-sha256:${createHash('sha256').update(value).digest('hex')}`; +const material = (seed: string) => createHash('sha256').update(seed).digest('base64'); +function keyringDocument(fingerprintIds: readonly string[], encryptionIds: readonly string[]) { + const keys = (purpose: string, ids: readonly string[]) => Object.fromEntries(ids.map((id) => + [id, material(`sync-keyrefs:${purpose}:${id}`)])); + return JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: encryptionIds[0], keys: keys('encryption', encryptionIds) }, + receipt_signing: { current: 'fixture-signing', + keys: keys('signing', ['fixture-signing']) }, + fingerprint: { current: fingerprintIds[0], keys: keys('fingerprint', fingerprintIds) }, + }); +} + +test('sync key inventory retains private projection and unresolved intent keys with tenant scope', { + skip: !safeDatabase, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + process.env.DEFT_APP_RUN_KEYRINGS = keyringDocument(['initial-fp'], ['initial-enc']); + const [{ db, closeDb }, schema, appService, reviewService, moduleService, fixture, + inventory, keyrings, drizzle, runtimeModule, runtimeAuthority, runtimeChannel] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), + import('../../../packages/db/scripts/fixtures/app-resource-sync-schema-fixture.js'), + import('../src/lib/app-resource-sync-key-references.js'), + import('../src/lib/app-run-keyrings.js'), import('drizzle-orm'), + import('../src/lib/app-run-runtime.js'), + import('../src/lib/app-runtime-authority.js'), + import('../src/lib/app-runtime-channel.js'), + ]); + const { and, eq } = drizzle; + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + async function lineage(label: string) { + const orgId = randomUUID(); + const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: `Sync inventory ${label}`, + slug: `sync-inventory-${label}-${randomUUID()}` }); + await db.insert(schema.users).values({ id: ownerId, + email: `sync-inventory-${label}-${randomUUID()}@example.test`, name: 'Synthetic owner' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, + role: 'owner', source: 'ui' }); + const staged = await appService.stageAppPackage(owner, await runtimeV3PackageJson()); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const request = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch }; + const prepared = await reviewService.prepareRuntimeAppReview(owner, staged.id, request); + const active = await reviewService.activateRuntimeApp(owner, staged.id, { + ...request, expected_review_digest: prepared.review_digest, accept_host_policy: true, + }); + return { org_id: orgId, app_installation_id: staged.id, + app_version_id: version.id, grant_snapshot_id: active.grant_snapshot_id, + owner_user_id: ownerId }; + } + + const a = await lineage('a'); + const b = await lineage('b'); + // The v2 rows are SQL ancestry fixtures, not a supported author submission. + const aIds = await fixture.insertAppResourceSyncSchemaFixture(client, a, + { key_suffix: 'a', with_projections: false }); + const bIds = await fixture.insertAppResourceSyncSchemaFixture(client, b, + { key_suffix: 'b', with_run_intent: false, with_projections: true, + projection_states: ['tombstone'] }); + + // A valid resource-sync session deliberately uses the *v1 token hash*. + // This proves the channel/authority audience and exclusive-target checks, + // rather than merely relying on a future token-domain hash difference. + const v1Token = `${randomUUID().replaceAll('-', '')}${randomUUID().replaceAll('-', '')}`; + const sessionId = randomUUID(); + const tokenHash = runtimeAuthority.hashAppRuntimeToken(v1Token); + const installationEpochs = await client.query<{ + lifecycle_epoch: number; grant_epoch: number; + }>('SELECT lifecycle_epoch, grant_epoch FROM app_installations WHERE org_id=$1 AND id=$2', + [a.org_id, a.app_installation_id]); + assert.equal(installationEpochs.rows.length, 1); + await client.query(`INSERT INTO app_runtime_sessions + (id, org_id, runtime_registration_id, runtime_binding_id, resource_binding_id, + operator_user_id, token_hash, audience, session_epoch, runtime_epoch, + lifecycle_epoch, grant_epoch, expires_at) + VALUES ($1,$2,$3,NULL,$4,$5,$6,'app_resource_sync',0,1,$7,$8,$9)`, [ + sessionId, a.org_id, aIds.registration_id, aIds.binding_id, + a.owner_user_id, tokenHash, installationEpochs.rows[0]!.lifecycle_epoch, + installationEpochs.rows[0]!.grant_epoch, new Date(Date.now() + 60_000), + ]); + let runnerCalls = 0; + const forbiddenRunner = Object.fromEntries(['claimRuntimeAttempt', 'startRuntimeAttempt', + 'heartbeatRuntimeAttempt', 'completeRuntimeAttempt'].map((name) => [name, () => { + runnerCalls += 1; + throw new Error(`v1 channel invoked runner ${name}`); + }])); + const channel = new runtimeChannel.AppRuntimeChannel(forbiddenRunner as unknown as + ConstructorParameters[0]); + assert.equal(runtimeChannel.appRuntimeChannelEnabled(), true, + 'audience denial must run with the v1 channel actually enabled'); + const credential = { schema_version: 'deft.app_runtime_channel.v1' as const, + session_id: sessionId, session_token: v1Token }; + const attempt = { ...credential, run_id: randomUUID(), attempt_id: randomUUID(), + claim_token: randomUUID(), sequence: 1 }; + assert.equal(await channel.claim({ ...credential, max_claims: 1 }), null); + assert.equal(await channel.start(attempt), null); + assert.equal(await channel.heartbeat(attempt), false); + assert.equal(await channel.complete({ ...attempt, status: 'indeterminate' }), null); + assert.equal(runnerCalls, 0); + assert.equal(await db.transaction((tx) => runtimeAuthority.loadLiveRuntimeAuthority( + tx, a.org_id, sessionId, tokenHash, () => new Date())), null); + + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(a.org_id), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-a' }, + ], 'empty cursor has no AES reference'); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(b.org_id), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-b' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-b' }, + ], 'a tombstone alone retains encrypted provider identity and locator keys'); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(randomUUID()), []); + await assert.rejects(inventory.listAppResourceSyncKeyReferences(null as unknown as string)); + await db.update(schema.appResourceBindings).set({ state: 'revoked' }).where(and( + eq(schema.appResourceBindings.org_id, b.org_id), + eq(schema.appResourceBindings.id, bIds.binding_id))); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(b.org_id), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-b' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-b' }, + ], 'revocation does not erase retained ciphertext references'); + + const [firstRun] = await db.select().from(schema.appRuns).where(and( + eq(schema.appRuns.org_id, a.org_id), eq(schema.appRuns.id, aIds.run_id))); + const [firstIntent] = await db.select().from(schema.appSyncIntents).where(and( + eq(schema.appSyncIntents.org_id, a.org_id), eq(schema.appSyncIntents.run_id, aIds.run_id))); + assert.ok(firstRun && firstIntent); + const settledRunId = randomUUID(); + await db.insert(schema.appRuns).values({ ...firstRun, id: settledRunId, + root_run_id: settledRunId, idempotency_fingerprint: hmac(settledRunId + ':idempotency'), + input_fingerprint: hmac(settledRunId + ':input') }); + await db.insert(schema.appSyncIntents).values({ ...firstIntent, + id: randomUUID(), run_id: settledRunId }); + const now = new Date(); + const encryptedCursor = Buffer.from(JSON.stringify('next-cursor'), 'utf8'); + await db.update(schema.appSyncCheckpoints).set({ cursor_sequence: 1, + cursor_hmac_key_version: 'fixture-fp-a-next', cursor_hmac: hmac('next-cursor'), + cursor_state: 'value', cursor_envelope_version: 'deft.secret.v1', + cursor_algorithm: 'aes-256-gcm', cursor_key_version: 'fixture-enc-a-cursor', + cursor_nonce_b64: Buffer.alloc(12, 4).toString('base64'), + cursor_ciphertext_b64: encryptedCursor.toString('base64'), + cursor_auth_tag_b64: Buffer.alloc(16, 5).toString('base64'), + cursor_bytes: encryptedCursor.length, + last_applied_run_id: settledRunId, last_applied_page_digest: digest('synthetic-page'), + last_applied_at: now, last_checked_at: now }).where(and( + eq(schema.appSyncCheckpoints.org_id, a.org_id), + eq(schema.appSyncCheckpoints.id, aIds.checkpoint_id))); + for (const runId of [aIds.run_id, settledRunId]) { + await db.update(schema.appRuns).set({ state: 'running', + execution_release_kind: 'policy_satisfied', execution_released_at: now, + started_at: now }).where(and(eq(schema.appRuns.org_id, a.org_id), + eq(schema.appRuns.id, runId))); + } + await db.update(schema.appRuns).set({ state: 'unknown_outcome', + unknown_outcome_at: now }).where(and(eq(schema.appRuns.org_id, a.org_id), + eq(schema.appRuns.id, aIds.run_id))); + await db.update(schema.appRuns).set({ state: 'succeeded', terminal_at: now }) + .where(and(eq(schema.appRuns.org_id, a.org_id), + eq(schema.appRuns.id, settledRunId))); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(a.org_id), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-a' }, + { purpose: 'fingerprint', key_id: 'fixture-fp-a-next' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-a-cursor' }, + ], 'unknown Run retains its old starting cursor key after another page advances'); + const withoutOldFingerprint = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'fixture-enc-b', keys: { + 'fixture-enc-b': material('sync-keyrefs:enc-b'), + 'fixture-enc-a-cursor': material('sync-keyrefs:enc-a-cursor'), + } }, + receipt_signing: { current: 'fixture-signing', + keys: { 'fixture-signing': material('sync-keyrefs:signing') } }, + fingerprint: { current: 'fixture-fp-a-next', keys: { + 'fixture-fp-a-next': material('sync-keyrefs:fp-a-next'), + 'fixture-fp-b': material('sync-keyrefs:fp-b'), + } }, + })); + try { + const references = await inventory.listAppResourceSyncKeyReferences(a.org_id); + assert.throws(() => keyrings.assertAppRunReferencedKeysAvailable(withoutOldFingerprint, + references), + keyrings.AppRunKeyVersionUnavailableError); + } finally { withoutOldFingerprint.destroy(); } + await db.update(schema.appRuns).set({ state: 'succeeded', terminal_at: new Date() }) + .where(and(eq(schema.appRuns.org_id, a.org_id), eq(schema.appRuns.id, aIds.run_id))); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(a.org_id), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-a-next' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-a-cursor' }, + ], 'terminal intent alone no longer retains its historical cursor key'); + assert.deepEqual(await inventory.listAppResourceSyncKeyReferences(), [ + { purpose: 'fingerprint', key_id: 'fixture-fp-a-next' }, + { purpose: 'fingerprint', key_id: 'fixture-fp-b' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-a-cursor' }, + { purpose: 'run_encryption', key_id: 'fixture-enc-b' }, + ]); + + const available = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'fixture-enc-b', keys: { + 'fixture-enc-b': material('sync-keyrefs:enc-b'), + 'fixture-enc-a-cursor': material('sync-keyrefs:enc-a-cursor'), + } }, + receipt_signing: { current: 'fixture-signing', + keys: { 'fixture-signing': material('sync-keyrefs:signing') } }, + fingerprint: { current: 'fixture-fp-a-next', keys: { + 'fixture-fp-a-next': material('sync-keyrefs:fp-a-next'), + 'fixture-fp-b': material('sync-keyrefs:fp-b'), + } }, + })); + try { + const references = await inventory.listAppResourceSyncKeyReferences(); + assert.doesNotThrow(() => keyrings.assertAppRunReferencedKeysAvailable(available, references)); + } finally { available.destroy(); } + const missingEncryption = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'replacement-enc', + keys: { 'replacement-enc': material('sync-keyrefs:replacement-enc') } }, + receipt_signing: { current: 'fixture-signing', + keys: { 'fixture-signing': material('sync-keyrefs:signing') } }, + fingerprint: { current: 'fixture-fp-a-next', keys: { + 'fixture-fp-a-next': material('sync-keyrefs:fp-a-next'), + 'fixture-fp-b': material('sync-keyrefs:fp-b'), + } }, + })); + try { + const references = await inventory.listAppResourceSyncKeyReferences(); + assert.throws(() => keyrings.assertAppRunReferencedKeysAvailable(missingEncryption, + references), + keyrings.AppRunKeyVersionUnavailableError); + } finally { missingEncryption.destroy(); } + // The composition root inventories sync state at process bootstrap. Its + // existing per-Run key scan is satisfied by fixture-fp-a in each ring. + process.env.DEFT_APP_RUN_KEYRINGS = keyringDocument( + ['fixture-fp-a', 'fixture-fp-a-next', 'fixture-fp-b'], + ['fixture-enc-a-cursor']); + await assert.rejects(runtimeModule.getAppRunRuntime(), + keyrings.AppRunKeyVersionUnavailableError, + 'a tombstone provider-ID encryption key cannot be retired'); + process.env.DEFT_APP_RUN_KEYRINGS = keyringDocument( + ['fixture-fp-a', 'fixture-fp-a-next'], + ['fixture-enc-a-cursor', 'fixture-enc-b']); + await assert.rejects(runtimeModule.getAppRunRuntime(), + keyrings.AppRunKeyVersionUnavailableError, + 'a tombstone locator HMAC key cannot be retired'); + process.env.DEFT_APP_RUN_KEYRINGS = keyringDocument( + ['fixture-fp-a', 'fixture-fp-a-next', 'fixture-fp-b'], + ['fixture-enc-a-cursor', 'fixture-enc-b']); + assert.ok(await runtimeModule.getAppRunRuntime(), + 'bootstrap accepts complete retained sync key inventory'); + } finally { + await runtimeModule.shutdownAppRunRuntime(); + await client.end(); + await closeDb(); + } +}); diff --git a/apps/api/test/fixtures/runtime-v3-package.ts b/apps/api/test/fixtures/runtime-v3-package.ts index 9d553c0a..4859fa97 100644 --- a/apps/api/test/fixtures/runtime-v3-package.ts +++ b/apps/api/test/fixtures/runtime-v3-package.ts @@ -10,7 +10,7 @@ export async function runtimeV3PackageJson(): Promise { } const suffix = randomUUID().replace(/-/g, ''); return (await buildDeftAppPackage({ manifest: { - schema_version: '3', id: `community.example.shipping.${suffix}`, + schema_version: '3', id: `community.example.shipping.app${suffix}`, version: '1.0.0', name: 'Shipping', license: 'AGPL-3.0-only', compatibility: { app_protocol: '3' }, modules: [], navigation: [], runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], diff --git a/packages/db/scripts/upgrade.test.ts b/packages/db/scripts/upgrade.test.ts index fadfd54a..1e947e2c 100644 --- a/packages/db/scripts/upgrade.test.ts +++ b/packages/db/scripts/upgrade.test.ts @@ -784,7 +784,8 @@ test('module fresh-install and supported-upgrade SQL stay identical and enforce const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0081_modules_v1.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql, 'fresh installs and supported upgrades must create the same module schema'); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n'), + 'fresh installs and supported upgrades must create the same module schema'); assert.match( applyExtrasSource, /'0081_modules_v1\.sql'/, @@ -946,7 +947,7 @@ test('module relations/views fresh-install and supported-upgrade SQL stay identi const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0082_module_relations_views.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0082_module_relations_views\.sql'/); assert.match( upgradeSql, @@ -1024,7 +1025,7 @@ test('Agent Channel lease schema converges across fresh installs and supported u const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0083_agent_channel_leases.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0083_agent_channel_leases\.sql'/); assert.match(upgradeSql, /claim_token text/i); assert.match(upgradeSql, /lease_expires_at timestamp/i); @@ -1048,7 +1049,7 @@ test('wiki memory sync schema converges across fresh installs and supported upgr const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0084_wiki_memory_sync.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0084_wiki_memory_sync\.sql'/); assert.match(upgradeSql, /wiki_memory_sync_identity_unique/i); assert.match(upgradeSql, /content_digest/i); @@ -1068,7 +1069,7 @@ test('runtime reconciliation outcome converges across fresh installs and support 'utf8', ); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0085_agent_channel_runtime_reconciliation\.sql'/); assert.match(upgradeSql, /ADD COLUMN IF NOT EXISTS channel_event_id text/i); assert.match(upgradeSql, /agent_action_runtime_request_idx/i); @@ -1086,7 +1087,7 @@ test('tenant-bound attachment links converge across fresh installs and supported const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0086_attachment_links.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0086_attachment_links\.sql'/); assert.match(upgradeSql, /INSERT INTO "message_attachments"/i); assert.match(upgradeSql, /INSERT INTO "task_attachments"/i); @@ -1120,7 +1121,7 @@ test('bounded attachment processing converges across fresh installs and supporte const upgradeSql = readFileSync(resolve(scriptsDir, '..', 'upgrades', migration.file), 'utf8'); const freshSql = readFileSync(resolve(scriptsDir, '..', 'drizzle', '0087_attachment_processing.sql'), 'utf8'); const applyExtrasSource = readFileSync(resolve(scriptsDir, 'apply-extras.ts'), 'utf8'); - assert.equal(upgradeSql, freshSql); + assert.equal(upgradeSql.replace(/\r\n/g, '\n'), freshSql.replace(/\r\n/g, '\n')); assert.match(applyExtrasSource, /'0087_attachment_processing\.sql'/); assert.match(upgradeSql, /CREATE TYPE "attachment_processing_status"/i); assert.match(upgradeSql, /CREATE TABLE IF NOT EXISTS "attachment_derivatives"/i); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index c89182e8..559cebd2 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -701,6 +701,20 @@ { "file": "apps/api/test/app-automation-operator-acceptance-db.test.ts", "name": "A01 operator HTTP pages 100+ governed definitions and exposes current results and receipts" }, { "file": "apps/api/test/app-automation-operator-acceptance-db.test.ts", "name": "A01 live reviewed definition has a next fire, then blocks changed resource and revoked connector authority" } ] + }, + { + "id": "resource-sync-schema-foundation", + "description": "Dormant preview37 SQL ancestry, immutable intent, checkpoint CAS and stored ciphertext capacity; synthetic envelopes do not establish live sync or cryptographic correctness.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-schema-db.test.ts", "name": "S04 v2 schema pins reviewed ancestry, intent CAS and encrypted capacity" } + ] + }, + { + "id": "resource-sync-key-retention", + "description": "Retained projection and unresolved-intent key inventory, bootstrap failure on missing keys, and v2 session denial at existing v1 Runtime boundaries on a dedicated synthetic database.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-key-references-db.test.ts", "name": "sync key inventory retains private projection and unresolved intent keys with tenant scope" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From 64945467992248d4c3fc6bf05485d29c8c05b71c Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:41:38 +0530 Subject: [PATCH 011/161] Add authoring-only App Protocol v5 resource package contract --- packages/app-kit/src/cli.ts | 6 +- packages/app-kit/src/index.ts | 102 ++++++++++++++++-- packages/app-kit/src/resource-authoring.ts | 68 ++++++++++++ packages/app-kit/test/protocol-v2.test.ts | 2 +- .../app-kit/test/resource-app-v5-cli.test.ts | 54 ++++++++++ packages/app-kit/test/resource-app-v5.test.ts | 94 ++++++++++++++++ 6 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 packages/app-kit/src/resource-authoring.ts create mode 100644 packages/app-kit/test/resource-app-v5-cli.test.ts create mode 100644 packages/app-kit/test/resource-app-v5.test.ts diff --git a/packages/app-kit/src/cli.ts b/packages/app-kit/src/cli.ts index d73455d3..3c2b2a3d 100644 --- a/packages/app-kit/src/cli.ts +++ b/packages/app-kit/src/cli.ts @@ -350,7 +350,7 @@ async function buildProject(writeOutput: boolean) { artifacts.push(artifact); modules.push({ ...reference, manifest_digest: artifact.digest }); } - const experiences = source.schema_version === '4' ? await Promise.all(source.experiences.map(async (reference) => { + const experiences = source.schema_version === '4' || source.schema_version === '5' ? await Promise.all(source.experiences.map(async (reference) => { const raw = JSON.parse(await readFile(await assertRegularUnslinkedFile(reference.artifact_path), 'utf8')) as unknown; const artifact = await prepareDeftExperienceArtifact(reference.artifact_path, raw); artifacts.push(artifact); @@ -530,6 +530,10 @@ async function main(): Promise { console.log(`Valid App Protocol v1 connected package ${built.digest}; staging grants zero authority; review and activation are explicit; execution is rollout-gated`); } else if (protocol === '3') { console.log(`Valid App Protocol v3 Runtime package ${built.digest}; staging grants zero authority; App and Runtime binding reviews are required`); + } else if (protocol === '5') { + console.log(`Valid App Protocol v5 resource Runtime authoring package ${built.digest}; requested authority only; this host does not support installation yet`); + } else if (protocol === '4') { + console.log(`Valid App Protocol v4 installed Experience package ${built.digest}; staging grants zero authority; App and Runtime binding reviews are required`); } else { console.log(`Valid App Protocol v2 automation-request package ${built.digest}; staging grants zero authority; automation requests are requested-only and non-executable; provider access: none`); } diff --git a/packages/app-kit/src/index.ts b/packages/app-kit/src/index.ts index bcd9e9c1..74a5a0fa 100644 --- a/packages/app-kit/src/index.ts +++ b/packages/app-kit/src/index.ts @@ -1,12 +1,14 @@ import { z } from 'zod'; import { RuntimeAuthoringShape, RuntimeAuthoringSchema, RuntimeRequestedAuthoritySchema } from './runtime-authoring.js'; import { InstalledAuthoringShape, InstalledAuthoringSchema, InstalledRequestedAuthoritySchema } from './installed-authoring.js'; +import { ResourceAuthoringShape, ResourceAuthoringSchema, ResourceRequestedAuthoritySchema } from './resource-authoring.js'; import { DeftExperienceArtifactSchema, verifyDeftExperienceArtifact } from './experience.js'; export * from './installed-authoring.js'; export * from './runtime-authoring.js'; export * from './runtime-client.js'; export * from './experience.js'; export * from './experience-sdk.js'; +export * from './resource-authoring.js'; import { abortOnUnknownContractKeys } from './module-contract/zod-compat.js'; import { classifyAppAutomationOccurrence, @@ -46,6 +48,9 @@ export const DEFT_APP_PACKAGE_FORMAT_V3 = 'deft.app.package.v3' as const; export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 = '4' as const; export const DEFT_APP_PROTOCOL_VERSION_V4 = '4' as const; export const DEFT_APP_PACKAGE_FORMAT_V4 = 'deft.app.package.v4' as const; +export const DEFT_APP_MANIFEST_SCHEMA_VERSION_V5 = '5' as const; +export const DEFT_APP_PROTOCOL_VERSION_V5 = '5' as const; +export const DEFT_APP_PACKAGE_FORMAT_V5 = 'deft.app.package.v5' as const; export const DEFT_MODULE_ARTIFACT_MEDIA_TYPE = 'application/vnd.deft.module+json' as const; export const DEFT_APP_KIT_PACKAGE_NAME = '@deft/app-kit' as const; export const DEFT_APP_KIT_VERSION = '0.1.0-alpha.5' as const; @@ -885,6 +890,11 @@ const V2_HANDLER_MATRIX = handlerMatrix({ }); export const DEFT_APP_PROTOCOL_SUPPORT = Object.freeze({ + '5': Object.freeze({ + manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions', 'sync_descriptors', 'experiences', 'public_actions']), + atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'modules.included', 'navigation.host_rendered', 'runtime.private_actions', 'resources.owner_private_sync', 'experiences.installed', 'public.claim_actions'], handlerMatrix({ authoring: 'app-kit:v5' })), + private_interfaces: Object.freeze([]), + }), '4': Object.freeze({ manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions', 'experiences', 'public_actions']), atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'modules.included', 'navigation.host_rendered', 'runtime.private_actions', 'experiences.installed', 'public.claim_actions'], handlerMatrix({ @@ -1070,6 +1080,43 @@ export const DeftAppPackageV4Schema = z.strictObject({ export type DeftAppManifestV4 = z.infer; export type DeftAppManifestV4Input = z.input; export type DeftAppPackageV4 = z.infer; +export const DeftAppManifestV5Schema = z.strictObject({ + ...DeftAppManifestV0Schema.shape, + schema_version: z.literal('5'), compatibility: z.strictObject({ app_protocol: z.literal('5') }), + modules: z.array(DeftAppModuleReferenceV0Schema).max(APP_LIMITS.artifacts_per_app - 1), + navigation: z.array(DeftAppNavigationItemV0Schema).default([]), + ...ResourceAuthoringShape, +}).superRefine((manifest, ctx) => { + const resource = ResourceAuthoringSchema.safeParse({ runtime_requirements: manifest.runtime_requirements, + private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions, + sync_descriptors: manifest.sync_descriptors, experiences: manifest.experiences, public_actions: manifest.public_actions }); + if (!resource.success) for (const issue of resource.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + if (manifest.modules.length) { + const declarative = DeftAppManifestV0Schema.safeParse({ ...Object.fromEntries( + Object.keys(DeftAppManifestV0Schema.shape).map((key) => [key, manifest[key as keyof typeof manifest]])), + schema_version: '0', compatibility: { app_protocol: '0' } }); + if (!declarative.success) for (const issue of declarative.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + } else if (manifest.navigation.length) ctx.addIssue({ code: 'custom', path: ['navigation'], message: 'Navigation requires included Modules' }); + for (const [index, item] of manifest.public_actions.entries()) { + if (!manifest.modules.some((module) => module.module_id === item.module_id)) ctx.addIssue({ code: 'custom', + path: ['public_actions', index, 'module_id'], message: 'Public claims require an included Module' }); + } + if (manifest.experiences.some((item) => manifest.modules.some((module) => module.manifest_path === item.artifact_path))) { + ctx.addIssue({ code: 'custom', path: ['experiences'], message: 'Artifact paths must be unique across surfaces' }); + } +}); +export const DeftAppPackageV5Schema = z.strictObject({ + package_format: z.literal(DEFT_APP_PACKAGE_FORMAT_V5), manifest: DeftAppManifestV5Schema, + manifest_digest: AppDigestSchema, + artifacts: z.array(z.union([DeftAppPackageArtifactV0Schema, DeftExperienceArtifactSchema])).max(APP_LIMITS.artifacts_per_app), +}); +export type DeftAppManifestV5 = z.infer; +export type DeftAppManifestV5Input = z.input; +export type DeftAppPackageV5 = z.infer; +export function parseResourceAppManifest(value: unknown): DeftAppManifestV5 { + if (recordWithString(value, 'schema_version') !== '5') throw new TypeError('Resource App manifest must use Protocol v5'); + return parseDeftAppManifest(value) as DeftAppManifestV5; +} export type RuntimeAppManifest = DeftAppManifestV3 | DeftAppManifestV4; export function parseRuntimeAppManifest(value: unknown): RuntimeAppManifest { return recordWithString(value, 'schema_version') === '4' @@ -1082,6 +1129,7 @@ export const DeftAppManifestSchema = z.union([ DeftAppManifestV2Schema, DeftAppManifestV3Schema, DeftAppManifestV4Schema, + DeftAppManifestV5Schema, ]); export const DeftAppPackageSchema = z.union([ DeftAppPackageV0Schema, @@ -1089,6 +1137,7 @@ export const DeftAppPackageSchema = z.union([ DeftAppPackageV2Schema, DeftAppPackageV3Schema, DeftAppPackageV4Schema, + DeftAppPackageV5Schema, ]); export type DeftAppManifestV0 = z.infer; @@ -1193,6 +1242,11 @@ export const DeftAppRequestedAuthorityProjectionAnySchema = z.union([ export const DeftAppRequestedAuthorityReportAnySchema = z.union([ DeftAppRequestedAuthorityReportSchema, DeftAppRequestedAuthorityReportV2Schema, + z.strictObject({ + schema: z.literal('deft.app.requested_authority.v5'), + app: z.strictObject({ id: AppIdSchema, version: AppSemverSchema, protocol_version: z.literal('5') }), + requested_authority: ResourceRequestedAuthoritySchema, + }), ]); export type DeftAppRequestedAuthorityProjection = @@ -1200,11 +1254,11 @@ export type DeftAppRequestedAuthorityProjection = export type DeftAppRequestedAuthorityProjectionV2 = z.infer; export type DeftAppRequestedAuthorityProjectionAny = - DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2 | z.infer | z.infer; + DeftAppRequestedAuthorityProjection | DeftAppRequestedAuthorityProjectionV2 | z.infer | z.infer | z.infer; export type DeftAppRequestedAuthorityReport = z.infer; export type DeftAppRequestedAuthorityReportV2 = z.infer; export type DeftAppRequestedAuthorityReportAny = - DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2 | { schema: 'deft.app.requested_authority.v3'; app: {id: string; version: string; protocol_version: '3'}; requested_authority: z.infer } | { schema: 'deft.app.requested_authority.v4'; app: {id: string; version: string; protocol_version: '4'}; requested_authority: z.infer }; + DeftAppRequestedAuthorityReport | DeftAppRequestedAuthorityReportV2 | { schema: 'deft.app.requested_authority.v3'; app: {id: string; version: string; protocol_version: '3'}; requested_authority: z.infer } | { schema: 'deft.app.requested_authority.v4'; app: {id: string; version: string; protocol_version: '4'}; requested_authority: z.infer } | { schema: 'deft.app.requested_authority.v5'; app: {id: string; version: string; protocol_version: '5'}; requested_authority: z.infer }; const DeftAppRequestedAuthorityAtomSchema = z.enum([ 'dependencies', @@ -1215,6 +1269,7 @@ const DeftAppRequestedAuthorityAtomSchema = z.enum([ 'automation_requests', 'experiences', 'public_actions', + 'sync_descriptors', ]); export const DeftAppRequestedAuthorityDiffSchema = z.strictObject({ @@ -1295,6 +1350,7 @@ export async function diffDeftAppRequestedAuthority(input: Readonly<{ const requirement = (value: DeftAppRequestedAuthorityProjectionAny, atom: typeof atoms[number]) => { if (atom === 'experiences') return 'experiences' in value.requirements ? value.requirements.experiences : []; if (atom === 'public_actions') return 'public_actions' in value.requirements ? value.requirements.public_actions : []; + if (atom === 'sync_descriptors') return 'sync_descriptors' in value.requirements ? value.requirements.sync_descriptors : []; if ('runtime_actions' in value.requirements) return atom === 'capabilities' ? value.requirements.private_capabilities : atom === 'connectors' ? value.requirements.runtime_requirements : atom === 'actions' ? value.requirements.runtime_actions : []; @@ -1418,6 +1474,12 @@ export function projectDeftAppRequestedAuthority( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityProjectionAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '5') return ResourceRequestedAuthoritySchema.parse({ + requirements: { runtime_requirements: manifest.runtime_requirements, private_capabilities: manifest.private_capabilities, + runtime_actions: manifest.runtime_actions, sync_descriptors: manifest.sync_descriptors, + experiences: manifest.experiences, public_actions: manifest.public_actions }, + classification: { authority_state: 'requested_only', executable: false, provider_access: false, review_required: true }, + }); if (manifest.schema_version === '4') return InstalledRequestedAuthoritySchema.parse({ requirements: { runtime_requirements: manifest.runtime_requirements, private_capabilities: manifest.private_capabilities, runtime_actions: manifest.runtime_actions, experiences: manifest.experiences, public_actions: manifest.public_actions }, @@ -1479,6 +1541,11 @@ export function buildDeftAppRequestedAuthorityReport( value: DeftAppManifestInput | DeftAppManifest, ): DeftAppRequestedAuthorityReportAny { const manifest = parseDeftAppManifest(value); + if (manifest.schema_version === '5') return DeftAppRequestedAuthorityReportAnySchema.parse({ + schema: 'deft.app.requested_authority.v5', + app: { id: manifest.id, version: manifest.version, protocol_version: '5' }, + requested_authority: ResourceRequestedAuthoritySchema.parse(projectDeftAppRequestedAuthority(manifest)), + }) as DeftAppRequestedAuthorityReportAny; if (manifest.schema_version === '4') return { schema: 'deft.app.requested_authority.v4', app: { id: manifest.id, version: manifest.version, protocol_version: '4' }, @@ -1596,7 +1663,9 @@ export function parseDeftAppManifest(value: unknown): DeftAppManifest { // v1 or v2 continues through the original direct v0 schema instead of a // union branch. const schemaVersion = recordWithString(value, 'schema_version'); - const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 + const manifest = schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V5 + ? DeftAppManifestV5Schema.parse(value) + : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 ? DeftAppManifestV4Schema.parse(value) : schemaVersion === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 ? DeftAppManifestV3Schema.parse(value) @@ -1661,6 +1730,8 @@ export function getDeftAppManifestV2JsonSchema(): Record { } export function getDeftAppManifestJsonSchema(schemaVersion: string): Record { + if (schemaVersion === '5') return { title: 'Deft resource Runtime App manifest v5', + ...z.toJSONSchema(DeftAppManifestV5Schema, { target: 'draft-2020-12', unrepresentable: 'any' }) }; if (schemaVersion === '4') return { title: 'Deft installed Runtime App manifest v4', ...z.toJSONSchema(DeftAppManifestV4Schema, { target: 'draft-2020-12', unrepresentable: 'any' }) }; if (schemaVersion === '3') return { title: 'Deft Runtime App manifest v3', @@ -1865,7 +1936,8 @@ async function verifyPackage(packageValue: DeftAppPackage): Promise { if ((await digestAppManifest(packageValue.manifest)) !== packageValue.manifest_digest) { throw new Error('App manifest digest mismatch'); } - const experiences = packageValue.manifest.schema_version === '4' ? packageValue.manifest.experiences : []; + const experiences = packageValue.manifest.schema_version === '4' || packageValue.manifest.schema_version === '5' + ? packageValue.manifest.experiences : []; if (artifacts.size !== packageValue.manifest.modules.length + experiences.length) { throw new Error('Package must contain exactly the artifacts declared by the app manifest'); } @@ -1890,15 +1962,21 @@ async function verifyPackage(packageValue: DeftAppPackage): Promise { moduleManifests.set(identity.id, moduleManifest); } verifyNavigationBindings(packageValue.manifest, moduleManifests); - if (packageValue.manifest.schema_version === '4') { + if (packageValue.manifest.schema_version === '4' || packageValue.manifest.schema_version === '5') { const installedManifest = packageValue.manifest; for (const reference of experiences) { const bundle = await verifyDeftExperienceArtifact({ artifact_path: reference.artifact_path, artifact_digest: reference.artifact_digest, bridge_version: reference.bridge_version, renderer_version: reference.renderer_version }, artifacts.get(reference.artifact_path)); - if (bundle.resource_keys.length || bundle.action_keys.some((key) => - !installedManifest.runtime_actions.some((action) => action.key === key))) { - throw new Error('Experience must use only declared Runtime actions; resource bridge is not supported yet'); + if (bundle.action_keys.some((key) => !installedManifest.runtime_actions.some((action) => action.key === key))) { + throw new Error('Experience must use only declared Runtime actions'); + } + if (installedManifest.schema_version === '4' && bundle.resource_keys.length) { + throw new Error('Experience resource bridge is not supported by Protocol v4'); + } + if (installedManifest.schema_version === '5' && (bundle.resource_keys.length > 8 + || bundle.resource_keys.some((key) => !installedManifest.sync_descriptors.some((descriptor) => descriptor.key === key)))) { + throw new Error('Experience must use at most eight declared sync resource keys'); } } for (const declaration of packageValue.manifest.public_actions) { @@ -1971,7 +2049,9 @@ export async function buildDeftAppPackage(input: { manifest_digest: await digestAppManifest(manifest), artifacts: [...input.artifacts].sort((left, right) => left.path.localeCompare(right.path)), }; - const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 + const packageValue: DeftAppPackage = manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V5 + ? DeftAppPackageV5Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V5, ...packageInput }) + : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V4 ? DeftAppPackageV4Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V4, ...packageInput }) : manifest.schema_version === DEFT_APP_MANIFEST_SCHEMA_VERSION_V3 ? DeftAppPackageV3Schema.parse({ package_format: DEFT_APP_PACKAGE_FORMAT_V3, ...packageInput }) @@ -1999,7 +2079,9 @@ export async function verifyDeftAppPackageJson( // As with manifest parsing, direct dispatch keeps invalid-v0 issue shapes // stable while allowing the explicitly versioned v1 and v2 formats. const packageFormat = recordWithString(raw, 'package_format'); - const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V4 + const packageValue = packageFormat === DEFT_APP_PACKAGE_FORMAT_V5 + ? DeftAppPackageV5Schema.parse(raw) + : packageFormat === DEFT_APP_PACKAGE_FORMAT_V4 ? DeftAppPackageV4Schema.parse(raw) : packageFormat === DEFT_APP_PACKAGE_FORMAT_V3 ? DeftAppPackageV3Schema.parse(raw) diff --git a/packages/app-kit/src/resource-authoring.ts b/packages/app-kit/src/resource-authoring.ts new file mode 100644 index 00000000..c22f0318 --- /dev/null +++ b/packages/app-kit/src/resource-authoring.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { InstalledExperienceSchema, PublicActionDeclarationSchema } from './installed-authoring.js'; +import { RuntimeActionSchema, RuntimePrivateCapabilitySchema, RuntimeRequirementSchema } from './runtime-authoring.js'; +import { SyncDescriptorV1Schema } from './resource-sync.js'; + +const key = z.string().min(1).max(48).regex(/^[a-z][a-z0-9_]*$/) + .refine((value) => !['constructor', 'prototype', '__proto__'].includes(value)); + +export const ResourceRuntimeRequirementSchema = z.union([ + RuntimeRequirementSchema, + z.strictObject({ key, protocol_version: z.literal('deft.app_runtime_channel.v2') }), +]); + +/** Authoring only. A v2 requirement asks for a private sync binding; it does + * not select a provider, issue a grant, or grant installed Experience access. */ +export const ResourceAuthoringShape = { + runtime_requirements: z.array(ResourceRuntimeRequirementSchema).min(1).max(8), + private_capabilities: z.array(RuntimePrivateCapabilitySchema).max(8), + runtime_actions: z.array(RuntimeActionSchema).max(16), + sync_descriptors: z.array(SyncDescriptorV1Schema).min(1).max(8), + experiences: z.array(InstalledExperienceSchema).max(1), + public_actions: z.array(PublicActionDeclarationSchema).max(8), +}; + +export const ResourceAuthoringSchema = z.strictObject(ResourceAuthoringShape).superRefine((value, ctx) => { + for (const name of ['runtime_requirements', 'private_capabilities', 'runtime_actions', + 'sync_descriptors', 'experiences', 'public_actions'] as const) { + if (new Set(value[name].map((item) => item.key)).size !== value[name].length) { + ctx.addIssue({ code: 'custom', path: [name], message: 'Keys must be unique' }); + } + } + const requirementByKey = new Map(value.runtime_requirements.map((item) => [item.key, item])); + const descriptorKeys = new Set(value.sync_descriptors.map((item) => item.key)); + const capabilityKeys = new Set(value.private_capabilities.map((item) => item.key)); + for (const [index, action] of value.runtime_actions.entries()) { + if (!capabilityKeys.has(action.capability_key) + || requirementByKey.get(action.runtime_requirement_key)?.protocol_version !== 'deft.app_runtime_channel.v1') { + ctx.addIssue({ code: 'custom', path: ['runtime_actions', index], + message: 'Runtime action must reference a declared capability and v1 Runtime requirement' }); + } + if (descriptorKeys.has(action.key)) ctx.addIssue({ code: 'custom', path: ['runtime_actions', index, 'key'], + message: 'Action and sync descriptor keys must not overlap' }); + } + for (const [index, descriptor] of value.sync_descriptors.entries()) { + if (requirementByKey.get(descriptor.runtime_requirement_key)?.protocol_version !== 'deft.app_runtime_channel.v2') { + ctx.addIssue({ code: 'custom', path: ['sync_descriptors', index, 'runtime_requirement_key'], + message: 'Sync descriptor must reference a declared v2 Runtime requirement' }); + } + if (capabilityKeys.has(descriptor.key)) ctx.addIssue({ code: 'custom', path: ['sync_descriptors', index, 'key'], + message: 'Sync descriptor and action capability keys must not overlap' }); + } + for (const [index, declaration] of value.public_actions.entries()) { + const action = value.runtime_actions.find((item) => item.key === declaration.action_key); + const capability = value.private_capabilities.find((item) => item.key === action?.capability_key); + if (!capability || capability.input_schema.required.some((field) => !Object.hasOwn(declaration.input_mapping, field)) + || Object.keys(declaration.input_mapping).some((field) => { + const schema = capability.input_schema.properties[field]; + return !schema || schema.type !== 'string' || schema.maxLength < 36; + })) ctx.addIssue({ code: 'custom', path: ['public_actions', index], + message: 'Public input must map declared string fields from canonical claim identifiers and cover every required field' }); + } +}); + +export const ResourceRequestedAuthoritySchema = z.strictObject({ + requirements: ResourceAuthoringSchema, + classification: z.strictObject({ authority_state: z.literal('requested_only'), executable: z.literal(false), + provider_access: z.literal(false), review_required: z.literal(true) }), +}); diff --git a/packages/app-kit/test/protocol-v2.test.ts b/packages/app-kit/test/protocol-v2.test.ts index 975c154a..1716682f 100644 --- a/packages/app-kit/test/protocol-v2.test.ts +++ b/packages/app-kit/test/protocol-v2.test.ts @@ -125,7 +125,7 @@ describe('App Protocol v2 bounded automation request contract', () => { assert.deepEqual(getDeftAppManifestJsonSchema('2'), schema); assert.deepEqual(getDeftAppManifestJsonSchema('1'), getDeftAppManifestV1JsonSchema()); assert.deepEqual(getDeftAppManifestJsonSchema('0'), getDeftAppManifestV0JsonSchema()); - assert.throws(() => getDeftAppManifestJsonSchema('5'), /schema v5 is not supported/); + assert.throws(() => getDeftAppManifestJsonSchema('6'), /schema v6 is not supported/); }); test('accepts only one bounded daily trigger declaration over a resolved action', async () => { diff --git a/packages/app-kit/test/resource-app-v5-cli.test.ts b/packages/app-kit/test/resource-app-v5-cli.test.ts new file mode 100644 index 00000000..922504ee --- /dev/null +++ b/packages/app-kit/test/resource-app-v5-cli.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { DEFT_EXPERIENCE_BRIDGE_VERSION, DEFT_EXPERIENCE_RENDERER_VERSION } from '../dist/index.js'; + +const cli = resolve(import.meta.dirname, '..', 'dist', 'cli.js'); +const descriptor = { schema_version: 'deft.app_sync_descriptor.v1', key: 'mail', runtime_requirement_key: 'mail_sync', + resource_type: 'email_message', requested_visibility: 'user_private', label_field: 'subject', + record_schema: { type: 'object', properties: { subject: { type: 'string', maxLength: 200 } }, + required: ['subject'], additionalProperties: false } }; +const source = { schema_version: '5', id: 'community.example.email-lite', version: '1.0.0', name: 'Email Lite', + license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'mail_sync', protocol_version: 'deft.app_runtime_channel.v2' }], + private_capabilities: [], runtime_actions: [], sync_descriptors: [descriptor], public_actions: [], + experiences: [{ key: 'inbox', label: 'Inbox', artifact_path: 'experiences/inbox.json', + artifact_digest: `sha256:${'0'.repeat(64)}`, bridge_version: DEFT_EXPERIENCE_BRIDGE_VERSION, + renderer_version: DEFT_EXPERIENCE_RENDERER_VERSION }] }; +const bundle = { schema_version: 'deft.experience_bundle.v1', entry_view: 'inbox', + worker_source: 'self.onmessage=()=>postMessage({kind:"view"});', resource_keys: ['mail'], action_keys: [] }; + +function run(cwd: string, command: string) { + return spawnSync(process.execPath, [cli, 'app', command], { cwd, encoding: 'utf8' }); +} + +test('v5 CLI packs external sync-only source deterministically and rejects unknown executable fields', async () => { + const dir = await mkdtemp(resolve(tmpdir(), 'deft-v5-source-')); + try { + await mkdir(resolve(dir, 'experiences')); + await writeFile(resolve(dir, 'deft.app.json'), JSON.stringify(source)); + await writeFile(resolve(dir, 'experiences/inbox.json'), JSON.stringify(bundle)); + const check = run(dir, 'check'); + assert.equal(check.status, 0, check.stderr); + assert.match(check.stdout, /v5 resource Runtime authoring package/); + const first = run(dir, 'build'); + assert.equal(first.status, 0, first.stderr); + const packed = await readFile(resolve(dir, '.deft/app.deftapp.json'), 'utf8'); + const lock = await readFile(resolve(dir, 'deft.app.lock.json'), 'utf8'); + const second = run(dir, 'build'); + assert.equal(second.status, 0, second.stderr); + assert.equal(await readFile(resolve(dir, '.deft/app.deftapp.json'), 'utf8'), packed); + assert.equal(await readFile(resolve(dir, 'deft.app.lock.json'), 'utf8'), lock); + assert.deepEqual((JSON.parse(packed) as {manifest:{runtime_actions:unknown[],sync_descriptors:unknown[]}}).manifest.runtime_actions, []); + await writeFile(resolve(dir, 'experiences/inbox.json'), JSON.stringify({ ...bundle, install_script: 'node bad.js' })); + const invalid = run(dir, 'build'); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /Unrecognized key|unrecognized_keys|install_script/i); + } finally { + assert.ok(resolve(dir).startsWith(resolve(tmpdir(), 'deft-v5-source-'))); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/app-kit/test/resource-app-v5.test.ts b/packages/app-kit/test/resource-app-v5.test.ts new file mode 100644 index 00000000..915d6f72 --- /dev/null +++ b/packages/app-kit/test/resource-app-v5.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildDeftAppPackage, buildDeftAppRequestedAuthorityReport, canonicalDeftAppRequestedAuthorityReportJson, + diffDeftAppRequestedAuthority, + DEFT_EXPERIENCE_BRIDGE_VERSION, DEFT_EXPERIENCE_RENDERER_VERSION, + isDeftAppProtocolOperationSupported, parseDeftAppManifest, parseResourceAppManifest, + parseRuntimeAppManifest, prepareDeftExperienceArtifact, verifyDeftAppPackageJson, +} from '../dist/index.js'; + +const recordSchema = { type: 'object' as const, properties: { subject: { type: 'string' as const, maxLength: 200 } }, + required: ['subject'], additionalProperties: false as const }; +const actionSchema = { type: 'object' as const, properties: { message_id: { type: 'string' as const, maxLength: 120 } }, + required: ['message_id'], additionalProperties: false as const }; +const base = { + schema_version: '5' as const, id: 'community.example.email-lite', version: '1.0.0', + name: 'Email Lite', license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' as const }, + modules: [], navigation: [], + runtime_requirements: [{ key: 'mail_sync', protocol_version: 'deft.app_runtime_channel.v2' as const }], + private_capabilities: [], runtime_actions: [], + sync_descriptors: [{ schema_version: 'deft.app_sync_descriptor.v1' as const, key: 'mail', + runtime_requirement_key: 'mail_sync', resource_type: 'email_message', requested_visibility: 'user_private' as const, + record_schema: recordSchema, label_field: 'subject' }], + experiences: [], public_actions: [], +}; +const bundle = { schema_version: 'deft.experience_bundle.v1', + worker_source: 'self.onmessage = () => postMessage({kind:"view"});', entry_view: 'inbox', + resource_keys: ['mail'], action_keys: [] }; + +async function withExperience(manifest: typeof base = base, resourceKeys: string[] = ['mail']) { + const artifact = await prepareDeftExperienceArtifact('experiences/inbox.json', { ...bundle, resource_keys: resourceKeys }); + const reference = { key: 'inbox', label: 'Inbox', artifact_path: artifact.path, artifact_digest: artifact.digest, + bridge_version: DEFT_EXPERIENCE_BRIDGE_VERSION, renderer_version: DEFT_EXPERIENCE_RENDERER_VERSION }; + return { manifest: { ...manifest, experiences: [reference] }, artifact }; +} + +test('v5 sync-only App and Experience package are deterministic, requested-only, and host unsupported', async () => { + const { manifest, artifact } = await withExperience(); + const built = await buildDeftAppPackage({ manifest, artifacts: [artifact] }); + assert.equal(built.package.package_format, 'deft.app.package.v5'); + assert.deepEqual(await verifyDeftAppPackageJson(built.json), built); + assert.equal((await buildDeftAppPackage({ manifest, artifacts: [artifact] })).json, built.json); + assert.equal(parseResourceAppManifest(manifest).sync_descriptors[0]?.key, 'mail'); + assert.throws(() => parseRuntimeAppManifest(manifest)); + assert.equal(isDeftAppProtocolOperationSupported('5', 'authoring'), true); + for (const operation of ['inspect', 'stage', 'review', 'activate', 'route', 'invoke'] as const) { + assert.equal(isDeftAppProtocolOperationSupported('5', operation), false); + } + const report = buildDeftAppRequestedAuthorityReport(manifest); + assert.equal(report.schema, 'deft.app.requested_authority.v5'); + assert.equal(report.requested_authority.classification.provider_access, false); + assert.equal(report.requested_authority.classification.executable, false); + assert.equal((report.requested_authority.requirements as { sync_descriptors: unknown[] }).sync_descriptors.length, 1); + assert.equal(canonicalDeftAppRequestedAuthorityReportJson(manifest), canonicalDeftAppRequestedAuthorityReportJson(manifest)); + assert.deepEqual((await diffDeftAppRequestedAuthority({ proposed: manifest })).changed_atoms, + ['connectors', 'experiences', 'sync_descriptors']); + const widened = { ...manifest, sync_descriptors: [{ ...manifest.sync_descriptors[0]!, resource_type: 'email_thread' }] }; + assert.deepEqual((await diffDeftAppRequestedAuthority({ prior: manifest, proposed: widened })).changed_atoms, + ['sync_descriptors']); +}); + +test('v5 mixed Runtime actions reference only v1; sync descriptors reference only v2', async () => { + const mixed = { ...base, + runtime_requirements: [...base.runtime_requirements, { key: 'mail_write', protocol_version: 'deft.app_runtime_channel.v1' as const }], + private_capabilities: [{ key: 'archive', version: '1' as const, input_schema: actionSchema, output_schema: actionSchema }], + runtime_actions: [{ key: 'archive_mail', label: 'Archive mail', capability_key: 'archive', runtime_requirement_key: 'mail_write' }], + }; + assert.equal(parseDeftAppManifest(mixed).schema_version, '5'); + assert.equal((await buildDeftAppPackage({ manifest: mixed, artifacts: [] })).package.package_format, 'deft.app.package.v5'); + assert.throws(() => parseDeftAppManifest({ ...mixed, runtime_actions: [{ ...mixed.runtime_actions[0], runtime_requirement_key: 'mail_sync' }] })); + assert.throws(() => parseDeftAppManifest({ ...mixed, sync_descriptors: [{ ...mixed.sync_descriptors[0], runtime_requirement_key: 'mail_write' }] })); + assert.throws(() => parseDeftAppManifest({ ...mixed, runtime_actions: [{ ...mixed.runtime_actions[0], key: 'mail' }] })); +}); + +test('v5 rejects undeclared/oversized Experience resources and tampered package bytes', async () => { + const { manifest, artifact } = await withExperience(); + await assert.rejects(() => buildDeftAppPackage({ manifest: { ...manifest, sync_descriptors: [] }, artifacts: [artifact] })); + await assert.rejects(() => buildDeftAppPackage({ manifest: { ...manifest, + runtime_requirements: [...manifest.runtime_requirements, ...manifest.runtime_requirements] }, artifacts: [artifact] })); + await assert.rejects(() => buildDeftAppPackage({ manifest, artifacts: [] }), /exactly the artifacts/); + const undeclared = await withExperience(base, ['other']); + await assert.rejects(() => buildDeftAppPackage({ manifest: undeclared.manifest, artifacts: [undeclared.artifact] }), /declared sync resource/); + const built = await buildDeftAppPackage({ manifest, artifacts: [artifact] }); + const changedManifest = JSON.parse(built.json); changedManifest.manifest.name = 'Changed'; + await assert.rejects(() => verifyDeftAppPackageJson(JSON.stringify(changedManifest)), /digest mismatch/); + const changedArtifact = JSON.parse(built.json); changedArtifact.artifacts[0].content += ' '; + await assert.rejects(() => verifyDeftAppPackageJson(JSON.stringify(changedArtifact)), /byte length mismatch/); + assert.throws(() => parseDeftAppManifest({ ...base, provider_url: 'https://provider.example' })); + assert.throws(() => parseDeftAppManifest({ ...base, sync_descriptors: [{ ...base.sync_descriptors[0], source_url: 'https://provider.example' }] })); + for (const older of ['3', '4']) { + assert.throws(() => parseDeftAppManifest({ ...base, schema_version: older, + compatibility: { app_protocol: older } })); + } +}); From 074b58197acc0e5f8393d8c8f29830c9bf6a8c20 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:52:54 +0530 Subject: [PATCH 012/161] Verify resource App authoring remains outside host activation --- .../test/app-resource-authoring-host.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/api/test/app-resource-authoring-host.test.ts diff --git a/apps/api/test/app-resource-authoring-host.test.ts b/apps/api/test/app-resource-authoring-host.test.ts new file mode 100644 index 00000000..3618a0a8 --- /dev/null +++ b/apps/api/test/app-resource-authoring-host.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { + buildDeftAppPackage, isDeftAppProtocolOperationSupported, + parseRuntimeAppManifest, verifyDeftAppPackageJson, +} from '@deft/app-kit'; + +test('Protocol 5 authoring remains rejected before host inspection and staging access', async () => { + const artifact = await buildDeftAppPackage({ manifest: { + schema_version: '5', id: 'community.example.private-inbox', version: '1.0.0', + name: 'Private inbox', license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' }, + modules: [], navigation: [], private_capabilities: [], runtime_actions: [], + experiences: [], public_actions: [], + runtime_requirements: [{ key: 'mail', protocol_version: 'deft.app_runtime_channel.v2' }], + sync_descriptors: [{ schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'mail', resource_type: 'message', + requested_visibility: 'user_private', label_field: 'subject', + record_schema: { type: 'object', properties: { + subject: { type: 'string', maxLength: 120 }, + }, required: ['subject'], additionalProperties: false } }], + }, artifacts: [] }); + const verified = await verifyDeftAppPackageJson(artifact.json); + assert.equal(verified.package.manifest.schema_version, '5'); + assert.equal(isDeftAppProtocolOperationSupported('5', 'authoring'), true); + assert.equal(isDeftAppProtocolOperationSupported('5', 'inspect'), false); + assert.equal(isDeftAppProtocolOperationSupported('5', 'stage'), false); + assert.throws(() => parseRuntimeAppManifest(verified.package.manifest), + 'the v1 Runtime manifest parser must not silently accept resource Apps'); + + const [{ inspectAppPackageJson, stageAppPackage }, { humanModuleActor }, { closeDb }] = + await Promise.all([import('../src/lib/app-service.js'), + import('../src/lib/module-service.js'), import('../src/lib/db.js')]); + const unsupported = (error: unknown) => { + assert.equal((error as { code: string }).code, 'APP_PROTOCOL_UNSUPPORTED'); + assert.equal((error as { status: number }).status, 409); + return true; + }; + try { + await assert.rejects(inspectAppPackageJson(artifact.json), unsupported); + const owner = humanModuleActor({ orgId: randomUUID(), userId: randomUUID(), + role: 'owner', source: 'ui' }); + await assert.rejects(stageAppPackage(owner, artifact.json), unsupported); + } finally { await closeDb(); } +}); From 7a844be82f6b40dfcfeb358c1089fe060ca8141d Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:55:37 +0530 Subject: [PATCH 013/161] Define bounded private sync consent and credential policy --- apps/api/src/lib/app-resource-sync-policy.ts | 62 +++++++++++++++++++ .../api/test/app-resource-sync-policy.test.ts | 52 ++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 apps/api/src/lib/app-resource-sync-policy.ts create mode 100644 apps/api/test/app-resource-sync-policy.test.ts diff --git a/apps/api/src/lib/app-resource-sync-policy.ts b/apps/api/src/lib/app-resource-sync-policy.ts new file mode 100644 index 00000000..21c1f241 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-policy.ts @@ -0,0 +1,62 @@ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; + +/** Host policy for explicitly reviewed private sync. Author descriptors and + * provider pages cannot override this policy or nominate the resource owner. */ +export const APP_RESOURCE_SYNC_HOST_POLICY = Object.freeze({ + risk_class: 'internal_write', review_requirement: 'policy', + review_scope: 'reviewed_resource_sync', retry_class: 'unsafe_or_unknown', + retention_class: 'standard', +} as const); +export const APP_RESOURCE_SYNC_MAX_CONSENT_MS = 90 * 24 * 60 * 60 * 1_000; +export const APP_RESOURCE_SYNC_SESSION_MS = 15 * 60 * 1_000; + +const identity = z.string().uuid(); +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const epoch = z.number().int().min(0).max(2_147_483_647); +const resourceKey = z.string().min(1).max(48).regex(/^[a-z][a-z0-9_]*$/u) + .refine((value) => !['constructor', 'prototype', '__proto__'].includes(value)); + +export const AppResourceSyncConsentLimitsSchema = z.strictObject({ + max_records_per_page: z.number().int().min(1).max(100), + max_page_bytes: z.number().int().min(1).max(524_288), + max_retained_records: z.number().int().min(1).max(100_000), + max_retained_bytes: z.number().int().min(1).max(1_073_741_824), + min_interval_seconds: z.number().int().min(60).max(86_400), +}); + +export const AppResourceSyncConsentRequestSchema = z.strictObject({ + installation_id: identity, + resource_key: resourceKey, + operator_user_id: identity, + expected_app_version_id: identity, + expected_package_digest: digest, + expected_grant_snapshot_digest: digest, + expected_lifecycle_epoch: epoch, + expected_grant_epoch: epoch, + consent_expires_at: z.string().max(40).datetime({ offset: true }), + limits: AppResourceSyncConsentLimitsSchema, +}); +export const AppResourceSyncConsentActivationSchema = AppResourceSyncConsentRequestSchema.extend({ + expected_review_digest: digest, + accept_host_policy: z.literal(true), +}); +export type AppResourceSyncConsentRequest = z.infer; + +/** Recheck at both preparation and activation; a review digest cannot extend + * an expired window. The clock is host-owned and never taken from input. */ +export function assertResourceSyncConsentWindow(expiresAt: string, checkedAt: Date): Date { + const expiry = new Date(z.string().datetime({ offset: true }).parse(expiresAt)); + const remaining = expiry.getTime() - checkedAt.getTime(); + if (!Number.isFinite(remaining) || remaining <= 0 || remaining > APP_RESOURCE_SYNC_MAX_CONSENT_MS) { + throw new TypeError('Resource sync consent must expire within 90 days of review'); + } + return expiry; +} + +/** Audience separation remains mandatory even if a database row is malformed; + * this domain is deliberately distinct from action Runtime credentials. */ +export function hashAppResourceSyncToken(token: string): string { + return `sha256:${createHash('sha256').update('deft.app_resource_sync.session.v2\0') + .update(token).digest('hex')}`; +} diff --git a/apps/api/test/app-resource-sync-policy.test.ts b/apps/api/test/app-resource-sync-policy.test.ts new file mode 100644 index 00000000..e14c7e55 --- /dev/null +++ b/apps/api/test/app-resource-sync-policy.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; +import { + AppResourceSyncConsentRequestSchema, AppResourceSyncConsentActivationSchema, + assertResourceSyncConsentWindow, hashAppResourceSyncToken, +} from '../src/lib/app-resource-sync-policy.js'; +import { hashAppRuntimeToken } from '../src/lib/app-runtime-authority.js'; + +const request = () => ({ installation_id: randomUUID(), resource_key: 'inbox', + operator_user_id: randomUUID(), expected_app_version_id: randomUUID(), + expected_package_digest: `sha256:${'1'.repeat(64)}`, + expected_grant_snapshot_digest: `sha256:${'2'.repeat(64)}`, + expected_lifecycle_epoch: 1, expected_grant_epoch: 1, + consent_expires_at: '2026-10-01T00:00:00.000Z', + limits: { max_records_per_page: 100, max_page_bytes: 524_288, + max_retained_records: 100_000, max_retained_bytes: 1_073_741_824, + min_interval_seconds: 60 } }); + +test('resource consent cannot supply ownership, provider authority or unbounded capacity', () => { + assert.ok(AppResourceSyncConsentRequestSchema.parse(request())); + for (const extra of [{ owner_user_id: randomUUID() }, { visibility: 'organization' }, + { provider_url: 'https://example.test' }, { host_policy: { retry_class: 'safe' } }, + { descriptor: {} }, { reviewed_at: '2026-09-24T00:00:00Z' }]) { + assert.throws(() => AppResourceSyncConsentRequestSchema.parse({ ...request(), ...extra })); + } + for (const extra of [{ max_records_per_page: 101 }, { max_page_bytes: 524_289 }, + { max_retained_records: 100_001 }, { max_retained_bytes: 1_073_741_825 }, + { min_interval_seconds: 59 }, { max_page_bytes: 1.5 }]) { + const value = request(); + assert.throws(() => AppResourceSyncConsentRequestSchema.parse({ ...value, + limits: { ...value.limits, ...extra } })); + } + assert.throws(() => AppResourceSyncConsentActivationSchema.parse(request())); + assert.throws(() => AppResourceSyncConsentActivationSchema.parse({ ...request(), + expected_review_digest: `sha256:${'3'.repeat(64)}`, accept_host_policy: false })); +}); + +test('resource consent expiry is rechecked at activation using the host clock', () => { + const preparedAt = new Date('2026-09-24T00:00:00.000Z'); + const expiry = '2026-12-23T00:00:00.000Z'; + assert.equal(assertResourceSyncConsentWindow(expiry, preparedAt).toISOString(), expiry); + assert.throws(() => assertResourceSyncConsentWindow('2026-12-23T00:00:00.001Z', preparedAt)); + assert.throws(() => assertResourceSyncConsentWindow(expiry, new Date(expiry))); + assert.throws(() => assertResourceSyncConsentWindow(expiry, new Date('invalid'))); +}); + +test('resource sync credentials never share the v1 action token hash domain', () => { + const token = 'synthetic-credential-value-for-domain-check'; + assert.match(hashAppResourceSyncToken(token), /^sha256:[a-f0-9]{64}$/u); + assert.notEqual(hashAppResourceSyncToken(token), hashAppRuntimeToken(token)); +}); From 788d9c8710b227a5d1628c199adf1e5f9cbf82fb Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:01:24 +0530 Subject: [PATCH 014/161] Record fresh schema history and reject untracked upgrades --- packages/db/package.json | 3 +- packages/db/scripts/push-full.ts | 101 ++++++++++++ packages/db/scripts/upgrade-ledger-db.test.ts | 148 ++++++++++++++++++ packages/db/scripts/upgrade.ts | 45 +++++- 4 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 packages/db/scripts/push-full.ts create mode 100644 packages/db/scripts/upgrade-ledger-db.test.ts diff --git a/packages/db/package.json b/packages/db/package.json index 681e22f2..f561c0e3 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,9 +12,10 @@ "assert-fresh": "tsx scripts/assert-fresh.ts", "generate": "drizzle-kit generate", "push": "drizzle-kit push", - "push-full": "tsx scripts/ensure-pgvector.ts && drizzle-kit push --force && tsx scripts/apply-extras.ts", + "push-full": "tsx scripts/push-full.ts", "upgrade": "tsx scripts/upgrade.ts", "test:upgrade": "tsx --test scripts/upgrade.test.ts", + "test:upgrade-ledger": "tsx --test scripts/upgrade-ledger-db.test.ts", "migrate": "drizzle-kit migrate", "seed": "tsx seed.ts", "seed:demo": "tsx seed-demo.ts", diff --git a/packages/db/scripts/push-full.ts b/packages/db/scripts/push-full.ts new file mode 100644 index 00000000..e80a9c79 --- /dev/null +++ b/packages/db/scripts/push-full.ts @@ -0,0 +1,101 @@ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import { loadRootEnv, maskDatabaseUrl, resolveDatabaseUrl } from './db-url.ts'; +import { LOCK_ID, baselineChecksum, sha256 } from './upgrade.ts'; +import { upgradeManifest } from '../upgrades/manifest.ts'; + +const { Client } = pg; +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const upgradesDir = resolve(packageDir, 'upgrades'); + +async function run(command: readonly string[]) { + const [program, ...args] = command; + const exitCode = await new Promise((resolveCode, reject) => { + // Windows pnpm is a .cmd shim. Use cmd only for these fixed literals; + // no URL or caller-controlled text enters the command line. + const windows = process.platform === 'win32'; + const child = spawn(windows ? process.env.ComSpec || 'cmd.exe' : program!, + windows ? ['/d', '/s', '/c', command.join(' ')] : args, { + cwd: packageDir, + env: process.env, + stdio: 'inherit', + windowsHide: true, + }); + child.once('error', reject); + child.once('close', (code) => resolveCode(code ?? 1)); + }); + if (exitCode !== 0) throw new Error(`${command.slice(0, 2).join(' ')} failed (${exitCode})`); +} + +export async function main() { + loadRootEnv(import.meta.url); + const databaseUrl = resolveDatabaseUrl(); + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + try { + // Hold the same lock as db:upgrade across the whole fresh operation. + await client.query('SELECT pg_advisory_lock($1)', [LOCK_ID]); + try { + const result = await client.query<{ count: string }>(` + SELECT count(*)::text AS count FROM information_schema.tables + WHERE table_schema='public' AND table_type='BASE TABLE'`); + const tableCount = Number(result.rows[0]?.count ?? 0); + if (tableCount !== 0) { + throw new Error(`Refusing fresh initialization: ${tableCount} application table(s) already exist at ${maskDatabaseUrl(databaseUrl)}. Use db:upgrade only for a supported, ledgered release or a verified v0.2.0-preview.1 baseline.`); + } + console.log(`[OK] Fresh database confirmed at ${maskDatabaseUrl(databaseUrl)}.`); + + await run(['pnpm', 'exec', 'tsx', 'scripts/ensure-pgvector.ts']); + await run(['pnpm', 'exec', 'drizzle-kit', 'push', '--force']); + await run(['pnpm', 'exec', 'tsx', 'scripts/apply-extras.ts']); + + // Only this verified-empty invocation, after every schema step succeeds, + // may assert that the complete current manifest is present. A crash + // before this transaction leaves an unledgered schema that both fresh + // initialization and the upgrader refuse to auto-certify. + await client.query('BEGIN'); + try { + const existing = await client.query<{ present: boolean }>( + "SELECT to_regclass('public.deft_schema_migrations') IS NOT NULL AS present"); + if (existing.rows[0]?.present) throw new Error('Fresh migration ledger already exists'); + await client.query(`CREATE TABLE deft_schema_migrations ( + version text PRIMARY KEY, + description text NOT NULL, + checksum text NOT NULL, + kind text NOT NULL CHECK (kind IN ('baseline', 'migration')), + applied_at timestamptz NOT NULL DEFAULT now() + )`); + await client.query(`INSERT INTO deft_schema_migrations + (version, description, checksum, kind) VALUES ($1,$2,$3,'baseline')`, + [upgradeManifest.baseline.version, + `Supported schema baseline ${upgradeManifest.baseline.releaseTag}`, + baselineChecksum()]); + for (const migration of upgradeManifest.migrations) { + await client.query(`INSERT INTO deft_schema_migrations + (version, description, checksum, kind) VALUES ($1,$2,$3,'migration')`, + [migration.version, migration.description, + sha256(readFileSync(resolve(upgradesDir, migration.file), 'utf8'))]); + } + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + console.log(`[OK] Recorded ${upgradeManifest.migrations.length + 1} current schema ledger version(s).`); + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [LOCK_ID]); + } + } finally { + await client.end(); + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + main().catch((error) => { + console.error('[FAIL]', error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/packages/db/scripts/upgrade-ledger-db.test.ts b/packages/db/scripts/upgrade-ledger-db.test.ts new file mode 100644 index 00000000..fb245f59 --- /dev/null +++ b/packages/db/scripts/upgrade-ledger-db.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import pg from 'pg'; +import { baselineChecksum, sha256 } from './upgrade.ts'; +import { upgradeManifest } from '../upgrades/manifest.ts'; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const upgradesDir = resolve(packageDir, 'upgrades'); + +function assignedUrl(name: string) { + const value = process.env[name]; + if (!value || value !== process.env.DATABASE_URL) return null; + const url = new URL(value); + if (!['127.0.0.1', 'localhost'].includes(url.hostname) + || !/(?:^|[_-])(test|ci)(?:$|[_-])/i.test(url.pathname.slice(1))) return null; + return value; +} + +async function command(url: string, args: readonly string[]) { + return new Promise<{ code: number; output: string }>((resolveCommand, reject) => { + const windows = process.platform === 'win32'; + const child = spawn(windows ? process.env.ComSpec || 'cmd.exe' : 'pnpm', + windows ? ['/d', '/s', '/c', ['pnpm', ...args].join(' ')] : args, { cwd: packageDir, + env: { ...process.env, DATABASE_URL: url }, + stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); + let output = ''; + child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.once('error', reject); + child.once('close', (code) => resolveCommand({ code: code ?? 1, output })); + }); +} + +async function withClient(url: string, work: (client: pg.Client) => Promise) { + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { return await work(client); } finally { await client.end(); } +} + +async function catalogState(client: pg.Client) { + const { rows } = await client.query(`SELECT + (SELECT count(*)::integer FROM information_schema.tables + WHERE table_schema='public' AND table_type='BASE TABLE') AS tables, + (SELECT count(*)::integer FROM pg_constraint c JOIN pg_namespace n + ON n.oid=c.connamespace WHERE n.nspname='public') AS constraints, + (SELECT count(*)::integer FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid + JOIN pg_namespace n ON n.oid=c.relnamespace + WHERE n.nspname='public' AND NOT t.tgisinternal) AS triggers, + to_regclass('public.deft_schema_migrations') IS NOT NULL AS ledger, + to_regclass('public.automation_runs') IS NOT NULL AS post_baseline`); + return rows[0]; +} + +test('fresh push records exact manifest history and the ordinary upgrader is a no-op', { + skip: !assignedUrl('DEFT_TEST_FRESH_DATABASE_URL'), +}, async () => { + const url = assignedUrl('DEFT_TEST_FRESH_DATABASE_URL')!; + assert.equal((await withClient(url, catalogState)).tables, 0, + 'This test requires a dedicated empty synthetic database'); + const pushed = await command(url, ['run', 'push-full']); + assert.equal(pushed.code, 0, pushed.output.slice(-2000)); + await withClient(url, async (client) => { + const { rows } = await client.query(`SELECT version,checksum,kind + FROM deft_schema_migrations`); + assert.equal(rows.length, upgradeManifest.migrations.length + 1); + const found = new Map(rows.map((row) => [row.version, row])); + assert.equal(found.get(upgradeManifest.baseline.version)?.checksum, baselineChecksum()); + assert.equal(found.get(upgradeManifest.baseline.version)?.kind, 'baseline'); + for (const migration of upgradeManifest.migrations) { + assert.equal(found.get(migration.version)?.checksum, + sha256(readFileSync(resolve(upgradesDir, migration.file), 'utf8')), + migration.version); + } + }); + const before = await withClient(url, catalogState); + const dryRun = await command(url, ['run', 'upgrade', '--dry-run']); + assert.equal(dryRun.code, 0, dryRun.output); + assert.match(dryRun.output, /migrations: 0 pending/); + const upgraded = await command(url, ['run', 'upgrade']); + assert.equal(upgraded.code, 0, upgraded.output); + assert.match(upgraded.output, /migrations: 0 pending/); + assert.match(upgraded.output, + new RegExp(`current at ${upgradeManifest.migrations.at(-1)!.version.replaceAll('.', '\\.')}`)); + const repeatedPush = await command(url, ['run', 'push-full']); + assert.notEqual(repeatedPush.code, 0); + assert.match(repeatedPush.output, /Refusing fresh initialization/); + assert.deepEqual(await withClient(url, catalogState), before); +}); + +test('advanced ledgerless schema is rejected before migration-ledger DDL', { + skip: !assignedUrl('DEFT_TEST_UNTRACKED_DATABASE_URL'), +}, async () => { + const url = assignedUrl('DEFT_TEST_UNTRACKED_DATABASE_URL')!; + const before = await withClient(url, catalogState); + assert.equal(before.ledger, false); + assert.equal(before.post_baseline, true); + const status = await command(url, ['run', 'upgrade', '--status']); + assert.equal(status.code, 0, status.output); + assert.match(status.output, /untracked post-baseline schema; reviewed adoption required/); + assert.match(status.output, /pending: unknown \(ledger missing\)/); + const dryRun = await command(url, ['run', 'upgrade', '--dry-run']); + assert.notEqual(dryRun.code, 0); + assert.match(dryRun.output, /post-baseline schema but no migration history/); + const upgraded = await command(url, ['run', 'upgrade']); + assert.notEqual(upgraded.code, 0); + assert.match(upgraded.output, /post-baseline schema but no migration history/); + assert.deepEqual(await withClient(url, catalogState), before); +}); + +test('partial interrupted initialization cannot be stamped as fresh', { + skip: !assignedUrl('DEFT_TEST_PARTIAL_DATABASE_URL'), +}, async () => { + const url = assignedUrl('DEFT_TEST_PARTIAL_DATABASE_URL')!; + assert.equal((await withClient(url, catalogState)).tables, 0, + 'This test requires a dedicated empty synthetic database'); + await withClient(url, async (client) => { + await client.query('CREATE TABLE synthetic_partial_setup (id integer PRIMARY KEY)'); + }); + const before = await withClient(url, catalogState); + const pushed = await command(url, ['run', 'push-full']); + assert.notEqual(pushed.code, 0); + assert.match(pushed.output, /Refusing fresh initialization/); + assert.deepEqual(await withClient(url, catalogState), before); +}); + +test('genuine untracked v0.2.0-preview.1 baseline remains adoptable', { + skip: !assignedUrl('DEFT_TEST_BASELINE_DATABASE_URL'), +}, async () => { + const url = assignedUrl('DEFT_TEST_BASELINE_DATABASE_URL')!; + const before = await withClient(url, catalogState); + assert.equal(before.ledger, false); + assert.equal(before.post_baseline, false); + const dryRun = await command(url, ['run', 'upgrade', '--dry-run']); + assert.equal(dryRun.code, 0, dryRun.output); + assert.match(dryRun.output, /baseline: adopt v0\.2\.0-preview\.1/); + const upgraded = await command(url, ['run', 'upgrade']); + assert.equal(upgraded.code, 0, upgraded.output); + assert.match(upgraded.output, + new RegExp(`current at ${upgradeManifest.migrations.at(-1)!.version.replaceAll('.', '\\.')}`)); + await withClient(url, async (client) => { + const { rows } = await client.query('SELECT count(*)::integer AS count FROM deft_schema_migrations'); + assert.equal(rows[0].count, upgradeManifest.migrations.length + 1); + }); +}); diff --git a/packages/db/scripts/upgrade.ts b/packages/db/scripts/upgrade.ts index acaef1bd..e9246f85 100644 --- a/packages/db/scripts/upgrade.ts +++ b/packages/db/scripts/upgrade.ts @@ -7,8 +7,10 @@ import { loadRootEnv, maskDatabaseUrl, resolveDatabaseUrl } from './db-url.ts'; import { upgradeManifest, type SchemaRequirement, type UpgradeMigration } from '../upgrades/manifest.ts'; const { Client } = pg; -const LOCK_ID = 7_314_029_421; +export const LOCK_ID = 7_314_029_421; const LEDGER_TABLE = 'deft_schema_migrations'; +const UNTRACKED_ADVANCED_ERROR = + 'This database has post-baseline schema but no migration history. Refusing to adopt the v0.2.0-preview.1 baseline or replay historical migrations. Restore a known release backup or use a reviewed, exact-release adoption procedure.'; type AppliedMigration = { version: string; @@ -94,6 +96,16 @@ async function ledgerExists(client: InstanceType): Promise): Promise { + // The first migration after v0.2.0-preview.1 creates this retained table. + // A ledgerless database with it is not the baseline, even if it satisfies + // every baseline-presence requirement. Never replay history over that state. + const result = await client.query<{ present: boolean }>( + "SELECT to_regclass('public.automation_runs') IS NOT NULL AS present", + ); + return result.rows[0]?.present === true; +} + async function countPublicTables(client: InstanceType): Promise { const result = await client.query<{ count: string }>( `SELECT count(*)::text AS count @@ -173,9 +185,18 @@ function loadMigrationChecksums(): Map { async function readApplied(client: InstanceType): Promise { if (!(await ledgerExists(client))) return []; const result = await client.query( - `SELECT version, checksum, kind FROM ${LEDGER_TABLE} ORDER BY applied_at, version`, + `SELECT version, checksum, kind FROM ${LEDGER_TABLE}`, ); - return result.rows; + // A fresh initializer records the whole manifest in one transaction, so + // applied_at can tie. Display and validation must follow manifest order, + // not lexical preview version order (where .7 sorts after .37). + const rank = new Map([ + upgradeManifest.baseline.version, + ...upgradeManifest.migrations.map((migration) => migration.version), + ].map((version, index) => [version, index])); + return result.rows.sort((a, b) => + (rank.get(a.version) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(b.version) ?? Number.MAX_SAFE_INTEGER)); } async function ensureLedger(client: InstanceType) { @@ -227,16 +248,20 @@ async function applyMigration( async function printStatus(client: InstanceType, applied: AppliedMigration[]) { const tableCount = await countPublicTables(client); const inspection = tableCount > 0 ? await inspectBaseline(client) : null; + const untrackedAdvanced = applied.length === 0 && tableCount > 0 + && await hasPostBaselineSchema(client); const compatible = inspection ? inspection.missingSchema.length === 0 && inspection.missingExtensions.length === 0 && - inspection.missingIndexes.length === 0 + inspection.missingIndexes.length === 0 && !untrackedAdvanced : false; console.log('Deft database upgrade status'); console.log(` public tables: ${tableCount}`); console.log(` ledger: ${applied.length > 0 ? `${applied.length} applied version(s)` : 'not initialized'}`); - console.log(` baseline: ${compatible ? `${upgradeManifest.baseline.releaseTag} compatible` : 'not compatible'}`); - console.log(` pending: ${upgradeManifest.migrations.filter((item) => !applied.some((row) => row.version === item.version)).length}`); + console.log(` baseline: ${untrackedAdvanced ? 'untracked post-baseline schema; reviewed adoption required' + : compatible ? `${upgradeManifest.baseline.releaseTag} compatible` : 'not compatible'}`); + console.log(` pending: ${untrackedAdvanced ? 'unknown (ledger missing)' + : upgradeManifest.migrations.filter((item) => !applied.some((row) => row.version === item.version)).length}`); } export async function main(argv = process.argv.slice(2)) { @@ -263,6 +288,9 @@ export async function main(argv = process.argv.slice(2)) { const inspection = await inspectBaseline(client); assertCompatibleBaseline(inspection); + if (appliedBefore.length === 0 && await hasPostBaselineSchema(client)) { + throw new Error(UNTRACKED_ADVANCED_ERROR); + } const hasBaseline = appliedBefore.some((row) => row.version === upgradeManifest.baseline.version); const pending = upgradeManifest.migrations.filter( (migration) => !appliedBefore.some((row) => row.version === migration.version), @@ -280,9 +308,12 @@ export async function main(argv = process.argv.slice(2)) { await client.query('SELECT pg_advisory_lock($1)', [LOCK_ID]); try { - await ensureLedger(client); const appliedLocked = await readApplied(client); validateAppliedMigrations(appliedLocked, upgradeManifest.migrations, migrationChecksums); + if (appliedLocked.length === 0 && await hasPostBaselineSchema(client)) { + throw new Error(UNTRACKED_ADVANCED_ERROR); + } + await ensureLedger(client); const hasLockedBaseline = appliedLocked.some((row) => row.version === upgradeManifest.baseline.version); const lockedPending = upgradeManifest.migrations.filter( (migration) => !appliedLocked.some((row) => row.version === migration.version), From 0cb0ded4a6779e8de527c93c56a9aa71609b3550 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:04:33 +0530 Subject: [PATCH 015/161] Run ledger upgrade proof on explicit four-database profile --- packages/db/scripts/upgrade-ledger-db.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/db/scripts/upgrade-ledger-db.test.ts b/packages/db/scripts/upgrade-ledger-db.test.ts index fb245f59..87067219 100644 --- a/packages/db/scripts/upgrade-ledger-db.test.ts +++ b/packages/db/scripts/upgrade-ledger-db.test.ts @@ -11,15 +11,34 @@ import { upgradeManifest } from '../upgrades/manifest.ts'; const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const upgradesDir = resolve(packageDir, 'upgrades'); -function assignedUrl(name: string) { +const profile = process.env.DEFT_TEST_LEDGER_PROFILE === 'gate_g_c05'; +const assignedDatabases = { + DEFT_TEST_FRESH_DATABASE_URL: 'gate_g_phase5_test_c05_ledger_accept_fresh', + DEFT_TEST_UNTRACKED_DATABASE_URL: 'gate_g_phase5_test_c05_ledger_accept_untracked', + DEFT_TEST_PARTIAL_DATABASE_URL: 'gate_g_phase5_test_c05_ledger_accept_partial', + DEFT_TEST_BASELINE_DATABASE_URL: 'gate_g_phase5_test_c05_ledger_accept_baseline', +} as const; + +function assignedUrl(name: keyof typeof assignedDatabases) { + if (!profile) return null; const value = process.env[name]; - if (!value || value !== process.env.DATABASE_URL) return null; + if (!value) throw new Error(`Missing assigned synthetic URL ${name}`); const url = new URL(value); - if (!['127.0.0.1', 'localhost'].includes(url.hostname) - || !/(?:^|[_-])(test|ci)(?:$|[_-])/i.test(url.pathname.slice(1))) return null; + if (url.protocol !== 'postgresql:' || url.hostname !== '127.0.0.1' + || url.port !== '55435' || url.username !== 'gate_g_test' + || url.password || url.search || url.hash + || url.pathname !== `/${assignedDatabases[name]}`) { + throw new Error(`Wrong assigned synthetic URL for ${name}`); + } return value; } +if (profile) { + const urls = (Object.keys(assignedDatabases) as Array) + .map(assignedUrl); + assert.equal(new Set(urls).size, 4, 'Each ledger case needs its own disposable database'); +} + async function command(url: string, args: readonly string[]) { return new Promise<{ code: number; output: string }>((resolveCommand, reject) => { const windows = process.platform === 'win32'; From af5b8372dd0bd761933beb29724d1b621f4ef8d8 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:09:41 +0530 Subject: [PATCH 016/161] Add atomic resource sync page store foundation --- apps/api/src/lib/app-resource-sync-secrets.ts | 13 +- apps/api/src/lib/app-resource-sync-store.ts | 313 ++++++++++++ .../test/app-resource-sync-store-db.test.ts | 451 ++++++++++++++++++ 3 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-store.ts create mode 100644 apps/api/test/app-resource-sync-store-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-secrets.ts b/apps/api/src/lib/app-resource-sync-secrets.ts index efcf89b4..dad1b542 100644 --- a/apps/api/src/lib/app-resource-sync-secrets.ts +++ b/apps/api/src/lib/app-resource-sync-secrets.ts @@ -92,11 +92,20 @@ export class AppResourceSyncSecretService { locatorCandidates(providerResourceId: string, rawContext: AppResourceSyncLocatorContext, requiredKeyVersions: readonly string[]): readonly AppResourceSyncFingerprint[] { const value = [locatorContext.parse(rawContext), providerId.parse(providerResourceId)]; - const keyIds = this.keys.keyIds('fingerprint'); - if (requiredKeyVersions.some((id) => !keyIds.includes(id))) throw new AppRunKeyVersionUnavailableError(); + const keyIds = this.assertLocatorKeyVersionsAvailable(requiredKeyVersions); return Object.freeze(keyIds.map((id) => this.fingerprint('locator', value, id))); } + /** A page with zero items still cannot advance a cursor when any retained + * projection's locator key is absent. Call under the checkpoint lock. */ + assertLocatorKeyVersionsAvailable(requiredKeyVersions: readonly string[]): readonly string[] { + const keyIds = this.keys.keyIds('fingerprint'); + if (requiredKeyVersions.some((id) => !keyIds.includes(id))) { + throw new AppRunKeyVersionUnavailableError(); + } + return keyIds; + } + cursorFingerprint(value: string | null, rawContext: Extract, keyVersion?: string): AppResourceSyncFingerprint { diff --git a/apps/api/src/lib/app-resource-sync-store.ts b/apps/api/src/lib/app-resource-sync-store.ts new file mode 100644 index 00000000..8bf9af80 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-store.ts @@ -0,0 +1,313 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { + appResourceBindings, appResourceProjections, appRunAttempts, appRuns, + appSyncCheckpoints, appSyncIntents, +} from '@deft/db/schema'; +import { + canonicalSyncPageJson, digestResourceSyncDescriptor, parseSyncDescriptor, + parseSyncPage, parseSyncRequest, +} from '@deft/app-kit/experimental/resource-sync'; +import type { AppRunTransaction } from './app-run-repository.js'; +import type { AppRunSecretRepository } from './app-run-secret-repository.js'; +import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; +import type { AppRunSecretEnvelope } from './app-run-secrets.js'; + +/** This primitive is deliberately not connected to either Runtime channel. + * Its caller must first lock the Run and verify live authority, session, + * claim, lease and exact-result replay, then retain this transaction through + * output settlement and receipt writing. A thrown error rolls back the page. */ +export class AppResourceSyncStore { + constructor( + private readonly secrets: AppResourceSyncSecretService, + private readonly runInputs: AppRunSecretRepository, + ) {} + + async applyPageInTransaction(tx: AppRunTransaction, input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; page: unknown; + clock: () => Date; + }>): Promise> { + // Preserve the existing Run -> authority -> attempt -> checkpoint order. + // The caller owns the authority locks; repeating the Run/attempt locks is + // safe and makes direct test calls use the same locked ancestry. + await tx.execute(sql`SELECT id FROM app_runs WHERE org_id = ${input.org_id} + AND id = ${input.run_id} FOR UPDATE`); + const [run] = await tx.select().from(appRuns).where(and( + eq(appRuns.org_id, input.org_id), eq(appRuns.id, input.run_id), + )).limit(1); + if (!run || run.origin_kind !== 'app' || run.provider_kind !== 'app_runtime' + || run.initiating_actor_type !== 'system' || run.execution_actor_type !== 'system' + || !run.origin_resource_binding_id + || run.initiating_actor_id !== run.origin_resource_binding_id + || run.execution_actor_id !== run.origin_resource_binding_id + || run.state !== 'running' || !run.execution_released_at + || run.cancel_requested_at) { + throw new Error('APP_RESOURCE_SYNC_RUN_NOT_RELEASED'); + } + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${input.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, input.attempt_id), + eq(appRunAttempts.run_id, run.id), + )).limit(1); + if (!attempt || attempt.resource_binding_id !== run.origin_resource_binding_id + || attempt.state !== 'provider_call_started' + || !attempt.lease_expires_at || attempt.provider_call_finished_at + || attempt.runtime_result_hmac) { + throw new Error('APP_RESOURCE_SYNC_ATTEMPT_NOT_CURRENT'); + } + const [intent] = await tx.select().from(appSyncIntents).where(and( + eq(appSyncIntents.org_id, input.org_id), eq(appSyncIntents.run_id, run.id), + )).limit(1); + if (!intent || intent.resource_binding_id !== run.origin_resource_binding_id + || intent.app_installation_id !== run.origin_app_installation_id + || intent.app_version_id !== run.origin_app_version_id + || intent.grant_snapshot_id !== run.origin_app_grant_snapshot_id + || intent.provider_snapshot_id !== run.provider_snapshot_id) { + throw new Error('APP_RESOURCE_SYNC_INTENT_MISMATCH'); + } + const [binding] = await tx.select().from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, input.org_id), eq(appResourceBindings.id, intent.resource_binding_id), + )).limit(1); + if (!binding || binding.state !== 'active' || !binding.consent_expires_at + || binding.owner_user_id !== intent.owner_user_id + || binding.app_installation_id !== intent.app_installation_id + || binding.app_version_id !== intent.app_version_id + || binding.grant_snapshot_id !== intent.grant_snapshot_id + || binding.provider_snapshot_id !== intent.provider_snapshot_id + || binding.descriptor_digest !== intent.descriptor_digest + || binding.operation_name !== run.operation_name + || binding.provider_instance_id !== run.provider_instance_id + || binding.risk_class !== run.risk_class + || binding.review_requirement !== run.review_requirement + || binding.review_scope !== run.review_scope + || binding.retry_class !== run.retry_class + || binding.retention_class !== run.retention_class) { + throw new Error('APP_RESOURCE_SYNC_BINDING_MISMATCH'); + } + const descriptor = parseSyncDescriptor(binding.reviewed_descriptor); + if (descriptor.key !== binding.resource_key + || descriptor.resource_type !== binding.resource_family + || await digestResourceSyncDescriptor(descriptor) !== binding.descriptor_digest) { + throw new Error('APP_RESOURCE_SYNC_DESCRIPTOR_MISMATCH'); + } + await tx.execute(sql`SELECT id FROM app_sync_checkpoints WHERE org_id = ${input.org_id} + AND id = ${intent.checkpoint_id} AND resource_binding_id = ${intent.resource_binding_id} + FOR UPDATE`); + const [checkpoint] = await tx.select().from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, input.org_id), eq(appSyncCheckpoints.id, intent.checkpoint_id), + eq(appSyncCheckpoints.resource_binding_id, intent.resource_binding_id), + )).limit(1); + if (!checkpoint || checkpoint.state !== 'active' + || checkpoint.generation !== intent.generation + || checkpoint.cursor_sequence !== intent.expected_cursor_sequence + || checkpoint.cursor_hmac_key_version !== intent.expected_cursor_hmac_key_version + || checkpoint.cursor_hmac !== intent.expected_cursor_hmac) { + throw new Error('APP_RESOURCE_SYNC_START_CURSOR_STALE'); + } + // A checkpoint lock may have waited past a lease or consent deadline. + // Read the host clock only after the final lock, before releasing input or + // writing provider records; the caller also checks live authority here. + const checkedAt = input.clock(); + if (!Number.isFinite(checkedAt.getTime())) throw new TypeError('Invalid settlement time'); + if (run.input_expires_at <= checkedAt || attempt.lease_expires_at <= checkedAt + || binding.consent_expires_at <= checkedAt) { + throw new Error('APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED'); + } + const currentCursorContext = { org_id: input.org_id, + resource_binding_id: intent.resource_binding_id, checkpoint_id: checkpoint.id, + payload_kind: 'cursor' as const, generation: checkpoint.generation, + cursor_sequence: checkpoint.cursor_sequence }; + const cursorValue = checkpoint.cursor_state === 'empty' ? null : this.secrets.openJson({ + schema_version: checkpoint.cursor_envelope_version, + algorithm: checkpoint.cursor_algorithm, key_version: checkpoint.cursor_key_version, + nonce_b64: checkpoint.cursor_nonce_b64, + ciphertext_b64: checkpoint.cursor_ciphertext_b64, + auth_tag_b64: checkpoint.cursor_auth_tag_b64, + }, currentCursorContext); + if (cursorValue !== null && typeof cursorValue !== 'string') { + throw new Error('APP_RESOURCE_SYNC_CURSOR_INVALID'); + } + const verifiedCursor = this.secrets.cursorFingerprint(cursorValue, currentCursorContext, + checkpoint.cursor_hmac_key_version); + if (verifiedCursor.fingerprint !== checkpoint.cursor_hmac) { + throw new Error('APP_RESOURCE_SYNC_CURSOR_HMAC_MISMATCH'); + } + const exactInput = await this.runInputs.readInput(input.org_id, run.id, tx); + const startingRequest = parseSyncRequest(exactInput); + if (startingRequest.cursor !== cursorValue + || startingRequest.max_items > binding.max_records_per_page) { + throw new Error('APP_RESOURCE_SYNC_RUN_INPUT_MISMATCH'); + } + const page = parseSyncPage(descriptor, startingRequest, input.page); + const pageJson = canonicalSyncPageJson(page); + if (Buffer.byteLength(pageJson, 'utf8') > binding.max_page_bytes) { + throw new Error('APP_RESOURCE_SYNC_PAGE_TOO_LARGE'); + } + const pageDigest = `sha256:${createHash('sha256').update(pageJson).digest('hex')}`; + const nextSequence = checkpoint.cursor_sequence + 1; + const locatorContext = { org_id: input.org_id, + resource_binding_id: intent.resource_binding_id, checkpoint_id: checkpoint.id }; + // The inventory is under the checkpoint lock. A lost historical locator + // key must fail even when a new ID appears to have no matching row. + const retainedVersions = await tx.selectDistinct({ + key_version: appResourceProjections.resource_id_hmac_key_version, + }).from(appResourceProjections).where(and( + eq(appResourceProjections.org_id, input.org_id), + eq(appResourceProjections.checkpoint_id, checkpoint.id), + )); + const requiredKeys = retainedVersions.map((row) => row.key_version); + this.secrets.assertLocatorKeyVersionsAvailable(requiredKeys); + const checkpointId = checkpoint.id; + const checkpointGeneration = checkpoint.generation; + async function candidatesFor(resourceId: string, secrets: AppResourceSyncSecretService) { + const fingerprints = secrets.locatorCandidates(resourceId, locatorContext, requiredKeys); + const matches = await tx.select().from(appResourceProjections).where(and( + eq(appResourceProjections.org_id, input.org_id), + eq(appResourceProjections.checkpoint_id, checkpointId), + inArray(appResourceProjections.resource_id_hmac_key_version, + fingerprints.map((item) => item.key_version)), + inArray(appResourceProjections.resource_id_hmac, + fingerprints.map((item) => item.fingerprint)), + )); + const exact = matches.filter((row) => fingerprints.some((fingerprint) => + row.resource_id_hmac_key_version === fingerprint.key_version + && row.resource_id_hmac === fingerprint.fingerprint)); + if (exact.length > 1) throw new Error('APP_RESOURCE_SYNC_AMBIGUOUS_LOCATOR'); + const row = exact[0]; + if (row) { + const oldProviderId = secrets.openJson({ + schema_version: row.provider_id_envelope_version, + algorithm: row.provider_id_algorithm, + key_version: row.provider_id_key_version, + nonce_b64: row.provider_id_nonce_b64, + ciphertext_b64: row.provider_id_ciphertext_b64, + auth_tag_b64: row.provider_id_auth_tag_b64, + }, { ...locatorContext, payload_kind: 'projection', + generation: row.generation, projection_id: row.id, slot: 'provider_id' }); + if (oldProviderId !== resourceId || row.generation !== checkpointGeneration) { + throw new Error('APP_RESOURCE_SYNC_LOCATOR_IDENTITY_MISMATCH'); + } + } + return row ?? null; + } + const writeProjection = async (item: { id: string; revision: string; + data?: Record }, state: 'live' | 'tombstone') => { + const prior = await candidatesFor(item.id, this.secrets); + const projectionId = prior?.id ?? randomUUID(); + const context = { ...locatorContext, payload_kind: 'projection' as const, + generation: checkpoint.generation, projection_id: projectionId }; + const bodyEnvelope = state === 'live' + ? this.secrets.sealJson({ revision: item.revision, data: item.data }, + { ...context, slot: 'record' }) : null; + const candidateIdEnvelope = this.secrets.sealJson(item.id, + { ...context, slot: 'provider_id' }); + const idEnvelope = !prior || candidateIdEnvelope.key_version !== prior.provider_id_key_version + ? candidateIdEnvelope : null; + if (prior && idEnvelope) { + // Only after decrypting and matching the old provider ID above may a + // page rewrap the retained identity to the current AES version. + await tx.execute(sql`SET LOCAL deft.app_resource_sync_rekey = 'on'`); + } + const idColumns = idEnvelope ? { + provider_id_envelope_version: idEnvelope.schema_version, + provider_id_algorithm: idEnvelope.algorithm, + provider_id_key_version: idEnvelope.key_version, + provider_id_nonce_b64: idEnvelope.nonce_b64, + provider_id_ciphertext_b64: idEnvelope.ciphertext_b64, + provider_id_auth_tag_b64: idEnvelope.auth_tag_b64, + provider_id_bytes: ciphertextBytes(idEnvelope), + } : {}; + const bodyColumns = bodyEnvelope ? { + body_envelope_version: bodyEnvelope.schema_version, + body_algorithm: bodyEnvelope.algorithm, + body_key_version: bodyEnvelope.key_version, + body_nonce_b64: bodyEnvelope.nonce_b64, + body_ciphertext_b64: bodyEnvelope.ciphertext_b64, + body_auth_tag_b64: bodyEnvelope.auth_tag_b64, + body_bytes: ciphertextBytes(bodyEnvelope), + } : { body_envelope_version: null, body_algorithm: null, body_key_version: null, + body_nonce_b64: null, body_ciphertext_b64: null, body_auth_tag_b64: null, + body_bytes: 0 }; + if (prior) { + await tx.update(appResourceProjections).set({ ...idColumns, ...bodyColumns, + state, applied_sequence: nextSequence, last_seen_at: checkedAt, + source_updated_at: null, fresh_until: null, + tombstoned_at: state === 'tombstone' ? checkedAt : null, + updated_at: checkedAt, + }).where(and(eq(appResourceProjections.org_id, input.org_id), + eq(appResourceProjections.id, prior.id))); + if (idEnvelope) await tx.execute(sql`SET LOCAL deft.app_resource_sync_rekey = 'off'`); + } else { + const locator = this.secrets.locator(item.id, locatorContext); + await tx.insert(appResourceProjections).values({ + id: projectionId, org_id: input.org_id, + resource_binding_id: intent.resource_binding_id, checkpoint_id: checkpoint.id, + generation: checkpoint.generation, + resource_id_hmac_key_version: locator.key_version, + resource_id_hmac: locator.fingerprint, + ...idColumns, ...bodyColumns, state, applied_sequence: nextSequence, + first_seen_at: checkedAt, last_seen_at: checkedAt, + tombstoned_at: state === 'tombstone' ? checkedAt : null, + fresh_until: null, + } as typeof appResourceProjections.$inferInsert); + } + }; + // Tombstones first let a replacement page reclaim body bytes before + // upserts are capacity-accounted by database triggers. + for (const item of page.tombstones) await writeProjection(item, 'tombstone'); + for (const item of page.upserts) await writeProjection(item, 'live'); + const [accounted] = await tx.select({ count: appSyncCheckpoints.retained_record_count, + bytes: appSyncCheckpoints.retained_bytes }).from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, input.org_id), eq(appSyncCheckpoints.id, checkpoint.id), + )).limit(1); + if (!accounted || accounted.count > binding.max_retained_records + || accounted.bytes > binding.max_retained_bytes) { + throw new Error('APP_RESOURCE_SYNC_CAPACITY_EXCEEDED'); + } + const nextCursorContext = { ...currentCursorContext, cursor_sequence: nextSequence }; + const nextCursorHmac = this.secrets.cursorFingerprint(page.next_cursor, nextCursorContext); + const nextCursorEnvelope = page.next_cursor === null ? null + : this.secrets.sealJson(page.next_cursor, nextCursorContext); + const [applied] = await tx.update(appSyncCheckpoints).set({ + cursor_sequence: nextSequence, + cursor_hmac_key_version: nextCursorHmac.key_version, + cursor_hmac: nextCursorHmac.fingerprint, + cursor_state: nextCursorEnvelope ? 'value' : 'empty', + cursor_envelope_version: nextCursorEnvelope?.schema_version ?? null, + cursor_algorithm: nextCursorEnvelope?.algorithm ?? null, + cursor_key_version: nextCursorEnvelope?.key_version ?? null, + cursor_nonce_b64: nextCursorEnvelope?.nonce_b64 ?? null, + cursor_ciphertext_b64: nextCursorEnvelope?.ciphertext_b64 ?? null, + cursor_auth_tag_b64: nextCursorEnvelope?.auth_tag_b64 ?? null, + cursor_bytes: nextCursorEnvelope ? ciphertextBytes(nextCursorEnvelope) : 0, + last_applied_run_id: run.id, last_applied_page_digest: pageDigest, + last_applied_at: checkedAt, last_checked_at: checkedAt, + fresh_until: null, updated_at: checkedAt, + }).where(and(eq(appSyncCheckpoints.org_id, input.org_id), + eq(appSyncCheckpoints.id, checkpoint.id), + eq(appSyncCheckpoints.generation, intent.generation), + eq(appSyncCheckpoints.cursor_sequence, intent.expected_cursor_sequence), + )).returning({ id: appSyncCheckpoints.id }); + if (!applied) throw new Error('APP_RESOURCE_SYNC_CURSOR_CAS_FAILED'); + // A bounded page can still outlive its lease while writing many rows. + // The caller performs its own final settlement check after this helper; + // this last local check rolls back the entire page if time ran out here. + const completedAt = input.clock(); + if (!Number.isFinite(completedAt.getTime())) throw new TypeError('Invalid settlement time'); + if (run.input_expires_at <= completedAt || attempt.lease_expires_at <= completedAt + || binding.consent_expires_at <= completedAt) { + throw new Error('APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED'); + } + return Object.freeze({ page_digest: pageDigest, applied_sequence: nextSequence, + upserts: page.upserts.length, tombstones: page.tombstones.length, + has_more: page.has_more }); + } +} + +function ciphertextBytes(envelope: AppRunSecretEnvelope): number { + return Buffer.byteLength(envelope.ciphertext_b64, 'base64'); +} diff --git a/apps/api/test/app-resource-sync-store-db.test.ts b/apps/api/test/app-resource-sync-store-db.test.ts new file mode 100644 index 00000000..6a5106d2 --- /dev/null +++ b/apps/api/test/app-resource-sync-store-db.test.ts @@ -0,0 +1,451 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const safeDatabase = (() => { + if (!databaseUrl || databaseUrl !== process.env.DATABASE_URL) return false; + try { + const url = new URL(databaseUrl); + return ['postgres:', 'postgresql:'].includes(url.protocol) + && url.hostname === '127.0.0.1' && url.port === '55435' + && url.pathname === '/gate_g_phase5_test_s05_sync_store' + && url.search === '' && url.hash === ''; + } catch { return false; } +})(); + +const material = (seed: string) => createHash('sha256').update(`sync-store:${seed}`).digest('base64'); +const shapedHmac = (value: string) => `hmac-sha256:${createHash('sha256').update(value).digest('hex')}`; +function keyring(currentEncryption: string, currentFingerprint: string, + fingerprintKeys = ['fixture-fp-v1', 'fixture-fp-v2']) { + const keys = (ids: string[]) => Object.fromEntries(ids.map((id) => [id, material(id)])); + return JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: currentEncryption, + keys: keys(['fixture-enc-v1', 'fixture-enc-v2']) }, + receipt_signing: { current: 'fixture-signing', keys: keys(['fixture-signing']) }, + fingerprint: { current: currentFingerprint, keys: keys(fingerprintKeys) }, + }); +} + +test('resource sync store encrypts one atomic page and fences stale or unsafe writes', { + skip: !safeDatabase, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_KEYRINGS = keyring('fixture-enc-v1', 'fixture-fp-v1'); + const [{ db, closeDb }, schema, kit, syncKit, appService, reviewService, moduleService, + keyrings, runSecretsModule, runInputModule, syncSecretsModule, storeModule, + drizzle] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('@deft/app-kit/experimental/resource-sync'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-secrets.js'), import('../src/lib/app-run-secret-repository.js'), + import('../src/lib/app-resource-sync-secrets.js'), + import('../src/lib/app-resource-sync-store.js'), import('drizzle-orm'), + ]); + const { and, eq, sql } = drizzle; + const firstRing = keyrings.parseEnvironmentAppRunKeyrings( + keyring('fixture-enc-v1', 'fixture-fp-v1')); + const rotatedRing = keyrings.parseEnvironmentAppRunKeyrings( + keyring('fixture-enc-v2', 'fixture-fp-v2')); + const missingHistoricalRing = keyrings.parseEnvironmentAppRunKeyrings( + keyring('fixture-enc-v2', 'fixture-fp-v2', ['fixture-fp-v2'])); + const firstSecrets = new syncSecretsModule.AppResourceSyncSecretService(firstRing); + const rotatedSecrets = new syncSecretsModule.AppResourceSyncSecretService(rotatedRing); + const firstInputs = new runInputModule.AppRunSecretRepository( + new runSecretsModule.AppRunSecretService(firstRing)); + const rotatedInputs = new runInputModule.AppRunSecretRepository( + new runSecretsModule.AppRunSecretService(rotatedRing)); + const missingInputs = new runInputModule.AppRunSecretRepository( + new runSecretsModule.AppRunSecretService(missingHistoricalRing)); + const firstStore = new storeModule.AppResourceSyncStore(firstSecrets, firstInputs); + const rotatedStore = new storeModule.AppResourceSyncStore(rotatedSecrets, rotatedInputs); + const missingStore = new storeModule.AppResourceSyncStore( + new syncSecretsModule.AppResourceSyncSecretService(missingHistoricalRing), missingInputs); + try { + const orgId = randomUUID(); + const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'Sync store synthetic org', + slug: `sync-store-${randomUUID()}` }); + await db.insert(schema.users).values({ id: ownerId, + email: `sync-store-${randomUUID()}@example.test`, name: 'Synthetic owner' }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = moduleService.humanModuleActor({ orgId, userId: ownerId, + role: 'owner', source: 'ui' }); + const packageJson = (await kit.buildDeftAppPackage({ manifest: { + schema_version: '3', id: `community.example.syncstoreapp${randomUUID().replaceAll('-', '')}`, + version: '1.0.0', name: 'Sync store fixture', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '3' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'provider', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'deliver', version: '1', + input_schema: { type: 'object', properties: { item: { type: 'string', maxLength: 80 } }, + required: ['item'], additionalProperties: false }, + output_schema: { type: 'object', properties: { done: { type: 'boolean' } }, + required: ['done'], additionalProperties: false } }], + runtime_actions: [{ key: 'deliver', label: 'Deliver', capability_key: 'deliver', + runtime_requirement_key: 'provider' }], + }, artifacts: [] })).json; + const staged = await appService.stageAppPackage(owner, packageJson); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const reviewInput = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch }; + const prepared = await reviewService.prepareRuntimeAppReview(owner, staged.id, reviewInput); + const active = await reviewService.activateRuntimeApp(owner, staged.id, { + ...reviewInput, expected_review_digest: prepared.review_digest, accept_host_policy: true, + }); + const now = new Date(); + const registrationId = randomUUID(); + const snapshotId = randomUUID(); + const bindingId = randomUUID(); + const checkpointId = randomUUID(); + const sessionId = randomUUID(); + const descriptor = syncKit.parseSyncDescriptor({ + schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'provider', resource_type: 'message', + requested_visibility: 'user_private', + record_schema: { type: 'object', properties: { + label: { type: 'string', maxLength: 120 }, + owner_id: { type: 'string', maxLength: 120 }, + }, required: ['label'], additionalProperties: false }, + label_field: 'label', + }); + const descriptorDigest = await syncKit.digestResourceSyncDescriptor(descriptor); + await db.insert(schema.appRuntimeRegistrations).values({ id: registrationId, + org_id: orgId, app_installation_id: staged.id, app_version_id: version.id, + grant_snapshot_id: active.grant_snapshot_id, operator_user_id: ownerId, + contract_version: 'deft.app_runtime_channel.v2' }); + await db.update(schema.appRuntimeRegistrations).set({ state: 'active', runtime_epoch: 1, + reviewed_by_user_id: ownerId, reviewed_at: now }).where(and( + eq(schema.appRuntimeRegistrations.org_id, orgId), + eq(schema.appRuntimeRegistrations.id, registrationId))); + await db.insert(schema.capabilityProviderSnapshots).values({ id: snapshotId, + org_id: orgId, provider_kind: 'app_runtime', provider_instance_id: registrationId, + adapter_contract_version: 'deft.app_runtime_channel.v2', + snapshot_digest: `sha256:${createHash('sha256').update(registrationId).digest('hex')}`, + safe_snapshot: {}, captured_at: now }); + await db.insert(schema.appResourceBindings).values({ id: bindingId, org_id: orgId, + app_installation_id: staged.id, app_version_id: version.id, + grant_snapshot_id: active.grant_snapshot_id, + runtime_registration_id: registrationId, provider_instance_id: registrationId, + provider_snapshot_id: snapshotId, resource_key: 'inbox', resource_family: 'message', + operation_name: 'sync_inbox', + interface_identity: `deft.resource_sync.v2:${orgId}:${staged.id}:inbox`, + reviewed_descriptor: descriptor, descriptor_digest: descriptorDigest, + owner_user_id: ownerId, max_records_per_page: 100, + max_page_bytes: 524288, max_retained_records: 2, + max_retained_bytes: 1_073_741_824, min_interval_seconds: 60 }); + await db.update(schema.appResourceBindings).set({ state: 'active', + reviewed_by_user_id: ownerId, reviewed_at: now, + consent_expires_at: new Date(now.getTime() + 24 * 60 * 60_000) }).where(and( + eq(schema.appResourceBindings.org_id, orgId), eq(schema.appResourceBindings.id, bindingId))); + const cursorContext = { org_id: orgId, resource_binding_id: bindingId, + checkpoint_id: checkpointId, payload_kind: 'cursor' as const, + generation: 1, cursor_sequence: 0 }; + const initialCursor = firstSecrets.cursorFingerprint(null, cursorContext); + await db.insert(schema.appSyncCheckpoints).values({ id: checkpointId, + org_id: orgId, resource_binding_id: bindingId, + cursor_hmac_key_version: initialCursor.key_version, + cursor_hmac: initialCursor.fingerprint }); + const [installation] = await db.select().from(schema.appInstallations).where(and( + eq(schema.appInstallations.org_id, orgId), eq(schema.appInstallations.id, staged.id))); + assert.ok(installation); + await db.insert(schema.appRuntimeSessions).values({ id: sessionId, + org_id: orgId, runtime_registration_id: registrationId, + resource_binding_id: bindingId, operator_user_id: ownerId, + token_hash: `sha256:${createHash('sha256').update(sessionId).digest('hex')}`, + audience: 'app_resource_sync', runtime_epoch: 1, + lifecycle_epoch: installation.lifecycle_epoch, grant_epoch: installation.grant_epoch, + expires_at: new Date(now.getTime() + 60 * 60_000) }); + + async function createRun(cursor: string | null) { + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints).where(and( + eq(schema.appSyncCheckpoints.org_id, orgId), eq(schema.appSyncCheckpoints.id, checkpointId))); + assert.ok(checkpoint); + const runId = randomUUID(); + const attemptId = randomUUID(); + const created = new Date(); + const inputExpiresAt = new Date(created.getTime() + 60 * 60_000); + await db.insert(schema.appRuns).values({ id: runId, org_id: orgId, + contract_version: 'deft.app_run.v1', origin_kind: 'app', + initiating_actor_type: 'system', initiating_actor_id: bindingId, + execution_actor_type: 'system', execution_actor_id: bindingId, + provider_kind: 'app_runtime', provider_instance_id: registrationId, + operation_name: 'sync_inbox', provider_snapshot_id: snapshotId, + origin_app_installation_id: staged.id, origin_app_version_id: version.id, + origin_app_grant_snapshot_id: active.grant_snapshot_id, + origin_resource_binding_id: bindingId, + risk_class: 'internal_write', review_requirement: 'policy', + review_scope: 'reviewed_resource_sync', retry_class: 'unsafe_or_unknown', + retention_class: 'standard', + idempotency_key_version: 'fixture-fp-v1', + idempotency_fingerprint: shapedHmac(`${runId}:idempotency`), + input_fingerprint_key_version: 'fixture-fp-v1', + input_fingerprint: shapedHmac(`${runId}:input`), + authorization_snapshot: {}, safe_preview: {}, root_run_id: runId, + input_expires_at: inputExpiresAt, + result_expires_at: new Date(created.getTime() + 2 * 60 * 60_000), + idempotency_expires_at: new Date(created.getTime() + 3 * 60 * 60_000), + attempt_limit: 1 }); + await db.insert(schema.appSyncIntents).values({ id: randomUUID(), org_id: orgId, + run_id: runId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + app_installation_id: staged.id, app_version_id: version.id, + grant_snapshot_id: active.grant_snapshot_id, provider_snapshot_id: snapshotId, + owner_user_id: ownerId, descriptor_digest: descriptorDigest, + generation: checkpoint.generation, + expected_cursor_sequence: checkpoint.cursor_sequence, + expected_cursor_hmac_key_version: checkpoint.cursor_hmac_key_version, + expected_cursor_hmac: checkpoint.cursor_hmac }); + await db.transaction(async (tx) => firstInputs.insertInput(tx, { org_id: orgId, + run_id: runId, value: { schema_version: 'deft.app_sync_request.v1', + cursor, max_items: 100 }, expires_at: inputExpiresAt })); + await db.update(schema.appRuns).set({ state: 'running', + execution_release_kind: 'policy_satisfied', execution_released_at: created, + started_at: created }).where(and(eq(schema.appRuns.org_id, orgId), + eq(schema.appRuns.id, runId))); + await db.insert(schema.appRunAttempts).values({ id: attemptId, org_id: orgId, + run_id: runId, attempt_number: 1, state: 'provider_call_started', + claim_owner: 'sync-store-test', claim_token: randomUUID(), + claimed_at: created, lease_expires_at: new Date(created.getTime() + 60 * 60_000), + provider_call_started_at: created, resource_binding_id: bindingId, + runtime_session_id: sessionId, runtime_session_epoch: 0, + runtime_epoch: 1, runtime_sequence: 1 }); + return { run_id: runId, attempt_id: attemptId }; + } + const first = await createRun(null); + const firstPage = { schema_version: 'deft.app_sync_page.v1', + upserts: [ + { id: 'provider-a', revision: 'r1', data: { label: 'Private A', owner_id: ownerId } }, + { id: 'provider-b', revision: 'r1', data: { label: 'Private B' } }, + ], tombstones: [], next_cursor: 'cursor-one', has_more: true }; + const firstApplied = await db.transaction((tx) => firstStore.applyPageInTransaction(tx, + { org_id: orgId, ...first, page: firstPage, clock: () => new Date() })); + assert.equal(firstApplied.applied_sequence, 1); + assert.equal(firstApplied.has_more, true); + let projections = await db.select().from(schema.appResourceProjections).where(and( + eq(schema.appResourceProjections.org_id, orgId), + eq(schema.appResourceProjections.checkpoint_id, checkpointId))); + assert.equal(projections.length, 2); + const aProjection = projections.find((row) => rotatedSecrets.openJson({ + schema_version: row.provider_id_envelope_version, + algorithm: row.provider_id_algorithm, key_version: row.provider_id_key_version, + nonce_b64: row.provider_id_nonce_b64, ciphertext_b64: row.provider_id_ciphertext_b64, + auth_tag_b64: row.provider_id_auth_tag_b64, + }, { org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'projection', generation: 1, projection_id: row.id, + slot: 'provider_id' }) === 'provider-a'); + assert.ok(aProjection); + assert.equal(aProjection.provider_id_key_version, 'fixture-enc-v1'); + assert.equal(aProjection.body_ciphertext_b64?.includes('Private A'), false); + assert.deepEqual(firstSecrets.openJson({ + schema_version: aProjection.body_envelope_version, + algorithm: aProjection.body_algorithm, key_version: aProjection.body_key_version, + nonce_b64: aProjection.body_nonce_b64, + ciphertext_b64: aProjection.body_ciphertext_b64, + auth_tag_b64: aProjection.body_auth_tag_b64, + }, { org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'projection', generation: 1, projection_id: aProjection.id, + slot: 'record' }), { data: { label: 'Private A', owner_id: ownerId }, revision: 'r1' }); + const [afterFirst] = await db.select().from(schema.appSyncCheckpoints).where(and( + eq(schema.appSyncCheckpoints.org_id, orgId), eq(schema.appSyncCheckpoints.id, checkpointId))); + assert.equal(afterFirst?.cursor_sequence, 1); + assert.equal(afterFirst.retained_record_count, 2); + assert.equal(afterFirst.fresh_until, null); + await assert.rejects(db.transaction((tx) => firstStore.applyPageInTransaction(tx, + { org_id: orgId, ...first, page: firstPage, clock: () => new Date() })), + /APP_RESOURCE_SYNC_START_CURSOR_STALE/); + + const second = await createRun('cursor-one'); + const stale = await createRun('cursor-one'); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: randomUUID(), ...second, page: firstPage, clock: () => new Date() })), + /APP_RESOURCE_SYNC_RUN_NOT_RELEASED/); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, run_id: second.run_id, attempt_id: first.attempt_id, + page: firstPage, clock: () => new Date() })), /APP_RESOURCE_SYNC_ATTEMPT_NOT_CURRENT/); + const duplicatePage = { schema_version: 'deft.app_sync_page.v1', + upserts: [{ id: 'provider-a', revision: 'r2', data: { label: 'Duplicate' } }], + tombstones: [{ id: 'provider-a', revision: 'r2' }], + next_cursor: 'cursor-two', has_more: false }; + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...second, page: duplicatePage, clock: () => new Date() }))); + const secondPage = { schema_version: 'deft.app_sync_page.v1', + tombstones: [{ id: 'provider-a', revision: 'r2' }], + upserts: [{ id: 'provider-b', revision: 'r2', data: { label: 'Private B updated' } }], + next_cursor: 'cursor-two', has_more: false }; + const secondApplied = await db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...second, page: secondPage, clock: () => new Date() })); + assert.equal(secondApplied.applied_sequence, 2); + projections = await db.select().from(schema.appResourceProjections).where(and( + eq(schema.appResourceProjections.org_id, orgId), + eq(schema.appResourceProjections.checkpoint_id, checkpointId))); + assert.equal(projections.length, 2); + const stableA = projections.find((row) => row.id === aProjection.id); + assert.ok(stableA); + assert.equal(stableA.state, 'tombstone'); + assert.equal(stableA.body_key_version, null); + assert.equal(stableA.provider_id_key_version, 'fixture-enc-v2'); + assert.equal(rotatedSecrets.openJson({ + schema_version: stableA.provider_id_envelope_version, + algorithm: stableA.provider_id_algorithm, + key_version: stableA.provider_id_key_version, + nonce_b64: stableA.provider_id_nonce_b64, + ciphertext_b64: stableA.provider_id_ciphertext_b64, + auth_tag_b64: stableA.provider_id_auth_tag_b64, + }, { org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'projection', generation: 1, projection_id: stableA.id, + slot: 'provider_id' }), 'provider-a'); + const stableB = projections.find((row) => row.id !== aProjection.id); + assert.ok(stableB); + assert.equal(stableB.state, 'live'); + assert.equal(stableB.provider_id_key_version, stableB.body_key_version); + assert.equal(stableB.provider_id_key_version, 'fixture-enc-v2'); + assert.deepEqual(rotatedSecrets.openJson({ + schema_version: stableB.body_envelope_version, + algorithm: stableB.body_algorithm, key_version: stableB.body_key_version, + nonce_b64: stableB.body_nonce_b64, ciphertext_b64: stableB.body_ciphertext_b64, + auth_tag_b64: stableB.body_auth_tag_b64, + }, { org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'projection', generation: 1, projection_id: stableB.id, + slot: 'record' }), { data: { label: 'Private B updated' }, revision: 'r2' }); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...stale, page: secondPage, clock: () => new Date() })), + /APP_RESOURCE_SYNC_START_CURSOR_STALE/); + + const third = await createRun('cursor-two'); + const badInput = await createRun('wrong-cursor'); + const thirdPage = { schema_version: 'deft.app_sync_page.v1', + upserts: [{ id: 'provider-c', revision: 'r1', data: { label: 'Private C' } }], + tombstones: [], next_cursor: null, has_more: false }; + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...badInput, page: thirdPage, clock: () => new Date() })), + /APP_RESOURCE_SYNC_RUN_INPUT_MISMATCH/); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: thirdPage, + clock: () => new Date(Date.now() + 4 * 60 * 60_000) })), + /APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED/, + 'deadline is rechecked after the checkpoint lock'); + await assert.rejects(db.transaction((tx) => missingStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: thirdPage, clock: () => new Date() })), + keyrings.AppRunKeyVersionUnavailableError, + 'a missing retained locator key denies even a new provider ID'); + await assert.rejects(db.transaction((tx) => missingStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: { ...thirdPage, upserts: [] }, clock: () => new Date() })), + keyrings.AppRunKeyVersionUnavailableError, + 'an empty page cannot bypass historical locator-key availability'); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: thirdPage, clock: () => new Date() })), + (error: unknown) => { + assert.match(String((error as { cause?: unknown }).cause), /APP_SYNC_CAPACITY_EXCEEDED/); + return true; + }); + const rollbackPage = { ...thirdPage, upserts: [ + { id: 'provider-b', revision: 'r3', data: { label: 'Rolled back' } }, + ] }; + let settlementClockReads = 0; + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: rollbackPage, + clock: () => ++settlementClockReads === 1 ? new Date() + : new Date(Date.now() + 4 * 60 * 60_000) })), + /APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED/, + 'expiry after projection writes rolls back the page'); + assert.equal(settlementClockReads, 2); + await assert.rejects(db.transaction(async (tx) => { + await rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...third, page: rollbackPage, clock: () => new Date() }); + throw new Error('synthetic downstream receipt failure'); + }), /synthetic downstream receipt failure/); + const [afterRollback] = await db.select().from(schema.appSyncCheckpoints).where(and( + eq(schema.appSyncCheckpoints.org_id, orgId), eq(schema.appSyncCheckpoints.id, checkpointId))); + assert.equal(afterRollback?.cursor_sequence, 2); + assert.equal(afterRollback.retained_record_count, 2); + const finalRows = await db.select().from(schema.appResourceProjections).where(and( + eq(schema.appResourceProjections.org_id, orgId), + eq(schema.appResourceProjections.checkpoint_id, checkpointId))); + assert.equal(finalRows.length, 2); + assert.equal(finalRows.find((row) => row.id === stableB.id)?.applied_sequence, 2); + + // Distinct Runs with the same immutable starting intent race at the + // checkpoint. The loser must observe the winner's CAS and write nothing. + const competingA = await createRun('cursor-two'); + const competingB = await createRun('cursor-two'); + const competingPage = { schema_version: 'deft.app_sync_page.v1', + upserts: [{ id: 'provider-b', revision: 'r3', + data: { label: 'Concurrent winner' } }], + tombstones: [], next_cursor: 'cursor-three', has_more: false }; + const race = await Promise.allSettled([competingA, competingB].map((run) => + db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '4s'`); + return rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...run, page: competingPage, clock: () => new Date() }); + }))); + const winners = race.filter((result) => result.status === 'fulfilled'); + const losers = race.filter((result) => result.status === 'rejected'); + assert.equal(winners.length, 1); + assert.equal(losers.length, 1); + assert.match(String(losers[0]!.reason), /APP_RESOURCE_SYNC_START_CURSOR_STALE/); + const [afterRace] = await db.select().from(schema.appSyncCheckpoints).where(and( + eq(schema.appSyncCheckpoints.org_id, orgId), eq(schema.appSyncCheckpoints.id, checkpointId))); + assert.ok(afterRace); + assert.equal(afterRace.cursor_sequence, 3); + assert.equal(afterRace.retained_record_count, 2); + const winningRun = race[0]!.status === 'fulfilled' ? competingA.run_id : competingB.run_id; + assert.equal(afterRace.last_applied_run_id, winningRun); + const afterRaceRows = await db.select().from(schema.appResourceProjections).where(and( + eq(schema.appResourceProjections.org_id, orgId), + eq(schema.appResourceProjections.checkpoint_id, checkpointId))); + assert.equal(afterRaceRows.length, 2); + assert.equal(afterRaceRows.find((row) => row.id === stableB.id)?.applied_sequence, 3); + + // Adversarial retained state: two different locator-key versions identify + // the same provider ID. The schema allows a privileged host rekey marker; + // this test deliberately seeds a duplicate through that marker only to + // prove normal page application rejects ambiguity instead of choosing a + // UUID. It is not a supported rekey implementation. + const duplicateLocator = rotatedSecrets.locator('provider-a', { + org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + }); + const duplicateIdEnvelope = rotatedSecrets.sealJson('provider-a', { + org_id: orgId, resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'projection', generation: 1, projection_id: stableB.id, + slot: 'provider_id', + }); + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL deft.app_resource_sync_rekey = 'on'`); + await tx.update(schema.appResourceProjections).set({ + resource_id_hmac_key_version: duplicateLocator.key_version, + resource_id_hmac: duplicateLocator.fingerprint, + provider_id_envelope_version: duplicateIdEnvelope.schema_version, + provider_id_algorithm: duplicateIdEnvelope.algorithm, + provider_id_key_version: duplicateIdEnvelope.key_version, + provider_id_nonce_b64: duplicateIdEnvelope.nonce_b64, + provider_id_ciphertext_b64: duplicateIdEnvelope.ciphertext_b64, + provider_id_auth_tag_b64: duplicateIdEnvelope.auth_tag_b64, + provider_id_bytes: Buffer.byteLength(duplicateIdEnvelope.ciphertext_b64, 'base64'), + }).where(and(eq(schema.appResourceProjections.org_id, orgId), + eq(schema.appResourceProjections.id, stableB.id))); + }); + const ambiguousRun = await createRun('cursor-three'); + await assert.rejects(db.transaction((tx) => rotatedStore.applyPageInTransaction(tx, + { org_id: orgId, ...ambiguousRun, + page: { ...competingPage, upserts: [{ id: 'provider-a', revision: 'r4', + data: { label: 'Must reject ambiguity' } }], next_cursor: null }, + clock: () => new Date() })), /APP_RESOURCE_SYNC_AMBIGUOUS_LOCATOR/); + const [afterAmbiguity] = await db.select().from(schema.appSyncCheckpoints).where(and( + eq(schema.appSyncCheckpoints.org_id, orgId), eq(schema.appSyncCheckpoints.id, checkpointId))); + assert.equal(afterAmbiguity?.cursor_sequence, 3); + } finally { + firstRing.destroy(); rotatedRing.destroy(); missingHistoricalRing.destroy(); + await closeDb(); + } +}); From c54e26572ae7ea3495b35a67b934fdb80db951f4 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:11:55 +0530 Subject: [PATCH 017/161] Add reviewed Protocol 5 App authority without resource execution --- apps/api/src/lib/app-runtime-review.ts | 46 +++- ...-resource-authoring-predecessor-db.test.ts | 56 +++++ .../app-resource-authoring-review-db.test.ts | 202 ++++++++++++++++++ ...-resource-authoring-review-http-db.test.ts | 107 ++++++++++ packages/app-kit/src/index.ts | 5 +- packages/app-kit/test/resource-app-v5.test.ts | 7 +- packages/db/scripts/apply-extras.ts | 1 + packages/db/src/schema.ts | 2 +- ....3.0-preview.38-app-resource-authoring.sql | 197 +++++++++++++++++ packages/db/upgrades/manifest.ts | 5 + 10 files changed, 613 insertions(+), 15 deletions(-) create mode 100644 apps/api/test/app-resource-authoring-predecessor-db.test.ts create mode 100644 apps/api/test/app-resource-authoring-review-db.test.ts create mode 100644 apps/api/test/app-resource-authoring-review-http-db.test.ts create mode 100644 packages/db/upgrades/0.3.0-preview.38-app-resource-authoring.sql diff --git a/apps/api/src/lib/app-runtime-review.ts b/apps/api/src/lib/app-runtime-review.ts index fa12363f..40a59358 100644 --- a/apps/api/src/lib/app-runtime-review.ts +++ b/apps/api/src/lib/app-runtime-review.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'; import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appInstallations, appVersions, appGrantSnapshots, appModuleBindings, moduleInstallations, auditLog } from '@deft/db/schema'; -import { parseRuntimeAppManifest, RUNTIME_ACTION_HOST_POLICY, AppDigestSchema, type RuntimeAppManifest, type DeftAppPackage } from '@deft/app-kit'; +import { parseRuntimeAppManifest, parseResourceAppManifest, RUNTIME_ACTION_HOST_POLICY, AppDigestSchema, + type RuntimeAppManifest, type DeftAppManifestV5, type DeftAppPackage } from '@deft/app-kit'; import type { ModuleActor } from '@deft/shared/modules'; import { db } from './db.js'; import { AppError } from './app-errors.js'; @@ -22,7 +23,7 @@ export const RuntimeAppActivateRequestSchema = RuntimeAppReviewRequestSchema.ext expected_review_digest: AppDigestSchema, accept_host_policy: z.literal(true), }); -export function runtimeActionDescriptors(manifest: RuntimeAppManifest) { +export function runtimeActionDescriptors(manifest: Pick) { return manifest.runtime_actions.map((action) => { const capability = manifest.private_capabilities.find((item) => item.key === action.capability_key)!; const identity = { namespace: 'app_lineage' as const, key: capability.key, version: capability.version }; @@ -35,6 +36,23 @@ export function runtimeActionDescriptors(manifest: RuntimeAppManifest) { } export type ReviewedRuntimeAction = ReturnType[number]; +/** Pure reviewed App authority. The descriptor hash is computed from the + * closed canonical authoring contract; this grants neither provider access nor + * owner consent and can be reconstructed by a future live v2 binding reader. */ +export function buildResourceAppReviewedAuthority(manifest: DeftAppManifestV5, pins: Readonly<{ + lineage_key: string; package_digest: string; manifest_digest: string; +}>) { + return { + schema: 'deft.app_runtime_grant.v2' as const, + ...pins, + runtime_actions: runtimeActionDescriptors(manifest), + sync_descriptors: manifest.sync_descriptors.map((descriptor) => ({ + ...descriptor, descriptor_digest: digestAppGrantValue(descriptor), + })), + modules: manifest.modules, experiences: manifest.experiences, public_actions: manifest.public_actions, + }; +} + async function reviewContext(tx: Executor, actor: ModuleActor, installationId: string, request: z.infer) { await assertCurrentModuleManagerWithExecutor(tx, actor); @@ -48,10 +66,11 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s eq(appVersions.org_id, actor.org_id), eq(appVersions.installation_id, installationId), eq(appVersions.id, request.app_version_id), )).limit(1).for('share'); - if (!version || !['3', '4'].includes(version.protocol_version) || !['staged', 'active'].includes(version.state) + if (!version || !['3', '4', '5'].includes(version.protocol_version) || !['staged', 'active'].includes(version.state) || version.package_digest !== request.expected_package_digest || (installation.active_version_id && installation.active_version_id !== version.id)) throw stale(); - const manifest = parseRuntimeAppManifest(version.manifest); + const manifest = version.protocol_version === '5' + ? parseResourceAppManifest(version.manifest) : parseRuntimeAppManifest(version.manifest); const [requested] = await tx.select().from(appGrantSnapshots).where(and( eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), eq(appGrantSnapshots.app_version_id, version.id), eq(appGrantSnapshots.id, version.requested_grant_snapshot_id ?? ''), @@ -63,10 +82,13 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s if (!requested || requested.snapshot_digest !== request.expected_requested_snapshot_digest || requested.snapshot_digest !== expected.snapshot_digest || digestAppGrantValue(requested.canonical_snapshot) !== expected.snapshot_digest) throw stale(); - const authority = { schema: 'deft.app_runtime_grant.v1' as const, lineage_key: installation.lineage_key, - package_digest: version.package_digest, manifest_digest: version.manifest_digest, - runtime_actions: runtimeActionDescriptors(manifest), - ...(manifest.schema_version === '4' ? { modules: manifest.modules, experiences: manifest.experiences, public_actions: manifest.public_actions } : {}) }; + const authority = manifest.schema_version === '5' + ? buildResourceAppReviewedAuthority(manifest, { lineage_key: installation.lineage_key, + package_digest: version.package_digest, manifest_digest: version.manifest_digest }) + : { schema: 'deft.app_runtime_grant.v1' as const, lineage_key: installation.lineage_key, + package_digest: version.package_digest, manifest_digest: version.manifest_digest, + runtime_actions: runtimeActionDescriptors(manifest), + ...(manifest.schema_version === '4' ? { modules: manifest.modules, experiences: manifest.experiences, public_actions: manifest.public_actions } : {}) }; const review = { ...request, installation_id: installationId, organization_id: actor.org_id, authority, requested_snapshot_id: requested.id }; return { installation, version, requested, authority, review: { ...review, review_digest: digestAppGrantValue(review) } }; @@ -83,8 +105,9 @@ export async function activateRuntimeApp(actor: ModuleActor, installationId: str const activated = await db.transaction(async (tx) => { const context = await reviewContext(tx, actor, installationId, request); if (context.review.review_digest !== expected_review_digest) throw stale(); - const manifest = parseRuntimeAppManifest(context.version.manifest); - if (manifest.schema_version === '4') { + const manifest = context.version.protocol_version === '5' + ? parseResourceAppManifest(context.version.manifest) : parseRuntimeAppManifest(context.version.manifest); + if (manifest.schema_version === '4' || manifest.schema_version === '5') { if (context.version.state === 'staged') { const pkg = context.version.package as unknown as DeftAppPackage; for (const reference of [...manifest.modules].sort((a, b) => a.module_id.localeCompare(b.module_id))) { @@ -113,7 +136,8 @@ export async function activateRuntimeApp(actor: ModuleActor, installationId: str const effectiveId = randomUUID(); const now = new Date(); const classification = { authority_state: 'effective', executable: false, provider_access: false, - runtime_binding_review_required: true }; + runtime_binding_review_required: true, + ...(manifest.schema_version === '5' ? { resource_binding_consent_required: true } : {}) }; const canonical = { ...context.authority, organization_id: actor.org_id, app_installation_id: installationId, app_version_id: context.version.id, requested_snapshot_id: context.requested.id, requested_snapshot_digest: context.requested.snapshot_digest, diff --git a/apps/api/test/app-resource-authoring-predecessor-db.test.ts b/apps/api/test/app-resource-authoring-predecessor-db.test.ts new file mode 100644 index 00000000..6c393a17 --- /dev/null +++ b/apps/api/test/app-resource-authoring-predecessor-db.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = process.env.DEFT_TEST_PRE38_SEED === 'true' && target === process.env.DATABASE_URL + && target !== undefined && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && new URL(target).pathname === '/gate_g_phase5_test_s05_v5_preserve'; + +test('seed real v3/v4 reviewed rows before preview38 for byte preservation', { skip: !safe }, async () => { + const [{ db, closeDb }, schema, kit, apps, review, modules] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), + ]); + const { eq } = await import('drizzle-orm'); + try { + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'v5 predecessor preservation', slug: `pre38-${suffix}` }); + await db.insert(schema.users).values({ id: ownerId, name: 'Owner', email: `pre38-${suffix}@example.test` }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const actor = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const object = { type: 'object' as const, + properties: { shipment_id: { type: 'string' as const, maxLength: 120 } }, + required: ['shipment_id'], additionalProperties: false as const }; + for (const protocol of ['3', '4'] as const) { + const manifest = { schema_version: protocol, id: `community.example.pre38-${protocol}.a${suffix}`, + version: '1.0.0', name: `Pre38 v${protocol}`, license: 'AGPL-3.0-only', + compatibility: { app_protocol: protocol }, modules: [], navigation: [], + runtime_requirements: [{ key: 'carrier', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'label', version: '1', input_schema: object, output_schema: object }], + runtime_actions: [{ key: 'create_label', label: 'Create label', + capability_key: 'label', runtime_requirement_key: 'carrier' }], + ...(protocol === '4' ? { experiences: [], public_actions: [] } : {}), + }; + const pkg = await kit.buildDeftAppPackage({ manifest, artifacts: [] }); + const staged = await apps.stageAppPackage(actor, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(eq(schema.appVersions.id, staged.version_id)); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(eq( + schema.appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + const request = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const prepared = await review.prepareRuntimeAppReview(actor, staged.id, request); + assert.equal(prepared.authority.schema, 'deft.app_runtime_grant.v1'); + const active = await review.activateRuntimeApp(actor, staged.id, { + ...request, expected_review_digest: prepared.review_digest, accept_host_policy: true }); + assert.equal(active.installation.state, 'active'); + } + } finally { await closeDb(); } +}); diff --git a/apps/api/test/app-resource-authoring-review-db.test.ts b/apps/api/test/app-resource-authoring-review-db.test.ts new file mode 100644 index 00000000..897b821c --- /dev/null +++ b/apps/api/test/app-resource-authoring-review-db.test.ts @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_s05_(?:v5_review(?:_v[0-9]+)?|root(?:_v[0-9]+)?)$/.test(new URL(target).pathname); + +after(async () => { if (safe) await (await import('../src/lib/db.js')).closeDb(); }); + +const record = { type: 'object' as const, properties: { subject: { type: 'string' as const, maxLength: 200 } }, + required: ['subject'], additionalProperties: false as const }; +const actionObject = { type: 'object' as const, + properties: { message_id: { type: 'string' as const, maxLength: 120 } }, + required: ['message_id'], additionalProperties: false as const }; +function v5Manifest(id: string, mixed = false) { + return { schema_version: '5' as const, id, version: '1.0.0', name: 'Resource review fixture', + license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' as const }, modules: [], navigation: [], + runtime_requirements: [ + { key: 'mail_sync', protocol_version: 'deft.app_runtime_channel.v2' as const }, + ...(mixed ? [{ key: 'mail_action', protocol_version: 'deft.app_runtime_channel.v1' as const }] : []), + ], + private_capabilities: mixed ? [{ key: 'archive', version: '1' as const, + input_schema: actionObject, output_schema: actionObject }] : [], + runtime_actions: mixed ? [{ key: 'archive_message', label: 'Archive message', + capability_key: 'archive', runtime_requirement_key: 'mail_action' }] : [], + sync_descriptors: [{ schema_version: 'deft.app_sync_descriptor.v1' as const, + key: 'mail', runtime_requirement_key: 'mail_sync', resource_type: 'email_message', + requested_visibility: 'user_private' as const, record_schema: record, label_field: 'subject' }], + experiences: [], public_actions: [], + }; +} + +test('v5 sync-only and mixed stage/review/activation pin declaration without resource authority', { skip: !safe }, async () => { + const [{ db }, schema, kit, sync, apps, review, modules] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('@deft/app-kit/experimental/resource-sync'), import('../src/lib/app-service.js'), + import('../src/lib/app-runtime-review.js'), import('../src/lib/module-service.js'), + ]); + const { and, eq } = await import('drizzle-orm'); + try { + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); const foreignOrgId = randomUUID(); + const ownerId = randomUUID(); const memberId = randomUUID(); const foreignId = randomUUID(); + await db.insert(schema.orgs).values([{ id: orgId, name: 'v5 review', slug: `v5-review-${suffix}` }, + { id: foreignOrgId, name: 'foreign v5', slug: `foreign-v5-${suffix}` }]); + await db.insert(schema.users).values([ + { id: ownerId, name: 'Owner', email: `v5-owner-${suffix}@example.test` }, + { id: memberId, name: 'Member', email: `v5-member-${suffix}@example.test` }, + { id: foreignId, name: 'Foreign owner', email: `v5-foreign-${suffix}@example.test` }, + ]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: memberId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: foreignOrgId, user_id: foreignId, role: 'owner', is_active: true }, + ]); + const owner = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const spoofed = modules.humanModuleActor({ orgId, userId: memberId, role: 'owner', source: 'rest' }); + const foreign = modules.humanModuleActor({ orgId: foreignOrgId, userId: foreignId, role: 'owner', source: 'rest' }); + for (const mixed of [false, true]) { + const manifest = v5Manifest(`community.example.v5-review-${mixed ? 'mixed' : 'sync'}.a${suffix}`, mixed); + const pkg = await kit.buildDeftAppPackage({ manifest, artifacts: [] }); + assert.equal(pkg.package.artifacts.length, 0); + assert.equal(pkg.package.package_format, 'deft.app.package.v5'); + const staged = await apps.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(eq( + schema.appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + assert.equal(requested.snapshot_kind, 'requested'); + assert.equal(requested.classification.provider_access, false); + assert.deepEqual(requested.resource_rights, []); + assert.deepEqual((requested.canonical_snapshot.requirements as Record).sync_descriptors, + manifest.sync_descriptors); + const input = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + await assert.rejects(review.prepareRuntimeAppReview(spoofed, staged.id, input)); + await assert.rejects(review.prepareRuntimeAppReview(foreign, staged.id, input)); + await assert.rejects(review.prepareRuntimeAppReview(owner, staged.id, { + ...input, expected_requested_snapshot_digest: `sha256:${'0'.repeat(64)}` })); + await assert.rejects(review.prepareRuntimeAppReview(owner, staged.id, { + ...input, expected_lifecycle_epoch: staged.lifecycle_epoch + 1 })); + const prepared = await review.prepareRuntimeAppReview(owner, staged.id, input); + assert.equal(prepared.authority.schema, 'deft.app_runtime_grant.v2'); + assert.equal(prepared.authority.runtime_actions.length, mixed ? 1 : 0); + assert.equal(prepared.authority.sync_descriptors.length, 1); + const pinned = prepared.authority.sync_descriptors[0]!; + assert.equal(pinned.descriptor_digest, await sync.digestResourceSyncDescriptor(manifest.sync_descriptors[0])); + assert.deepEqual({ ...pinned, descriptor_digest: undefined }, + { ...manifest.sync_descriptors[0], descriptor_digest: undefined }); + const active = await review.activateRuntimeApp(owner, staged.id, { + ...input, expected_review_digest: prepared.review_digest, accept_host_policy: true }); + assert.equal(active.installation.state, 'active'); + const [grant] = await db.select().from(schema.appGrantSnapshots).where(eq( + schema.appGrantSnapshots.id, active.grant_snapshot_id)); + assert.ok(grant); + assert.equal(grant.classification.executable, false); + assert.equal(grant.classification.provider_access, false); + assert.equal(grant.classification.resource_binding_consent_required, true); + assert.deepEqual(grant.resource_rights, []); + assert.equal(grant.canonical_snapshot.schema, 'deft.app_runtime_grant.v2'); + if (mixed) await assert.rejects(db.transaction((tx) => + review.loadReviewedRuntimeAction(tx, orgId, staged.id, 'archive_message'))); + for (const table of [schema.appRuntimeRegistrations, schema.appRuntimeBindings, + schema.appResourceBindings, schema.appRuntimeSessions, schema.appSyncCheckpoints, schema.appRuns]) { + assert.deepEqual(await db.select({ id: table.id }).from(table).where(eq(table.org_id, orgId)), []); + } + if (!mixed) { + const changedDescriptor = structuredClone(grant.canonical_snapshot); + (changedDescriptor.sync_descriptors as Record[])[0]!.resource_type = 'forged_mail'; + for (const tampered of [ + { ...grant.canonical_snapshot, lineage_key: 'forged' }, + { ...grant.canonical_snapshot, classification: undefined }, + changedDescriptor, + ]) { + await assert.rejects(db.insert(schema.appGrantSnapshots).values({ ...grant, + id: randomUUID(), supersedes_snapshot_id: grant.id, canonical_snapshot: tampered, + snapshot_digest: kit.AppDigestSchema.parse(grant.snapshot_digest) }), (error: unknown) => { + assert.match(String((error as { cause?: { message?: string } }).cause?.message ?? error), + /APP_V5_GRANT_/); + return true; + }); + } + const disabled = await apps.disableAppInstallation(owner, staged.id, active.installation.lifecycle_epoch); + await assert.rejects(review.activateRuntimeApp(owner, staged.id, { + ...input, expected_review_digest: prepared.review_digest, accept_host_policy: true })); + const fresh = { ...input, expected_lifecycle_epoch: disabled.lifecycle_epoch, + expected_grant_epoch: disabled.grant_epoch }; + const nextReview = await review.prepareRuntimeAppReview(owner, staged.id, fresh); + assert.notEqual(nextReview.review_digest, prepared.review_digest); + const again = await review.activateRuntimeApp(owner, staged.id, { + ...fresh, expected_review_digest: nextReview.review_digest, accept_host_policy: true }); + assert.notEqual(again.grant_snapshot_id, grant.id); + assert.equal(again.installation.active_grant_snapshot_id, again.grant_snapshot_id); + } + } + } finally { /* shared pool closes after both cases */ } +}); + +test('v5 activation rolls back earlier included Module, grant and lifecycle on later Module conflict', { skip: !safe }, async () => { + const [{ db }, schema, kit, apps, review, modules] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../src/lib/app-service.js'), import('../src/lib/app-runtime-review.js'), + import('../src/lib/module-service.js'), + ]); + const { eq, and } = await import('drizzle-orm'); + try { + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); const ownerId = randomUUID(); + await db.insert(schema.orgs).values({ id: orgId, name: 'v5 atomic', slug: `v5-atomic-${suffix}` }); + await db.insert(schema.users).values({ id: ownerId, name: 'Owner', email: `v5-atomic-${suffix}@example.test` }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const moduleManifest = (name: string) => ({ schema_version: '1', id: `community.example.${name}`, + slug: name, version: '1.0.0', name, + collections: [{ key: 'items', name: 'Items', singular_name: 'Item', + fields: [{ key: 'title', label: 'Title', type: 'text', required: true }], + views: [{ key: 'all', name: 'All', type: 'table', fields: ['title'] }], + search: { title_field: 'title', subtitle_fields: [], fields: ['title'] } }], + navigation: { default_collection: 'items', default_view: 'all' } }); + const first = moduleManifest('a-v5-atomic'); + const conflict = moduleManifest('z-v5-existing'); + await modules.installModuleFromManifest(owner, conflict, { source: 'sideloaded' }); + const artifacts = await Promise.all([first, conflict].map((manifest) => + kit.prepareModuleArtifact({ path: `modules/${manifest.slug}/deft.module.json`, manifest }))); + const manifest = { ...v5Manifest(`community.example.v5-atomic.a${suffix}`), + modules: artifacts.map((artifact, index) => ({ module_id: [first, conflict][index]!.id, + version: '1.0.0', manifest_path: artifact.path, manifest_digest: artifact.digest })) }; + const pkg = await kit.buildDeftAppPackage({ manifest, artifacts }); + const staged = await apps.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(eq(schema.appVersions.id, staged.version_id)); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(eq( + schema.appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + const input = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const prepared = await review.prepareRuntimeAppReview(owner, staged.id, input); + await assert.rejects(review.activateRuntimeApp(owner, staged.id, { + ...input, expected_review_digest: prepared.review_digest, accept_host_policy: true }), + (error: unknown) => error instanceof Error && 'code' in error && error.code === 'MODULE_ALREADY_INSTALLED'); + assert.deepEqual(await db.select().from(schema.appModuleBindings).where(eq( + schema.appModuleBindings.app_installation_id, staged.id)), []); + assert.deepEqual(await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.app_installation_id, staged.id), + eq(schema.appGrantSnapshots.snapshot_kind, 'effective'))), []); + const [after] = await db.select().from(schema.appInstallations).where(eq(schema.appInstallations.id, staged.id)); + assert.equal(after?.state, 'staged'); + assert.equal(after?.active_version_id, null); + assert.equal(after?.lifecycle_epoch, staged.lifecycle_epoch); + assert.equal(after?.grant_epoch, staged.grant_epoch); + const installed = await db.select().from(schema.moduleInstallations).where(eq(schema.moduleInstallations.org_id, orgId)); + assert.equal(installed.length, 1); + assert.equal(installed[0]?.module_id, conflict.id); + } finally { /* shared pool closes after both cases */ } +}); diff --git a/apps/api/test/app-resource-authoring-review-http-db.test.ts b/apps/api/test/app-resource-authoring-review-http-db.test.ts new file mode 100644 index 00000000..1cef357e --- /dev/null +++ b/apps/api/test/app-resource-authoring-review-http-db.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = target === process.env.DATABASE_URL && target !== undefined + && new URL(target).hostname === '127.0.0.1' && new URL(target).port === '55435' + && /^\/gate_g_phase5_test_s05_(?:v5_review(?:_v[0-9]+)?|root(?:_v[0-9]+)?)$/.test(new URL(target).pathname); + +test('authenticated HTTP v5 review and activation reject foreign and stale requests', { skip: !safe }, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`v5-review-http:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': key('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': key('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': key('fp') } } }); + const [{ app }, { db, closeDb }, schema, kit, apps, modules, sessions, serverModule] = await Promise.all([ + import('../src/index.js'), import('../src/lib/db.js'), import('@deft/db/schema'), + import('@deft/app-kit'), import('../src/lib/app-service.js'), import('../src/lib/module-service.js'), + import('../src/lib/web-sessions.js'), import('@hono/node-server'), + ]); + const { eq } = await import('drizzle-orm'); + const base = 'http://127.0.0.1:4341'; + let server: ReturnType | undefined; + try { + await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 4341 }, resolve); + }); + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); const otherOrgId = randomUUID(); + const ownerId = randomUUID(); const memberId = randomUUID(); const foreignId = randomUUID(); + const ownerEmail = `v5-http-owner-${suffix}@example.test`; + await db.insert(schema.orgs).values([{ id: orgId, name: 'v5 HTTP', slug: `v5-http-${suffix}` }, + { id: otherOrgId, name: 'other v5 HTTP', slug: `v5-http-other-${suffix}` }]); + await db.insert(schema.users).values([ + { id: ownerId, name: 'Owner', email: ownerEmail }, + { id: memberId, name: 'Member', email: `v5-http-member-${suffix}@example.test` }, + { id: foreignId, name: 'Foreign', email: `v5-http-foreign-${suffix}@example.test` }, + ]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: memberId, role: 'member', is_active: true }, + { id: randomUUID(), org_id: otherOrgId, user_id: foreignId, role: 'owner', is_active: true }, + ]); + const owner = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const descriptor = { schema_version: 'deft.app_sync_descriptor.v1' as const, key: 'mail', + runtime_requirement_key: 'mail_sync', resource_type: 'email_message', + requested_visibility: 'user_private' as const, label_field: 'subject', + record_schema: { type: 'object' as const, + properties: { subject: { type: 'string' as const, maxLength: 200 } }, + required: ['subject'], additionalProperties: false as const } }; + const pkg = await kit.buildDeftAppPackage({ manifest: { + schema_version: '5', id: `community.example.v5-http.a${suffix}`, version: '1.0.0', + name: 'v5 HTTP', license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' }, + modules: [], navigation: [], + runtime_requirements: [{ key: 'mail_sync', protocol_version: 'deft.app_runtime_channel.v2' }], + private_capabilities: [], runtime_actions: [], sync_descriptors: [descriptor], + experiences: [], public_actions: [], + }, artifacts: [] }); + const staged = await apps.stageAppPackage(owner, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(eq(schema.appVersions.id, staged.version_id)); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(eq( + schema.appGrantSnapshots.id, version.requested_grant_snapshot_id)); + assert.ok(requested); + const input = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const ownerWeb = await sessions.createWebSession({ id: ownerId, email: ownerEmail, org_id: orgId }); + const memberWeb = await sessions.createWebSession({ id: memberId, + email: `v5-http-member-${suffix}@example.test`, org_id: orgId }); + const foreignWeb = await sessions.createWebSession({ id: foreignId, + email: `v5-http-foreign-${suffix}@example.test`, org_id: otherOrgId }); + async function post(operation: string, body: unknown, token: string) { + const response = await fetch(`${base}/api/app-runtime-review/${staged.id}/${operation}`, { + method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { response, value: await response.json() as Record }; + } + for (const token of [memberWeb.accessToken, foreignWeb.accessToken]) { + const denied = await post('review', input, token); + assert.notEqual(denied.response.status, 200); + assert.equal(denied.response.headers.get('cache-control'), 'no-store'); + } + const stale = await post('review', { ...input, expected_grant_epoch: input.expected_grant_epoch + 1 }, + ownerWeb.accessToken); + assert.equal(stale.response.status, 409); + const prepared = await post('review', input, ownerWeb.accessToken); + assert.equal(prepared.response.status, 200, JSON.stringify(prepared.value)); + assert.equal(prepared.value.authority.schema, 'deft.app_runtime_grant.v2'); + const activated = await post('activate', { ...input, expected_review_digest: prepared.value.review_digest, + accept_host_policy: true }, ownerWeb.accessToken); + assert.equal(activated.response.status, 200, JSON.stringify(activated.value)); + assert.equal(activated.value.installation.state, 'active'); + assert.ok(activated.value.grant_snapshot_id); + assert.deepEqual(await db.select({ id: schema.appResourceBindings.id }).from(schema.appResourceBindings) + .where(eq(schema.appResourceBindings.org_id, orgId)), []); + } finally { + server?.closeAllConnections(); + await new Promise((resolve) => server?.close(() => resolve()) ?? resolve()); + await closeDb(); + } +}); diff --git a/packages/app-kit/src/index.ts b/packages/app-kit/src/index.ts index 74a5a0fa..28dc1aa9 100644 --- a/packages/app-kit/src/index.ts +++ b/packages/app-kit/src/index.ts @@ -892,7 +892,10 @@ const V2_HANDLER_MATRIX = handlerMatrix({ export const DEFT_APP_PROTOCOL_SUPPORT = Object.freeze({ '5': Object.freeze({ manifest_keys: Object.freeze(['schema_version', 'id', 'version', 'name', 'description', 'license', 'compatibility', 'provenance', 'modules', 'navigation', 'runtime_requirements', 'private_capabilities', 'runtime_actions', 'sync_descriptors', 'experiences', 'public_actions']), - atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'modules.included', 'navigation.host_rendered', 'runtime.private_actions', 'resources.owner_private_sync', 'experiences.installed', 'public.claim_actions'], handlerMatrix({ authoring: 'app-kit:v5' })), + atoms: protocolAtoms(['manifest.identity', 'manifest.provenance', 'modules.included', 'navigation.host_rendered', 'runtime.private_actions', 'resources.owner_private_sync', 'experiences.installed', 'public.claim_actions'], handlerMatrix({ + authoring: 'app-kit:v5', inspect: 'app-service:inspect-v5', stage: 'app-service:stage-v5', + review: 'app-runtime-review:v5', activate: 'app-runtime-review:v5', + })), private_interfaces: Object.freeze([]), }), '4': Object.freeze({ diff --git a/packages/app-kit/test/resource-app-v5.test.ts b/packages/app-kit/test/resource-app-v5.test.ts index 915d6f72..4f6bf7a5 100644 --- a/packages/app-kit/test/resource-app-v5.test.ts +++ b/packages/app-kit/test/resource-app-v5.test.ts @@ -34,7 +34,7 @@ async function withExperience(manifest: typeof base = base, resourceKeys: string return { manifest: { ...manifest, experiences: [reference] }, artifact }; } -test('v5 sync-only App and Experience package are deterministic, requested-only, and host unsupported', async () => { +test('v5 sync-only App and Experience package are deterministic and only reviewable by the host', async () => { const { manifest, artifact } = await withExperience(); const built = await buildDeftAppPackage({ manifest, artifacts: [artifact] }); assert.equal(built.package.package_format, 'deft.app.package.v5'); @@ -43,7 +43,10 @@ test('v5 sync-only App and Experience package are deterministic, requested-only, assert.equal(parseResourceAppManifest(manifest).sync_descriptors[0]?.key, 'mail'); assert.throws(() => parseRuntimeAppManifest(manifest)); assert.equal(isDeftAppProtocolOperationSupported('5', 'authoring'), true); - for (const operation of ['inspect', 'stage', 'review', 'activate', 'route', 'invoke'] as const) { + for (const operation of ['inspect', 'stage', 'review', 'activate'] as const) { + assert.equal(isDeftAppProtocolOperationSupported('5', operation), true); + } + for (const operation of ['route', 'invoke'] as const) { assert.equal(isDeftAppProtocolOperationSupported('5', operation), false); } const report = buildDeftAppRequestedAuthorityReport(manifest); diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index d4b37df0..56bdf000 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -150,6 +150,7 @@ async function main() { '0.3.0-preview.35-app-public-runtime.sql', '0.3.0-preview.36-app-experience-sessions.sql', '0.3.0-preview.37-app-resource-sync.sql', + '0.3.0-preview.38-app-resource-authoring.sql', ]) { await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); console.log(`[apply-extras] reconciled ${platformFile}`); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 25047881..25e57925 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1777,7 +1777,7 @@ export const appVersions = pgTable('app_versions', { uniqueIndex('app_versions_one_active_unique') .on(t.org_id, t.installation_id) .where(sql`${t.state} = 'active'`), - check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2', '3', '4')`), + check('app_versions_protocol_supported_check', sql`${t.protocol_version} IN ('0', '1', '2', '3', '4', '5')`), check('app_versions_connected_request_check', sql` ${t.protocol_version} = '0' OR ${t.requested_grant_snapshot_id} IS NOT NULL `), diff --git a/packages/db/upgrades/0.3.0-preview.38-app-resource-authoring.sql b/packages/db/upgrades/0.3.0-preview.38-app-resource-authoring.sql new file mode 100644 index 00000000..aa54eac2 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.38-app-resource-authoring.sql @@ -0,0 +1,197 @@ +-- Additive v5 staging/review authority. Retain old rows; downgrade is not safe after v5 state. +ALTER TABLE app_versions DROP CONSTRAINT IF EXISTS app_versions_protocol_supported_check; +ALTER TABLE app_versions ADD CONSTRAINT app_versions_protocol_supported_check CHECK (protocol_version IN ('0', '1', '2', '3', '4', '5')); + +CREATE OR REPLACE FUNCTION assert_app_installation_grant_coherence( + checked_org_id text, + checked_installation_id text +) RETURNS void AS $$ +DECLARE + installation app_installations%ROWTYPE; + version_protocol text; + version_state text; +BEGIN + SELECT * INTO installation FROM app_installations + WHERE org_id = checked_org_id AND id = checked_installation_id; + IF NOT FOUND THEN RETURN; END IF; + + IF installation.active_version_id IS NULL THEN + IF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_GRANT_POINTER_WITHOUT_VERSION' USING ERRCODE = '23514'; + END IF; + RETURN; + END IF; + + SELECT protocol_version, state INTO version_protocol, version_state + FROM app_versions + WHERE org_id = checked_org_id + AND installation_id = checked_installation_id + AND id = installation.active_version_id; + IF NOT FOUND OR version_state <> 'active' THEN + RAISE EXCEPTION 'APP_ACTIVE_VERSION_INVALID' USING ERRCODE = '23514'; + END IF; + + IF installation.state = 'active' AND version_protocol IN ('1', '2', '3', '4', '5') THEN + IF installation.active_grant_snapshot_id IS NULL + OR installation.active_grant_snapshot_kind <> 'effective' + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_REQUIRED' USING ERRCODE = '23514'; + END IF; + ELSIF installation.active_grant_snapshot_id IS NOT NULL + OR installation.active_grant_snapshot_kind IS NOT NULL + THEN + RAISE EXCEPTION 'APP_EFFECTIVE_GRANT_NOT_ALLOWED' USING ERRCODE = '23514'; + END IF; +END; +$$ LANGUAGE plpgsql; + +-- A v5 effective snapshot is only a reviewed App declaration. It must pin +-- the exact stored App/requested ancestry and cannot masquerade as consent. +-- Older effective snapshots keep their historical shape and bytes. +CREATE OR REPLACE FUNCTION enforce_app_v5_effective_grant_shape() RETURNS trigger AS $$ +DECLARE + version_row app_versions%ROWTYPE; + installation_row app_installations%ROWTYPE; + requested_row app_grant_snapshots%ROWTYPE; + descriptor jsonb; + snapshot_json jsonb; + declared_descriptors jsonb; +BEGIN + IF NEW.snapshot_kind <> 'effective' THEN RETURN NEW; END IF; + SELECT * INTO version_row FROM app_versions WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id AND id = NEW.app_version_id; + IF NOT FOUND OR version_row.protocol_version <> '5' THEN RETURN NEW; END IF; + SELECT * INTO installation_row FROM app_installations WHERE org_id = NEW.org_id + AND id = NEW.app_installation_id; + SELECT * INTO requested_row FROM app_grant_snapshots WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id AND app_version_id = NEW.app_version_id + AND id = NEW.requested_snapshot_id AND snapshot_kind = 'requested'; + IF installation_row.id IS NULL OR requested_row.id IS NULL THEN + RAISE EXCEPTION 'APP_V5_GRANT_ANCESTRY_MISSING' USING ERRCODE = '23514'; + END IF; + snapshot_json := NEW.canonical_snapshot; + IF NOT (snapshot_json ?& ARRAY['schema','lineage_key','package_digest','manifest_digest', + 'runtime_actions','sync_descriptors','modules','experiences','public_actions', + 'organization_id','app_installation_id','app_version_id','requested_snapshot_id', + 'requested_snapshot_digest','classification','review_digest']) + OR snapshot_json - ARRAY['schema','lineage_key','package_digest','manifest_digest', + 'runtime_actions','sync_descriptors','modules','experiences','public_actions', + 'organization_id','app_installation_id','app_version_id','requested_snapshot_id', + 'requested_snapshot_digest','classification','review_digest'] <> '{}'::jsonb + OR snapshot_json->>'schema' IS DISTINCT FROM 'deft.app_runtime_grant.v2' + OR snapshot_json->>'lineage_key' IS DISTINCT FROM installation_row.lineage_key + OR snapshot_json->>'package_digest' IS DISTINCT FROM version_row.package_digest + OR snapshot_json->>'manifest_digest' IS DISTINCT FROM version_row.manifest_digest + OR snapshot_json->>'organization_id' IS DISTINCT FROM NEW.org_id + OR snapshot_json->>'app_installation_id' IS DISTINCT FROM NEW.app_installation_id + OR snapshot_json->>'app_version_id' IS DISTINCT FROM NEW.app_version_id + OR snapshot_json->>'requested_snapshot_id' IS DISTINCT FROM NEW.requested_snapshot_id + OR snapshot_json->>'requested_snapshot_digest' IS DISTINCT FROM requested_row.snapshot_digest + OR NOT COALESCE((snapshot_json->>'review_digest' ~ '^sha256:[a-f0-9]{64}$'), false) + OR NEW.resource_rights <> '[]'::jsonb + OR NEW.classification <> '{"authority_state":"effective","executable":false,"provider_access":false,"runtime_binding_review_required":true,"resource_binding_consent_required":true}'::jsonb + OR snapshot_json->'classification' IS DISTINCT FROM NEW.classification THEN + RAISE EXCEPTION 'APP_V5_GRANT_SHAPE_INVALID' USING ERRCODE = '23514'; + END IF; + IF jsonb_typeof(snapshot_json->'runtime_actions') IS DISTINCT FROM 'array' + OR jsonb_typeof(snapshot_json->'sync_descriptors') IS DISTINCT FROM 'array' + OR jsonb_typeof(snapshot_json->'modules') IS DISTINCT FROM 'array' + OR jsonb_typeof(snapshot_json->'experiences') IS DISTINCT FROM 'array' + OR jsonb_typeof(snapshot_json->'public_actions') IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'APP_V5_GRANT_ARRAY_INVALID' USING ERRCODE = '23514'; + END IF; + IF jsonb_array_length(snapshot_json->'sync_descriptors') NOT BETWEEN 1 AND 8 + OR jsonb_array_length(snapshot_json->'runtime_actions') > 16 + OR jsonb_array_length(snapshot_json->'modules') > 15 + OR jsonb_array_length(snapshot_json->'experiences') > 1 + OR jsonb_array_length(snapshot_json->'public_actions') > 8 THEN + RAISE EXCEPTION 'APP_V5_GRANT_LIMIT_INVALID' USING ERRCODE = '23514'; + END IF; + SELECT COALESCE(jsonb_agg(item.value - 'descriptor_digest' ORDER BY item.ordinality), '[]'::jsonb) + INTO declared_descriptors FROM jsonb_array_elements(snapshot_json->'sync_descriptors') + WITH ORDINALITY AS item(value, ordinality); + IF declared_descriptors IS DISTINCT FROM version_row.manifest->'sync_descriptors' + OR snapshot_json->'modules' IS DISTINCT FROM version_row.manifest->'modules' + OR snapshot_json->'experiences' IS DISTINCT FROM version_row.manifest->'experiences' + OR snapshot_json->'public_actions' IS DISTINCT FROM version_row.manifest->'public_actions' + OR jsonb_array_length(snapshot_json->'runtime_actions') + IS DISTINCT FROM jsonb_array_length(version_row.manifest->'runtime_actions') THEN + RAISE EXCEPTION 'APP_V5_GRANT_DECLARATION_MISMATCH' USING ERRCODE = '23514'; + END IF; + FOR descriptor IN SELECT value FROM jsonb_array_elements(snapshot_json->'sync_descriptors') LOOP + IF jsonb_typeof(descriptor) IS DISTINCT FROM 'object' + OR NOT (descriptor ?& ARRAY['schema_version','key','runtime_requirement_key', + 'resource_type','requested_visibility','record_schema','label_field','descriptor_digest']) + OR descriptor - ARRAY['schema_version','key','runtime_requirement_key', + 'resource_type','requested_visibility','record_schema','label_field','descriptor_digest'] <> '{}'::jsonb + OR descriptor->>'schema_version' IS DISTINCT FROM 'deft.app_sync_descriptor.v1' + OR descriptor->>'requested_visibility' IS DISTINCT FROM 'user_private' + OR NOT COALESCE((descriptor->>'descriptor_digest' ~ '^sha256:[a-f0-9]{64}$'), false) + OR jsonb_typeof(descriptor->'record_schema') IS DISTINCT FROM 'object' THEN + RAISE EXCEPTION 'APP_V5_GRANT_DESCRIPTOR_INVALID' USING ERRCODE = '23514'; + END IF; + END LOOP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS app_grant_snapshots_v5_shape_trigger ON app_grant_snapshots; +CREATE CONSTRAINT TRIGGER app_grant_snapshots_v5_shape_trigger + AFTER INSERT ON app_grant_snapshots DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION enforce_app_v5_effective_grant_shape(); + +CREATE OR REPLACE FUNCTION enforce_app_grant_snapshot_lineage() RETURNS trigger AS $$ +BEGIN + IF NEW.snapshot_kind = 'requested' THEN + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND requested_grant_snapshot_id = NEW.id + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_POINTER_MISMATCH' USING ERRCODE = '23514'; + END IF; + ELSE + IF NOT EXISTS ( + SELECT 1 FROM app_versions + WHERE org_id = NEW.org_id + AND installation_id = NEW.app_installation_id + AND id = NEW.app_version_id + AND protocol_version IN ('1', '2', '3', '4', '5') + ) THEN + RAISE EXCEPTION 'APP_GRANT_EFFECTIVE_PROTOCOL_UNSUPPORTED' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND app_version_id = NEW.app_version_id + AND id = NEW.requested_snapshot_id + AND snapshot_kind = 'requested' + ) THEN + RAISE EXCEPTION 'APP_GRANT_REQUEST_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM org_members + WHERE org_id = NEW.org_id + AND user_id = NEW.reviewed_by_actor_id + AND is_active = true + AND role IN ('owner', 'admin') + ) THEN + RAISE EXCEPTION 'APP_GRANT_REVIEWER_NOT_AUTHORIZED' USING ERRCODE = '23514'; + END IF; + IF NEW.supersedes_snapshot_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM app_grant_snapshots + WHERE org_id = NEW.org_id + AND app_installation_id = NEW.app_installation_id + AND id = NEW.supersedes_snapshot_id + AND snapshot_kind = 'effective' + ) THEN + RAISE EXCEPTION 'APP_GRANT_SUPERSEDES_LINEAGE_MISMATCH' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index f9f7ffca..2b0c101f 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -207,6 +207,11 @@ export const upgradeManifest = { file: '0.3.0-preview.37-app-resource-sync.sql', description: 'Add dormant host-reviewed resource sync bindings, sessions, intent and encrypted projections', }, + { + version: '0.3.0-preview.38', + file: '0.3.0-preview.38-app-resource-authoring.sql', + description: 'Permit reviewed App Protocol v5 stage and activation with pinned resource descriptors', + }, ] satisfies UpgradeMigration[], } as const; From 21a506af205a6a7c7dba24ec58b86b08dd28f877 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:20:20 +0530 Subject: [PATCH 018/161] Reconstruct reviewed sync grants and verify merged foundations --- .../api/src/lib/app-resource-sync-reviewed.ts | 76 ++++++++++++ .../test/app-resource-authoring-host.test.ts | 28 ++--- ...pp-resource-reviewed-descriptor-db.test.ts | 110 ++++++++++++++++++ .../test/app-resource-sync-store-db.test.ts | 3 +- scripts/gate-g/required-tests.json | 34 ++++++ 5 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-reviewed.ts create mode 100644 apps/api/test/app-resource-reviewed-descriptor-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-reviewed.ts b/apps/api/src/lib/app-resource-sync-reviewed.ts new file mode 100644 index 00000000..a3dd6509 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-reviewed.ts @@ -0,0 +1,76 @@ +import { and, eq } from 'drizzle-orm'; +import { appGrantSnapshots, appInstallations, appVersions } from '@deft/db/schema'; +import { parseResourceAppManifest } from '@deft/app-kit'; +import { digestResourceSyncDescriptor } from '@deft/app-kit/experimental/resource-sync'; +import type { AppRunTransaction } from './app-run-repository.js'; +import { AppError } from './app-errors.js'; +import { buildRequestedAppGrantProjection, digestAppGrantValue } from './app-grant-service.js'; +import { buildResourceAppReviewedAuthority } from './app-runtime-review.js'; + +const stale = () => new AppError('Reviewed App resource authority changed', 'APP_STALE', 409); + +/** App-level authority only. Callers must lock and authorize the current human + * owner/operator before this reader, and separately check resource consent, + * registration, session and Run intent. A reviewed App never grants a read. */ +export async function loadReviewedResourceSyncDescriptor( + tx: AppRunTransaction, orgId: string, installationId: string, resourceKey: string, +) { + const [installation] = await tx.select().from(appInstallations).where(and( + eq(appInstallations.org_id, orgId), eq(appInstallations.id, installationId), + )).limit(1).for('share'); + if (!installation || installation.state !== 'active' + || !installation.active_version_id || !installation.active_grant_snapshot_id + || installation.active_grant_snapshot_kind !== 'effective') throw stale(); + const [version] = await tx.select().from(appVersions).where(and( + eq(appVersions.org_id, orgId), eq(appVersions.installation_id, installation.id), + eq(appVersions.id, installation.active_version_id), eq(appVersions.state, 'active'), + eq(appVersions.protocol_version, '5'), + )).limit(1).for('share'); + if (!version) throw stale(); + let manifest: ReturnType; + try { manifest = parseResourceAppManifest(version.manifest); } + catch { throw stale(); } + const descriptor = manifest.sync_descriptors.find((item) => item.key === resourceKey); + if (!descriptor) throw stale(); + const [grant] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, orgId), eq(appGrantSnapshots.app_installation_id, installation.id), + eq(appGrantSnapshots.app_version_id, version.id), + eq(appGrantSnapshots.id, installation.active_grant_snapshot_id), + eq(appGrantSnapshots.snapshot_kind, 'effective'), + )).limit(1); + const [requested] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, orgId), eq(appGrantSnapshots.app_installation_id, installation.id), + eq(appGrantSnapshots.app_version_id, version.id), + eq(appGrantSnapshots.id, version.requested_grant_snapshot_id ?? ''), + eq(appGrantSnapshots.snapshot_kind, 'requested'), + )).limit(1); + if (!grant || !requested || grant.requested_snapshot_id !== requested.id + || grant.manifest_digest !== version.manifest_digest + || grant.package_digest !== version.package_digest + || grant.app_id !== installation.app_id || grant.app_version !== version.version + || manifest.id !== installation.app_id || manifest.version !== version.version + || digestAppGrantValue(manifest) !== version.manifest_digest + || digestAppGrantValue(grant.canonical_snapshot) !== grant.snapshot_digest) throw stale(); + const requestedProjection = buildRequestedAppGrantProjection({ organization_id: orgId, + app_installation_id: installation.id, app_version_id: version.id, manifest, + manifest_digest: version.manifest_digest, package_digest: version.package_digest }); + if (requested.snapshot_digest !== requestedProjection.snapshot_digest + || digestAppGrantValue(requested.canonical_snapshot) !== requestedProjection.snapshot_digest) throw stale(); + const authority = buildResourceAppReviewedAuthority(manifest, { + lineage_key: installation.lineage_key, + package_digest: version.package_digest, manifest_digest: version.manifest_digest, + }); + const stored = grant.canonical_snapshot; + const classification = { authority_state: 'effective', executable: false, provider_access: false, + runtime_binding_review_required: true, resource_binding_consent_required: true }; + if (typeof stored.review_digest !== 'string' || !/^sha256:[a-f0-9]{64}$/u.test(stored.review_digest) + || digestAppGrantValue(grant.classification) !== digestAppGrantValue(classification) + || grant.resource_rights.length !== 0) throw stale(); + const expected = { ...authority, organization_id: orgId, + app_installation_id: installation.id, app_version_id: version.id, + requested_snapshot_id: requested.id, requested_snapshot_digest: requested.snapshot_digest, + classification, review_digest: stored.review_digest }; + if (digestAppGrantValue(expected) !== grant.snapshot_digest) throw stale(); + const descriptorDigest = await digestResourceSyncDescriptor(descriptor); + return { installation, version, grant, descriptor, descriptor_digest: descriptorDigest }; +} diff --git a/apps/api/test/app-resource-authoring-host.test.ts b/apps/api/test/app-resource-authoring-host.test.ts index 3618a0a8..487e9a24 100644 --- a/apps/api/test/app-resource-authoring-host.test.ts +++ b/apps/api/test/app-resource-authoring-host.test.ts @@ -1,12 +1,11 @@ import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; import test from 'node:test'; import { buildDeftAppPackage, isDeftAppProtocolOperationSupported, parseRuntimeAppManifest, verifyDeftAppPackageJson, } from '@deft/app-kit'; -test('Protocol 5 authoring remains rejected before host inspection and staging access', async () => { +test('Protocol 5 inspection accepts reviewed authoring while action routing remains unavailable', async () => { const artifact = await buildDeftAppPackage({ manifest: { schema_version: '5', id: 'community.example.private-inbox', version: '1.0.0', name: 'Private inbox', license: 'AGPL-3.0-only', compatibility: { app_protocol: '5' }, @@ -23,23 +22,20 @@ test('Protocol 5 authoring remains rejected before host inspection and staging a const verified = await verifyDeftAppPackageJson(artifact.json); assert.equal(verified.package.manifest.schema_version, '5'); assert.equal(isDeftAppProtocolOperationSupported('5', 'authoring'), true); - assert.equal(isDeftAppProtocolOperationSupported('5', 'inspect'), false); - assert.equal(isDeftAppProtocolOperationSupported('5', 'stage'), false); + for (const operation of ['inspect', 'stage', 'review', 'activate'] as const) { + assert.equal(isDeftAppProtocolOperationSupported('5', operation), true); + } + assert.equal(isDeftAppProtocolOperationSupported('5', 'route'), false); + assert.equal(isDeftAppProtocolOperationSupported('5', 'invoke'), false); assert.throws(() => parseRuntimeAppManifest(verified.package.manifest), 'the v1 Runtime manifest parser must not silently accept resource Apps'); - const [{ inspectAppPackageJson, stageAppPackage }, { humanModuleActor }, { closeDb }] = - await Promise.all([import('../src/lib/app-service.js'), - import('../src/lib/module-service.js'), import('../src/lib/db.js')]); - const unsupported = (error: unknown) => { - assert.equal((error as { code: string }).code, 'APP_PROTOCOL_UNSUPPORTED'); - assert.equal((error as { status: number }).status, 409); - return true; - }; + const [{ inspectAppPackageJson }, { closeDb }] = + await Promise.all([import('../src/lib/app-service.js'), import('../src/lib/db.js')]); try { - await assert.rejects(inspectAppPackageJson(artifact.json), unsupported); - const owner = humanModuleActor({ orgId: randomUUID(), userId: randomUUID(), - role: 'owner', source: 'ui' }); - await assert.rejects(stageAppPackage(owner, artifact.json), unsupported); + const inspected = await inspectAppPackageJson(artifact.json); + assert.equal(inspected.manifest.schema_version, '5'); + assert.equal(inspected.package_digest, verified.digest); + assert.deepEqual(inspected.permissions, []); } finally { await closeDb(); } }); diff --git a/apps/api/test/app-resource-reviewed-descriptor-db.test.ts b/apps/api/test/app-resource-reviewed-descriptor-db.test.ts new file mode 100644 index 00000000..492f6022 --- /dev/null +++ b/apps/api/test/app-resource-reviewed-descriptor-db.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const assigned = databaseUrl === process.env.DATABASE_URL && databaseUrl !== undefined + && databaseUrl === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_phase5_test_s05_resource_reader'; + +test('reviewed resource reader reconstructs current v5 grants and rejects stale or foreign lineage', { + skip: !assigned, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + const [{ db, closeDb }, schema, kit, apps, reviews, modules, resources, drizzle, grants] = + await Promise.all([import('../src/lib/db.js'), import('@deft/db/schema'), + import('@deft/app-kit'), import('../src/lib/app-service.js'), + import('../src/lib/app-runtime-review.js'), import('../src/lib/module-service.js'), + import('../src/lib/app-resource-sync-reviewed.js'), import('drizzle-orm'), + import('../src/lib/app-grant-service.js')]); + const { and, eq, sql } = drizzle; + const orgId = randomUUID(); + const ownerId = randomUUID(); + try { + await db.insert(schema.orgs).values({ id: orgId, name: 'Resource grant reader', + slug: `resource-reader-${orgId}` }); + await db.insert(schema.users).values({ id: ownerId, + name: 'Synthetic owner', email: `resource-reader-${ownerId}@example.test` }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: orgId, + user_id: ownerId, role: 'owner', is_active: true }); + const owner = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'ui' }); + const artifact = await kit.buildDeftAppPackage({ manifest: { + schema_version: '5', id: `community.example.reader.app${orgId.replaceAll('-', '')}`, + version: '1.0.0', name: 'Private resources', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '5' }, modules: [], navigation: [], + runtime_requirements: [{ key: 'provider', protocol_version: 'deft.app_runtime_channel.v2' }, + { key: 'actions', protocol_version: 'deft.app_runtime_channel.v1' }], + private_capabilities: [{ key: 'archive', version: '1', + input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false }, + output_schema: { type: 'object', properties: {}, required: [], additionalProperties: false } }], + runtime_actions: [{ key: 'archive_message', label: 'Archive', capability_key: 'archive', + runtime_requirement_key: 'actions' }], experiences: [], public_actions: [], + sync_descriptors: [{ schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'provider', resource_type: 'message', + requested_visibility: 'user_private', label_field: 'subject', + record_schema: { type: 'object', properties: { + subject: { type: 'string', maxLength: 120 }, + }, required: ['subject'], additionalProperties: false } }], + }, artifacts: [] }); + const staged = await apps.stageAppPackage(owner, artifact.json); + const [version] = await db.select().from(schema.appVersions).where(and( + eq(schema.appVersions.org_id, orgId), eq(schema.appVersions.id, staged.version_id))); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(and( + eq(schema.appGrantSnapshots.org_id, orgId), + eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + assert.ok(requested); + const stale = (error: unknown) => (error as { code?: string }).code === 'APP_STALE'; + const read = (organization = orgId, key = 'inbox') => db.transaction((tx) => + resources.loadReviewedResourceSyncDescriptor(tx, organization, staged.id, key)); + await assert.rejects(read(), stale, 'a staged package has no effective authority'); + const request = { app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const review = await reviews.prepareRuntimeAppReview(owner, staged.id, request); + const activated = await reviews.activateRuntimeApp(owner, staged.id, { ...request, + expected_review_digest: review.review_digest, accept_host_policy: true }); + const current = await read(); + assert.equal(current.grant.id, activated.grant_snapshot_id); + assert.equal(current.descriptor.key, 'inbox'); + assert.equal(current.descriptor.requested_visibility, 'user_private'); + assert.match(current.descriptor_digest, /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual(current.grant.resource_rights, [], 'App review confers no resource read right'); + // These forged snapshots satisfy SQL shape checks, so force deferred + // constraints before proving the host reconstructs the complete grant. + for (const field of ['sync_descriptors', 'runtime_actions'] as const) { + const rollback = new Error(`rollback forged ${field}`); + await assert.rejects(db.transaction(async (tx) => { + const canonical = structuredClone(current.grant.canonical_snapshot); + const items = canonical[field] as Array>; + if (field === 'sync_descriptors') items[0]!.descriptor_digest = `sha256:${'0'.repeat(64)}`; + else items[0]!.operation_name = 'forged_operation'; + const forgedId = randomUUID(); + await tx.insert(schema.appGrantSnapshots).values({ ...current.grant, id: forgedId, + supersedes_snapshot_id: current.grant.id, canonical_snapshot: canonical, + snapshot_digest: grants.digestAppGrantValue(canonical) }); + await tx.update(schema.appInstallations).set({ active_grant_snapshot_id: forgedId, + grant_epoch: sql`${schema.appInstallations.grant_epoch} + 1` }).where(and( + eq(schema.appInstallations.org_id, orgId), eq(schema.appInstallations.id, staged.id))); + await tx.execute(sql`SET CONSTRAINTS ALL IMMEDIATE`); + await assert.rejects(resources.loadReviewedResourceSyncDescriptor(tx, orgId, + staged.id, 'inbox'), stale); + throw rollback; + }), (error) => error === rollback); + } + await assert.rejects(read(randomUUID()), stale); + await assert.rejects(read(orgId, 'undeclared'), stale); + const disabled = await apps.disableAppInstallation(owner, staged.id, + activated.installation.lifecycle_epoch); + await assert.rejects(read(), stale); + const renewedRequest = { ...request, expected_lifecycle_epoch: disabled.lifecycle_epoch, + expected_grant_epoch: disabled.grant_epoch }; + const renewedReview = await reviews.prepareRuntimeAppReview(owner, staged.id, renewedRequest); + const renewed = await reviews.activateRuntimeApp(owner, staged.id, { ...renewedRequest, + expected_review_digest: renewedReview.review_digest, accept_host_policy: true }); + assert.notEqual(renewed.grant_snapshot_id, activated.grant_snapshot_id); + assert.equal((await read()).grant.id, renewed.grant_snapshot_id); + assert.deepEqual(await db.select({ id: schema.appResourceBindings.id }) + .from(schema.appResourceBindings).where(eq(schema.appResourceBindings.org_id, orgId)), [], + 'descriptor review and resolution never create resource consent'); + } finally { await closeDb(); } +}); diff --git a/apps/api/test/app-resource-sync-store-db.test.ts b/apps/api/test/app-resource-sync-store-db.test.ts index 6a5106d2..92a37158 100644 --- a/apps/api/test/app-resource-sync-store-db.test.ts +++ b/apps/api/test/app-resource-sync-store-db.test.ts @@ -9,7 +9,8 @@ const safeDatabase = (() => { const url = new URL(databaseUrl); return ['postgres:', 'postgresql:'].includes(url.protocol) && url.hostname === '127.0.0.1' && url.port === '55435' - && url.pathname === '/gate_g_phase5_test_s05_sync_store' + && ['/gate_g_phase5_test_s05_sync_store', + '/gate_g_phase5_test_s05_sync_store_root38'].includes(url.pathname) && url.search === '' && url.hash === ''; } catch { return false; } })(); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 559cebd2..20ddaecc 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -715,6 +715,40 @@ "cases": [ { "file": "apps/api/test/app-resource-sync-key-references-db.test.ts", "name": "sync key inventory retains private projection and unresolved intent keys with tenant scope" } ] + }, + { + "id": "resource-sync-page-store", + "description": "Unwired transactional encrypted page store: cursor fencing, retained keys, identity, concurrent writers and rollback on a dedicated synthetic database; no live v2 receipt settlement claim.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-store-db.test.ts", "name": "resource sync store encrypts one atomic page and fences stale or unsafe writes" } + ] + }, + { + "id": "resource-reviewed-descriptor", + "description": "Current Protocol5 effective-grant reconstruction and stale/foreign denial; declaration review is not resource consent or execution.", + "cases": [ + { "file": "apps/api/test/app-resource-reviewed-descriptor-db.test.ts", "name": "reviewed resource reader reconstructs current v5 grants and rejects stale or foreign lineage" }, + { "file": "apps/api/test/app-resource-authoring-host.test.ts", "name": "Protocol 5 inspection accepts reviewed authoring while action routing remains unavailable" } + ] + }, + { + "id": "resource-app-review", + "description": "Protocol5 sync-only/mixed host review, Module rollback and authenticated HTTP scope/staleness; no resource consent or execution.", + "cases": [ + { "file": "apps/api/test/app-resource-authoring-review-db.test.ts", "name": "v5 sync-only and mixed stage/review/activation pin declaration without resource authority" }, + { "file": "apps/api/test/app-resource-authoring-review-db.test.ts", "name": "v5 activation rolls back earlier included Module, grant and lifecycle on later Module conflict" }, + { "file": "apps/api/test/app-resource-authoring-review-http-db.test.ts", "name": "authenticated HTTP v5 review and activation reject foreign and stale requests" } + ] + }, + { + "id": "fresh-upgrade-ledger", + "description": "Explicit four-database synthetic profile proving current fresh history, advanced untracked rejection before mutation, interrupted fresh refusal and supported baseline adoption.", + "cases": [ + { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "fresh push records exact manifest history and the ordinary upgrader is a no-op" }, + { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "advanced ledgerless schema is rejected before migration-ledger DDL" }, + { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "partial interrupted initialization cannot be stamped as fresh" }, + { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "genuine untracked v0.2.0-preview.1 baseline remains adoptable" } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From 7366c1047184331b44449a0af40bdc9da1efe74a Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:30:53 +0530 Subject: [PATCH 019/161] Accept fixed resource sync policy in signed receipts --- packages/shared/src/app-runs.ts | 15 ++++++++- packages/shared/test/app-runs.test.ts | 46 ++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/app-runs.ts b/packages/shared/src/app-runs.ts index 94d64b30..cebda2b5 100644 --- a/packages/shared/src/app-runs.ts +++ b/packages/shared/src/app-runs.ts @@ -309,6 +309,19 @@ export const AppRunPolicySnapshotSchema = z.object({ }).strict(); export type AppRunPolicySnapshot = z.infer; +// Resource sync is a host-created Run. Its fixed policy may appear in a +// signed receipt, but it must never enter the generic submission contract. +export const AppRunReceiptPolicySnapshotSchema = z.union([ + AppRunPolicySnapshotSchema, + z.object({ + risk_class: z.literal('internal_write'), + review_requirement: z.literal('policy'), + review_scope: z.literal('reviewed_resource_sync'), + retry_class: z.literal('unsafe_or_unknown'), + }).strict(), +]); +export type AppRunReceiptPolicySnapshot = z.infer; + export const AppRunAuthorityRefSchema = z.object({ authority_kind: z.enum([ 'membership', @@ -447,7 +460,7 @@ export const AppRunReceiptEnvelopeSchema = z.object({ run_state: AppRunStateSchema, actor: AppRunActorSchema.optional(), operation: CapabilityProviderOperationIdentitySchema, - policy: AppRunPolicySnapshotSchema, + policy: AppRunReceiptPolicySnapshotSchema, input_fingerprint: z.object({ key_version: ExactIdentitySchema, fingerprint: z.string().regex(/^hmac-sha256:[a-f0-9]{64}$/), diff --git a/packages/shared/test/app-runs.test.ts b/packages/shared/test/app-runs.test.ts index 8be62cf1..4ce91263 100644 --- a/packages/shared/test/app-runs.test.ts +++ b/packages/shared/test/app-runs.test.ts @@ -11,6 +11,8 @@ import { AppRunAttemptStateSchema, AppRunStateSchema, AppRunOriginSchema, + AppRunPolicySnapshotSchema, + AppRunReviewScopeSchema, isAppRunAttemptStateTransitionAllowed, classifyAppRunCrashRecovery, isAppRunStateTransitionAllowed, @@ -294,7 +296,10 @@ describe('App Run contract', () => { facts: { result_status: 'retained' }, occurred_at: '2026-08-30T00:00:01.000Z', }; - assert.equal(parseAppRunReceiptEnvelope(receipt).receipt_kind, 'attempt_terminal'); + const parsedReceipt = parseAppRunReceiptEnvelope(receipt); + assert.equal(parsedReceipt.receipt_kind, 'attempt_terminal'); + assert.equal(JSON.stringify(parsedReceipt), JSON.stringify(receipt), + 'existing receipt bytes retain their canonical field order and values'); assert.throws(() => parseAppRunReceiptEnvelope({ ...receipt, attempt_id: undefined })); assert.throws(() => parseAppRunReceiptEnvelope({ ...receipt, @@ -302,6 +307,45 @@ describe('App Run contract', () => { }), /secret-bearing/); }); + test('accepts only the fixed reviewed resource-sync policy in receipts', () => { + const syncPolicy = { + risk_class: 'internal_write', + review_requirement: 'policy', + review_scope: 'reviewed_resource_sync', + retry_class: 'unsafe_or_unknown', + } as const; + const syncReceipt = { + schema_version: APP_RUN_CONTRACT_VERSIONS.receipt, + receipt_id: 'receipt-sync-1', + receipt_kind: 'attempt_terminal', + org_id: 'org-1', + run_id: 'run-sync-1', + attempt_id: 'attempt-sync-1', + run_state: 'succeeded', + operation: { + provider: { org_id: 'org-1', provider_kind: 'app_runtime', provider_instance_id: 'registration-1' }, + operation_name: 'sync_message', + }, + policy: syncPolicy, + input_fingerprint: { key_version: 'fp-v1', fingerprint: `hmac-sha256:${'a'.repeat(64)}` }, + facts: { applied_sequence: 1, upserts: 2 }, + occurred_at: '2026-09-24T00:00:00.000Z', + }; + assert.deepEqual(parseAppRunReceiptEnvelope(syncReceipt), syncReceipt); + for (const policy of [ + { ...syncPolicy, risk_class: 'external_write' }, + { ...syncPolicy, review_requirement: 'always' }, + { ...syncPolicy, retry_class: 'safe' }, + { ...syncPolicy, review_scope: 'reviewed_resource_sync', extra_authority: true }, + ]) { + assert.throws(() => parseAppRunReceiptEnvelope({ ...syncReceipt, policy })); + } + assert.equal(AppRunReviewScopeSchema.safeParse(syncPolicy.review_scope).success, false); + assert.equal(AppRunPolicySnapshotSchema.safeParse(syncPolicy).success, false); + assert.throws(() => parseAppRunSubmission(submission({ policy: syncPolicy })), + 'receipt-only policy must not authorize generic Run submission'); + }); + test('uses fixed retention ceilings independent of later permission changes', () => { const from = new Date('2026-08-30T00:00:00.000Z'); assert.equal( From 0d634aad0a9b7194be6791b9ebdc99aeb9b6e261 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:48:46 +0530 Subject: [PATCH 020/161] Add owner-reviewed private sync consent and v2 session authority --- .../src/lib/app-resource-sync-authority.ts | 179 +++++++++ .../src/lib/app-resource-sync-discovery.ts | 60 +++ .../src/lib/app-resource-sync-management.ts | 367 ++++++++++++++++++ apps/api/src/lib/app-runtime-management.ts | 22 +- .../test/app-resource-sync-consent-db.test.ts | 268 +++++++++++++ apps/api/test/fixtures/resource-sync-v5.ts | 93 +++++ packages/db/scripts/apply-extras.ts | 1 + packages/db/src/schema.ts | 3 + .../0.3.0-preview.39-app-resource-consent.sql | 7 + packages/db/upgrades/manifest.ts | 5 + 10 files changed, 999 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-authority.ts create mode 100644 apps/api/src/lib/app-resource-sync-discovery.ts create mode 100644 apps/api/src/lib/app-resource-sync-management.ts create mode 100644 apps/api/test/app-resource-sync-consent-db.test.ts create mode 100644 apps/api/test/fixtures/resource-sync-v5.ts create mode 100644 packages/db/upgrades/0.3.0-preview.39-app-resource-consent.sql diff --git a/apps/api/src/lib/app-resource-sync-authority.ts b/apps/api/src/lib/app-resource-sync-authority.ts new file mode 100644 index 00000000..51072eb1 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-authority.ts @@ -0,0 +1,179 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { + appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, + capabilityProviderSnapshots, orgMembers, +} from '@deft/db/schema'; +import { parseSyncDescriptor, digestResourceSyncDescriptor } from '@deft/app-kit/experimental/resource-sync'; +import { CapabilityProviderDiscoverySnapshotSchema } from '@deft/shared'; +import type { AppRunTransaction } from './app-run-repository.js'; +import { APP_RESOURCE_SYNC_HOST_POLICY } from './app-resource-sync-policy.js'; +import { loadReviewedResourceSyncDescriptor } from './app-resource-sync-reviewed.js'; +import { createResourceSyncDiscoverySnapshot } from './app-resource-sync-discovery.js'; +import { digestAppGrantValue } from './app-grant-service.js'; + +type Registration = typeof appRuntimeRegistrations.$inferSelect; +type Binding = typeof appResourceBindings.$inferSelect; +type Session = typeof appRuntimeSessions.$inferSelect; +type ProviderSnapshot = typeof capabilityProviderSnapshots.$inferSelect; +type Reviewed = Awaited>; + +export type LiveResourceSyncBindingAuthority = Readonly; +export type LiveResourceSyncAuthority = Readonly; + +type BindingLocator = Readonly<{ org_id: string; resource_binding_id: string; clock: () => Date }>; +type SessionLocator = Readonly<{ org_id: string; session_id: string; + token_hash: string; clock: () => Date }>; + +function currentTime(clock: () => Date): Date | null { + const checked = clock(); + return checked instanceof Date && Number.isFinite(checked.getTime()) ? checked : null; +} + +async function validProviderSnapshot(row: ProviderSnapshot, registration: Registration, + binding: Binding, descriptor: Reviewed['descriptor']): Promise { + if (row.org_id !== binding.org_id || row.id !== binding.provider_snapshot_id + || row.provider_kind !== 'app_runtime' || row.provider_instance_id !== registration.id + || row.adapter_contract_version !== 'deft.app_runtime_channel.v2') return false; + const parsed = CapabilityProviderDiscoverySnapshotSchema.safeParse(row.safe_snapshot); + if (!parsed.success || parsed.data.snapshot_digest !== row.snapshot_digest + || parsed.data.provider.org_id !== binding.org_id + || parsed.data.provider.provider_kind !== 'app_runtime' + || parsed.data.provider.provider_instance_id !== registration.id + || parsed.data.adapter_contract_version !== 'deft.app_runtime_channel.v2' + || new Date(parsed.data.captured_at).getTime() !== row.captured_at.getTime()) return false; + const expected = await createResourceSyncDiscoverySnapshot({ org_id: binding.org_id, + registration_id: registration.id, descriptor, captured_at: row.captured_at }); + return expected.snapshot_digest === row.snapshot_digest + && digestAppGrantValue(expected) === digestAppGrantValue(parsed.data); +} + +/** Caller owns any Run lock. All mutable human rows are locked before App, + * then registration and binding; this reader does not create authority. */ +export async function loadLiveResourceSyncBindingAuthority(tx: AppRunTransaction, + input: BindingLocator): Promise { + const [locator] = await tx.select({ + owner_user_id: appResourceBindings.owner_user_id, + registration_id: appResourceBindings.runtime_registration_id, + installation_id: appResourceBindings.app_installation_id, + resource_key: appResourceBindings.resource_key, + }).from(appResourceBindings).where(and(eq(appResourceBindings.org_id, input.org_id), + eq(appResourceBindings.id, input.resource_binding_id))).limit(1); + if (!locator) return null; + const [registrationLocator] = await tx.select({ operator_user_id: appRuntimeRegistrations.operator_user_id, + app_installation_id: appRuntimeRegistrations.app_installation_id }) + .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, input.org_id), + eq(appRuntimeRegistrations.id, locator.registration_id))).limit(1); + if (!registrationLocator || registrationLocator.app_installation_id !== locator.installation_id) return null; + for (const userId of [...new Set([locator.owner_user_id, registrationLocator.operator_user_id])].sort()) { + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${input.org_id} + AND user_id = ${userId} FOR SHARE`); + } + const [owner] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) + .from(orgMembers).where(and(eq(orgMembers.org_id, input.org_id), + eq(orgMembers.user_id, locator.owner_user_id))).limit(1); + const [operator] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) + .from(orgMembers).where(and(eq(orgMembers.org_id, input.org_id), + eq(orgMembers.user_id, registrationLocator.operator_user_id))).limit(1); + if (!owner?.is_active || !['owner', 'admin'].includes(owner.role) + || !operator?.is_active || operator.role === 'guest') return null; + let reviewed: Reviewed; + try { reviewed = await loadReviewedResourceSyncDescriptor(tx, input.org_id, + locator.installation_id, locator.resource_key); } + catch { return null; } + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${input.org_id} + AND id = ${locator.registration_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_resource_bindings WHERE org_id = ${input.org_id} + AND id = ${input.resource_binding_id} FOR SHARE`); + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, input.org_id), eq(appRuntimeRegistrations.id, locator.registration_id))) + .limit(1); + const [binding] = await tx.select().from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, input.org_id), eq(appResourceBindings.id, input.resource_binding_id))) + .limit(1); + if (!registration || !binding || registration.state !== 'active' || registration.runtime_epoch < 1 + || registration.contract_version !== 'deft.app_runtime_channel.v2' + || registration.operator_user_id !== registrationLocator.operator_user_id + || registration.reviewed_by_user_id !== locator.owner_user_id + || registration.app_installation_id !== reviewed.installation.id + || registration.app_version_id !== reviewed.version.id + || registration.grant_snapshot_id !== reviewed.grant.id + || binding.state !== 'active' || binding.runtime_registration_id !== registration.id + || binding.registration_contract_version !== registration.contract_version + || binding.app_installation_id !== reviewed.installation.id + || binding.app_version_id !== reviewed.version.id + || binding.grant_snapshot_id !== reviewed.grant.id + || binding.owner_user_id !== locator.owner_user_id + || binding.reviewed_by_user_id !== locator.owner_user_id + || binding.owner_scope !== 'private_user' + || binding.resource_key !== reviewed.descriptor.key + || binding.resource_family !== reviewed.descriptor.resource_type + || binding.descriptor_digest !== reviewed.descriptor_digest + || binding.provider_kind !== 'app_runtime' + || binding.provider_instance_id !== registration.id + || binding.operation_name !== `sync_${binding.resource_key}` + || binding.interface_identity !== `deft.resource_sync.v2:${input.org_id.toLowerCase()}:${reviewed.installation.id.toLowerCase()}:${binding.resource_key}` + || binding.risk_class !== APP_RESOURCE_SYNC_HOST_POLICY.risk_class + || binding.review_requirement !== APP_RESOURCE_SYNC_HOST_POLICY.review_requirement + || binding.review_scope !== APP_RESOURCE_SYNC_HOST_POLICY.review_scope + || binding.retry_class !== APP_RESOURCE_SYNC_HOST_POLICY.retry_class + || binding.retention_class !== APP_RESOURCE_SYNC_HOST_POLICY.retention_class + || !binding.consent_expires_at || !binding.reviewed_at + || binding.consent_expires_at <= binding.reviewed_at) return null; + try { + const parsed = parseSyncDescriptor(binding.reviewed_descriptor); + if (await digestResourceSyncDescriptor(parsed) !== reviewed.descriptor_digest) return null; + } catch { return null; } + const [providerSnapshot] = await tx.select().from(capabilityProviderSnapshots).where(and( + eq(capabilityProviderSnapshots.org_id, input.org_id), + eq(capabilityProviderSnapshots.id, binding.provider_snapshot_id))).limit(1); + if (!providerSnapshot) return null; + try { if (!await validProviderSnapshot(providerSnapshot, registration, binding, + reviewed.descriptor)) return null; } + catch { return null; } + const checkedAt = currentTime(input.clock); + if (!checkedAt || binding.consent_expires_at <= checkedAt) return null; + return Object.freeze({ ...reviewed, registration, binding, provider_snapshot: providerSnapshot, + checked_at: checkedAt }); +} + +/** V2 token/hash and stored target are disjoint from the v1 action channel. */ +export async function loadLiveResourceSyncAuthority(tx: AppRunTransaction, + input: SessionLocator): Promise { + const [locator] = await tx.select({ resource_binding_id: appRuntimeSessions.resource_binding_id, + runtime_registration_id: appRuntimeSessions.runtime_registration_id, + operator_user_id: appRuntimeSessions.operator_user_id, + audience: appRuntimeSessions.audience, runtime_binding_id: appRuntimeSessions.runtime_binding_id }) + .from(appRuntimeSessions).where(and(eq(appRuntimeSessions.org_id, input.org_id), + eq(appRuntimeSessions.id, input.session_id), eq(appRuntimeSessions.token_hash, input.token_hash))) + .limit(1); + if (!locator || locator.audience !== 'app_resource_sync' + || locator.runtime_binding_id !== null || !locator.resource_binding_id) return null; + const bindingAuthority = await loadLiveResourceSyncBindingAuthority(tx, { + org_id: input.org_id, resource_binding_id: locator.resource_binding_id, clock: input.clock, + }); + if (!bindingAuthority || bindingAuthority.registration.id !== locator.runtime_registration_id + || bindingAuthority.registration.operator_user_id !== locator.operator_user_id) return null; + await tx.execute(sql`SELECT id FROM app_runtime_sessions WHERE org_id = ${input.org_id} + AND id = ${input.session_id} FOR UPDATE`); + const [session] = await tx.select().from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.org_id, input.org_id), eq(appRuntimeSessions.id, input.session_id), + eq(appRuntimeSessions.token_hash, input.token_hash))).limit(1); + const checkedAt = currentTime(input.clock); + if (!session || !checkedAt || session.audience !== 'app_resource_sync' + || session.runtime_binding_id !== null + || session.resource_binding_id !== bindingAuthority.binding.id + || session.runtime_registration_id !== bindingAuthority.registration.id + || session.operator_user_id !== bindingAuthority.registration.operator_user_id + || session.runtime_epoch !== bindingAuthority.registration.runtime_epoch + || session.lifecycle_epoch !== bindingAuthority.installation.lifecycle_epoch + || session.grant_epoch !== bindingAuthority.installation.grant_epoch + || session.revoked_at || session.expires_at <= checkedAt + || bindingAuthority.binding.consent_expires_at === null + || bindingAuthority.binding.consent_expires_at <= checkedAt) return null; + return Object.freeze({ ...bindingAuthority, session, checked_at: checkedAt }); +} diff --git a/apps/api/src/lib/app-resource-sync-discovery.ts b/apps/api/src/lib/app-resource-sync-discovery.ts new file mode 100644 index 00000000..a2b79e95 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-discovery.ts @@ -0,0 +1,60 @@ +import { createCapabilityProviderDiscoverySnapshot } from '@deft/shared'; +import { parseSyncDescriptor, type SyncDescriptorV1 } from '@deft/app-kit/experimental/resource-sync'; + +type JsonSchema = Record; + +function scalarField(value: SyncDescriptorV1['record_schema']['properties'][string]): JsonSchema { + if (value.type === 'string') return { type: 'string', maxLength: value.maxLength }; + if (value.type === 'number') return { type: 'number', minimum: value.minimum, maximum: value.maximum }; + return { type: 'boolean' }; +} + +const opaqueId = { type: 'string', minLength: 1, maxLength: 256, + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$' } as const; +const revision = { type: 'string', minLength: 1, maxLength: 128, + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$' } as const; +const cursor = { anyOf: [{ type: 'null' }, { type: 'string', minLength: 1, maxLength: 2_048 }] } as const; + +/** Safe discovery only. The Kit parser remains the execution authority for + * UTF-8 bytes, combined item counts, duplicate IDs and cursor progression. */ +export function resourceSyncDiscoverySchemas(rawDescriptor: SyncDescriptorV1): Readonly<{ + input_schema: JsonSchema; output_schema: JsonSchema; +}> { + const descriptor = parseSyncDescriptor(rawDescriptor); + const recordProperties = Object.fromEntries(Object.entries(descriptor.record_schema.properties) + .map(([key, value]) => [key, scalarField(value)])); + const data = { type: 'object', properties: recordProperties, + required: descriptor.record_schema.required, additionalProperties: false }; + const upsert = { type: 'object', properties: { id: opaqueId, revision, data }, + required: ['id', 'revision', 'data'], additionalProperties: false }; + const tombstone = { type: 'object', properties: { id: opaqueId, revision }, + required: ['id', 'revision'], additionalProperties: false }; + return Object.freeze({ + input_schema: { type: 'object', properties: { + schema_version: { const: 'deft.app_sync_request.v1' }, cursor, + max_items: { type: 'integer', minimum: 1, maximum: 100 }, + }, required: ['schema_version', 'cursor', 'max_items'], additionalProperties: false }, + output_schema: { type: 'object', properties: { + schema_version: { const: 'deft.app_sync_page.v1' }, + upserts: { type: 'array', maxItems: 100, items: upsert }, + tombstones: { type: 'array', maxItems: 100, items: tombstone }, + next_cursor: cursor, has_more: { type: 'boolean' }, + }, required: ['schema_version', 'upserts', 'tombstones', 'next_cursor', 'has_more'], + additionalProperties: false }, + }); +} + +export async function createResourceSyncDiscoverySnapshot(input: Readonly<{ + org_id: string; registration_id: string; descriptor: SyncDescriptorV1; captured_at: Date; +}>) { + const descriptor = parseSyncDescriptor(input.descriptor); + const provider = { org_id: input.org_id, provider_kind: 'app_runtime' as const, + provider_instance_id: input.registration_id }; + return createCapabilityProviderDiscoverySnapshot({ + adapter_contract_version: 'deft.app_runtime_channel.v2', provider, + captured_at: input.captured_at.toISOString(), + operations: [{ identity: { provider, operation_name: `sync_${descriptor.key}` }, + title: `Sync ${descriptor.key}`, description: '', + ...resourceSyncDiscoverySchemas(descriptor) }], + }); +} diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts new file mode 100644 index 00000000..ddb65979 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -0,0 +1,367 @@ +import { randomBytes, randomUUID } from 'node:crypto'; +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { + appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, + appSyncCheckpoints, auditLog, orgMembers, +} from '@deft/db/schema'; +import type { ModuleActor } from '@deft/shared/modules'; +import type { AppRunKeyProvider } from './app-run-keyrings.js'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { isModuleError } from './module-errors.js'; +import { assertCurrentModuleManagerWithExecutor } from './module-service.js'; +import { digestAppGrantValue } from './app-grant-service.js'; +import { persistCapabilityProviderSnapshotWithExecutor } from './capability-provider-snapshot-repository.js'; +import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; +import { createResourceSyncDiscoverySnapshot } from './app-resource-sync-discovery.js'; +import { loadReviewedResourceSyncDescriptor } from './app-resource-sync-reviewed.js'; +import { loadLiveResourceSyncBindingAuthority, + loadLiveResourceSyncAuthority } from './app-resource-sync-authority.js'; +import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, + AppResourceSyncConsentActivationSchema, AppResourceSyncConsentRequestSchema, + assertResourceSyncConsentWindow, hashAppResourceSyncToken, + type AppResourceSyncConsentRequest } from './app-resource-sync-policy.js'; + +type Tx = Parameters[0]>[0]; +type Human = Extract; +const stale = () => new AppError('Private resource sync authority changed', 'APP_STALE', 409); +const denied = () => new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); +const conflict = () => new AppError('Private resource sync already has current consent', 'APP_STATE_CONFLICT', 409); + +function reviewer(actor: ModuleActor): asserts actor is Human { + if (actor.kind !== 'human' || !['owner', 'admin'].includes(actor.role) + || !['ui', 'rest'].includes(actor.source)) throw denied(); +} +function operator(actor: ModuleActor): asserts actor is Human { + if (actor.kind !== 'human' || !['ui', 'rest'].includes(actor.source)) throw denied(); +} +async function assertManager(tx: Tx, actor: Human) { + try { await assertCurrentModuleManagerWithExecutor(tx, actor); } + catch (error) { if (isModuleError(error)) throw denied(); throw error; } +} +async function lockMembers(tx: Tx, orgId: string, userIds: readonly string[], mode: 'SHARE' | 'UPDATE') { + for (const userId of [...new Set(userIds)].sort()) { + if (mode === 'UPDATE') await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} + AND user_id = ${userId} FOR UPDATE`); + else await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${orgId} + AND user_id = ${userId} FOR SHARE`); + } +} + +export class AppResourceSyncManagement { + readonly #secrets: AppResourceSyncSecretService; + constructor(keys: AppRunKeyProvider, private readonly clock: () => Date = () => new Date()) { + this.#secrets = new AppResourceSyncSecretService(keys); + } + + async #reviewContext(tx: Tx, actor: Human, input: AppResourceSyncConsentRequest, + activation: boolean) { + await lockMembers(tx, actor.org_id, [actor.actor_id, input.operator_user_id], 'UPDATE'); + await assertManager(tx, actor); + const [operatorMember] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) + .from(orgMembers).where(and(eq(orgMembers.org_id, actor.org_id), + eq(orgMembers.user_id, input.operator_user_id))).limit(1); + if (!operatorMember?.is_active || operatorMember.role === 'guest') throw denied(); + if (activation) await tx.execute(sql`SELECT id FROM app_installations + WHERE org_id = ${actor.org_id} AND id = ${input.installation_id} FOR UPDATE`); + const reviewed = await loadReviewedResourceSyncDescriptor(tx, actor.org_id, + input.installation_id, input.resource_key); + const { installation, version, grant, descriptor, descriptor_digest } = reviewed; + if (version.id !== input.expected_app_version_id + || version.package_digest !== input.expected_package_digest + || grant.snapshot_digest !== input.expected_grant_snapshot_digest + || installation.lifecycle_epoch !== input.expected_lifecycle_epoch + || installation.grant_epoch !== input.expected_grant_epoch) throw stale(); + let expiresAt: Date; + try { expiresAt = assertResourceSyncConsentWindow(input.consent_expires_at, this.clock()); } + catch { throw activation ? stale() : new AppError('Invalid private sync consent window', + 'APP_ACTION_INVALID', 400); } + const review = Object.freeze({ schema_version: 'deft.app_resource_sync_consent_review.v1' as const, + org_id: actor.org_id, owner_user_id: actor.actor_id, + installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, grant_snapshot_digest: grant.snapshot_digest, + package_digest: version.package_digest, lifecycle_epoch: installation.lifecycle_epoch, + grant_epoch: installation.grant_epoch, operator_user_id: input.operator_user_id, + resource_key: descriptor.key, descriptor_digest, + consent_expires_at: expiresAt.toISOString(), limits: input.limits, + host_policy: APP_RESOURCE_SYNC_HOST_POLICY }); + return { ...reviewed, expiresAt, + review: Object.freeze({ ...review, review_digest: digestAppGrantValue(review) }) }; + } + + async prepareConsent(actor: ModuleActor, value: unknown) { + reviewer(actor); + const input = AppResourceSyncConsentRequestSchema.parse(value); + return db.transaction(async (tx) => (await this.#reviewContext(tx, actor, input, false)).review); + } + + async activateConsent(actor: ModuleActor, value: unknown) { + reviewer(actor); + const input = AppResourceSyncConsentActivationSchema.parse(value); + return db.transaction(async (tx) => { + const { installation, version, grant, descriptor, descriptor_digest, expiresAt, review } = + await this.#reviewContext(tx, actor, input, true); + if (review.review_digest !== input.expected_review_digest) throw stale(); + const [existing] = await tx.select({ id: appResourceBindings.id }).from(appResourceBindings) + .where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.app_installation_id, installation.id), + eq(appResourceBindings.grant_snapshot_id, grant.id), + eq(appResourceBindings.owner_user_id, actor.actor_id), + eq(appResourceBindings.resource_key, descriptor.key), + inArray(appResourceBindings.state, ['disabled', 'active']))).limit(1); + if (existing) throw conflict(); + const now = this.clock(); + if (expiresAt <= now) throw stale(); + const registrationId = randomUUID(); + const bindingId = randomUUID(); + const checkpointId = randomUUID(); + const providerSnapshot = await createResourceSyncDiscoverySnapshot({ + org_id: actor.org_id, registration_id: registrationId, descriptor, captured_at: now }); + await tx.insert(appRuntimeRegistrations).values({ id: registrationId, org_id: actor.org_id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, operator_user_id: input.operator_user_id, + contract_version: 'deft.app_runtime_channel.v2', state: 'disabled', + created_at: now, updated_at: now }); + const providerSnapshotId = await persistCapabilityProviderSnapshotWithExecutor(tx, providerSnapshot); + await tx.insert(appResourceBindings).values({ id: bindingId, org_id: actor.org_id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, runtime_registration_id: registrationId, + registration_contract_version: 'deft.app_runtime_channel.v2', + provider_kind: 'app_runtime', provider_instance_id: registrationId, + provider_snapshot_id: providerSnapshotId, + resource_key: descriptor.key, resource_family: descriptor.resource_type, + operation_name: `sync_${descriptor.key}`, + interface_identity: `deft.resource_sync.v2:${actor.org_id.toLowerCase()}:${installation.id.toLowerCase()}:${descriptor.key}`, + reviewed_descriptor: descriptor, descriptor_digest, + owner_user_id: actor.actor_id, owner_scope: 'private_user', + ...APP_RESOURCE_SYNC_HOST_POLICY, + ...input.limits, + state: 'disabled', created_at: now, updated_at: now }); + const cursor = this.#secrets.cursorFingerprint(null, { org_id: actor.org_id, + resource_binding_id: bindingId, checkpoint_id: checkpointId, + payload_kind: 'cursor', generation: 1, cursor_sequence: 0 }); + await tx.insert(appSyncCheckpoints).values({ id: checkpointId, org_id: actor.org_id, + resource_binding_id: bindingId, generation: 1, state: 'active', cursor_sequence: 0, + cursor_hmac_key_version: cursor.key_version, cursor_hmac: cursor.fingerprint, + cursor_state: 'empty', cursor_bytes: 0, retained_record_count: 0, retained_bytes: 0, + created_at: now, updated_at: now }); + await tx.update(appRuntimeRegistrations).set({ state: 'active', runtime_epoch: 1, + reviewed_by_user_id: actor.actor_id, reviewed_at: now, updated_at: now }) + .where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, registrationId))); + await tx.update(appResourceBindings).set({ state: 'active', + reviewed_by_user_id: actor.actor_id, reviewed_at: now, + consent_expires_at: expiresAt, updated_at: now }) + .where(and(eq(appResourceBindings.org_id, actor.org_id), eq(appResourceBindings.id, bindingId))); + const live = await loadLiveResourceSyncBindingAuthority(tx, { org_id: actor.org_id, + resource_binding_id: bindingId, clock: this.clock }); + if (!live) throw stale(); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.resource_sync_consent_activate', + entity_type: 'app_resource_binding', entity_id: bindingId, + before_state: null, after_state: { registration_id: registrationId, + binding_id: bindingId, checkpoint_id: checkpointId, installation_id: installation.id, + app_version_id: version.id, grant_snapshot_id: grant.id, + descriptor_digest, review_digest: review.review_digest }, + metadata: { source: actor.source } }); + return Object.freeze({ registration_id: registrationId, binding_id: bindingId, + checkpoint_id: checkpointId, app_version_id: version.id, + grant_snapshot_id: grant.id, resource_key: descriptor.key, + review_digest: review.review_digest }); + }); + } + + async issueOperatorSession(actor: ModuleActor, bindingId: string) { + operator(actor); + const sessionId = randomUUID(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashAppResourceSyncToken(token); + const issued = await db.transaction(async (tx) => { + const live = await loadLiveResourceSyncBindingAuthority(tx, { org_id: actor.org_id, + resource_binding_id: bindingId, clock: this.clock }); + if (!live || live.registration.operator_user_id !== actor.actor_id) throw denied(); + const checkedAt = this.clock(); + if (live.binding.consent_expires_at === null || live.binding.consent_expires_at <= checkedAt) throw stale(); + const expiresAt = new Date(Math.min(checkedAt.getTime() + APP_RESOURCE_SYNC_SESSION_MS, + live.binding.consent_expires_at.getTime())); + await tx.insert(appRuntimeSessions).values({ id: sessionId, org_id: actor.org_id, + runtime_registration_id: live.registration.id, runtime_binding_id: null, + resource_binding_id: live.binding.id, operator_user_id: actor.actor_id, + token_hash: tokenHash, audience: 'app_resource_sync', session_epoch: 0, + runtime_epoch: live.registration.runtime_epoch, + lifecycle_epoch: live.installation.lifecycle_epoch, + grant_epoch: live.installation.grant_epoch, expires_at: expiresAt, + created_at: checkedAt, updated_at: checkedAt }); + if (!await loadLiveResourceSyncAuthority(tx, { org_id: actor.org_id, + session_id: sessionId, token_hash: tokenHash, clock: this.clock })) throw stale(); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.resource_sync_session_issue', + entity_type: 'app_runtime_session', entity_id: sessionId, + before_state: null, after_state: { resource_binding_id: live.binding.id, + runtime_registration_id: live.registration.id, + audience: 'app_resource_sync', expires_at: expiresAt.toISOString() }, + metadata: { source: actor.source } }); + return expiresAt; + }); + return Object.freeze({ session_id: sessionId, session_token: token, expires_at: issued }); + } + + /** The private owner can end consent without retaining a live App grant. */ + async revokeConsent(actor: ModuleActor, bindingId: string) { + reviewer(actor); + return db.transaction(async (tx) => { + const [locator] = await tx.select({ owner_user_id: appResourceBindings.owner_user_id, + installation_id: appResourceBindings.app_installation_id, + registration_id: appResourceBindings.runtime_registration_id }) + .from(appResourceBindings).where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.id, bindingId))).limit(1); + if (!locator || locator.owner_user_id !== actor.actor_id) throw denied(); + const [registrationLocator] = await tx.select({ operator_user_id: appRuntimeRegistrations.operator_user_id }) + .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, locator.registration_id))).limit(1); + if (!registrationLocator) throw stale(); + await lockMembers(tx, actor.org_id, [actor.actor_id, registrationLocator.operator_user_id], 'UPDATE'); + await assertManager(tx, actor); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${locator.installation_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${locator.registration_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_resource_bindings WHERE org_id = ${actor.org_id} + AND id = ${bindingId} FOR UPDATE`); + const [binding] = await tx.select().from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, actor.org_id), eq(appResourceBindings.id, bindingId))).limit(1); + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, locator.registration_id))).limit(1); + if (!binding || !registration || binding.owner_user_id !== actor.actor_id + || binding.app_installation_id !== locator.installation_id + || binding.runtime_registration_id !== registration.id + || registration.operator_user_id !== registrationLocator.operator_user_id) throw stale(); + if (binding.state === 'revoked') return { revoked: true }; + const now = this.clock(); + await tx.update(appResourceBindings).set({ state: 'revoked', updated_at: now }).where(and( + eq(appResourceBindings.org_id, actor.org_id), eq(appResourceBindings.id, bindingId))); + await tx.update(appRuntimeRegistrations).set({ state: 'revoked', + runtime_epoch: registration.runtime_epoch + 1, updated_at: now }).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, registration.id))); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), + eq(appRuntimeSessions.resource_binding_id, bindingId), + eq(appRuntimeSessions.audience, 'app_resource_sync'), + sql`${appRuntimeSessions.revoked_at} IS NULL`)); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.resource_sync_consent_revoke', + entity_type: 'app_resource_binding', entity_id: bindingId, + before_state: { state: binding.state }, after_state: { state: 'revoked' }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); + } + + /** Emergency operator registration revoke. A registration is per consent. */ + async revokeRegistration(actor: ModuleActor, registrationId: string) { + reviewer(actor); + return db.transaction(async (tx) => { + const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id, + operator_user_id: appRuntimeRegistrations.operator_user_id }) + .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, registrationId), + eq(appRuntimeRegistrations.contract_version, 'deft.app_runtime_channel.v2'))).limit(1); + if (!locator) throw denied(); + const [bindingLocator] = await tx.select({ id: appResourceBindings.id, + owner_user_id: appResourceBindings.owner_user_id }) + .from(appResourceBindings).where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.runtime_registration_id, registrationId))).limit(1); + if (!bindingLocator) throw stale(); + await lockMembers(tx, actor.org_id, + [actor.actor_id, locator.operator_user_id, bindingLocator.owner_user_id], 'UPDATE'); + await assertManager(tx, actor); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${locator.installation_id} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${registrationId} FOR UPDATE`); + await tx.execute(sql`SELECT id FROM app_resource_bindings WHERE org_id = ${actor.org_id} + AND id = ${bindingLocator.id} FOR UPDATE`); + const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), + eq(appRuntimeRegistrations.id, registrationId))).limit(1); + const [binding] = await tx.select().from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.id, bindingLocator.id))).limit(1); + if (!registration || !binding || registration.contract_version !== 'deft.app_runtime_channel.v2' + || registration.app_installation_id !== locator.installation_id + || registration.operator_user_id !== locator.operator_user_id + || binding.runtime_registration_id !== registration.id + || binding.owner_user_id !== bindingLocator.owner_user_id) throw stale(); + if (registration.state === 'revoked') return { revoked: true }; + const now = this.clock(); + await tx.update(appRuntimeRegistrations).set({ state: 'revoked', + runtime_epoch: registration.runtime_epoch + 1, updated_at: now }).where(and( + eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, registrationId))); + await tx.update(appResourceBindings).set({ state: 'revoked', updated_at: now }).where(and( + eq(appResourceBindings.org_id, actor.org_id), eq(appResourceBindings.id, binding.id))); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), + eq(appRuntimeSessions.runtime_registration_id, registrationId), + eq(appRuntimeSessions.audience, 'app_resource_sync'), + sql`${appRuntimeSessions.revoked_at} IS NULL`)); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.resource_sync_registration_revoke', + entity_type: 'app_runtime_registration', entity_id: registrationId, + before_state: { state: registration.state, runtime_epoch: registration.runtime_epoch }, + after_state: { state: 'revoked', runtime_epoch: registration.runtime_epoch + 1 }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); + } + + async revokeOperatorSession(actor: ModuleActor, sessionId: string) { + operator(actor); + return db.transaction(async (tx) => { + const [locator] = await tx.select({ audience: appRuntimeSessions.audience, + operator_user_id: appRuntimeSessions.operator_user_id, + registration_id: appRuntimeSessions.runtime_registration_id, + resource_binding_id: appRuntimeSessions.resource_binding_id, + runtime_binding_id: appRuntimeSessions.runtime_binding_id }) + .from(appRuntimeSessions).where(and(eq(appRuntimeSessions.org_id, actor.org_id), + eq(appRuntimeSessions.id, sessionId))).limit(1); + if (!locator || locator.audience !== 'app_resource_sync' + || !locator.resource_binding_id || locator.runtime_binding_id !== null + || locator.operator_user_id !== actor.actor_id) throw denied(); + const [bindingLocator] = await tx.select({ owner_user_id: appResourceBindings.owner_user_id, + installation_id: appResourceBindings.app_installation_id }) + .from(appResourceBindings).where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.id, locator.resource_binding_id))).limit(1); + if (!bindingLocator) throw stale(); + await lockMembers(tx, actor.org_id, [actor.actor_id, bindingLocator.owner_user_id], 'SHARE'); + const [member] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) + .from(orgMembers).where(and(eq(orgMembers.org_id, actor.org_id), + eq(orgMembers.user_id, actor.actor_id))).limit(1); + if (!member?.is_active || member.role === 'guest') throw denied(); + await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} + AND id = ${bindingLocator.installation_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} + AND id = ${locator.registration_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_resource_bindings WHERE org_id = ${actor.org_id} + AND id = ${locator.resource_binding_id} FOR SHARE`); + await tx.execute(sql`SELECT id FROM app_runtime_sessions WHERE org_id = ${actor.org_id} + AND id = ${sessionId} FOR UPDATE`); + const [session] = await tx.select().from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, sessionId))).limit(1); + if (!session || session.audience !== 'app_resource_sync' || session.runtime_binding_id !== null + || session.resource_binding_id !== locator.resource_binding_id + || session.runtime_registration_id !== locator.registration_id + || session.operator_user_id !== actor.actor_id) throw stale(); + if (session.revoked_at) return { revoked: true }; + const now = this.clock(); + await tx.update(appRuntimeSessions).set({ revoked_at: now, updated_at: now }).where(and( + eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, sessionId))); + await tx.insert(auditLog).values({ org_id: actor.org_id, actor_type: 'human', + actor_id: actor.actor_id, action: 'app.resource_sync_session_revoke', + entity_type: 'app_runtime_session', entity_id: sessionId, + before_state: { revoked: false }, after_state: { revoked: true }, + metadata: { source: actor.source } }); + return { revoked: true }; + }); + } +} diff --git a/apps/api/src/lib/app-runtime-management.ts b/apps/api/src/lib/app-runtime-management.ts index 464f8ddf..2c4dc98c 100644 --- a/apps/api/src/lib/app-runtime-management.ts +++ b/apps/api/src/lib/app-runtime-management.ts @@ -253,17 +253,21 @@ export async function revokeRuntimeRegistration(actor: ModuleActor, registration manager(actor); return db.transaction(async (tx) => { await assertManager(tx, actor); - const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id }) + const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id, + contract_version: appRuntimeRegistrations.contract_version }) .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, Id.parse(registrationId)))).limit(1); - if (!locator) throw new AppError('App Runtime registration not found', 'APP_NOT_FOUND', 404); + if (!locator || locator.contract_version !== 'deft.app_runtime_channel.v1') { + throw new AppError('App Runtime registration not found', 'APP_NOT_FOUND', 404); + } await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} AND id = ${locator.installation_id} FOR UPDATE`); await tx.execute(sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${actor.org_id} AND id = ${registrationId} FOR UPDATE`); const [registration] = await tx.select().from(appRuntimeRegistrations).where(and( eq(appRuntimeRegistrations.org_id, actor.org_id), eq(appRuntimeRegistrations.id, registrationId))).limit(1); - if (!registration || registration.app_installation_id !== locator.installation_id) stale(); + if (!registration || registration.contract_version !== 'deft.app_runtime_channel.v1' + || registration.app_installation_id !== locator.installation_id) stale(); if (registration.state !== 'active') return { revoked: registration.state === 'revoked' }; const now = new Date(); await tx.update(appRuntimeRegistrations).set({ state: 'revoked', @@ -290,10 +294,15 @@ export async function revokeRuntimeSession(actor: ModuleActor, sessionId: string return db.transaction(async (tx) => { const [locator] = await tx.select({ operator_user_id: appRuntimeSessions.operator_user_id, registration_id: appRuntimeSessions.runtime_registration_id, - binding_id: appRuntimeSessions.runtime_binding_id }) + binding_id: appRuntimeSessions.runtime_binding_id, + audience: appRuntimeSessions.audience, + resource_binding_id: appRuntimeSessions.resource_binding_id }) .from(appRuntimeSessions).where(and(eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, Id.parse(sessionId)))).limit(1); - if (!locator) throw new AppError('App Runtime session not found', 'APP_NOT_FOUND', 404); + if (!locator || locator.audience !== 'app_runtime' || !locator.binding_id + || locator.resource_binding_id !== null) { + throw new AppError('App Runtime session not found', 'APP_NOT_FOUND', 404); + } if (locator.operator_user_id !== actor.actor_id) await assertManager(tx, actor); else await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${actor.org_id} AND user_id = ${actor.actor_id} FOR UPDATE`); @@ -311,7 +320,8 @@ export async function revokeRuntimeSession(actor: ModuleActor, sessionId: string AND id = ${sessionId} FOR UPDATE`); const [session] = await tx.select().from(appRuntimeSessions).where(and( eq(appRuntimeSessions.org_id, actor.org_id), eq(appRuntimeSessions.id, sessionId))).limit(1); - if (!session || session.operator_user_id !== locator.operator_user_id + if (!session || session.audience !== 'app_runtime' || session.resource_binding_id !== null + || session.operator_user_id !== locator.operator_user_id || session.runtime_binding_id !== locator.binding_id) stale(); if (session.revoked_at) return { revoked: true }; const now = new Date(); diff --git a/apps/api/test/app-resource-sync-consent-db.test.ts b/apps/api/test/app-resource-sync-consent-db.test.ts new file mode 100644 index 00000000..b72cb96f --- /dev/null +++ b/apps/api/test/app-resource-sync-consent-db.test.ts @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const databaseUrl = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!databaseUrl || databaseUrl !== process.env.DATABASE_URL) return false; + try { + const url = new URL(databaseUrl); + return ['postgres:', 'postgresql:'].includes(url.protocol) + && url.hostname === '127.0.0.1' && url.port === '55435' + && /^\/gate_g_phase5_test_s05_(?:consent_v[0-9]+|root(?:_v[0-9]+)?)$/.test(url.pathname) + && url.search === '' && url.hash === ''; + } catch { return false; } +})(); +after(async () => { if (safe) await (await import('../src/lib/db.js')).closeDb(); }); + +function keyring() { + const material = (seed: string) => createHash('sha256').update(`consent:${seed}`).digest('base64'); + const ring = (id: string) => ({ current: id, keys: { [id]: material(id) } }); + return JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('consent-enc-v1'), + receipt_signing: ring('consent-sign-v1'), + fingerprint: ring('consent-fp-v1') }); +} + +test('owner-reviewed v2 consent pins current App, provider and session authority', { skip: !safe }, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_KEYRINGS = keyring(); + const [{ db }, schema, drizzle, keyrings, authority, secretsModule, v1Management, v1Authority, policy] = + await Promise.all([import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/app-run-keyrings.js'), import('../src/lib/app-resource-sync-authority.js'), + import('../src/lib/app-resource-sync-secrets.js'), import('../src/lib/app-runtime-management.js'), + import('../src/lib/app-runtime-authority.js'), import('../src/lib/app-resource-sync-policy.js')]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(keyring()); + try { + let time = new Date('2026-09-24T12:00:00.000Z'); + const clock = () => time; + const fixture = await createReviewedResourceSyncFixture({ keys, clock }); + const bindingAuthority = await db.transaction((tx) => + authority.loadLiveResourceSyncBindingAuthority(tx, { org_id: fixture.org_id, + resource_binding_id: fixture.binding_id, clock })); + assert.ok(bindingAuthority); + assert.equal(bindingAuthority.binding.owner_user_id, fixture.owner_user_id); + assert.equal(bindingAuthority.registration.operator_user_id, fixture.operator_user_id); + assert.equal(bindingAuthority.provider_snapshot.adapter_contract_version, + 'deft.app_runtime_channel.v2'); + const safeSnapshot = bindingAuthority.provider_snapshot.safe_snapshot as { + operations: Array<{ input_schema: unknown; output_schema: { properties: { + upserts: { items: { properties: { data: unknown } } } } } }> }; + assert.deepEqual(safeSnapshot.operations[0]!.output_schema.properties.upserts.items.properties.data, + { type: 'object', properties: { subject: { type: 'string', maxLength: 200 } }, + required: ['subject'], additionalProperties: false }); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints).where(drizzle.and( + drizzle.eq(schema.appSyncCheckpoints.org_id, fixture.org_id), + drizzle.eq(schema.appSyncCheckpoints.id, fixture.checkpoint_id))); + assert.ok(checkpoint); + assert.equal(checkpoint.generation, 1); + assert.equal(checkpoint.cursor_sequence, 0); + assert.equal(checkpoint.cursor_state, 'empty'); + const fingerprint = new secretsModule.AppResourceSyncSecretService(keys).cursorFingerprint(null, + { org_id: fixture.org_id, resource_binding_id: fixture.binding_id, + checkpoint_id: checkpoint.id, payload_kind: 'cursor', generation: 1, cursor_sequence: 0 }, + checkpoint.cursor_hmac_key_version); + assert.equal(checkpoint.cursor_hmac, fingerprint.fingerprint); + const issued = await fixture.management.issueOperatorSession(fixture.operator_actor, fixture.binding_id); + const [stored] = await db.select().from(schema.appRuntimeSessions).where(drizzle.and( + drizzle.eq(schema.appRuntimeSessions.org_id, fixture.org_id), + drizzle.eq(schema.appRuntimeSessions.id, issued.session_id))); + assert.ok(stored); + assert.equal(stored.audience, 'app_resource_sync'); + assert.equal(stored.runtime_binding_id, null); + assert.equal(stored.resource_binding_id, fixture.binding_id); + assert.equal(stored.token_hash, policy.hashAppResourceSyncToken(issued.session_token)); + assert.notEqual(stored.token_hash, v1Authority.hashAppRuntimeToken(issued.session_token)); + const liveSession = await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: issued.session_id, + token_hash: stored.token_hash, clock })); + assert.equal(liveSession?.session.id, issued.session_id); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: issued.session_id, + token_hash: v1Authority.hashAppRuntimeToken(issued.session_token), clock })), null); + assert.equal(await db.transaction((tx) => v1Authority.loadLiveRuntimeAuthority(tx, + fixture.org_id, issued.session_id, stored.token_hash, clock)), null); + await assert.rejects(v1Management.revokeRuntimeSession(fixture.operator_actor, issued.session_id), + (error: unknown) => (error as { code?: string }).code === 'APP_NOT_FOUND'); + await assert.rejects(v1Management.revokeRuntimeRegistration(fixture.owner_actor, + fixture.registration_id), + (error: unknown) => (error as { code?: string }).code === 'APP_NOT_FOUND'); + await fixture.management.revokeOperatorSession(fixture.operator_actor, issued.session_id); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: issued.session_id, + token_hash: stored.token_hash, clock })), null); + const renewed = await fixture.management.issueOperatorSession(fixture.operator_actor, fixture.binding_id); + time = new Date('2026-09-24T12:16:00.000Z'); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: renewed.session_id, + token_hash: policy.hashAppResourceSyncToken(renewed.session_token), clock })), null); + await fixture.management.revokeConsent(fixture.owner_actor, fixture.binding_id); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncBindingAuthority(tx, + { org_id: fixture.org_id, resource_binding_id: fixture.binding_id, clock })), null); + const attempts = await Promise.allSettled([fixture.management.activateConsent(fixture.owner_actor, + { ...fixture.consent_request, expected_review_digest: fixture.consent_review.review_digest, + accept_host_policy: true }), fixture.management.activateConsent(fixture.owner_actor, + { ...fixture.consent_request, expected_review_digest: fixture.consent_review.review_digest, + accept_host_policy: true })]); + assert.equal(attempts.filter((item) => item.status === 'fulfilled').length, 1); + assert.equal(attempts.filter((item) => item.status === 'rejected').length, 1); + const winner = attempts.find((item) => item.status === 'fulfilled'); + assert.ok(winner && winner.status === 'fulfilled'); + const [activeCount] = await db.select({ count: drizzle.sql`count(*)::int` }) + .from(schema.appResourceBindings).where(drizzle.and( + drizzle.eq(schema.appResourceBindings.org_id, fixture.org_id), + drizzle.eq(schema.appResourceBindings.app_installation_id, fixture.installation_id), + drizzle.eq(schema.appResourceBindings.owner_user_id, fixture.owner_user_id), + drizzle.eq(schema.appResourceBindings.resource_key, fixture.descriptor.key), + drizzle.sql`${schema.appResourceBindings.state} <> 'revoked'`)); + assert.equal(activeCount?.count, 1); + const v2Session = await fixture.management.issueOperatorSession(fixture.operator_actor, + winner.value.binding_id); + await fixture.management.revokeRegistration(fixture.owner_actor, winner.value.registration_id); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: v2Session.session_id, + token_hash: policy.hashAppResourceSyncToken(v2Session.session_token), clock })), null); + const countRows = async () => Promise.all([ + schema.appRuntimeRegistrations, schema.appResourceBindings, + schema.capabilityProviderSnapshots, schema.appSyncCheckpoints, + ].map(async (table) => { + const [row] = await db.select({ count: drizzle.sql`count(*)::int` }) + .from(table).where(drizzle.eq(table.org_id, fixture.org_id)); + return row?.count; + })); + const beforeFailure = await countRows(); + keys.destroy(); + await assert.rejects(fixture.management.activateConsent(fixture.owner_actor, + { ...fixture.consent_request, expected_review_digest: fixture.consent_review.review_digest, + accept_host_policy: true })); + assert.deepEqual(await countRows(), beforeFailure, + 'failure after inserts leaves no orphan registration, binding, provider or checkpoint'); + } finally { keys.destroy(); } +}); + +test('session issuance rechecks owner, deadlines and App ancestry after blocking locks', { + skip: !safe, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + const [{ db }, schema, drizzle, keyrings, authority, apps, policy] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/app-run-keyrings.js'), import('../src/lib/app-resource-sync-authority.js'), + import('../src/lib/app-service.js'), import('../src/lib/app-resource-sync-policy.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(keyring()); + try { + let time = new Date('2026-09-24T14:00:00.000Z'); + const clock = () => time; + const fixture = await createReviewedResourceSyncFixture({ keys, clock }); + let locked!: () => void; + let release!: () => void; + const acquired = new Promise((resolve) => { locked = resolve; }); + const released = new Promise((resolve) => { release = resolve; }); + const blocker = db.transaction(async (tx) => { + await tx.execute(drizzle.sql`SELECT id FROM org_members + WHERE org_id = ${fixture.org_id} AND user_id = ${fixture.operator_user_id} FOR UPDATE`); + locked(); + await released; + }); + await acquired; + const pending = assert.rejects(fixture.management.issueOperatorSession( + fixture.operator_actor, fixture.binding_id)); + let observedWait = false; + for (let attempt = 0; attempt < 40; attempt += 1) { + const result = await db.execute(drizzle.sql<{ waiting: number }>` + SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname = current_database() AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' AND query LIKE '%org_members%'`); + if ((result.rows[0]?.waiting ?? 0) > 0) { observedWait = true; break; } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(observedWait, true, 'issuance actually waited on the held membership row'); + time = new Date('2026-09-24T15:01:00.000Z'); + release(); + await blocker; + await pending; + const [count] = await db.select({ count: drizzle.sql`count(*)::int` }) + .from(schema.appRuntimeSessions).where(drizzle.and( + drizzle.eq(schema.appRuntimeSessions.org_id, fixture.org_id), + drizzle.eq(schema.appRuntimeSessions.resource_binding_id, fixture.binding_id))); + assert.equal(count?.count, 0, 'deadline after member wait leaves no token row'); + + time = new Date('2026-09-24T14:01:00.000Z'); + const issued = await fixture.management.issueOperatorSession(fixture.operator_actor, fixture.binding_id); + await db.update(schema.orgMembers).set({ role: 'member' }).where(drizzle.and( + drizzle.eq(schema.orgMembers.org_id, fixture.org_id), + drizzle.eq(schema.orgMembers.user_id, fixture.owner_user_id))); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncBindingAuthority(tx, + { org_id: fixture.org_id, resource_binding_id: fixture.binding_id, clock })), null); + await assert.rejects(fixture.management.issueOperatorSession(fixture.operator_actor, + fixture.binding_id)); + await db.update(schema.orgMembers).set({ role: 'owner' }).where(drizzle.and( + drizzle.eq(schema.orgMembers.org_id, fixture.org_id), + drizzle.eq(schema.orgMembers.user_id, fixture.owner_user_id))); + const [installation] = await db.select().from(schema.appInstallations).where(drizzle.and( + drizzle.eq(schema.appInstallations.org_id, fixture.org_id), + drizzle.eq(schema.appInstallations.id, fixture.installation_id))); + assert.ok(installation); + await apps.disableAppInstallation(fixture.owner_actor, fixture.installation_id, + installation.lifecycle_epoch); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncBindingAuthority(tx, + { org_id: fixture.org_id, resource_binding_id: fixture.binding_id, clock })), null); + assert.equal(await db.transaction((tx) => authority.loadLiveResourceSyncAuthority(tx, + { org_id: fixture.org_id, session_id: issued.session_id, + token_hash: policy.hashAppResourceSyncToken(issued.session_token), clock })), null); + } finally { keys.destroy(); } +}); + +test('consent input cannot spoof a manager, stale pin, other tenant or second current binding', { + skip: !safe, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + const [{ db }, schema, drizzle, keyrings, modules] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/app-run-keyrings.js'), import('../src/lib/module-service.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(keyring()); + try { + const clock = () => new Date('2026-09-24T18:00:00.000Z'); + const fixture = await createReviewedResourceSyncFixture({ keys, clock }); + const forgedManager = modules.humanModuleActor({ orgId: fixture.org_id, + userId: fixture.operator_user_id, role: 'owner', source: 'rest' }); + const foreignManager = modules.humanModuleActor({ orgId: randomUUID(), + userId: fixture.owner_user_id, role: 'owner', source: 'rest' }); + await assert.rejects(fixture.management.prepareConsent(forgedManager, + fixture.consent_request), (error: unknown) => (error as { code?: string }).code === 'APP_ACCESS_DENIED'); + await assert.rejects(fixture.management.prepareConsent(foreignManager, + fixture.consent_request)); + await assert.rejects(fixture.management.prepareConsent(fixture.owner_actor, + { ...fixture.consent_request, expected_grant_snapshot_digest: `sha256:${'0'.repeat(64)}` })); + await assert.rejects(fixture.management.prepareConsent(fixture.owner_actor, + { ...fixture.consent_request, expected_lifecycle_epoch: 999 })); + await assert.rejects(fixture.management.prepareConsent(fixture.owner_actor, + { ...fixture.consent_request, consent_expires_at: + new Date(clock().getTime() + 91 * 24 * 60 * 60 * 1_000).toISOString() })); + await db.update(schema.orgMembers).set({ role: 'guest' }).where(drizzle.and( + drizzle.eq(schema.orgMembers.org_id, fixture.org_id), + drizzle.eq(schema.orgMembers.user_id, fixture.operator_user_id))); + await assert.rejects(fixture.management.prepareConsent(fixture.owner_actor, + fixture.consent_request)); + await db.update(schema.orgMembers).set({ role: 'member' }).where(drizzle.and( + drizzle.eq(schema.orgMembers.org_id, fixture.org_id), + drizzle.eq(schema.orgMembers.user_id, fixture.operator_user_id))); + const [binding] = await db.select().from(schema.appResourceBindings).where(drizzle.and( + drizzle.eq(schema.appResourceBindings.org_id, fixture.org_id), + drizzle.eq(schema.appResourceBindings.id, fixture.binding_id))); + assert.ok(binding); + await assert.rejects(db.insert(schema.appResourceBindings).values({ ...binding, + id: randomUUID(), state: 'disabled', reviewed_by_user_id: null, + reviewed_at: null, consent_expires_at: null }), (error: unknown) => + (error as { cause?: { constraint?: string } }).cause?.constraint + === 'app_resource_bindings_one_current_consent_unique'); + await assert.rejects(db.update(schema.appResourceBindings).set({ + reviewed_descriptor: { ...binding.reviewed_descriptor, resource_type: 'forged_type' }, + }).where(drizzle.and(drizzle.eq(schema.appResourceBindings.org_id, fixture.org_id), + drizzle.eq(schema.appResourceBindings.id, fixture.binding_id)))); + } finally { keys.destroy(); } +}); diff --git a/apps/api/test/fixtures/resource-sync-v5.ts b/apps/api/test/fixtures/resource-sync-v5.ts new file mode 100644 index 00000000..33965bda --- /dev/null +++ b/apps/api/test/fixtures/resource-sync-v5.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto'; +import type { SyncDescriptorV1 } from '@deft/app-kit/experimental/resource-sync'; +import type { AppRunKeyProvider } from '../../src/lib/app-run-keyrings.js'; + +const defaultDescriptor: SyncDescriptorV1 = { + schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'provider', resource_type: 'email_message', + requested_visibility: 'user_private', label_field: 'subject', + record_schema: { type: 'object', properties: { + subject: { type: 'string', maxLength: 200 }, + }, required: ['subject'], additionalProperties: false }, +}; + +/** Real synthetic authoring→App review→private consent fixture. The caller + * configures feature flags before calling and owns its DB pool/keyring. */ +export async function createReviewedResourceSyncFixture(input: Readonly<{ + keys: AppRunKeyProvider; clock: () => Date; descriptor?: SyncDescriptorV1; +}>) { + const [{ db }, schema, kit, apps, reviews, modules, managementModule, drizzle] = await Promise.all([ + import('../../src/lib/db.js'), import('@deft/db/schema'), import('@deft/app-kit'), + import('../../src/lib/app-service.js'), import('../../src/lib/app-runtime-review.js'), + import('../../src/lib/module-service.js'), import('../../src/lib/app-resource-sync-management.js'), + import('drizzle-orm'), + ]); + const suffix = randomUUID().replaceAll('-', ''); + const orgId = randomUUID(); + const ownerId = randomUUID(); + const operatorId = randomUUID(); + const descriptor = input.descriptor ?? defaultDescriptor; + await db.insert(schema.orgs).values({ id: orgId, name: 'Private sync consent fixture', + slug: `private-sync-${suffix}` }); + await db.insert(schema.users).values([ + { id: ownerId, name: 'Private sync owner', email: `sync-owner-${suffix}@example.test` }, + { id: operatorId, name: 'Runtime operator', email: `sync-operator-${suffix}@example.test` }, + ]); + await db.insert(schema.orgMembers).values([ + { id: randomUUID(), org_id: orgId, user_id: ownerId, role: 'owner', is_active: true }, + { id: randomUUID(), org_id: orgId, user_id: operatorId, role: 'member', is_active: true }, + ]); + const ownerActor = modules.humanModuleActor({ orgId, userId: ownerId, role: 'owner', source: 'rest' }); + const operatorActor = modules.humanModuleActor({ orgId, userId: operatorId, role: 'member', source: 'rest' }); + const manifest = { schema_version: '5' as const, + id: `community.example.private-sync.a${suffix}`, version: '1.0.0', + name: 'Private sync fixture', license: 'AGPL-3.0-only', + compatibility: { app_protocol: '5' as const }, modules: [], navigation: [], + runtime_requirements: [{ key: descriptor.runtime_requirement_key, + protocol_version: 'deft.app_runtime_channel.v2' as const }], + private_capabilities: [], runtime_actions: [], sync_descriptors: [descriptor], + experiences: [], public_actions: [], + }; + const pkg = await kit.buildDeftAppPackage({ manifest, artifacts: [] }); + const staged = await apps.stageAppPackage(ownerActor, pkg.json); + const [version] = await db.select().from(schema.appVersions).where(drizzle.and( + drizzle.eq(schema.appVersions.org_id, orgId), + drizzle.eq(schema.appVersions.id, staged.version_id))); + if (!version?.requested_grant_snapshot_id) throw new Error('Fixture staging omitted requested grant'); + const [requested] = await db.select().from(schema.appGrantSnapshots).where(drizzle.and( + drizzle.eq(schema.appGrantSnapshots.org_id, orgId), + drizzle.eq(schema.appGrantSnapshots.id, version.requested_grant_snapshot_id))); + if (!requested) throw new Error('Fixture requested grant unavailable'); + const appRequest = { app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, expected_grant_epoch: staged.grant_epoch }; + const appReview = await reviews.prepareRuntimeAppReview(ownerActor, staged.id, appRequest); + const activated = await reviews.activateRuntimeApp(ownerActor, staged.id, { + ...appRequest, expected_review_digest: appReview.review_digest, accept_host_policy: true }); + const [grant] = await db.select().from(schema.appGrantSnapshots).where(drizzle.and( + drizzle.eq(schema.appGrantSnapshots.org_id, orgId), + drizzle.eq(schema.appGrantSnapshots.id, activated.grant_snapshot_id))); + if (!grant) throw new Error('Fixture effective grant unavailable'); + const management = new managementModule.AppResourceSyncManagement(input.keys, input.clock); + const consentRequest = { installation_id: staged.id, resource_key: descriptor.key, + operator_user_id: operatorId, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: activated.installation.lifecycle_epoch, + expected_grant_epoch: activated.installation.grant_epoch, + consent_expires_at: new Date(input.clock().getTime() + 60 * 60 * 1_000).toISOString(), + limits: { max_records_per_page: 100, max_page_bytes: 524_288, + max_retained_records: 100_000, max_retained_bytes: 1_073_741_824, + min_interval_seconds: 60 }, + }; + const consentReview = await management.prepareConsent(ownerActor, consentRequest); + const consent = await management.activateConsent(ownerActor, { ...consentRequest, + expected_review_digest: consentReview.review_digest, accept_host_policy: true }); + return Object.freeze({ org_id: orgId, owner_user_id: ownerId, + operator_user_id: operatorId, owner_actor: ownerActor, operator_actor: operatorActor, + installation_id: staged.id, app_version_id: version.id, + grant_snapshot_id: grant.id, registration_id: consent.registration_id, + binding_id: consent.binding_id, checkpoint_id: consent.checkpoint_id, + descriptor, management, consent_request: consentRequest, consent_review: consentReview }); +} diff --git a/packages/db/scripts/apply-extras.ts b/packages/db/scripts/apply-extras.ts index 56bdf000..019889e8 100644 --- a/packages/db/scripts/apply-extras.ts +++ b/packages/db/scripts/apply-extras.ts @@ -151,6 +151,7 @@ async function main() { '0.3.0-preview.36-app-experience-sessions.sql', '0.3.0-preview.37-app-resource-sync.sql', '0.3.0-preview.38-app-resource-authoring.sql', + '0.3.0-preview.39-app-resource-consent.sql', ]) { await client.query(readFileSync(resolve(upgradesDir, platformFile), 'utf8')); console.log(`[apply-extras] reconciled ${platformFile}`); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 25e57925..d45c1f64 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -2346,6 +2346,9 @@ export const appResourceBindings = pgTable('app_resource_bindings', { t.provider_instance_id, t.operation_name, t.provider_snapshot_id, t.risk_class, t.review_requirement, t.review_scope, t.retry_class, t.retention_class), index('app_resource_bindings_owner_idx').on(t.org_id, t.owner_user_id, t.state), + uniqueIndex('app_resource_bindings_one_current_consent_unique') + .on(t.org_id, t.app_installation_id, t.grant_snapshot_id, t.owner_user_id, t.resource_key) + .where(sql`${t.state} <> 'revoked'`), check('app_resource_bindings_identity_check', sql` ${t.grant_snapshot_kind} = 'effective' AND ${t.registration_contract_version} = 'deft.app_runtime_channel.v2' diff --git a/packages/db/upgrades/0.3.0-preview.39-app-resource-consent.sql b/packages/db/upgrades/0.3.0-preview.39-app-resource-consent.sql new file mode 100644 index 00000000..5e809fb3 --- /dev/null +++ b/packages/db/upgrades/0.3.0-preview.39-app-resource-consent.sql @@ -0,0 +1,7 @@ +-- One current private consent per owner, App and declared resource under the +-- exact reviewed grant. Revoked history is retained; a new grant never revives +-- its historical bindings. Host admission still checks all live ancestry. +CREATE UNIQUE INDEX IF NOT EXISTS app_resource_bindings_one_current_consent_unique + ON app_resource_bindings (org_id, app_installation_id, grant_snapshot_id, + owner_user_id, resource_key) + WHERE state <> 'revoked'; diff --git a/packages/db/upgrades/manifest.ts b/packages/db/upgrades/manifest.ts index 2b0c101f..0d80c319 100644 --- a/packages/db/upgrades/manifest.ts +++ b/packages/db/upgrades/manifest.ts @@ -212,6 +212,11 @@ export const upgradeManifest = { file: '0.3.0-preview.38-app-resource-authoring.sql', description: 'Permit reviewed App Protocol v5 stage and activation with pinned resource descriptors', }, + { + version: '0.3.0-preview.39', + file: '0.3.0-preview.39-app-resource-consent.sql', + description: 'Prevent duplicate current owner-private resource consent for one reviewed App grant', + }, ] satisfies UpgradeMigration[], } as const; From b52c5037331d3739a13fdff23eb7a2d473af4d43 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:52:05 +0530 Subject: [PATCH 021/161] Validate private sync management identifiers --- apps/api/src/lib/app-resource-sync-management.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index ddb65979..aba21705 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -1,5 +1,6 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { and, eq, inArray, sql } from 'drizzle-orm'; +import { z } from 'zod'; import { appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, appSyncCheckpoints, auditLog, orgMembers, @@ -24,6 +25,7 @@ import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, type Tx = Parameters[0]>[0]; type Human = Extract; +const Id = z.string().uuid(); const stale = () => new AppError('Private resource sync authority changed', 'APP_STALE', 409); const denied = () => new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); const conflict = () => new AppError('Private resource sync already has current consent', 'APP_STATE_CONFLICT', 409); @@ -173,6 +175,7 @@ export class AppResourceSyncManagement { async issueOperatorSession(actor: ModuleActor, bindingId: string) { operator(actor); + bindingId = Id.parse(bindingId); const sessionId = randomUUID(); const token = randomBytes(32).toString('base64url'); const tokenHash = hashAppResourceSyncToken(token); @@ -209,6 +212,7 @@ export class AppResourceSyncManagement { /** The private owner can end consent without retaining a live App grant. */ async revokeConsent(actor: ModuleActor, bindingId: string) { reviewer(actor); + bindingId = Id.parse(bindingId); return db.transaction(async (tx) => { const [locator] = await tx.select({ owner_user_id: appResourceBindings.owner_user_id, installation_id: appResourceBindings.app_installation_id, @@ -261,6 +265,7 @@ export class AppResourceSyncManagement { /** Emergency operator registration revoke. A registration is per consent. */ async revokeRegistration(actor: ModuleActor, registrationId: string) { reviewer(actor); + registrationId = Id.parse(registrationId); return db.transaction(async (tx) => { const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id, operator_user_id: appRuntimeRegistrations.operator_user_id }) @@ -317,6 +322,7 @@ export class AppResourceSyncManagement { async revokeOperatorSession(actor: ModuleActor, sessionId: string) { operator(actor); + sessionId = Id.parse(sessionId); return db.transaction(async (tx) => { const [locator] = await tx.select({ audience: appRuntimeSessions.audience, operator_user_id: appRuntimeSessions.operator_user_id, From 4f3a536c61427a09ef0330ba397683d13abe5182 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:56:48 +0530 Subject: [PATCH 022/161] Admit reviewed private sync through atomic host-owned Runs --- .../src/lib/app-resource-sync-admission.ts | 168 +++++++++++++ .../lib/app-resource-sync-authorization.ts | 39 +++ .../app-resource-sync-admission-db.test.ts | 226 ++++++++++++++++++ scripts/gate-g/required-tests.json | 16 ++ 4 files changed, 449 insertions(+) create mode 100644 apps/api/src/lib/app-resource-sync-admission.ts create mode 100644 apps/api/src/lib/app-resource-sync-authorization.ts create mode 100644 apps/api/test/app-resource-sync-admission-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-admission.ts b/apps/api/src/lib/app-resource-sync-admission.ts new file mode 100644 index 00000000..4be1b999 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-admission.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto'; +import { and, desc, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { appRuns, appSyncCheckpoints, appSyncIntents } from '@deft/db/schema'; +import { APP_RUN_CONTRACT_VERSIONS, APP_RUN_DEFAULT_ATTEMPT_LIMIT, + AppRunSafePreviewSchema, + idempotencyDeadline, retentionDeadline } from '@deft/shared'; +import { parseSyncRequest } from '@deft/app-kit/experimental/resource-sync'; +import { AppError } from './app-errors.js'; +import { loadLiveResourceSyncBindingAuthority } from './app-resource-sync-authority.js'; +import { buildResourceSyncAuthorizationSnapshot } from './app-resource-sync-authorization.js'; +import { APP_RESOURCE_SYNC_HOST_POLICY } from './app-resource-sync-policy.js'; +import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; +import { PostgresAppRunRepository, safeRunSelection, + type AppRunSafeView, type AppRunTransaction } from './app-run-repository.js'; +import { AppRunSecretRepository } from './app-run-secret-repository.js'; +import { AppRunSecretService } from './app-run-secrets.js'; + +const HostTargetSchema = z.strictObject({ org_id: z.string().uuid(), + resource_binding_id: z.string().uuid() }); +const unavailable = () => new AppError('Resource sync authority unavailable', 'APP_ACCESS_DENIED', 403); + +export interface ResourceSyncAttemptScheduler { + scheduleResourceSyncInTransaction(tx: AppRunTransaction, run: AppRunSafeView, + now: Date): Promise; +} +export type ResourceSyncAdmissionResult = Readonly< + | { state: 'created'; run_id: string; attempt_id: string } + | { state: 'existing'; run_id: string } + | { state: 'blocked'; reason: 'cursor_requires_recovery' } + | { state: 'not_due'; due_at: string } +>; + +/** Host-only intake. No request route may forward caller-selected owner, + * cursor, policy, actor or descriptor into this service. It uses the existing + * Run and attempt ledger, and never invokes a provider itself. */ +export class AppResourceSyncAdmissionService { + constructor( + private readonly repository: PostgresAppRunRepository, + private readonly runInputs: AppRunSecretRepository, + private readonly runSecrets: AppRunSecretService, + private readonly syncSecrets: AppResourceSyncSecretService, + private readonly scheduler: ResourceSyncAttemptScheduler, + private readonly clock: () => Date = () => new Date(), + private readonly enabled: () => boolean = () => false, + ) {} + + async admitDue(raw: unknown): Promise { + if (!this.enabled()) throw new AppError('Resource sync is disabled', 'APP_FEATURE_DISABLED', 503); + const target = HostTargetSchema.parse(raw); + return this.repository.transaction(async (tx) => { + // A new Run is not visible yet. Lock authority first, then checkpoint; + // never take an existing Run lock while holding these later locks. + const authority = await loadLiveResourceSyncBindingAuthority(tx, { + ...target, clock: this.clock, + }); + if (!authority) throw unavailable(); + const { binding, installation, version, grant, registration } = authority; + const [checkpoint] = await tx.select().from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, target.org_id), + eq(appSyncCheckpoints.resource_binding_id, binding.id), + )).limit(1).for('update'); + const now = this.clock(); + if (!checkpoint || checkpoint.state !== 'active' || !Number.isFinite(now.getTime()) + || !binding.consent_expires_at || binding.consent_expires_at <= now) throw unavailable(); + + // The checkpoint lock serializes host admission. Read existing Runs + // without locking them: completion holds Run before checkpoint, so + // reversing that order here would deadlock. Returned IDs confer no + // authority; the channel always rechecks current state and intent. + const prior = await tx.select({ run_id: appSyncIntents.run_id, + state: appRuns.state, expires_at: appRuns.input_expires_at }) + .from(appSyncIntents).innerJoin(appRuns, and(eq(appRuns.org_id, appSyncIntents.org_id), + eq(appRuns.id, appSyncIntents.run_id))).where(and( + eq(appSyncIntents.org_id, target.org_id), + eq(appSyncIntents.resource_binding_id, binding.id), + eq(appSyncIntents.checkpoint_id, checkpoint.id), + eq(appSyncIntents.generation, checkpoint.generation), + eq(appSyncIntents.expected_cursor_sequence, checkpoint.cursor_sequence), + )).limit(2); + if (prior.length) { + const existing = prior[0]!; + if (prior.length !== 1 || existing.expires_at <= now + || !['pending', 'running', 'waiting_external'].includes(existing.state)) { + return Object.freeze({ state: 'blocked', reason: 'cursor_requires_recovery' }); + } + return Object.freeze({ state: 'existing', run_id: existing.run_id }); + } + const [latest] = await tx.select({ created_at: appSyncIntents.created_at }) + .from(appSyncIntents).where(and(eq(appSyncIntents.org_id, target.org_id), + eq(appSyncIntents.resource_binding_id, binding.id))) + .orderBy(desc(appSyncIntents.created_at)).limit(1); + if (latest) { + const due = new Date(latest.created_at.getTime() + binding.min_interval_seconds * 1_000); + if (due > now) return Object.freeze({ state: 'not_due', due_at: due.toISOString() }); + } + + const cursorContext = { org_id: target.org_id, resource_binding_id: binding.id, + checkpoint_id: checkpoint.id, payload_kind: 'cursor' as const, + generation: checkpoint.generation, cursor_sequence: checkpoint.cursor_sequence }; + const cursor = checkpoint.cursor_state === 'empty' ? null : this.syncSecrets.openJson({ + schema_version: checkpoint.cursor_envelope_version, + algorithm: checkpoint.cursor_algorithm, key_version: checkpoint.cursor_key_version, + nonce_b64: checkpoint.cursor_nonce_b64, ciphertext_b64: checkpoint.cursor_ciphertext_b64, + auth_tag_b64: checkpoint.cursor_auth_tag_b64, + }, cursorContext); + if (cursor !== null && typeof cursor !== 'string') throw unavailable(); + const fingerprint = this.syncSecrets.cursorFingerprint(cursor, cursorContext, + checkpoint.cursor_hmac_key_version); + if (fingerprint.fingerprint !== checkpoint.cursor_hmac) throw unavailable(); + const request = parseSyncRequest({ schema_version: 'deft.app_sync_request.v1', + cursor, max_items: binding.max_records_per_page }); + const idempotency = this.runSecrets.fingerprintJson('idempotency', { + domain: 'deft.app_resource_sync.admission.v1', org_id: target.org_id, + resource_binding_id: binding.id, checkpoint_id: checkpoint.id, + generation: checkpoint.generation, cursor_sequence: checkpoint.cursor_sequence, + }); + const inputFingerprint = this.runSecrets.fingerprintJson('input', request); + const runId = randomUUID(); + const inputExpiresAt = new Date(Math.min(retentionDeadline('standard', now).getTime(), + binding.consent_expires_at.getTime())); + const actor = { actor_type: 'system' as const, system_id: binding.id }; + const authorization = buildResourceSyncAuthorizationSnapshot(authority); + const [run] = await tx.insert(appRuns).values({ id: runId, org_id: target.org_id, + contract_version: APP_RUN_CONTRACT_VERSIONS.run, origin_kind: 'app', + initiating_actor_type: 'system', initiating_actor_id: binding.id, + execution_actor_type: 'system', execution_actor_id: binding.id, + provider_kind: 'app_runtime', provider_instance_id: registration.id, + provider_snapshot_id: binding.provider_snapshot_id, operation_name: binding.operation_name, + origin_app_installation_id: installation.id, origin_app_version_id: version.id, + origin_app_grant_snapshot_id: grant.id, origin_resource_binding_id: binding.id, + state: 'pending', ...APP_RESOURCE_SYNC_HOST_POLICY, + idempotency_key_version: idempotency.key_version, + idempotency_fingerprint: idempotency.fingerprint, + input_fingerprint_key_version: inputFingerprint.key_version, + input_fingerprint: inputFingerprint.fingerprint, authorization_snapshot: authorization, + safe_preview: AppRunSafePreviewSchema.parse({ schema_version: APP_RUN_CONTRACT_VERSIONS.run, + title: 'Sync private App resource', resource_refs: [] }), + root_run_id: runId, input_expires_at: inputExpiresAt, + result_expires_at: retentionDeadline('standard', now), + idempotency_expires_at: idempotencyDeadline('standard', now), + attempt_limit: APP_RUN_DEFAULT_ATTEMPT_LIMIT, + execution_release_kind: 'policy_satisfied', execution_released_at: now, + created_at: now, updated_at: now, + }).returning(safeRunSelection); + if (!run) throw unavailable(); + await this.runInputs.insertInput(tx, { org_id: target.org_id, run_id: runId, + value: request, expires_at: inputExpiresAt }); + await tx.insert(appSyncIntents).values({ id: randomUUID(), org_id: target.org_id, + run_id: runId, resource_binding_id: binding.id, checkpoint_id: checkpoint.id, + app_installation_id: installation.id, app_version_id: version.id, + grant_snapshot_id: grant.id, provider_snapshot_id: binding.provider_snapshot_id, + owner_user_id: binding.owner_user_id, descriptor_digest: authority.descriptor_digest, + generation: checkpoint.generation, expected_cursor_sequence: checkpoint.cursor_sequence, + expected_cursor_hmac_key_version: checkpoint.cursor_hmac_key_version, + expected_cursor_hmac: checkpoint.cursor_hmac, created_at: now }); + await this.repository.appendEvent(tx, { id: randomUUID(), org_id: target.org_id, + run_id: runId, event_type: 'run_created', actor, now, + payload: { resource_binding_id: binding.id, checkpoint_id: checkpoint.id, + generation: checkpoint.generation, cursor_sequence: checkpoint.cursor_sequence } }); + const attemptId = await this.scheduler.scheduleResourceSyncInTransaction(tx, run, now); + const completedAt = this.clock(); + if (!attemptId || !Number.isFinite(completedAt.getTime()) + || inputExpiresAt <= completedAt || binding.consent_expires_at <= completedAt) throw unavailable(); + return Object.freeze({ state: 'created', run_id: runId, attempt_id: attemptId }); + }); + } +} diff --git a/apps/api/src/lib/app-resource-sync-authorization.ts b/apps/api/src/lib/app-resource-sync-authorization.ts new file mode 100644 index 00000000..0df81d9c --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-authorization.ts @@ -0,0 +1,39 @@ +import { APP_RUN_CONTRACT_VERSIONS, AppRunAuthorizationSnapshotSchema } from '@deft/shared'; +import { digestAppGrantValue } from './app-grant-service.js'; +import type { LiveResourceSyncBindingAuthority } from './app-resource-sync-authority.js'; + +/** One host projection shared by admission and every v2 execution boundary. + * checked_at is deliberately excluded: it is a deadline check, not authority. */ +export function buildResourceSyncAuthorizationSnapshot(authority: LiveResourceSyncBindingAuthority) { + const { binding, installation, version, grant, registration } = authority; + const consentDigest = digestAppGrantValue({ + schema_version: 'deft.app_resource_sync.consent_pin.v1', + owner_user_id: binding.owner_user_id, reviewed_by_user_id: binding.reviewed_by_user_id, + reviewed_at: binding.reviewed_at?.toISOString() ?? null, + consent_expires_at: binding.consent_expires_at?.toISOString() ?? null, + risk_class: binding.risk_class, review_requirement: binding.review_requirement, + review_scope: binding.review_scope, retry_class: binding.retry_class, + retention_class: binding.retention_class, + max_records_per_page: binding.max_records_per_page, max_page_bytes: binding.max_page_bytes, + max_retained_records: binding.max_retained_records, max_retained_bytes: binding.max_retained_bytes, + min_interval_seconds: binding.min_interval_seconds, + }); + return AppRunAuthorizationSnapshotSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.run, + authenticated_subject: { actor_type: 'system', system_id: binding.id }, + authority_refs: [ + ...[...new Set([binding.owner_user_id, registration.operator_user_id])].sort() + .map((id) => ({ authority_kind: 'membership', authority_id: id, version: 'active' })), + { authority_kind: 'app_installation', authority_id: installation.id, + version: `lifecycle:${installation.lifecycle_epoch}:grant:${installation.grant_epoch}` }, + { authority_kind: 'app_version', authority_id: version.id, version: version.package_digest }, + { authority_kind: 'app_grant', authority_id: grant.id, version: grant.snapshot_digest }, + { authority_kind: 'app_runtime_registration', authority_id: registration.id, + version: String(registration.runtime_epoch) }, + { authority_kind: 'resource', authority_id: binding.id, version: authority.descriptor_digest }, + { authority_kind: 'policy', authority_id: binding.id, version: consentDigest }, + { authority_kind: 'provider_schema', authority_id: authority.provider_snapshot.id, + version: authority.provider_snapshot.snapshot_digest }, + ], + }); +} diff --git a/apps/api/test/app-resource-sync-admission-db.test.ts b/apps/api/test/app-resource-sync-admission-db.test.ts new file mode 100644 index 00000000..7dbacc2e --- /dev/null +++ b/apps/api/test/app-resource-sync-admission-db.test.ts @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import type { AppRunTransaction } from '../src/lib/app-run-repository.js'; + +const assigned = process.env.DATABASE_URL === process.env.DEFT_TEST_DATABASE_URL + && process.env.DATABASE_URL === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_phase5_test_s06_admission'; + +test('host sync admission atomically creates one due intent and refuses stale or repeated authority', { + skip: !assigned, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`s06-admission:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'admit-enc', keys: { 'admit-enc': key('enc') } }, + receipt_signing: { current: 'admit-sig', keys: { 'admit-sig': key('sig') } }, + fingerprint: { current: 'admit-fp', keys: { 'admit-fp': key('fp') } } }); + const [{ db, closeDb }, schema, { and, eq, sql }, fixture, keyrings, repositories, + runSecretModule, inputModule, syncSecretModule, admissionModule, runnerModule, + providers, queues, access, operations] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('./fixtures/resource-sync-v5.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-repository.js'), import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-secret-repository.js'), import('../src/lib/app-resource-sync-secrets.js'), + import('../src/lib/app-resource-sync-admission.js'), import('../src/lib/app-run-attempt-runner.js'), + import('../src/lib/app-run-provider-executor.js'), import('../src/lib/app-run-scheduler.js'), + import('../src/lib/app-run-authorization.js'), import('../src/lib/app-run-operations.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(process.env.DEFT_APP_RUN_KEYRINGS); + let checkedAt = new Date(); + const clock = () => new Date(checkedAt); + const marker = `sync-admission-${randomUUID()}`; + class TestRepository extends repositories.PostgresAppRunRepository { + override transaction(work: (tx: AppRunTransaction) => Promise): Promise { + return super.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('application_name', ${marker}, true)`); + await tx.execute(sql`SET LOCAL lock_timeout = '8s'`); + return work(tx); + }); + } + } + const repository = new TestRepository(); + const runSecrets = new runSecretModule.AppRunSecretService(keys); + const inputs = new inputModule.AppRunSecretRepository(runSecrets); + const syncSecrets = new syncSecretModule.AppResourceSyncSecretService(keys); + const runner = new runnerModule.AppRunAttemptRunner(repository, inputs, runSecrets, + new providers.PinnedMcpAppRunProviderExecutor(), undefined, clock, 60_000, + 20_000, undefined, undefined, queues.postgresAppRunAttemptQueue); + const admission = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + runSecrets, syncSecrets, runner, clock, () => true); + const denied = (error: unknown) => (error as { code?: string }).code === 'APP_ACCESS_DENIED'; + try { + const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const target = { org_id: owned.org_id, resource_binding_id: owned.binding_id }; + const disabled = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + runSecrets, syncSecrets, runner, clock); + await assert.rejects(disabled.admitDue(target), (error: unknown) => + (error as { code?: string }).code === 'APP_FEATURE_DISABLED'); + await assert.rejects(admission.admitDue({ ...target, owner_user_id: owned.owner_user_id })); + await assert.rejects(admission.admitDue({ ...target, org_id: randomUUID() }), denied); + const concurrent = await Promise.all([admission.admitDue(target), admission.admitDue(target)]); + assert.deepEqual(concurrent.map((result) => result.state).sort(), ['created', 'existing']); + const created = concurrent.find((result) => result.state === 'created'); + assert.ok(created && created.state === 'created'); + const existing = concurrent.find((result) => result.state === 'existing'); + assert.ok(existing && existing.state === 'existing'); + assert.equal(existing.run_id, created.run_id); + const runs = await db.select().from(schema.appRuns).where(eq(schema.appRuns.org_id, owned.org_id)); + assert.equal(runs.length, 1); + const run = runs[0]!; + assert.equal(run.origin_resource_binding_id, owned.binding_id); + assert.equal(run.origin_runtime_binding_id, null); + assert.equal(run.execution_actor_type, 'system'); + assert.equal(run.execution_actor_id, owned.binding_id); + assert.equal(run.initiating_actor_id, owned.binding_id); + assert.equal(run.review_scope, 'reviewed_resource_sync'); + assert.equal(run.state, 'pending'); + assert.equal(run.execution_release_kind, 'policy_satisfied'); + assert.equal(run.origin_app_grant_snapshot_id, owned.grant_snapshot_id); + const [binding] = await db.select().from(schema.appResourceBindings) + .where(eq(schema.appResourceBindings.id, owned.binding_id)); + assert.ok(binding); + assert.deepEqual(await inputs.readInput(owned.org_id, run.id), { + schema_version: 'deft.app_sync_request.v1', cursor: null, + max_items: binding.max_records_per_page, + }); + const intents = await db.select().from(schema.appSyncIntents) + .where(eq(schema.appSyncIntents.org_id, owned.org_id)); + assert.equal(intents.length, 1); + assert.equal(intents[0]!.checkpoint_id, owned.checkpoint_id); + assert.equal(intents[0]!.expected_cursor_sequence, 0); + assert.equal(intents[0]!.owner_user_id, owned.owner_user_id); + assert.equal(intents[0]!.descriptor_digest, binding.descriptor_digest); + const attempts = await db.select().from(schema.appRunAttempts) + .where(eq(schema.appRunAttempts.org_id, owned.org_id)); + assert.equal(attempts.length, 1); + assert.equal(attempts[0]!.id, created.attempt_id); + const jobs = await db.select().from(schema.jobQueue).where(and( + eq(schema.jobQueue.org_id, owned.org_id), + eq(schema.jobQueue.dedupe_key, `app-run-attempt:${created.attempt_id}`))); + assert.equal(jobs.length, 1, 'attempt queue is committed with Run/input/intent'); + assert.equal(run.safe_preview.title, 'Sync private App resource'); + assert.deepEqual(run.safe_preview.resource_refs, []); + const events = await db.select().from(schema.appRunEvents) + .where(eq(schema.appRunEvents.run_id, run.id)); + assert.deepEqual(events.map((event) => event.event_type).sort(), ['attempt_created', 'run_created']); + const safeRun = await repository.inspect(owned.org_id, run.id); + assert.ok(safeRun); + const authorizer = new access.PostgresAppRunAuthorizer(); + for (const action of ['inspect', 'result'] as const) { + assert.equal(await authorizer.authorize({ action, org_id: owned.org_id, + actor: { actor_type: 'human', user_id: owned.owner_user_id }, run: safeRun, + required_authority_ref: null }), false, 'ordinary human Run entrance cannot read system sync'); + } + const ops = new operations.AppRunOperationsService(repository, + operations.postgresAppRunReadOperationsAuthorizer); + const operational = await ops.list({ org_id: owned.org_id, + actor: { actor_type: 'human', user_id: owned.owner_user_id } }); + assert.equal(operational.length, 1); + const safeJson = JSON.stringify(operational); + assert.ok(!safeJson.includes(owned.owner_user_id) && !safeJson.includes(owned.operator_user_id)); + for (const privateField of ['authorization_snapshot', 'cursor', 'input', 'output', + 'reviewed_descriptor', 'owner_user_id', 'record_schema']) { + assert.equal(privateField in operational[0]!, false); + } + // Operational managers retain generic infrastructure IDs and state only. + assert.equal(operational[0]!.initiating_actor_id, owned.binding_id); + assert.equal(operational[0]!.execution_actor_id, owned.binding_id); + await assert.rejects(ops.list({ org_id: owned.org_id, + actor: { actor_type: 'human', user_id: owned.operator_user_id } })); + + const rotatedEnvironment = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + rotatedEnvironment.fingerprint.current = 'admit-fp2'; + rotatedEnvironment.fingerprint.keys['admit-fp2'] = key('fp2'); + const rotatedKeys = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify(rotatedEnvironment)); + try { + const rotatedRunSecrets = new runSecretModule.AppRunSecretService(rotatedKeys); + const rotatedAdmission = new admissionModule.AppResourceSyncAdmissionService(repository, + new inputModule.AppRunSecretRepository(rotatedRunSecrets), rotatedRunSecrets, + new syncSecretModule.AppResourceSyncSecretService(rotatedKeys), runner, clock, () => true); + assert.deepEqual(await rotatedAdmission.admitDue(target), { state: 'existing', run_id: run.id }); + } finally { rotatedKeys.destroy(); } + + // Cancel through the existing transition seam. The checkpoint is still + // unchanged; a terminal intent must not silently create another Run. + await repository.transaction(async (tx) => { + const current = await repository.lockRun(tx, owned.org_id, run.id); + assert.ok(current); + await tx.update(schema.appRunAttempts).set({ state: 'cancelled', updated_at: clock() }) + .where(eq(schema.appRunAttempts.id, created.attempt_id)); + await repository.transition(tx, { run: current, state: 'cancelled', now: clock() }); + }); + assert.deepEqual(await admission.admitDue(target), { + state: 'blocked', reason: 'cursor_requires_recovery', + }); + + const rollbackFixture = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const missingEnvironment = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + missingEnvironment.fingerprint = { current: 'admit-fp2', keys: { 'admit-fp2': key('fp2') } }; + const missingKeys = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify(missingEnvironment)); + try { + const missingSecrets = new runSecretModule.AppRunSecretService(missingKeys); + const missingAdmission = new admissionModule.AppResourceSyncAdmissionService(repository, + new inputModule.AppRunSecretRepository(missingSecrets), missingSecrets, + new syncSecretModule.AppResourceSyncSecretService(missingKeys), runner, clock, () => true); + await assert.rejects(missingAdmission.admitDue({ org_id: rollbackFixture.org_id, + resource_binding_id: rollbackFixture.binding_id }), (error: unknown) => + (error as { code?: string }).code === 'APP_RUN_KEY_VERSION_UNAVAILABLE'); + assert.equal((await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, rollbackFixture.org_id))).length, 0); + } finally { missingKeys.destroy(); } + const injected = new Error('fault after durable attempt and queue insertion'); + const rollbackAdmission = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + runSecrets, syncSecrets, { async scheduleResourceSyncInTransaction(tx, current, now) { + assert.ok(await runner.scheduleResourceSyncInTransaction(tx, current, now)); + throw injected; + } }, clock, () => true); + await assert.rejects(rollbackAdmission.admitDue({ org_id: rollbackFixture.org_id, + resource_binding_id: rollbackFixture.binding_id }), (error) => error === injected); + for (const table of [schema.appRuns, schema.appRunAttempts, schema.appRunSecretPayloads, + schema.appSyncIntents, schema.appRunEvents, schema.jobQueue]) { + assert.equal((await db.select({ id: table.id }).from(table) + .where(eq(table.org_id, rollbackFixture.org_id))).length, 0, + 'failed admission leaves no Run/input/intent/attempt/event/queue rows'); + } + + // Prove the post-checkpoint-lock clock is authoritative, not a timestamp + // captured before waiting. Observe PostgreSQL's actual lock wait. + const expiring = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + let unlock!: () => void; + let locked!: () => void; + const lockReady = new Promise((resolve) => { locked = resolve; }); + const release = new Promise((resolve) => { unlock = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.select().from(schema.appSyncCheckpoints) + .where(eq(schema.appSyncCheckpoints.id, expiring.checkpoint_id)).for('update'); + locked(); + await release; + }); + await lockReady; + const pending = assert.rejects(admission.admitDue({ org_id: expiring.org_id, + resource_binding_id: expiring.binding_id }), denied); + try { + let waiting = false; + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const result = await db.execute(sql`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE application_name = ${marker} AND wait_event_type = 'Lock'`); + if (Number((result as { rows: Array<{ count: number }> }).rows[0]?.count) > 0) { + waiting = true; + break; + } + await delay(10); + } + assert.ok(waiting, 'admission must actually wait on the locked checkpoint'); + checkedAt = new Date(checkedAt.getTime() + 91 * 24 * 60 * 60 * 1_000); + } finally { unlock(); await holder; } + await pending; + assert.equal((await db.select({ id: schema.appRuns.id }).from(schema.appRuns) + .where(eq(schema.appRuns.org_id, expiring.org_id))).length, 0); + } finally { keys.destroy(); await closeDb(); } +}); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 20ddaecc..fc80e900 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -740,6 +740,22 @@ { "file": "apps/api/test/app-resource-authoring-review-http-db.test.ts", "name": "authenticated HTTP v5 review and activation reject foreign and stale requests" } ] }, + { + "id": "resource-sync-consent", + "description": "Reviewed private consent and audience-separated operator sessions with current owner/App/grant checks, bounded authority, actual lock waits and strict duplicate/foreign/spoofed input rejection.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-consent-db.test.ts", "name": "owner-reviewed v2 consent pins current App, provider and session authority" }, + { "file": "apps/api/test/app-resource-sync-consent-db.test.ts", "name": "session issuance rechecks owner, deadlines and App ancestry after blocking locks" }, + { "file": "apps/api/test/app-resource-sync-consent-db.test.ts", "name": "consent input cannot spoof a manager, stale pin, other tenant or second current binding" } + ] + }, + { + "id": "resource-sync-admission", + "description": "Real reviewed private consent and host-only Run/input/intent/attempt/queue admission, concurrent dedupe, rollback, key rotation, metadata boundaries and expiry after a checkpoint lock wait.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-admission-db.test.ts", "name": "host sync admission atomically creates one due intent and refuses stale or repeated authority" } + ] + }, { "id": "fresh-upgrade-ledger", "description": "Explicit four-database synthetic profile proving current fresh history, advanced untracked rejection before mutation, interrupted fresh refusal and supported baseline adoption.", From 158f03fe1c2e4c9a7025b15a1708497292c60d9b Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:01:09 +0530 Subject: [PATCH 023/161] Add guarded resource sync channel settlement --- apps/api/src/index.ts | 4 + apps/api/src/lib/app-resource-sync-channel.ts | 98 ++++ apps/api/src/lib/app-run-attempt-runner.ts | 441 +++++++++++++++++- apps/api/src/lib/app-run-runtime.ts | 16 + apps/api/src/lib/env.ts | 6 + .../middleware/app-resource-sync-limits.ts | 89 ++++ .../src/routes/app-resource-sync-channel.ts | 123 +++++ .../test/app-resource-sync-channel-db.test.ts | 358 ++++++++++++++ ...app-resource-sync-channel-disabled.test.ts | 45 ++ 9 files changed, 1177 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-channel.ts create mode 100644 apps/api/src/middleware/app-resource-sync-limits.ts create mode 100644 apps/api/src/routes/app-resource-sync-channel.ts create mode 100644 apps/api/test/app-resource-sync-channel-db.test.ts create mode 100644 apps/api/test/app-resource-sync-channel-disabled.test.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0a828f1e..76afa6bd 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -75,6 +75,8 @@ import { appActionRoutes } from './routes/app-actions.js'; import { appRunRoutes } from './routes/app-runs.js'; import { appDeveloperRoutes } from './routes/app-developer.js'; import { appRuntimeChannelRoutes } from './routes/app-runtime-channel.js'; +import { appResourceSyncChannelRoutes } from './routes/app-resource-sync-channel.js'; +import { appResourceSyncLimits } from './middleware/app-resource-sync-limits.js'; import { appRuntimeManagementRoutes } from './routes/app-runtime-management.js'; import { appRuntimeReviewRoutes } from './routes/app-runtime-review.js'; import { appRuntimeActionRoutes } from './routes/app-runtime-actions.js'; @@ -197,6 +199,8 @@ if (APP_DEVELOPER_PAIRING_ENABLED) { if (APPS_ENABLED) { app.use('/api/app-runtime/channel/*', authLimiter); app.route('/api/app-runtime/channel', appRuntimeChannelRoutes); + app.use('/api/app-resource-sync/channel/*', appResourceSyncLimits); + app.route('/api/app-resource-sync/channel', appResourceSyncChannelRoutes); } if (APPS_ENABLED && process.env.DEFT_APP_PUBLIC_INGRESS_ENABLED === 'true') { app.route('/api/public/apps', createAppPublicRoutes(new AppPublicClaimService({ enabled: true }))); diff --git a/apps/api/src/lib/app-resource-sync-channel.ts b/apps/api/src/lib/app-resource-sync-channel.ts new file mode 100644 index 00000000..d5e624de --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-channel.ts @@ -0,0 +1,98 @@ +import { and, asc, eq, gt, isNotNull, isNull } from 'drizzle-orm'; +import { appRunAttempts, appRuns, appRuntimeSessions } from '@deft/db/schema'; +import { db } from './db.js'; +import { isAppResourceSyncChannelEnabled } from './env.js'; +import type { AppRunAttemptRunner } from './app-run-attempt-runner.js'; +import { hashAppResourceSyncToken } from './app-resource-sync-policy.js'; +import { + AppResourceSyncClaimRequestSchema, AppResourceSyncHeartbeatRequestSchema, + AppResourceSyncResultRequestSchema, AppResourceSyncStartRequestSchema, +} from './app-resource-sync-contract.js'; + +/** Independent v2 rollout: a v1 action session cannot address this channel. */ +export function appResourceSyncChannelEnabled(): boolean { + return isAppResourceSyncChannelEnabled(); +} + +export class AppResourceSyncChannel { + constructor(private readonly runner: AppRunAttemptRunner) {} + + async claim(value: unknown) { + if (!appResourceSyncChannelEnabled()) return null; + const request = AppResourceSyncClaimRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return null; + const candidates = await db.select({ + run_id: appRunAttempts.run_id, attempt_id: appRunAttempts.id, + }).from(appRunAttempts).innerJoin(appRuns, and( + eq(appRuns.org_id, appRunAttempts.org_id), eq(appRuns.id, appRunAttempts.run_id), + )).where(and( + eq(appRunAttempts.org_id, identity.org_id), eq(appRunAttempts.state, 'pending'), + eq(appRuns.origin_kind, 'app'), eq(appRuns.provider_kind, 'app_runtime'), + eq(appRuns.origin_resource_binding_id, identity.resource_binding_id), + eq(appRuns.review_scope, 'reviewed_resource_sync'), + isNull(appRuns.origin_runtime_binding_id), + isNotNull(appRuns.execution_released_at), gt(appRuns.input_expires_at, new Date()), + )).orderBy(asc(appRunAttempts.created_at)).limit(8); + for (const candidate of candidates) { + const claimed = await this.runner.claimResourceSyncAttempt({ + org_id: identity.org_id, run_id: candidate.run_id, + attempt_id: candidate.attempt_id, session_id: request.session_id, + token_hash: identity.token_hash, + }); + if (claimed) return claimed; + } + return null; + } + + async start(value: unknown) { + if (!appResourceSyncChannelEnabled()) return null; + const request = AppResourceSyncStartRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return null; + return this.runner.startResourceSyncAttempt({ + org_id: identity.org_id, run_id: request.run_id, attempt_id: request.attempt_id, + session_id: request.session_id, token_hash: identity.token_hash, + claim_token: request.claim_token, sequence: request.sequence, + }); + } + + async heartbeat(value: unknown) { + if (!appResourceSyncChannelEnabled()) return null; + const request = AppResourceSyncHeartbeatRequestSchema.parse(value); + const identity = await this.#session(request.session_id, request.session_token); + if (!identity) return null; + return this.runner.heartbeatResourceSyncAttempt({ + org_id: identity.org_id, run_id: request.run_id, attempt_id: request.attempt_id, + session_id: request.session_id, token_hash: identity.token_hash, + claim_token: request.claim_token, sequence: request.sequence, + }); + } + + async complete(value: unknown) { + if (!appResourceSyncChannelEnabled()) return null; + const result = AppResourceSyncResultRequestSchema.parse(value); + const identity = await this.#session(result.session_id, result.session_token); + if (!identity) return null; + return this.runner.completeResourceSyncAttempt({ + org_id: identity.org_id, token_hash: identity.token_hash, result, + }); + } + + async #session(sessionId: string, token: string): Promise | null> { + const tokenHash = hashAppResourceSyncToken(token); + const [session] = await db.select({ + org_id: appRuntimeSessions.org_id, + resource_binding_id: appRuntimeSessions.resource_binding_id, + token_hash: appRuntimeSessions.token_hash, + }).from(appRuntimeSessions).where(and( + eq(appRuntimeSessions.id, sessionId), eq(appRuntimeSessions.token_hash, tokenHash), + eq(appRuntimeSessions.audience, 'app_resource_sync'), + isNull(appRuntimeSessions.runtime_binding_id), + )).limit(1); + if (!session?.resource_binding_id) return null; + return { ...session, resource_binding_id: session.resource_binding_id }; + } +} diff --git a/apps/api/src/lib/app-run-attempt-runner.ts b/apps/api/src/lib/app-run-attempt-runner.ts index 86325c13..5798a2ca 100644 --- a/apps/api/src/lib/app-run-attempt-runner.ts +++ b/apps/api/src/lib/app-run-attempt-runner.ts @@ -1,8 +1,9 @@ import { createHash } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; -import { appRunAttempts, appRuntimeSessions } from '@deft/db/schema'; +import { appRunAttempts, appRuns, appRuntimeSessions, appSyncCheckpoints, appSyncIntents } from '@deft/db/schema'; import { parseRuntimeObjectInput } from '@deft/app-kit'; +import { parseSyncRequest } from '@deft/app-kit/experimental/resource-sync'; import { APP_RUN_CONTRACT_VERSIONS, AppRunRetainedProviderResultSchema, @@ -13,6 +14,7 @@ import { type AppRunSafeOutcome, } from '@deft/shared'; import { db } from './db.js'; +import { isAppResourceSyncChannelEnabled } from './env.js'; import { denyAllAppRunExecutionAuthorizer, type AppRunExecutionAuthorizer, @@ -36,6 +38,15 @@ import { import { AppRunSecretRepository } from './app-run-secret-repository.js'; import type { AppRunSecretService } from './app-run-secrets.js'; import { loadLiveRuntimeAuthority, runtimeRunMatchesAuthority } from './app-runtime-authority.js'; +import { + loadLiveResourceSyncAuthority, loadLiveResourceSyncBindingAuthority, + type LiveResourceSyncAuthority, type LiveResourceSyncBindingAuthority, +} from './app-resource-sync-authority.js'; +import { buildResourceSyncAuthorizationSnapshot } from './app-resource-sync-authorization.js'; +import { AppResourceSyncStore } from './app-resource-sync-store.js'; +import { parseAppResourceSyncResult, APP_RESOURCE_SYNC_AUDIENCE, + APP_RESOURCE_SYNC_CHANNEL_VERSION } from './app-resource-sync-contract.js'; +import type { ResourceSyncResultRequest } from '@deft/app-kit/experimental/resource-sync'; import { APP_RUNTIME_CHANNEL_VERSION, type AppRuntimeClaimEnvelope, type AppRuntimeResultRequest, type AppRuntimeStartEnvelope, @@ -84,8 +95,55 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { private readonly receiptWriter: AppRunReceiptWriter = noOpAppRunReceiptWriter, private readonly attention: AppRunAttentionProjector = noOpAppRunAttentionProjector, private readonly attemptQueue: AppRunAttemptQueue = noOpAppRunAttemptQueue, + private readonly resourceSyncStore: AppResourceSyncStore | null = null, ) {} + /** A v2 Run is host-created for one reviewed private binding. The live + * authority reader owns the mutable membership/App/consent locks; this + * comparison binds that authority to the immutable Run and intent. */ + async #resourceSyncRunMatchesAuthority( + tx: AppRunTransaction, run: AppRunSafeView, + authority: LiveResourceSyncBindingAuthority, + ): Promise { + const [stored] = await tx.select().from(appRuns).where(and( + eq(appRuns.org_id, run.org_id), eq(appRuns.id, run.id), + )).limit(1); + const { binding, registration, installation, version, grant, provider_snapshot } = authority; + if (!stored || stored.origin_kind !== 'app' || stored.provider_kind !== 'app_runtime' + || stored.origin_resource_binding_id !== binding.id + || stored.origin_runtime_binding_id !== null + || stored.initiating_actor_type !== 'system' || stored.execution_actor_type !== 'system' + || stored.initiating_actor_id !== binding.id || stored.execution_actor_id !== binding.id + || stored.origin_app_installation_id !== installation.id + || stored.origin_app_version_id !== version.id + || stored.origin_app_grant_snapshot_id !== grant.id + || stored.provider_instance_id !== registration.id + || stored.provider_snapshot_id !== provider_snapshot.id + || stored.operation_name !== binding.operation_name + || stored.risk_class !== binding.risk_class + || stored.review_requirement !== binding.review_requirement + || stored.review_scope !== binding.review_scope + || stored.retry_class !== binding.retry_class + || stored.retention_class !== binding.retention_class + || stored.review_scope !== 'reviewed_resource_sync' + || stored.retry_class !== 'unsafe_or_unknown') return null; + try { + if (canonicalCapabilityJson(stored.authorization_snapshot) + !== canonicalCapabilityJson(buildResourceSyncAuthorizationSnapshot(authority))) return null; + } catch { return null; } + const [intent] = await tx.select().from(appSyncIntents).where(and( + eq(appSyncIntents.org_id, run.org_id), eq(appSyncIntents.run_id, run.id), + )).limit(1); + if (!intent || intent.resource_binding_id !== binding.id + || intent.app_installation_id !== installation.id + || intent.app_version_id !== version.id + || intent.grant_snapshot_id !== grant.id + || intent.provider_snapshot_id !== provider_snapshot.id + || intent.owner_user_id !== binding.owner_user_id + || intent.descriptor_digest !== authority.descriptor_digest) return null; + return intent; + } + async run( orgId: string, runId: string, @@ -193,7 +251,10 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { const now = this.now(); return this.repository.transaction(async (tx) => { const run = await this.repository.lockRun(tx, orgId, runId); - return run ? this.scheduleInTransaction(tx, run, now) : null; + if (!run) return null; + return run.review_scope === 'reviewed_resource_sync' + ? this.scheduleResourceSyncInTransaction(tx, run, now) + : this.scheduleInTransaction(tx, run, now); }); } @@ -280,6 +341,65 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { return this.renewLease(input.org_id, input.attempt_id, input.claim_token, input); } + async heartbeatResourceSyncAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; session_id: string; + token_hash: string; claim_token: string; sequence: number; + }>) { + if (!isAppResourceSyncChannelEnabled()) return null; + return this.repository.transaction(async (tx) => { + const run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.review_scope !== 'reviewed_resource_sync' + || run.provider_kind !== 'app_runtime') return null; + const authority = await loadLiveResourceSyncAuthority(tx, { org_id: input.org_id, + session_id: input.session_id, token_hash: input.token_hash, clock: this.now }); + if (!authority) return null; + const intent = await this.#resourceSyncRunMatchesAuthority(tx, run, authority); + if (!intent) return null; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${input.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, input.attempt_id), + eq(appRunAttempts.run_id, input.run_id), + )).limit(1); + // Do not release a cursor already displaced by a prior page or host + // maintenance. This lock follows the attempt and precedes any effect. + await tx.execute(sql`SELECT id FROM app_sync_checkpoints WHERE org_id = ${input.org_id} + AND id = ${intent.checkpoint_id} FOR SHARE`); + const [checkpoint] = await tx.select().from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, input.org_id), + eq(appSyncCheckpoints.id, intent.checkpoint_id), + eq(appSyncCheckpoints.resource_binding_id, authority.binding.id), + )).limit(1); + const now = this.now(); + if (!checkpoint || checkpoint.state !== 'active' + || checkpoint.generation !== intent.generation + || checkpoint.cursor_sequence !== intent.expected_cursor_sequence + || checkpoint.cursor_hmac_key_version !== intent.expected_cursor_hmac_key_version + || checkpoint.cursor_hmac !== intent.expected_cursor_hmac) return null; + if (!attempt || !['claimed', 'provider_call_started'].includes(attempt.state) + || attempt.claim_token !== input.claim_token + || attempt.runtime_session_id !== authority.session.id + || attempt.resource_binding_id !== authority.binding.id + || attempt.runtime_session_epoch !== authority.session.session_epoch + || attempt.runtime_epoch !== authority.registration.runtime_epoch + || attempt.runtime_sequence !== input.sequence + || !attempt.lease_expires_at || attempt.lease_expires_at <= now + || run.cancel_requested_at || !run.execution_released_at + || !['pending', 'running'].includes(run.state) + || run.input_expires_at <= now || authority.binding.consent_expires_at! <= now + || authority.session.expires_at <= now) return null; + const leaseExpiresAt = new Date(now.getTime() + boundedLeaseMs(this.leaseMs)); + const [renewed] = await tx.update(appRunAttempts).set({ + lease_expires_at: leaseExpiresAt, updated_at: now, + }).where(and(eq(appRunAttempts.org_id, input.org_id), + eq(appRunAttempts.id, attempt.id), + eq(appRunAttempts.claim_token, input.claim_token))).returning({ id: appRunAttempts.id }); + if (!renewed) return null; + return Object.freeze({ run_id: input.run_id, attempt_id: input.attempt_id, + sequence: input.sequence, lease_expires_at: leaseExpiresAt.toISOString() }); + }); + } + async completeRuntimeAttempt(input: Readonly<{ org_id: string; token_hash: string; result: AppRuntimeResultRequest; }>): Promise { @@ -407,6 +527,317 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { return (await this.#createAttempt(tx, run, now))?.id ?? null; } + async completeResourceSyncAttempt(input: Readonly<{ + org_id: string; token_hash: string; result: ResourceSyncResultRequest; + }>) { + const store = this.resourceSyncStore; + if (!isAppResourceSyncChannelEnabled() || !store) return null; + const result = input.result; + const fingerprintValue = `deft.app_resource_sync.result.v2:${createHash('sha256') + .update(canonicalCapabilityJson(result)).digest('hex')}`; + const fingerprintCandidates = this.secrets.fingerprintTextCandidates('idempotency', fingerprintValue); + const replayDigests = new Set(fingerprintCandidates.map((candidate) => candidate.fingerprint)); + const accepted = await this.repository.transaction(async (tx) => { + const run = await this.repository.lockRun(tx, input.org_id, result.run_id); + if (!run || run.review_scope !== 'reviewed_resource_sync' + || run.provider_kind !== 'app_runtime') return false; + const [runKey] = await tx.select({ key_version: appRuns.idempotency_key_version }) + .from(appRuns).where(and(eq(appRuns.org_id, input.org_id), + eq(appRuns.id, result.run_id))).limit(1); + const digest = fingerprintCandidates.find((candidate) => + candidate.key_version === runKey?.key_version)?.fingerprint; + // Run idempotency retention already pins this purpose/key version. + if (!digest) return false; + const authority = await loadLiveResourceSyncAuthority(tx, { org_id: input.org_id, + session_id: result.session_id, token_hash: input.token_hash, clock: this.now }); + if (!authority) return false; + const intent = await this.#resourceSyncRunMatchesAuthority(tx, run, authority); + if (!intent) return false; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${result.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, result.attempt_id), + eq(appRunAttempts.run_id, result.run_id), + )).limit(1); + if (!attempt || attempt.claim_token !== result.claim_token + || attempt.runtime_session_id !== authority.session.id + || attempt.resource_binding_id !== authority.binding.id + || attempt.runtime_session_epoch !== authority.session.session_epoch + || attempt.runtime_epoch !== authority.registration.runtime_epoch + || attempt.runtime_sequence !== result.sequence) return false; + // A committed callback is accepted byte-for-byte without revisiting the + // page store. Retained fingerprint keys permit a key rotation replay. + if (attempt.runtime_result_hmac) return replayDigests.has(attempt.runtime_result_hmac); + const now = this.now(); + if (attempt.state !== 'provider_call_started' || !attempt.lease_expires_at + || attempt.lease_expires_at <= now || run.state !== 'running' + || run.cancel_requested_at || run.input_expires_at <= now + || authority.binding.consent_expires_at! <= now + || authority.session.expires_at <= now) return false; + if (result.status === 'indeterminate') { + await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); + return true; + } + let outcome: AppRunSafeOutcome; + let receiptFacts: Record | undefined; + if (result.status === 'returned' && result.provider_succeeded) { + const rawInput = await this.secretRepository.readInput(input.org_id, run.id, tx); + let page: Extract['page']; + try { + const parsed = parseAppResourceSyncResult(result, { + descriptor: authority.descriptor, starting_request: parseSyncRequest(rawInput), + }); + if (parsed.status !== 'returned' || !parsed.provider_succeeded) return false; + page = parsed.page; + } catch { + // The provider may already have observed an external source effect. + // An invalid page is never signed as success or automatically retried. + await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); + return true; + } + if (run.result_expires_at <= now) { + await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); + return true; + } + const applied = await store.applyPageInTransaction(tx, { + org_id: input.org_id, run_id: run.id, attempt_id: attempt.id, + page, clock: this.now, + }); + const finalClock = this.now(); + if (attempt.lease_expires_at <= finalClock || authority.session.expires_at <= finalClock + || authority.binding.consent_expires_at! <= finalClock + || run.input_expires_at <= finalClock || run.result_expires_at <= finalClock) { + // A page has already been written in this transaction. Returning + // false would commit it without output, terminal Run or receipt. + throw new Error('APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED'); + } + const envelope = AppRunRetainedProviderResultSchema.parse({ + schema_version: APP_RUN_CONTRACT_VERSIONS.provider_result, + provider_succeeded: true, output: page, + }); + assertAppRunOutputWithinBudget(envelope); + await this.secretRepository.insertOutput(tx, { + org_id: input.org_id, run_id: run.id, attempt_id: attempt.id, + value: envelope, expires_at: run.result_expires_at, + }); + outcome = AppRunSafeOutcomeSchema.parse({ success: true, + provider_call_attempted: true, result_status: 'retained' }); + receiptFacts = { resource_binding_id: authority.binding.id, + checkpoint_id: intent.checkpoint_id, page_digest: applied.page_digest, + cursor_sequence: applied.applied_sequence }; + } else if (result.status === 'returned') { + outcome = AppRunSafeOutcomeSchema.parse({ success: false, + provider_call_attempted: true, result_status: 'unavailable', + error_code: 'APP_RUN_PROVIDER_ERROR' }); + } else { + outcome = AppRunSafeOutcomeSchema.parse({ success: false, + provider_call_attempted: false, result_status: 'unavailable', + error_code: result.error_code }); + } + const finalClock = this.now(); + if (attempt.lease_expires_at <= finalClock || authority.session.expires_at <= finalClock + || authority.binding.consent_expires_at! <= finalClock + || run.input_expires_at <= finalClock) { + throw new Error('APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED'); + } + await tx.update(appRunAttempts).set({ provider_call_finished_at: finalClock, + safe_outcome: outcome, runtime_result_hmac: digest, updated_at: finalClock, + }).where(and(eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, attempt.id))); + await this.#finalizeKnownInTransaction(tx, run, { ...attempt, + provider_call_finished_at: finalClock, safe_outcome: outcome }, finalClock, receiptFacts); + const committedAt = this.now(); + if (attempt.lease_expires_at <= committedAt || authority.session.expires_at <= committedAt + || authority.binding.consent_expires_at! <= committedAt + || run.input_expires_at <= committedAt || run.result_expires_at <= committedAt) { + // Includes page, output and receipt writes: abort the whole transaction. + throw new Error('APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED'); + } + return true; + }); + if (!accepted) return null; + return Object.freeze({ run_id: result.run_id, attempt_id: result.attempt_id, + sequence: result.sequence }); + } + + async startResourceSyncAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; session_id: string; + token_hash: string; claim_token: string; sequence: number; + }>) { + if (!isAppResourceSyncChannelEnabled()) return null; + return this.repository.transaction(async (tx) => { + let run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.review_scope !== 'reviewed_resource_sync' + || run.provider_kind !== 'app_runtime') return null; + const authority = await loadLiveResourceSyncAuthority(tx, { org_id: input.org_id, + session_id: input.session_id, token_hash: input.token_hash, clock: this.now }); + if (!authority) return null; + const intent = await this.#resourceSyncRunMatchesAuthority(tx, run, authority); + if (!intent) return null; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${input.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, input.attempt_id), + eq(appRunAttempts.run_id, input.run_id), + )).limit(1); + await tx.execute(sql`SELECT id FROM app_sync_checkpoints WHERE org_id = ${input.org_id} + AND id = ${intent.checkpoint_id} FOR SHARE`); + const [checkpoint] = await tx.select().from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, input.org_id), + eq(appSyncCheckpoints.id, intent.checkpoint_id), + eq(appSyncCheckpoints.resource_binding_id, authority.binding.id), + )).limit(1); + const now = this.now(); + if (!checkpoint || checkpoint.state !== 'active' + || checkpoint.generation !== intent.generation + || checkpoint.cursor_sequence !== intent.expected_cursor_sequence + || checkpoint.cursor_hmac_key_version !== intent.expected_cursor_hmac_key_version + || checkpoint.cursor_hmac !== intent.expected_cursor_hmac + || !attempt || !['claimed', 'provider_call_started'].includes(attempt.state) + || attempt.claim_token !== input.claim_token + || attempt.runtime_session_id !== authority.session.id + || attempt.resource_binding_id !== authority.binding.id + || attempt.runtime_session_epoch !== authority.session.session_epoch + || attempt.runtime_epoch !== authority.registration.runtime_epoch + || attempt.runtime_sequence !== input.sequence + || !attempt.lease_expires_at || attempt.lease_expires_at <= now + || !run.execution_released_at || run.cancel_requested_at + || !['pending', 'running'].includes(run.state) + || run.input_expires_at <= now || authority.binding.consent_expires_at! <= now + || authority.session.expires_at <= now) return null; + const rawInput = await this.secretRepository.readInput(input.org_id, run.id, tx); + let request: ReturnType; + try { request = parseSyncRequest(rawInput); } + catch { return null; } + if (request.max_items > authority.binding.max_records_per_page) return null; + if (attempt.state === 'claimed') { + const [started] = await tx.update(appRunAttempts).set({ state: 'provider_call_started', + provider_call_started_at: now, updated_at: now }).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, attempt.id), + eq(appRunAttempts.claim_token, input.claim_token), + eq(appRunAttempts.state, 'claimed'), + )).returning({ id: appRunAttempts.id }); + if (!started) return null; + if (run.state === 'pending') run = await this.repository.transition(tx, { + run, state: 'running', now, + }); + await this.repository.appendEvent(tx, { id: crypto.randomUUID(), + org_id: input.org_id, run_id: run.id, event_type: 'provider_call_started', + payload: { attempt_id: attempt.id }, now }); + } + const checkedAt = this.now(); + if (attempt.lease_expires_at <= checkedAt || run.input_expires_at <= checkedAt + || authority.binding.consent_expires_at! <= checkedAt + || authority.session.expires_at <= checkedAt) { + if (attempt.state === 'claimed') throw new Error('APP_RESOURCE_SYNC_START_EXPIRED'); + return null; + } + return Object.freeze({ schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE, work_kind: 'sync_page' as const, + resource_binding_id: authority.binding.id, run_id: run.id, + attempt_id: attempt.id, sequence: input.sequence, + lease_expires_at: attempt.lease_expires_at.toISOString(), + descriptor_digest: authority.descriptor_digest, + descriptor: authority.descriptor, input: request }); + }); + } + + async claimResourceSyncAttempt(input: Readonly<{ + org_id: string; run_id: string; attempt_id: string; + session_id: string; token_hash: string; + }>) { + if (!isAppResourceSyncChannelEnabled()) return null; + await this.recoverRun(input.org_id, input.run_id, input.attempt_id); + return this.repository.transaction(async (tx) => { + const run = await this.repository.lockRun(tx, input.org_id, input.run_id); + if (!run || run.review_scope !== 'reviewed_resource_sync' + || run.provider_kind !== 'app_runtime') return null; + const authority = await loadLiveResourceSyncAuthority(tx, { org_id: input.org_id, + session_id: input.session_id, token_hash: input.token_hash, clock: this.now }); + if (!authority || !await this.#resourceSyncRunMatchesAuthority(tx, run, authority)) return null; + await tx.execute(sql`SELECT id FROM app_run_attempts WHERE org_id = ${input.org_id} + AND id = ${input.attempt_id} FOR UPDATE`); + const [attempt] = await tx.select().from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, input.attempt_id), + eq(appRunAttempts.run_id, run.id), + )).limit(1); + const now = this.now(); + if (!run.execution_released_at || run.cancel_requested_at + || !['pending', 'running'].includes(run.state) + || run.input_expires_at <= now || authority.binding.consent_expires_at! <= now + || authority.session.expires_at <= now) return null; + if (!attempt || attempt.state !== 'pending') return null; + const [session] = await tx.update(appRuntimeSessions).set({ + next_sequence: sql`${appRuntimeSessions.next_sequence} + 1`, updated_at: now, + }).where(and(eq(appRuntimeSessions.org_id, input.org_id), + eq(appRuntimeSessions.id, authority.session.id))) + .returning({ next_sequence: appRuntimeSessions.next_sequence }); + if (!session) return null; + const sequence = session.next_sequence - 1; + const leaseExpiresAt = new Date(now.getTime() + boundedLeaseMs(this.leaseMs)); + const [claimed] = await tx.update(appRunAttempts).set({ + state: 'claimed', claim_owner: `app_resource_sync:${input.session_id}`, + claim_token: crypto.randomUUID(), resource_binding_id: authority.binding.id, + runtime_session_id: authority.session.id, + runtime_session_epoch: authority.session.session_epoch, + runtime_epoch: authority.registration.runtime_epoch, + runtime_sequence: sequence, + claimed_at: now, lease_expires_at: leaseExpiresAt, updated_at: now, + }).where(and(eq(appRunAttempts.org_id, input.org_id), + eq(appRunAttempts.id, attempt.id), eq(appRunAttempts.state, 'pending'))).returning(); + if (!claimed?.claim_token) return null; + await this.repository.appendEvent(tx, { id: crypto.randomUUID(), org_id: input.org_id, + run_id: run.id, event_type: 'attempt_claimed', + payload: { attempt_id: claimed.id }, now }); + return Object.freeze({ schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE, work_kind: 'sync_page' as const, + org_id: input.org_id, app_installation_id: authority.installation.id, + app_version_id: authority.version.id, grant_snapshot_id: authority.grant.id, + lifecycle_epoch: authority.installation.lifecycle_epoch, + grant_epoch: authority.installation.grant_epoch, + runtime_registration_id: authority.registration.id, + resource_binding_id: authority.binding.id, + runtime_epoch: authority.registration.runtime_epoch, + session_id: authority.session.id, session_epoch: authority.session.session_epoch, + run_id: run.id, attempt_id: claimed.id, attempt_number: claimed.attempt_number, + claim_token: claimed.claim_token, sequence, + lease_expires_at: leaseExpiresAt.toISOString(), + descriptor_digest: authority.descriptor_digest }); + }); + } + + /** Admission owns a newly inserted, locked system Run and its immutable + * intent. This uses the existing attempt/event/queue rather than a second + * scheduler. It is safe to call again for the same still-live attempt. */ + async scheduleResourceSyncInTransaction( + tx: AppRunTransaction, run: AppRunSafeView, _now = this.now(), + ): Promise { + if (!isAppResourceSyncChannelEnabled() + || run.review_scope !== 'reviewed_resource_sync' || run.provider_kind !== 'app_runtime' + || run.origin_kind !== 'app' || run.initiating_actor_type !== 'system' + || run.execution_actor_type !== 'system' || !run.execution_released_at + || run.cancel_requested_at + || ['succeeded', 'failed', 'cancelled', 'expired', 'unknown_outcome'].includes(run.state)) return null; + const [stored] = await tx.select({ binding_id: appRuns.origin_resource_binding_id }) + .from(appRuns).where(and(eq(appRuns.org_id, run.org_id), eq(appRuns.id, run.id))).limit(1); + if (!stored?.binding_id) return null; + const authority = await loadLiveResourceSyncBindingAuthority(tx, { + org_id: run.org_id, resource_binding_id: stored.binding_id, clock: this.now, + }); + if (!authority || !await this.#resourceSyncRunMatchesAuthority(tx, run, authority)) return null; + const checkedAt = this.now(); + if (run.input_expires_at <= checkedAt || authority.binding.consent_expires_at! <= checkedAt) return null; + const [existing] = await tx.select({ id: appRunAttempts.id }).from(appRunAttempts).where(and( + eq(appRunAttempts.org_id, run.org_id), eq(appRunAttempts.run_id, run.id), + inArray(appRunAttempts.state, ['pending', 'claimed', 'provider_call_started']), + )).orderBy(asc(appRunAttempts.attempt_number)).limit(1); + if (existing) { + await this.attemptQueue.enqueue(tx, run.org_id, run.id, existing.id); + return existing.id; + } + return (await this.#createAttempt(tx, run, checkedAt))?.id ?? null; + } + async renewLease(orgId: string, attemptId: string, claimToken: string, runtime?: Readonly<{ run_id: string; session_id: string; token_hash: string; sequence: number }>, ): Promise { @@ -814,6 +1245,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { run: AppRunSafeView, attempt: typeof appRunAttempts.$inferSelect, now: Date, + extraReceiptFacts?: Readonly>, ): Promise { const outcome = AppRunSafeOutcomeSchema.parse(attempt.safe_outcome); const attemptState = outcome.success ? 'succeeded' : 'failed'; @@ -830,7 +1262,8 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { run, state: outcome.success ? 'succeeded' : 'failed', safe_outcome: outcome, error_code: outcome.error_code, now, }); - await this.#writeAttemptReceipt(tx, terminalRun, attempt.id, attemptState, now, outcome.error_code); + await this.#writeAttemptReceipt(tx, terminalRun, attempt.id, attemptState, now, + outcome.error_code, false, extraReceiptFacts); } async #settleBeforeCallFailure(claimed: ClaimedAttempt, code: 'APP_RUN_EXPIRED'): Promise { @@ -981,6 +1414,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { occurredAt: Date, errorCode?: string, retryScheduled = false, + extraFacts?: Readonly>, ): Promise { await this.receiptWriter.write(tx, { receipt_key: `attempt-terminal:${attemptId}`, @@ -991,6 +1425,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { attempt_state: attemptState, retry_scheduled: retryScheduled, ...(errorCode ? { error_code: errorCode } : {}), + ...extraFacts, }, occurred_at: occurredAt, }); diff --git a/apps/api/src/lib/app-run-runtime.ts b/apps/api/src/lib/app-run-runtime.ts index 21b30870..42889c43 100644 --- a/apps/api/src/lib/app-run-runtime.ts +++ b/apps/api/src/lib/app-run-runtime.ts @@ -20,10 +20,15 @@ import { AppRunSecretService } from './app-run-secrets.js'; import { AppRunPreparedInputService } from './app-run-prepared-input.js'; import { AppRunService } from './app-run-service.js'; import { AppRuntimeChannel } from './app-runtime-channel.js'; +import { AppResourceSyncChannel } from './app-resource-sync-channel.js'; +import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; +import { AppResourceSyncStore } from './app-resource-sync-store.js'; +import { AppResourceSyncAdmissionService } from './app-resource-sync-admission.js'; import { APP_AUTOMATIONS_ENABLED, APP_RUN_APP_ORIGIN_ENABLED, APP_RUNS_ENABLED, + isAppResourceSyncChannelEnabled, } from './env.js'; export type AppRunRuntime = Readonly<{ @@ -35,6 +40,8 @@ export type AppRunRuntime = Readonly<{ service: AppRunService; attemptRunner: AppRunAttemptRunner; runtimeChannel: AppRuntimeChannel; + resourceSyncChannel: AppResourceSyncChannel; + resourceSyncAdmission: AppResourceSyncAdmissionService; approvalResolver: PostgresAppRunApprovalResolver; receiptReader: PostgresAppRunReceiptReader; operations: AppRunOperationsService; @@ -47,6 +54,8 @@ async function createAppRunRuntime(): Promise { const secrets = new AppRunSecretService(keys); const repository = new PostgresAppRunRepository(); const secretRepository = new AppRunSecretRepository(secrets); + const resourceSyncSecrets = new AppResourceSyncSecretService(keys); + const resourceSyncStore = new AppResourceSyncStore(resourceSyncSecrets, secretRepository); const inputPreparation = new AppRunPreparedInputService(secrets); const liveAuthorization = new PostgresAppRunLiveAuthorization(() => APP_AUTOMATIONS_ENABLED); const accessAuthorization = new PostgresAppRunAuthorizer(); @@ -66,6 +75,7 @@ async function createAppRunRuntime(): Promise { receipts, attention, postgresAppRunAttemptQueue, + resourceSyncStore, ); const service = new AppRunService( repository, @@ -84,6 +94,10 @@ async function createAppRunRuntime(): Promise { () => APP_AUTOMATIONS_ENABLED, ); const runtimeChannel = new AppRuntimeChannel(attemptRunner); + const resourceSyncChannel = new AppResourceSyncChannel(attemptRunner); + const resourceSyncAdmission = new AppResourceSyncAdmissionService(repository, + secretRepository, secrets, resourceSyncSecrets, attemptRunner, clock, + isAppResourceSyncChannelEnabled); const approvalResolver = new PostgresAppRunApprovalResolver( repository, liveAuthorization, @@ -119,6 +133,8 @@ async function createAppRunRuntime(): Promise { service, attemptRunner, runtimeChannel, + resourceSyncChannel, + resourceSyncAdmission, approvalResolver, receiptReader, operations, diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 443a0c78..558f7cba 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -128,6 +128,12 @@ export function isAppRuntimeChannelEnabled(): boolean { return APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED && process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED === 'true'; } +// Candidate resource sync is a distinct credential audience and rollout. +// Enabling v1 actions never enables v2 sync work. +export function isAppResourceSyncChannelEnabled(): boolean { + return APPS_ENABLED && APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED + && process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED === 'true'; +} // Track A automation is an independent, deny-by-default privileged plane. export const APP_AUTOMATIONS_ENABLED = process.env.DEFT_APP_AUTOMATIONS_ENABLED === 'true'; diff --git a/apps/api/src/middleware/app-resource-sync-limits.ts b/apps/api/src/middleware/app-resource-sync-limits.ts new file mode 100644 index 00000000..1f562b40 --- /dev/null +++ b/apps/api/src/middleware/app-resource-sync-limits.ts @@ -0,0 +1,89 @@ +import { isIP } from 'node:net'; +import { getConnInfo } from '@hono/node-server/conninfo'; +import type { Context, MiddlewareHandler } from 'hono'; + +type Bucket = { window_start: number; count: number; in_flight: number }; +export type AppResourceSyncLimitsOptions = Readonly<{ + /** Only the socket peer or an explicitly trusted host-proxy result. */ + peerAddress?: (c: Context) => string | null | undefined; + now?: () => number; + globalPerMinute?: number; + peerPerMinute?: number; + globalConcurrent?: number; + peerConcurrent?: number; + maxPeerBuckets?: number; +}>; + +function positive(value: number | undefined, fallback: number) { + return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : fallback; +} +function socketPeer(c: Context): string | null { + try { return getConnInfo(c).remote.address ?? null; } + catch { return null; } +} +function peerKey(value: string | null | undefined) { + const candidate = value?.trim(); + return candidate && candidate.length <= 45 && isIP(candidate) !== 0 ? candidate : 'unknown'; +} +function tick(bucket: Bucket, now: number) { + if (now < bucket.window_start || now - bucket.window_start >= 60_000) { + bucket.window_start = now; + bucket.count = 0; + } +} +function unavailable(c: Context, status: 429 | 503, retryAfter: string) { + c.header('Retry-After', retryAfter); + return c.json({ error: 'Resource sync request failed', code: 'APP_RESOURCE_SYNC_FAILURE' }, status); +} + +/** Process-local bound ahead of the v2 JSON reader and session DB lookup. + * Forwarding headers are never consulted. Invalid/unknown peers share a + * bucket and every request is charged to the global bucket. */ +export function createAppResourceSyncLimits( + options: AppResourceSyncLimitsOptions = {}, +): MiddlewareHandler { + const clock = options.now ?? Date.now; + const resolvePeer = options.peerAddress ?? socketPeer; + const globalPerMinute = positive(options.globalPerMinute, 600); + const peerPerMinute = positive(options.peerPerMinute, 60); + const globalConcurrent = positive(options.globalConcurrent, 32); + const peerConcurrent = positive(options.peerConcurrent, 2); + const maxPeerBuckets = positive(options.maxPeerBuckets, 1024); + const global: Bucket = { window_start: clock(), count: 0, in_flight: 0 }; + const unknown: Bucket = { window_start: clock(), count: 0, in_flight: 0 }; + const overflow: Bucket = { window_start: clock(), count: 0, in_flight: 0 }; + const peers = new Map(); + let admissions = 0; + return async (c, next) => { + const now = clock(); + tick(global, now); + global.count += 1; + if (global.count > globalPerMinute) return unavailable(c, 429, '60'); + const key = peerKey(resolvePeer(c)); + let peer = key === 'unknown' ? unknown : peers.get(key); + if (!peer) { + admissions += 1; + if (admissions % 64 === 0 && peers.size >= maxPeerBuckets) { + for (const [id, bucket] of peers) { + if (bucket.in_flight === 0 && now - bucket.window_start >= 60_000) peers.delete(id); + } + } + peer = peers.size < maxPeerBuckets + ? { window_start: now, count: 0, in_flight: 0 } : overflow; + if (peer !== overflow) peers.set(key, peer); + } + tick(peer, now); + peer.count += 1; + if (peer.count > peerPerMinute) return unavailable(c, 429, '60'); + if (c.req.raw.signal.aborted) return unavailable(c, 503, '1'); + if (global.in_flight >= globalConcurrent || peer.in_flight >= peerConcurrent) { + return unavailable(c, 503, '1'); + } + global.in_flight += 1; + peer.in_flight += 1; + try { await next(); } + finally { global.in_flight -= 1; peer.in_flight -= 1; } + }; +} + +export const appResourceSyncLimits = createAppResourceSyncLimits(); diff --git a/apps/api/src/routes/app-resource-sync-channel.ts b/apps/api/src/routes/app-resource-sync-channel.ts new file mode 100644 index 00000000..d97af7ef --- /dev/null +++ b/apps/api/src/routes/app-resource-sync-channel.ts @@ -0,0 +1,123 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { appResourceSyncChannelEnabled } from '../lib/app-resource-sync-channel.js'; +import { getAppRunRuntime } from '../lib/app-run-runtime.js'; +import { APP_RESOURCE_SYNC_AUDIENCE, APP_RESOURCE_SYNC_CHANNEL_VERSION } from '../lib/app-resource-sync-contract.js'; + +const MAX_BODY_BYTES = 1_100_000; +const READ_DEADLINE_MS = 15_000; +const identity = { schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, + audience: APP_RESOURCE_SYNC_AUDIENCE } as const; + +/** Mounted before human/employee middleware; the token only comes from the + * dedicated Authorization header and never from a cookie or JSON body. */ +export const appResourceSyncChannelRoutes = new Hono(); +appResourceSyncChannelRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + if (!appResourceSyncChannelEnabled()) { + return c.json({ error: 'Resource sync channel unavailable', code: 'APP_RESOURCE_SYNC_DISABLED' }, 503); + } + if (c.req.header('cookie')) { + return c.json({ error: 'Resource sync credential required', code: 'APP_RESOURCE_SYNC_ACCESS_DENIED' }, 403); + } + await next(); +}); + +async function request(c: Context, maxBytes: number): Promise> { + const match = /^AppRuntime ([A-Za-z0-9_-]{32,512})$/u.exec(c.req.header('authorization') ?? ''); + if (!match) throw new Error('AUTH'); + if (c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new Error('JSON'); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > maxBytes) throw new Error('SIZE'); + const reader = c.req.raw.body?.getReader(); + if (!reader) throw new Error('JSON'); + const chunks: Uint8Array[] = []; + let total = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('TIMEOUT'); + let timer: ReturnType | undefined; + const next = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('TIMEOUT')), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (next.done) break; + total += next.value.byteLength; + if (total > maxBytes) throw new Error('SIZE'); + chunks.push(next.value); + } + } catch (error) { + void reader.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + const body: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if (!body || typeof body !== 'object' || Array.isArray(body) + || Object.hasOwn(body, 'session_token')) throw new Error('JSON'); + return { ...body, session_token: match[1] }; +} + +function failure(c: Context, error: unknown) { + if (error instanceof z.ZodError || error instanceof SyntaxError + || error instanceof TypeError || (error instanceof Error && ['JSON', 'SIZE'].includes(error.message))) { + const tooLarge = error instanceof Error && error.message === 'SIZE'; + return c.json({ error: tooLarge ? 'Resource sync request too large' : 'Invalid resource sync request', + code: tooLarge ? 'APP_RESOURCE_SYNC_TOO_LARGE' : 'APP_RESOURCE_SYNC_INVALID_REQUEST' }, + tooLarge ? 413 : 400); + } + if (error instanceof Error && error.message === 'AUTH') { + return c.json({ error: 'Resource sync credential required', + code: 'APP_RESOURCE_SYNC_ACCESS_DENIED' }, 403); + } + if (error instanceof Error && error.message === 'TIMEOUT') { + return c.json({ error: 'Resource sync request timed out', + code: 'APP_RESOURCE_SYNC_TIMEOUT' }, 408); + } + console.error('[app-resource-sync] channel request failed'); + return c.json({ error: 'Resource sync request failed', code: 'APP_RESOURCE_SYNC_FAILURE' }, 500); +} + +appResourceSyncChannelRoutes.post('/claim', async (c) => { + try { + const payload = await request(c, 4096); + const claim = await (await getAppRunRuntime()).resourceSyncChannel.claim(payload); + return c.json({ ...identity, claim }); + } catch (error) { return failure(c, error); } +}); +appResourceSyncChannelRoutes.post('/start', async (c) => { + try { + const payload = await request(c, 4096); + const started = await (await getAppRunRuntime()).resourceSyncChannel.start(payload); + return started ? c.json({ ...identity, started }) + : c.json({ error: 'Resource sync credential required', + code: 'APP_RESOURCE_SYNC_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); +appResourceSyncChannelRoutes.post('/heartbeat', async (c) => { + try { + const payload = await request(c, 4096); + const renewed = await (await getAppRunRuntime()).resourceSyncChannel.heartbeat(payload); + return renewed ? c.json({ ...identity, work_kind: 'sync_page', ...renewed, renewed: true }) + : c.json({ error: 'Resource sync credential required', + code: 'APP_RESOURCE_SYNC_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); +appResourceSyncChannelRoutes.post('/result', async (c) => { + try { + const payload = await request(c, MAX_BODY_BYTES); + const accepted = await (await getAppRunRuntime()).resourceSyncChannel.complete(payload); + return accepted ? c.json({ ...identity, work_kind: 'sync_page', ...accepted, accepted: true }) + : c.json({ error: 'Resource sync credential required', + code: 'APP_RESOURCE_SYNC_ACCESS_DENIED' }, 403); + } catch (error) { return failure(c, error); } +}); diff --git a/apps/api/test/app-resource-sync-channel-db.test.ts b/apps/api/test/app-resource-sync-channel-db.test.ts new file mode 100644 index 00000000..72e6b799 --- /dev/null +++ b/apps/api/test/app-resource-sync-channel-db.test.ts @@ -0,0 +1,358 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test from 'node:test'; + +const assigned = process.env.DATABASE_URL === process.env.DEFT_TEST_DATABASE_URL + && process.env.DATABASE_URL === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_phase5_test_s06_channel'; + +test('reviewed v2 Run claims and atomically settles page, output and signed receipt', { + skip: !assigned, +}, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`s06-channel:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'channel-enc', keys: { 'channel-enc': key('enc') } }, + receipt_signing: { current: 'channel-sig', keys: { 'channel-sig': key('sig') } }, + fingerprint: { current: 'channel-fp1', keys: { 'channel-fp1': key('fp1') } } }); + const [{ db, closeDb }, schema, drizzle, fixture, keyrings, repositories, + secretModule, inputModule, syncSecretModule, storeModule, admissionModule, + runnerModule, channelModule, providerModule, queueModule, receiptModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('./fixtures/resource-sync-v5.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-repository.js'), import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-secret-repository.js'), import('../src/lib/app-resource-sync-secrets.js'), + import('../src/lib/app-resource-sync-store.js'), import('../src/lib/app-resource-sync-admission.js'), + import('../src/lib/app-run-attempt-runner.js'), import('../src/lib/app-resource-sync-channel.js'), + import('../src/lib/app-run-provider-executor.js'), import('../src/lib/app-run-scheduler.js'), + import('../src/lib/app-run-receipts.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(process.env.DEFT_APP_RUN_KEYRINGS); + let checkedAt = new Date(); + const clock = () => new Date(checkedAt); + const repository = new repositories.PostgresAppRunRepository(); + const secrets = new secretModule.AppRunSecretService(keys); + const inputs = new inputModule.AppRunSecretRepository(secrets); + const syncSecrets = new syncSecretModule.AppResourceSyncSecretService(keys); + const store = new storeModule.AppResourceSyncStore(syncSecrets, inputs); + const receiptWriter = new receiptModule.PostgresAppRunReceiptWriter(secrets, inputs); + const makeRunner = (writer: typeof receiptWriter = receiptWriter) => + new runnerModule.AppRunAttemptRunner(repository, inputs, secrets, + new providerModule.PinnedMcpAppRunProviderExecutor(), undefined, clock, + 60_000, 20_000, writer, undefined, queueModule.postgresAppRunAttemptQueue, store); + const runner = makeRunner(); + const admission = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + secrets, syncSecrets, runner, clock, () => true); + const channel = new channelModule.AppResourceSyncChannel(runner); + const page = { schema_version: 'deft.app_sync_page.v1' as const, + upserts: [{ id: 'provider-message-1', revision: 'rev1', data: { subject: 'Private subject' } }], + tombstones: [], next_cursor: 'cursor-1', has_more: false }; + const newClaim = async () => { + const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const admitted = await admission.admitDue({ org_id: owned.org_id, + resource_binding_id: owned.binding_id }); + if (admitted.state !== 'created') throw new Error('Expected a fresh due sync Run'); + const issued = await owned.management.issueOperatorSession(owned.operator_actor, owned.binding_id); + const base = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, + session_id: issued.session_id, session_token: issued.session_token }; + const claim = await channel.claim({ ...base, max_claims: 1 }); + assert.ok(claim); + return { owned, admitted, issued, base, claim, + attempt: { ...base, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence } }; + }; + try { + const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const admitted = await admission.admitDue({ org_id: owned.org_id, + resource_binding_id: owned.binding_id }); + assert.equal(admitted.state, 'created'); + if (admitted.state !== 'created') return; + const issued = await owned.management.issueOperatorSession(owned.operator_actor, owned.binding_id); + const base = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, session_id: issued.session_id, + session_token: issued.session_token }; + const claim = await channel.claim({ ...base, max_claims: 1 }); + assert.ok(claim); + assert.equal(claim.run_id, admitted.run_id); + assert.equal(claim.resource_binding_id, owned.binding_id); + assert.equal(claim.descriptor_digest.length, 71); + const attemptBase = { ...base, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + const started = await channel.start(attemptBase); + assert.ok(started); + assert.deepEqual(started.input, { schema_version: 'deft.app_sync_request.v1', + cursor: null, max_items: 100 }); + assert.equal(started.descriptor_digest, claim.descriptor_digest); + const heartbeat = await channel.heartbeat(attemptBase); + assert.ok(heartbeat); + assert.equal(heartbeat.sequence, claim.sequence); + const result = { ...attemptBase, status: 'returned' as const, + provider_succeeded: true as const, page }; + assert.deepEqual(await channel.complete(result), { run_id: claim.run_id, + attempt_id: claim.attempt_id, sequence: claim.sequence }); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(drizzle.eq(schema.appSyncCheckpoints.id, owned.checkpoint_id)); + assert.equal(checkpoint?.cursor_sequence, 1); + const projections = await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, owned.binding_id)); + assert.equal(projections.length, 1); + assert.equal(projections[0]?.state, 'live'); + const [run] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, admitted.run_id)); + assert.equal(run?.state, 'succeeded'); + const output = await inputs.readOutput(owned.org_id, claim.run_id, claim.attempt_id); + assert.equal((output as { provider_succeeded?: boolean })?.provider_succeeded, true); + const receipts = await new receiptModule.PostgresAppRunReceiptReader(secrets) + .readVerified(owned.org_id, admitted.run_id); + assert.equal(receipts.filter((item) => item.receipt_kind === 'attempt_terminal').length, 1); + const [rawReceipt] = await db.select().from(schema.appRunReceipts) + .where(drizzle.eq(schema.appRunReceipts.attempt_id, claim.attempt_id)); + assert.equal(typeof rawReceipt?.envelope.output_envelope_digest, 'string'); + assert.equal(rawReceipt?.envelope.facts?.checkpoint_id, owned.checkpoint_id); + const [attempt] = await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.id, claim.attempt_id)); + assert.equal(attempt?.runtime_result_hmac, secrets.fingerprintTextCandidates('idempotency', + `deft.app_resource_sync.result.v2:${createHash('sha256') + .update((await import('@deft/shared')).canonicalCapabilityJson(result)).digest('hex')}`) + .find((candidate) => candidate.key_version === run?.idempotency_key_version)?.fingerprint); + assert.deepEqual(await channel.complete(result), { run_id: claim.run_id, + attempt_id: claim.attempt_id, sequence: claim.sequence }); + assert.equal(await channel.complete({ ...result, page: { ...page, next_cursor: 'altered' } }), null); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, owned.binding_id))).length, 1); + const immediate = await admission.admitDue({ org_id: owned.org_id, + resource_binding_id: owned.binding_id }); + assert.equal(immediate.state, 'not_due', 'provider cannot schedule its own cadence'); + checkedAt = new Date(checkedAt.getTime() + 61_000); + const nextDue = await admission.admitDue({ org_id: owned.org_id, + resource_binding_id: owned.binding_id }); + assert.equal(nextDue.state, 'created'); + if (nextDue.state === 'created') { + assert.deepEqual(await inputs.readInput(owned.org_id, nextDue.run_id), { + schema_version: 'deft.app_sync_request.v1', cursor: 'cursor-1', max_items: 100, + }); + } + + const rollbackOwned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const lateWriter = { async write(tx: Parameters[0], + value: Parameters[1]) { + await receiptWriter.write(tx, value); + if (value.receipt_kind === 'attempt_terminal' && value.run.state === 'succeeded') { + checkedAt = new Date(checkedAt.getTime() + 2 * 60_000); + } + } }; + const rollbackRunner = makeRunner(lateWriter as typeof receiptWriter); + const rollbackAdmission = new admissionModule.AppResourceSyncAdmissionService(repository, + inputs, secrets, syncSecrets, rollbackRunner, clock, () => true); + const rollbackRun = await rollbackAdmission.admitDue({ org_id: rollbackOwned.org_id, + resource_binding_id: rollbackOwned.binding_id }); + assert.equal(rollbackRun.state, 'created'); + if (rollbackRun.state !== 'created') return; + const rollbackIssued = await rollbackOwned.management.issueOperatorSession( + rollbackOwned.operator_actor, rollbackOwned.binding_id); + const rollbackBase = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, session_id: rollbackIssued.session_id, + session_token: rollbackIssued.session_token }; + const rollbackChannel = new channelModule.AppResourceSyncChannel(rollbackRunner); + const rollbackClaim = await rollbackChannel.claim({ ...rollbackBase, max_claims: 1 }); + assert.ok(rollbackClaim); + const rollbackAttempt = { ...rollbackBase, run_id: rollbackClaim.run_id, + attempt_id: rollbackClaim.attempt_id, claim_token: rollbackClaim.claim_token, + sequence: rollbackClaim.sequence }; + assert.ok(await rollbackChannel.start(rollbackAttempt)); + await assert.rejects(rollbackChannel.complete({ ...rollbackAttempt, + status: 'returned', provider_succeeded: true, page }), + /APP_RESOURCE_SYNC_SETTLEMENT_EXPIRED/); + const [rollbackCheckpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(drizzle.eq(schema.appSyncCheckpoints.id, rollbackOwned.checkpoint_id)); + assert.equal(rollbackCheckpoint?.cursor_sequence, 0); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, rollbackOwned.binding_id))).length, 0); + assert.equal(await inputs.readOutput(rollbackOwned.org_id, rollbackClaim.run_id, + rollbackClaim.attempt_id), null); + assert.equal((await db.select().from(schema.appRunReceipts) + .where(drizzle.eq(schema.appRunReceipts.run_id, rollbackClaim.run_id))).length, 0); + const [rollbackStoredRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, rollbackClaim.run_id)); + assert.equal(rollbackStoredRun?.state, 'running'); + + // Result HMAC uses the Run-pinned FP1 even when the callback host's + // current fingerprint key has rotated to FP2. This no-page failure + // result lets FP2 be removed without hiding projection/cursor refs. + const failureOwned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + const failureAdmission = await admission.admitDue({ org_id: failureOwned.org_id, + resource_binding_id: failureOwned.binding_id }); + assert.equal(failureAdmission.state, 'created'); + if (failureAdmission.state !== 'created') return; + const failureIssued = await failureOwned.management.issueOperatorSession( + failureOwned.operator_actor, failureOwned.binding_id); + const failureBase = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, session_id: failureIssued.session_id, + session_token: failureIssued.session_token }; + const failureClaim = await channel.claim({ ...failureBase, max_claims: 1 }); + assert.ok(failureClaim); + const failureAttempt = { ...failureBase, run_id: failureClaim.run_id, + attempt_id: failureClaim.attempt_id, claim_token: failureClaim.claim_token, + sequence: failureClaim.sequence }; + assert.ok(await channel.start(failureAttempt)); + const baseRing = JSON.parse(process.env.DEFT_APP_RUN_KEYRINGS!); + const fp2Ring = structuredClone(baseRing); + fp2Ring.fingerprint.current = 'channel-fp2'; + fp2Ring.fingerprint.keys['channel-fp2'] = key('fp2'); + const fp2Keys = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify(fp2Ring)); + const fp3Ring = structuredClone(baseRing); + fp3Ring.fingerprint.current = 'channel-fp3'; + fp3Ring.fingerprint.keys['channel-fp3'] = key('fp3'); + const fp3Keys = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify(fp3Ring)); + const channelFor = (provider: typeof keys) => { + const runSecrets = new secretModule.AppRunSecretService(provider); + const runInputs = new inputModule.AppRunSecretRepository(runSecrets); + const sync = new syncSecretModule.AppResourceSyncSecretService(provider); + const writer = new receiptModule.PostgresAppRunReceiptWriter(runSecrets, runInputs); + const rotatedRunner = new runnerModule.AppRunAttemptRunner(repository, runInputs, + runSecrets, new providerModule.PinnedMcpAppRunProviderExecutor(), undefined, + clock, 60_000, 20_000, writer, undefined, + queueModule.postgresAppRunAttemptQueue, + new storeModule.AppResourceSyncStore(sync, runInputs)); + return new channelModule.AppResourceSyncChannel(rotatedRunner); + }; + try { + const failed = { ...failureAttempt, status: 'returned' as const, + provider_succeeded: false as const, error_code: 'APP_RUN_PROVIDER_ERROR' as const }; + assert.ok(await channelFor(fp2Keys).complete(failed)); + const [failureRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, failureClaim.run_id)); + const [failureAttemptRow] = await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.id, failureClaim.attempt_id)); + const canonical = (await import('@deft/shared')).canonicalCapabilityJson(failed); + const value = `deft.app_resource_sync.result.v2:${createHash('sha256') + .update(canonical).digest('hex')}`; + assert.equal(failureAttemptRow?.runtime_result_hmac, + new secretModule.AppRunSecretService(fp2Keys) + .fingerprintTextCandidates('idempotency', value) + .find((candidate) => candidate.key_version === failureRun?.idempotency_key_version)?.fingerprint); + assert.equal(failureRun?.idempotency_key_version, 'channel-fp1'); + assert.ok(await channelFor(fp3Keys).complete(failed), + 'identical replay survives removal of unreferenced completion-current FP2'); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, + failureOwned.binding_id))).length, 0); + } finally { fp2Keys.destroy(); fp3Keys.destroy(); } + + const cross = await newClaim(); + const other = await newClaim(); + assert.equal(await channel.start({ ...cross.attempt, + session_id: other.issued.session_id, session_token: other.issued.session_token }), null); + assert.equal(await channel.start({ ...cross.attempt, claim_token: randomUUID() }), null); + assert.equal(await channel.start({ ...cross.attempt, sequence: cross.claim.sequence + 1 }), null); + assert.equal(await runner.startResourceSyncAttempt({ org_id: other.owned.org_id, + run_id: cross.claim.run_id, attempt_id: cross.claim.attempt_id, + session_id: cross.issued.session_id, + token_hash: (await import('../src/lib/app-resource-sync-policy.js')) + .hashAppResourceSyncToken(cross.issued.session_token), + claim_token: cross.claim.claim_token, sequence: cross.claim.sequence }), null); + assert.ok(await channel.start(cross.attempt)); + assert.equal(await channel.complete({ ...other.attempt, + run_id: cross.claim.run_id, attempt_id: cross.claim.attempt_id, + claim_token: cross.claim.claim_token, sequence: cross.claim.sequence, + status: 'returned', provider_succeeded: true, page }), null); + + const preStart = await newClaim(); + await preStart.owned.management.revokeConsent(preStart.owned.owner_actor, + preStart.owned.binding_id); + assert.equal(await channel.start(preStart.attempt), null); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, + preStart.owned.binding_id))).length, 0); + + const postStart = await newClaim(); + assert.ok(await channel.start(postStart.attempt)); + await postStart.owned.management.revokeConsent(postStart.owned.owner_actor, + postStart.owned.binding_id); + assert.equal(await channel.complete({ ...postStart.attempt, + status: 'returned', provider_succeeded: true, page }), null); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, + postStart.owned.binding_id))).length, 0); + checkedAt = new Date(checkedAt.getTime() + 61_000); + assert.equal(await runner.recoverRun(postStart.owned.org_id, postStart.claim.run_id), 1); + const [unknownRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, postStart.claim.run_id)); + assert.equal(unknownRun?.state, 'unknown_outcome'); + assert.equal((await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.run_id, postStart.claim.run_id))).length, 1, + 'started unknown sync work is never retried automatically'); + + const revokedSession = await newClaim(); + assert.ok(await channel.start(revokedSession.attempt)); + await revokedSession.owned.management.revokeOperatorSession( + revokedSession.owned.operator_actor, revokedSession.issued.session_id); + assert.equal(await channel.heartbeat(revokedSession.attempt), null); + assert.equal(await channel.complete({ ...revokedSession.attempt, + status: 'returned', provider_succeeded: true, page }), null); + + const cancelled = await newClaim(); + await repository.transaction(async (tx) => { + const run = await repository.lockRun(tx, cancelled.owned.org_id, cancelled.claim.run_id); + assert.ok(run); + await repository.transition(tx, { run, state: 'cancelled', + actor: { actor_type: 'system', system_id: cancelled.owned.binding_id }, + now: clock() }); + }); + assert.equal(await channel.start(cancelled.attempt), null); + const cancelledAfterStart = await newClaim(); + assert.ok(await channel.start(cancelledAfterStart.attempt)); + await repository.transaction(async (tx) => { + const run = await repository.lockRun(tx, cancelledAfterStart.owned.org_id, + cancelledAfterStart.claim.run_id); + assert.ok(run); + await repository.requestCancellation(tx, run, + { actor_type: 'system', system_id: cancelledAfterStart.owned.binding_id }, clock()); + }); + assert.equal(await channel.complete({ ...cancelledAfterStart.attempt, + status: 'returned', provider_succeeded: true, page }), null); + + const neverStarted = await newClaim(); + checkedAt = new Date(checkedAt.getTime() + 61_000); + assert.equal(await channel.start(neverStarted.attempt), null); + assert.equal(await runner.recoverRun(neverStarted.owned.org_id, + neverStarted.claim.run_id), 1); + const [beforeCall] = await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.id, neverStarted.claim.attempt_id)); + assert.equal(beforeCall?.state, 'failed'); + const [neverRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, neverStarted.claim.run_id)); + assert.notEqual(neverRun?.state, 'unknown_outcome', + 'the provider-call-started boundary distinguishes pre-effect recovery'); + + const rekeyBeforeStart = await newClaim(); + await assert.rejects(db.execute(drizzle.sql`UPDATE app_sync_checkpoints + SET cursor_hmac_key_version = 'forged-rekey' + WHERE id = ${rekeyBeforeStart.owned.checkpoint_id}`), + (error: unknown) => error instanceof Error + && error.cause instanceof Error + && error.cause.message.includes('APP_SYNC_CURSOR_CAS_REQUIRED')); + assert.ok(await channel.start(rekeyBeforeStart.attempt), + 'same-sequence cursor rekey is prohibited by the current DB contract'); + + const expiredAfterStart = await newClaim(); + assert.ok(await channel.start(expiredAfterStart.attempt)); + checkedAt = new Date(checkedAt.getTime() + 61_000); + assert.equal(await channel.complete({ ...expiredAfterStart.attempt, + status: 'returned', provider_succeeded: true, page }), null); + assert.equal(await runner.recoverRun(expiredAfterStart.owned.org_id, + expiredAfterStart.claim.run_id), 1); + const [expiredRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, expiredAfterStart.claim.run_id)); + assert.equal(expiredRun?.state, 'unknown_outcome'); + assert.equal((await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.run_id, + expiredAfterStart.claim.run_id))).length, 1); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, + expiredAfterStart.owned.binding_id))).length, 0); + } finally { keys.destroy(); await closeDb(); } +}); diff --git a/apps/api/test/app-resource-sync-channel-disabled.test.ts b/apps/api/test/app-resource-sync-channel-disabled.test.ts new file mode 100644 index 00000000..8fbb16fd --- /dev/null +++ b/apps/api/test/app-resource-sync-channel-disabled.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { createAppResourceSyncLimits } from '../src/middleware/app-resource-sync-limits.js'; + +test('v2 resource channel fails closed before parsing a request', async () => { + const prior = process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + try { + const { appResourceSyncChannelRoutes } = await import('../src/routes/app-resource-sync-channel.js'); + const response = await appResourceSyncChannelRoutes.request('/result', { + method: 'POST', headers: { 'content-type': 'text/plain', cookie: 'sid=human' }, + body: 'this is not JSON', + }); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: 'Resource sync channel unavailable', + code: 'APP_RESOURCE_SYNC_DISABLED' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } finally { + if (prior === undefined) delete process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED; + else process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = prior; + } +}); + +test('v2 pre-auth limits ignore spoofed forwarding headers and cap concurrency', async () => { + const capped = new Hono(); + capped.use('*', createAppResourceSyncLimits({ globalPerMinute: 10, + peerPerMinute: 2, globalConcurrent: 1, peerConcurrent: 1 })); + let release!: () => void; + let entered!: () => void; + const began = new Promise((resolve) => { entered = resolve; }); + const waiting = new Promise((resolve) => { release = resolve; }); + capped.post('/work', async (c) => { entered(); await waiting; return c.json({ ok: true }); }); + const first = capped.request('/work', { method: 'POST', + headers: { 'x-forwarded-for': '192.0.2.1', 'x-real-ip': '192.0.2.1' } }); + await began; + const concurrent = await capped.request('/work', { method: 'POST', + headers: { 'x-forwarded-for': '198.51.100.2', 'x-real-ip': '198.51.100.2' } }); + assert.equal(concurrent.status, 503); + release(); + assert.equal((await first).status, 200); + const rotated = await capped.request('/work', { method: 'POST', + headers: { 'x-forwarded-for': '203.0.113.3', 'x-real-ip': '203.0.113.3' } }); + assert.equal(rotated.status, 429, 'all in-memory peers share the unknown bucket'); +}); From ec1ceb39bbc3264f2d889dbb3c922744358e62a1 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:04:25 +0530 Subject: [PATCH 024/161] Limit sync start to one input release --- apps/api/src/lib/app-run-attempt-runner.ts | 44 +++++++++---------- .../test/app-resource-sync-channel-db.test.ts | 17 +++++++ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/apps/api/src/lib/app-run-attempt-runner.ts b/apps/api/src/lib/app-run-attempt-runner.ts index 5798a2ca..e3cd5bc3 100644 --- a/apps/api/src/lib/app-run-attempt-runner.ts +++ b/apps/api/src/lib/app-run-attempt-runner.ts @@ -574,7 +574,10 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { || run.cancel_requested_at || run.input_expires_at <= now || authority.binding.consent_expires_at! <= now || authority.session.expires_at <= now) return false; - if (result.status === 'indeterminate') { + if (result.status === 'indeterminate' || result.status === 'not_attempted') { + // The host crossed provider_call_started before releasing the input. + // A provider assertion that it did not call the source cannot prove + // that no external effect occurred. await this.#recoverUnknownInTransaction(tx, run, attempt, now, digest); return true; } @@ -626,14 +629,10 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { receiptFacts = { resource_binding_id: authority.binding.id, checkpoint_id: intent.checkpoint_id, page_digest: applied.page_digest, cursor_sequence: applied.applied_sequence }; - } else if (result.status === 'returned') { + } else { outcome = AppRunSafeOutcomeSchema.parse({ success: false, provider_call_attempted: true, result_status: 'unavailable', error_code: 'APP_RUN_PROVIDER_ERROR' }); - } else { - outcome = AppRunSafeOutcomeSchema.parse({ success: false, - provider_call_attempted: false, result_status: 'unavailable', - error_code: result.error_code }); } const finalClock = this.now(); if (attempt.lease_expires_at <= finalClock || authority.session.expires_at <= finalClock @@ -693,7 +692,7 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { || checkpoint.cursor_sequence !== intent.expected_cursor_sequence || checkpoint.cursor_hmac_key_version !== intent.expected_cursor_hmac_key_version || checkpoint.cursor_hmac !== intent.expected_cursor_hmac - || !attempt || !['claimed', 'provider_call_started'].includes(attempt.state) + || !attempt || attempt.state !== 'claimed' || attempt.claim_token !== input.claim_token || attempt.runtime_session_id !== authority.session.id || attempt.resource_binding_id !== authority.binding.id @@ -710,27 +709,24 @@ export class AppRunAttemptRunner implements AppRunAttemptScheduler { try { request = parseSyncRequest(rawInput); } catch { return null; } if (request.max_items > authority.binding.max_records_per_page) return null; - if (attempt.state === 'claimed') { - const [started] = await tx.update(appRunAttempts).set({ state: 'provider_call_started', - provider_call_started_at: now, updated_at: now }).where(and( - eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, attempt.id), - eq(appRunAttempts.claim_token, input.claim_token), - eq(appRunAttempts.state, 'claimed'), - )).returning({ id: appRunAttempts.id }); - if (!started) return null; - if (run.state === 'pending') run = await this.repository.transition(tx, { - run, state: 'running', now, - }); - await this.repository.appendEvent(tx, { id: crypto.randomUUID(), - org_id: input.org_id, run_id: run.id, event_type: 'provider_call_started', - payload: { attempt_id: attempt.id }, now }); - } + const [started] = await tx.update(appRunAttempts).set({ state: 'provider_call_started', + provider_call_started_at: now, updated_at: now }).where(and( + eq(appRunAttempts.org_id, input.org_id), eq(appRunAttempts.id, attempt.id), + eq(appRunAttempts.claim_token, input.claim_token), + eq(appRunAttempts.state, 'claimed'), + )).returning({ id: appRunAttempts.id }); + if (!started) return null; + if (run.state === 'pending') run = await this.repository.transition(tx, { + run, state: 'running', now, + }); + await this.repository.appendEvent(tx, { id: crypto.randomUUID(), + org_id: input.org_id, run_id: run.id, event_type: 'provider_call_started', + payload: { attempt_id: attempt.id }, now }); const checkedAt = this.now(); if (attempt.lease_expires_at <= checkedAt || run.input_expires_at <= checkedAt || authority.binding.consent_expires_at! <= checkedAt || authority.session.expires_at <= checkedAt) { - if (attempt.state === 'claimed') throw new Error('APP_RESOURCE_SYNC_START_EXPIRED'); - return null; + throw new Error('APP_RESOURCE_SYNC_START_EXPIRED'); } return Object.freeze({ schema_version: APP_RESOURCE_SYNC_CHANNEL_VERSION, audience: APP_RESOURCE_SYNC_AUDIENCE, work_kind: 'sync_page' as const, diff --git a/apps/api/test/app-resource-sync-channel-db.test.ts b/apps/api/test/app-resource-sync-channel-db.test.ts index 72e6b799..4ff51a7b 100644 --- a/apps/api/test/app-resource-sync-channel-db.test.ts +++ b/apps/api/test/app-resource-sync-channel-db.test.ts @@ -83,6 +83,8 @@ test('reviewed v2 Run claims and atomically settles page, output and signed rece claim_token: claim.claim_token, sequence: claim.sequence }; const started = await channel.start(attemptBase); assert.ok(started); + assert.equal(await channel.start(attemptBase), null, + 'a started unsafe sync attempt never releases the cursor/input twice'); assert.deepEqual(started.input, { schema_version: 'deft.app_sync_request.v1', cursor: null, max_items: 100 }); assert.equal(started.descriptor_digest, claim.descriptor_digest); @@ -354,5 +356,20 @@ test('reviewed v2 Run claims and atomically settles page, output and signed rece assert.equal((await db.select().from(schema.appResourceProjections) .where(drizzle.eq(schema.appResourceProjections.resource_binding_id, expiredAfterStart.owned.binding_id))).length, 0); + + const claimedNoEffect = await newClaim(); + assert.ok(await channel.start(claimedNoEffect.attempt)); + assert.deepEqual(await channel.complete({ ...claimedNoEffect.attempt, + status: 'not_attempted', error_code: 'APP_RUN_PROVIDER_UNAVAILABLE' }), + { run_id: claimedNoEffect.claim.run_id, + attempt_id: claimedNoEffect.claim.attempt_id, + sequence: claimedNoEffect.claim.sequence }); + const [noEffectRun] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, claimedNoEffect.claim.run_id)); + assert.equal(noEffectRun?.state, 'unknown_outcome', + 'the provider cannot prove no effect after host input release'); + assert.equal((await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.run_id, + claimedNoEffect.claim.run_id))).length, 1); } finally { keys.destroy(); await closeDb(); } }); From e3a4fa973f071257366704a6ac25d5ca7e16da3e Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:09:43 +0530 Subject: [PATCH 025/161] Prove packed resource sync SDK over separate-process HTTP --- .../test/app-resource-sync-http-db.test.ts | 328 ++++++++++++++++++ .../app-resource-sync-provider-child.ts | 76 ++++ 2 files changed, 404 insertions(+) create mode 100644 apps/api/test/app-resource-sync-http-db.test.ts create mode 100644 apps/api/test/fixtures/app-resource-sync-provider-child.ts diff --git a/apps/api/test/app-resource-sync-http-db.test.ts b/apps/api/test/app-resource-sync-http-db.test.ts new file mode 100644 index 00000000..85918e17 --- /dev/null +++ b/apps/api/test/app-resource-sync-http-db.test.ts @@ -0,0 +1,328 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { fork, spawnSync, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { setTimeout as sleep } from 'node:timers/promises'; +import * as ts from 'typescript'; +import type { + ResourceSyncClaim, ResourceSyncStart, SyncPageV1, +} from '@deft/app-kit/experimental/resource-sync'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const assigned = target && target === process.env.DATABASE_URL + && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && new URL(target).pathname === '/gate_g_phase5_test_s05_sync_http'; + +type ChildEvent = Record & { type: string }; + +const repositoryRoot = resolve(import.meta.dirname, '..', '..', '..'); +const packageRoot = resolve(repositoryRoot, 'packages', 'app-kit'); +const fixtureSource = fileURLToPath(new URL('./fixtures/app-resource-sync-provider-child.ts', + import.meta.url)); + +function runPnpm(args: string[]) { + const invokedThroughPnpm = process.env.npm_execpath; + const completed = spawnSync(invokedThroughPnpm ? process.execPath : 'pnpm', + invokedThroughPnpm ? [invokedThroughPnpm, ...args] : args, { + cwd: repositoryRoot, encoding: 'utf8', timeout: 120_000, windowsHide: true, + shell: !invokedThroughPnpm && process.platform === 'win32', + }); + assert.equal(completed.status, 0, + [completed.error?.message, completed.stdout, completed.stderr].filter(Boolean).join('\n')); +} + +async function packedProviderChild(root: string) { + const artifacts = resolve(root, 'artifacts'); + const consumer = resolve(root, 'consumer'); + await mkdir(artifacts, { recursive: true }); + await mkdir(consumer, { recursive: true }); + runPnpm(['--dir', packageRoot, 'pack', '--pack-destination', artifacts, '--json']); + const archives = (await readdir(artifacts)).filter((name) => name.endsWith('.tgz')); + assert.equal(archives.length, 1); + const tarball = resolve(artifacts, archives[0]!); + await writeFile(resolve(consumer, 'package.json'), JSON.stringify({ + name: 'deft-sync-http-external-consumer', version: '1.0.0', private: true, + type: 'module', dependencies: { '@deft/app-kit': `file:${tarball.replace(/\\/gu, '/')}` }, + }), 'utf8'); + runPnpm(['--dir', consumer, 'install', '--ignore-workspace', '--offline']); + const installed = await realpath(resolve(consumer, 'node_modules', '@deft', 'app-kit')); + assert.ok(installed.startsWith(await realpath(consumer))); + const compiled = ts.transpileModule(await readFile(fixtureSource, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const script = resolve(consumer, 'provider-child.mjs'); + await writeFile(script, compiled, 'utf8'); + return { script, consumer, tarball }; +} + +function providerChild(kit: Readonly<{ script: string; consumer: string }>) { + const child = fork(kit.script, [], { + cwd: kit.consumer, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + windowsHide: true, + env: { ...process.env, DEFT_RESOURCE_SYNC_PROVIDER_FIXTURE: 'true' }, + }); + const events: ChildEvent[] = []; + child.on('message', (value: unknown) => { + if (value && typeof value === 'object' && 'type' in value + && typeof value.type === 'string') events.push(value as ChildEvent); + }); + const wait = async (type: string): Promise => { + const failed = events.find((event) => event.type === 'error'); + if (failed) throw new Error(`Provider child failed: ${String(failed.code)}`); + const found = events.find((event) => event.type === type); + if (found) return found; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); reject(new Error(`Provider child timed out waiting for ${type}`)); + }, 20_000); + const onMessage = (value: unknown) => { + if (!value || typeof value !== 'object' || !('type' in value)) return; + if (value.type === 'error') { + cleanup(); reject(new Error(`Provider child failed: ${String((value as ChildEvent).code)}`)); + } else if (value.type === type) { cleanup(); resolve(value as ChildEvent); } + }; + const onExit = (code: number | null) => { + cleanup(); reject(new Error(`Provider child exited ${code} before ${type}`)); + }; + function cleanup() { + clearTimeout(timer); child.off('message', onMessage); child.off('exit', onExit); + } + child.on('message', onMessage); + child.once('exit', onExit); + }); + }; + return { child, events, wait }; +} + +async function stopChild(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; +} + +test('v2 SDK over HTTP commits one private page and survives provider crash/replay', + { skip: !assigned, timeout: 300_000 }, async () => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + const material = (purpose: string) => createHash('sha256') + .update(`sync-http:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ + schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'enc-v1', keys: { 'enc-v1': material('enc') } }, + receipt_signing: { current: 'sig-v1', keys: { 'sig-v1': material('sig') } }, + fingerprint: { current: 'fp-v1', keys: { 'fp-v1': material('fp') } }, + }); + const [{ db, closeDb }, schema, keyringModule, serverModule, drizzle] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), + import('../src/lib/app-run-keyrings.js'), import('@hono/node-server'), + import('drizzle-orm'), + ]); + const ring = { keys: keyringModule.parseEnvironmentAppRunKeyrings( + process.env.DEFT_APP_RUN_KEYRINGS) }; + const [{ app }, runtimeModule, fixture] = + await Promise.all([ + import('../src/index.js'), import('../src/lib/app-run-runtime.js'), + import('./fixtures/resource-sync-v5.js'), + ]); + const children: ChildProcess[] = []; + const dir = await mkdtemp(join(tmpdir(), 'deft-resource-sync-http-')); + assert.ok(dir.startsWith(tmpdir())); + let server: ReturnType | undefined; + try { + const packed = await packedProviderChild(dir); + const port = await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, + (info) => resolve(info.port)); + }); + const base = `http://127.0.0.1:${port}`; + const runtime = await runtimeModule.getAppRunRuntime(); + const admission = runtime.resourceSyncAdmission; + const page: SyncPageV1 = { schema_version: 'deft.app_sync_page.v1', + upserts: [{ id: 'synthetic-record-1', revision: 'rev-1', + data: { subject: 'synthetic private title' } }], + tombstones: [], next_cursor: 'synthetic-cursor-1', has_more: false }; + const sourcePath = join(dir, 'source-page.json'); + const ledgerPath = join(dir, 'source-observations.jsonl'); + await writeFile(sourcePath, JSON.stringify(page), 'utf8'); + + const reviewed = await fixture.createReviewedResourceSyncFixture({ + keys: ring.keys, clock: () => new Date() }); + const admitted = await admission.admitDue({ org_id: reviewed.org_id, + resource_binding_id: reviewed.binding_id }); + assert.equal(admitted.state, 'created'); + if (admitted.state !== 'created') return; + const session = await reviewed.management.issueOperatorSession( + reviewed.operator_actor, reviewed.binding_id); + const credential = { session_id: session.session_id, + session_token: session.session_token }; + const first = providerChild(packed); + children.push(first.child); + first.child.send({ type: 'start', channel_url: `${base}/api/app-resource-sync/channel`, + credential, source_path: sourcePath, ledger_path: ledgerPath, mode: 'normal' }); + const observed = await first.wait('observed'); + assert.equal(observed.run_id, admitted.run_id); + assert.equal((await first.wait('result')).run_id, admitted.run_id); + await stopChild(first.child); + const claim = observed.claim as ResourceSyncClaim; + const started = observed.started as ResourceSyncStart; + const observedPage = observed.page as SyncPageV1; + assert.deepEqual(observedPage, page); + const ledger = (await readFile(ledgerPath, 'utf8')).trim().split('\n') + .map((line) => JSON.parse(line) as { run_id: string; observation: string }); + assert.deepEqual(ledger, [{ run_id: admitted.run_id, + attempt_id: admitted.attempt_id, observation: 'synthetic_source_page_read' }]); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(drizzle.eq(schema.appSyncCheckpoints.id, reviewed.checkpoint_id)); + const projections = await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.checkpoint_id, + reviewed.checkpoint_id)); + assert.equal(checkpoint?.cursor_sequence, 1); + assert.equal(checkpoint?.cursor_state, 'value'); + assert.ok(checkpoint?.cursor_ciphertext_b64); + assert.equal(projections.length, 1); + assert.ok(projections[0]?.body_ciphertext_b64); + assert.equal(JSON.stringify({ checkpoint, projections }).includes('synthetic private title'), false); + assert.equal(JSON.stringify({ checkpoint, projections }).includes('synthetic-cursor-1'), false); + const output = await runtime.secretRepository.readOutput(reviewed.org_id, + admitted.run_id, admitted.attempt_id); + assert.deepEqual(output, { schema_version: 'deft.app_run_provider_result.v1', + provider_succeeded: true, output: page }); + const verified = await runtime.receiptReader.readVerified(reviewed.org_id, admitted.run_id); + assert.ok(verified.some((row) => row.receipt_kind === 'attempt_terminal' && row.verified)); + const [terminal] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, admitted.run_id)); + assert.equal(terminal?.state, 'succeeded'); + + const replay = providerChild(packed); + children.push(replay.child); + replay.child.send({ type: 'replay', channel_url: `${base}/api/app-resource-sync/channel`, + credential, claim, started, page: observedPage }); + assert.equal((await replay.wait('replayed')).run_id, admitted.run_id); + await stopChild(replay.child); + const [afterReplay] = await db.select().from(schema.appSyncCheckpoints) + .where(drizzle.eq(schema.appSyncCheckpoints.id, reviewed.checkpoint_id)); + assert.equal(afterReplay?.cursor_sequence, 1); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.checkpoint_id, + reviewed.checkpoint_id))).length, 1); + assert.equal((await readFile(ledgerPath, 'utf8')).trim().split('\n').length, 1); + + const crashed = await fixture.createReviewedResourceSyncFixture({ + keys: ring.keys, clock: () => new Date() }); + const crashAdmission = await admission.admitDue({ org_id: crashed.org_id, + resource_binding_id: crashed.binding_id }); + assert.equal(crashAdmission.state, 'created'); + if (crashAdmission.state !== 'created') return; + const crashSession = await crashed.management.issueOperatorSession( + crashed.operator_actor, crashed.binding_id); + const crashCredential = { session_id: crashSession.session_id, + session_token: crashSession.session_token }; + const killed = providerChild(packed); + children.push(killed.child); + killed.child.send({ type: 'start', channel_url: `${base}/api/app-resource-sync/channel`, + credential: crashCredential, source_path: sourcePath, + ledger_path: ledgerPath, mode: 'pause_after_observe' }); + assert.equal((await killed.wait('observed')).run_id, crashAdmission.run_id); + await stopChild(killed.child); + const [crashAttempt] = await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.id, crashAdmission.attempt_id)); + assert.ok(crashAttempt?.lease_expires_at); + const leaseWait = crashAttempt.lease_expires_at.getTime() - Date.now() + 250; + assert.ok(leaseWait > 0 && leaseWait <= 65_000); + await sleep(leaseWait); + assert.equal(await runtime.attemptRunner.recoverRun(crashed.org_id, + crashAdmission.run_id, crashAdmission.attempt_id), 1); + const [unknown] = await db.select().from(schema.appRuns) + .where(drizzle.eq(schema.appRuns.id, crashAdmission.run_id)); + assert.equal(unknown?.state, 'unknown_outcome'); + assert.equal((await db.select().from(schema.appResourceProjections) + .where(drizzle.eq(schema.appResourceProjections.checkpoint_id, + crashed.checkpoint_id))).length, 0); + const [crashCheckpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(drizzle.eq(schema.appSyncCheckpoints.id, crashed.checkpoint_id)); + assert.equal(crashCheckpoint?.cursor_sequence, 0); + assert.equal(await runtime.secretRepository.readOutput(crashed.org_id, + crashAdmission.run_id, crashAdmission.attempt_id), null); + assert.equal((await db.select().from(schema.appRunAttempts) + .where(drizzle.eq(schema.appRunAttempts.run_id, crashAdmission.run_id))).length, 1, + 'an observed but unacknowledged source read must not auto-retry'); + assert.deepEqual(await admission.admitDue({ org_id: crashed.org_id, + resource_binding_id: crashed.binding_id }), + { state: 'blocked', reason: 'cursor_requires_recovery' }); + assert.ok((await runtime.receiptReader.readVerified(crashed.org_id, + crashAdmission.run_id)).some((row) => row.receipt_kind === 'attempt_terminal' + && row.verified)); + + // A v2 token cannot be presented to the enabled v1 action channel. + const v1 = await fetch(`${base}/api/app-runtime/channel/claim`, { method: 'POST', + headers: { authorization: `AppRuntime ${session.session_token}`, + 'content-type': 'application/json' }, + body: JSON.stringify({ schema_version: 'deft.app_runtime_channel.v1', + session_id: session.session_id, max_claims: 1 }) }); + assert.equal(v1.status, 200); + assert.deepEqual(await v1.json(), { claim: null }); + + const revoked = await fixture.createReviewedResourceSyncFixture({ + keys: ring.keys, clock: () => new Date() }); + const revokeAdmission = await admission.admitDue({ org_id: revoked.org_id, + resource_binding_id: revoked.binding_id }); + assert.equal(revokeAdmission.state, 'created'); + const revokeSession = await revoked.management.issueOperatorSession( + revoked.operator_actor, revoked.binding_id); + await revoked.management.revokeConsent(revoked.owner_actor, revoked.binding_id); + const claimBody = { schema_version: 'deft.app_runtime_channel.v2', + audience: 'app_resource_sync', session_id: revokeSession.session_id, max_claims: 1 }; + const denied = await fetch(`${base}/api/app-resource-sync/channel/claim`, { + method: 'POST', headers: { authorization: `AppRuntime ${revokeSession.session_token}`, + 'content-type': 'application/json' }, body: JSON.stringify(claimBody) }); + assert.equal(denied.status, 200); + assert.equal((await denied.json() as { claim: unknown }).claim, null); + + const sourceObservations = (await readFile(ledgerPath, 'utf8')).trim().split('\n') + .map((line) => JSON.parse(line) as { + run_id: string; attempt_id: string; observation: string; + }); + assert.equal(sourceObservations.length, 2); + assert.equal(sourceObservations[1]?.run_id, crashAdmission.run_id); + const evidenceDir = process.env.DEFT_SYNC_HTTP_EVIDENCE_DIR; + if (evidenceDir) { + const resolved = resolve(evidenceDir); + const fromRepo = relative(repositoryRoot, resolved); + assert.ok(fromRepo.startsWith('..') || isAbsolute(fromRepo), + 'Evidence must be outside the repository'); + await mkdir(resolved, { recursive: true }); + await writeFile(resolve(resolved, 'checkpoint06-v2-http-evidence.json'), + JSON.stringify({ schema_version: 'deft.gate_g.sync_http_evidence.v1', + packed_tarball_sha256: createHash('sha256') + .update(await readFile(packed.tarball)).digest('hex'), + source_observations: sourceObservations, + happy: { state: terminal?.state, cursor_sequence: afterReplay?.cursor_sequence, + projection_count: projections.length, replayed: true, + signed_terminal_receipt: true }, + crash: { state: unknown?.state, cursor_sequence: crashCheckpoint?.cursor_sequence, + projection_count: 0, attempts: 1, admission_blocked: true }, + cross_audience_denied: true, revoked_consent_denied: true, + }, null, 2), 'utf8'); + } + } finally { + await Promise.allSettled(children.map(stopChild)); + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + await runtimeModule.shutdownAppRunRuntime(); + ring.keys.destroy(); + await closeDb(); + await rm(dir, { recursive: true, force: true }); + } + }); diff --git a/apps/api/test/fixtures/app-resource-sync-provider-child.ts b/apps/api/test/fixtures/app-resource-sync-provider-child.ts new file mode 100644 index 00000000..15e4f54e --- /dev/null +++ b/apps/api/test/fixtures/app-resource-sync-provider-child.ts @@ -0,0 +1,76 @@ +import { open, readFile } from 'node:fs/promises'; +import { + createResourceSyncClient, SyncPageV1Schema, + type ResourceSyncClaim, type ResourceSyncStart, type SyncPageV1, +} from '@deft/app-kit/experimental/resource-sync'; + +type Credential = Readonly<{ session_id: string; session_token: string }>; +type Start = Readonly<{ + type: 'start'; channel_url: string; credential: Credential; + source_path: string; ledger_path: string; + mode: 'normal' | 'pause_after_observe'; +}>; +type Replay = Readonly<{ + type: 'replay'; channel_url: string; credential: Credential; + claim: ResourceSyncClaim; started: ResourceSyncStart; page: SyncPageV1; +}>; + +function send(value: Record) { process.send?.(value); } + +async function observeSource(path: string, ledgerPath: string, claim: ResourceSyncClaim) { + const page = SyncPageV1Schema.parse(JSON.parse(await readFile(path, 'utf8'))); + const handle = await open(ledgerPath, 'a'); + try { + await handle.write(`${JSON.stringify({ run_id: claim.run_id, + attempt_id: claim.attempt_id, observation: 'synthetic_source_page_read' })}\n`); + await handle.sync(); + } finally { await handle.close(); } + return page; +} + +async function run(config: Start | Replay) { + const client = createResourceSyncClient({ + channel_url: config.channel_url, credential: config.credential, + }); + if (config.type === 'replay') { + await client.result(config.claim, config.started, + { status: 'returned', provider_succeeded: true, page: config.page }); + send({ type: 'replayed', run_id: config.claim.run_id }); + return; + } + const claim = await client.claim(); + if (!claim) { send({ type: 'idle' }); return; } + send({ type: 'claimed', run_id: claim.run_id, attempt_id: claim.attempt_id }); + const started = await client.start(claim); + send({ type: 'started', run_id: claim.run_id }); + const page = await observeSource(config.source_path, config.ledger_path, claim); + // The parent may SIGKILL after this IPC event; fsync has already completed. + send({ type: 'observed', run_id: claim.run_id, claim, started, page }); + if (config.mode === 'pause_after_observe') { + await new Promise(() => { setInterval(() => {}, 1_000); }); + } + await client.result(claim, started, { status: 'returned', provider_succeeded: true, page }); + send({ type: 'result', run_id: claim.run_id }); +} + +const target = process.env.DEFT_TEST_DATABASE_URL; +const assigned = target && target === process.env.DATABASE_URL + && new URL(target).hostname === '127.0.0.1' + && new URL(target).port === '55435' + && new URL(target).pathname === '/gate_g_phase5_test_s05_sync_http'; +if (!process.send || process.env.DEFT_RESOURCE_SYNC_PROVIDER_FIXTURE !== 'true' + || !assigned) { + throw new Error('Resource sync provider child requires an explicit disposable test process'); +} +process.once('message', (value: unknown) => { + if (!value || typeof value !== 'object' || !('type' in value) + || !['start', 'replay'].includes(String(value.type))) { + send({ type: 'error', code: 'INVALID_FIXTURE_CONFIG' }); + process.exitCode = 1; + return; + } + void run(value as Start | Replay).then(() => { process.exitCode = 0; }, (error: unknown) => { + send({ type: 'error', code: error instanceof Error ? error.message : 'FIXTURE_FAILED' }); + process.exitCode = 1; + }); +}); From 7f7fddfac1519e27c202e56ea0a26de87ea43b16 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:12:09 +0530 Subject: [PATCH 026/161] Freeze private sync checkpoint acceptance inventory --- scripts/gate-g/required-tests.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index fc80e900..e032b82d 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -756,6 +756,22 @@ { "file": "apps/api/test/app-resource-sync-admission-db.test.ts", "name": "host sync admission atomically creates one due intent and refuses stale or repeated authority" } ] }, + { + "id": "resource-sync-channel", + "description": "Existing Run machinery settles encrypted pages, retained output and signed receipts atomically; exact replay, key rotation, expiry rollback, authority and unsafe retry boundaries.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-channel-db.test.ts", "name": "reviewed v2 Run claims and atomically settles page, output and signed receipt" }, + { "file": "apps/api/test/app-resource-sync-channel-disabled.test.ts", "name": "v2 resource channel fails closed before parsing a request" }, + { "file": "apps/api/test/app-resource-sync-channel-disabled.test.ts", "name": "v2 pre-auth limits ignore spoofed forwarding headers and cap concurrency" } + ] + }, + { + "id": "resource-sync-http", + "description": "Outside-repository packed SDK over mounted HTTP: one encrypted page and signed receipt, exact result replay in a new provider process, and kill after fsynced source observation followed by honest unknown with no page or automatic redispatch.", + "cases": [ + { "file": "apps/api/test/app-resource-sync-http-db.test.ts", "name": "v2 SDK over HTTP commits one private page and survives provider crash/replay" } + ] + }, { "id": "fresh-upgrade-ledger", "description": "Explicit four-database synthetic profile proving current fresh history, advanced untracked rejection before mutation, interrupted fresh refusal and supported baseline adoption.", From 6fc237ad88d24cc2572d7e6e70569aae342fa3f9 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:01:00 +0530 Subject: [PATCH 027/161] Add authenticated private sync management and safe status --- .../src/lib/app-resource-sync-management.ts | 33 ++- apps/api/src/lib/app-resource-sync-status.ts | 89 +++++++ .../lib/app-resource-sync-web-authority.ts | 52 ++++ .../routes/app-resource-sync-management.ts | 159 ++++++++++++ ...p-resource-sync-management-http-db.test.ts | 232 ++++++++++++++++++ 5 files changed, 553 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-status.ts create mode 100644 apps/api/src/lib/app-resource-sync-web-authority.ts create mode 100644 apps/api/src/routes/app-resource-sync-management.ts create mode 100644 apps/api/test/app-resource-sync-management-http-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index aba21705..5b1c4ef7 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -24,6 +24,15 @@ import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, type AppResourceSyncConsentRequest } from './app-resource-sync-policy.js'; type Tx = Parameters[0]>[0]; +export type ResourceSyncManagementGuard = (tx: Tx) => Promise; + +async function managementTransaction(guard: ResourceSyncManagementGuard | undefined, operation: (tx: Tx) => Promise): Promise { + return db.transaction(async (tx) => { + const result = await operation(tx); + await guard?.(tx); + return result; + }); +} type Human = Extract; const Id = z.string().uuid(); const stale = () => new AppError('Private resource sync authority changed', 'APP_STALE', 409); @@ -91,16 +100,16 @@ export class AppResourceSyncManagement { review: Object.freeze({ ...review, review_digest: digestAppGrantValue(review) }) }; } - async prepareConsent(actor: ModuleActor, value: unknown) { + async prepareConsent(actor: ModuleActor, value: unknown, guard?: ResourceSyncManagementGuard) { reviewer(actor); const input = AppResourceSyncConsentRequestSchema.parse(value); - return db.transaction(async (tx) => (await this.#reviewContext(tx, actor, input, false)).review); + return managementTransaction(guard, async (tx) => (await this.#reviewContext(tx, actor, input, false)).review); } - async activateConsent(actor: ModuleActor, value: unknown) { + async activateConsent(actor: ModuleActor, value: unknown, guard?: ResourceSyncManagementGuard) { reviewer(actor); const input = AppResourceSyncConsentActivationSchema.parse(value); - return db.transaction(async (tx) => { + return managementTransaction(guard, async (tx) => { const { installation, version, grant, descriptor, descriptor_digest, expiresAt, review } = await this.#reviewContext(tx, actor, input, true); if (review.review_digest !== input.expected_review_digest) throw stale(); @@ -173,13 +182,13 @@ export class AppResourceSyncManagement { }); } - async issueOperatorSession(actor: ModuleActor, bindingId: string) { + async issueOperatorSession(actor: ModuleActor, bindingId: string, guard?: ResourceSyncManagementGuard) { operator(actor); bindingId = Id.parse(bindingId); const sessionId = randomUUID(); const token = randomBytes(32).toString('base64url'); const tokenHash = hashAppResourceSyncToken(token); - const issued = await db.transaction(async (tx) => { + const issued = await managementTransaction(guard, async (tx) => { const live = await loadLiveResourceSyncBindingAuthority(tx, { org_id: actor.org_id, resource_binding_id: bindingId, clock: this.clock }); if (!live || live.registration.operator_user_id !== actor.actor_id) throw denied(); @@ -210,10 +219,10 @@ export class AppResourceSyncManagement { } /** The private owner can end consent without retaining a live App grant. */ - async revokeConsent(actor: ModuleActor, bindingId: string) { + async revokeConsent(actor: ModuleActor, bindingId: string, guard?: ResourceSyncManagementGuard) { reviewer(actor); bindingId = Id.parse(bindingId); - return db.transaction(async (tx) => { + return managementTransaction(guard, async (tx) => { const [locator] = await tx.select({ owner_user_id: appResourceBindings.owner_user_id, installation_id: appResourceBindings.app_installation_id, registration_id: appResourceBindings.runtime_registration_id }) @@ -263,10 +272,10 @@ export class AppResourceSyncManagement { } /** Emergency operator registration revoke. A registration is per consent. */ - async revokeRegistration(actor: ModuleActor, registrationId: string) { + async revokeRegistration(actor: ModuleActor, registrationId: string, guard?: ResourceSyncManagementGuard) { reviewer(actor); registrationId = Id.parse(registrationId); - return db.transaction(async (tx) => { + return managementTransaction(guard, async (tx) => { const [locator] = await tx.select({ installation_id: appRuntimeRegistrations.app_installation_id, operator_user_id: appRuntimeRegistrations.operator_user_id }) .from(appRuntimeRegistrations).where(and(eq(appRuntimeRegistrations.org_id, actor.org_id), @@ -320,10 +329,10 @@ export class AppResourceSyncManagement { }); } - async revokeOperatorSession(actor: ModuleActor, sessionId: string) { + async revokeOperatorSession(actor: ModuleActor, sessionId: string, guard?: ResourceSyncManagementGuard) { operator(actor); sessionId = Id.parse(sessionId); - return db.transaction(async (tx) => { + return managementTransaction(guard, async (tx) => { const [locator] = await tx.select({ audience: appRuntimeSessions.audience, operator_user_id: appRuntimeSessions.operator_user_id, registration_id: appRuntimeSessions.runtime_registration_id, diff --git a/apps/api/src/lib/app-resource-sync-status.ts b/apps/api/src/lib/app-resource-sync-status.ts new file mode 100644 index 00000000..9795d8df --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-status.ts @@ -0,0 +1,89 @@ +import { and, asc, desc, eq, gt, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { appResourceBindings, appRuntimeRegistrations, appSyncCheckpoints, + appRuns, appRunReceipts, orgMembers } from '@deft/db/schema'; +import type { ModuleActor } from '@deft/shared/modules'; +import { db } from './db.js'; +import { AppError } from './app-errors.js'; +import { assertResourceSyncManager } from './app-resource-sync-web-authority.js'; +import type { ResourceSyncManagementGuard } from './app-resource-sync-management.js'; + +type Tx = Parameters[0]>[0]; +const Id = z.string().uuid(); +export const ResourceSyncListQuery = z.strictObject({ + after: Id.optional(), limit: z.coerce.number().int().min(1).max(50).default(20), +}); +const denied = () => new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); +const bindingFields = { binding_id: appResourceBindings.id, + installation_id: appResourceBindings.app_installation_id, + app_version_id: appResourceBindings.app_version_id, + resource_key: appResourceBindings.resource_key, state: appResourceBindings.state, + consent_expires_at: appResourceBindings.consent_expires_at, + reviewed_at: appResourceBindings.reviewed_at, updated_at: appResourceBindings.updated_at }; +async function manager(tx: Tx, actor: ModuleActor) { + assertResourceSyncManager(actor); + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${actor.org_id} + AND user_id = ${actor.actor_id} FOR SHARE`); + const [member] = await tx.select({ role: orgMembers.role, active: orgMembers.is_active }) + .from(orgMembers).where(and(eq(orgMembers.org_id, actor.org_id), + eq(orgMembers.user_id, actor.actor_id))); + if (!member?.active || !['owner', 'admin'].includes(member.role)) throw denied(); +} +/** Operational observation only. Revoked/expired consent does not prevent its + * current manager-owner from inspecting lifecycle. This is not delivery authority. */ +export async function listResourceSyncBindings(actor: ModuleActor, value: unknown, + guard?: ResourceSyncManagementGuard) { + const query = ResourceSyncListQuery.parse(value); + return db.transaction(async (tx) => { + await manager(tx, actor); + const rows = await tx.select(bindingFields).from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.owner_user_id, actor.actor_id), + query.after ? gt(appResourceBindings.id, query.after) : undefined)) + .orderBy(asc(appResourceBindings.id)).limit(query.limit + 1); + const bindings = rows.slice(0, query.limit); + await guard?.(tx); + return { bindings, next_after: rows.length > query.limit ? bindings.at(-1)!.binding_id : null }; + }); +} +export async function inspectResourceSyncBinding(actor: ModuleActor, bindingId: string, + guard?: ResourceSyncManagementGuard) { + bindingId = Id.parse(bindingId); + return db.transaction(async (tx) => { + await manager(tx, actor); + const [binding] = await tx.select(bindingFields).from(appResourceBindings).where(and( + eq(appResourceBindings.org_id, actor.org_id), eq(appResourceBindings.id, bindingId), + eq(appResourceBindings.owner_user_id, actor.actor_id))).for('share'); + if (!binding) throw denied(); + const [checkpoint] = await tx.select({ checkpoint_id: appSyncCheckpoints.id, + state: appSyncCheckpoints.state, generation: appSyncCheckpoints.generation, + cursor_sequence: appSyncCheckpoints.cursor_sequence, + retained_record_count: appSyncCheckpoints.retained_record_count, + last_applied_at: appSyncCheckpoints.last_applied_at, + last_checked_at: appSyncCheckpoints.last_checked_at, + fresh_until: appSyncCheckpoints.fresh_until }).from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, actor.org_id), eq(appSyncCheckpoints.resource_binding_id, bindingId))); + const [run] = await tx.select({ run_id: appRuns.id, state: appRuns.state, + created_at: appRuns.created_at, terminal_at: appRuns.terminal_at }).from(appRuns).where(and( + eq(appRuns.org_id, actor.org_id), eq(appRuns.origin_resource_binding_id, bindingId))) + .orderBy(desc(appRuns.created_at), desc(appRuns.id)).limit(1); + const [receipt] = run ? await tx.select({ receipt_id: appRunReceipts.id }).from(appRunReceipts) + .where(and(eq(appRunReceipts.org_id, actor.org_id), eq(appRunReceipts.run_id, run.run_id))) + .orderBy(desc(appRunReceipts.created_at), desc(appRunReceipts.id)).limit(1) : []; + await guard?.(tx); + return { binding, checkpoint: checkpoint ?? null, + latest_run: run ? { ...run, receipt_id: receipt?.receipt_id ?? null } : null }; + }); +} +/** The web surface narrows the host emergency manager API to self-owned consent. + * Called as a final guard under the host method's registration/binding locks. */ +export async function assertOwnedResourceSyncRegistration(tx: Tx, actor: ModuleActor, registrationId: string) { + const [binding] = await tx.select({ id: appResourceBindings.id }).from(appResourceBindings) + .innerJoin(appRuntimeRegistrations, and(eq(appRuntimeRegistrations.org_id, appResourceBindings.org_id), + eq(appRuntimeRegistrations.id, appResourceBindings.runtime_registration_id))) + .where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.runtime_registration_id, registrationId), + eq(appResourceBindings.owner_user_id, actor.actor_id), + eq(appRuntimeRegistrations.contract_version, 'deft.app_runtime_channel.v2'))).limit(1); + if (!binding) throw denied(); +} diff --git a/apps/api/src/lib/app-resource-sync-web-authority.ts b/apps/api/src/lib/app-resource-sync-web-authority.ts new file mode 100644 index 00000000..c6111688 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-web-authority.ts @@ -0,0 +1,52 @@ +import { and, eq, sql } from 'drizzle-orm'; +import { webSessions, orgMembers } from '@deft/db/schema'; +import type { ModuleActor } from '@deft/shared/modules'; +import { verifyWebAccess } from './web-sessions.js'; +import { humanModuleActor } from './module-service.js'; +import { AppError } from './app-errors.js'; +import type { ResourceSyncManagementGuard } from './app-resource-sync-management.js'; + +export class ResourceSyncWebAuthenticationError extends Error { + readonly code = 'APP_ACCESS_DENIED'; + readonly status = 401; +} + +/** Only the exact web-access bearer purpose is accepted; context-injected human, + * Employee, personal MCP, app developer and Runtime identities confer no authority. */ +export async function resourceSyncWebAuthority(authorization: string | undefined) { + const match = /^Bearer ([^\s]+)$/u.exec(authorization ?? ''); + if (!match) throw new ResourceSyncWebAuthenticationError('Web authentication required'); + let user: Awaited>; + try { user = await verifyWebAccess(match[1]!); } + catch { throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); } + const actor = humanModuleActor({ orgId: user.org_id, userId: user.id, + role: user.role, source: 'rest' }); + const guard: ResourceSyncManagementGuard = async (tx) => { + // Service locks run member -> App -> registration -> binding -> runtime session. + // Web session comes last, as in password/membership revocation. Do not take a + // users lock here: password changes hold users before membership and session. + await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${user.org_id} + AND user_id = ${user.id} FOR SHARE`); + const [member] = await tx.select({ role: orgMembers.role, active: orgMembers.is_active }) + .from(orgMembers).where(and(eq(orgMembers.org_id, user.org_id), eq(orgMembers.user_id, user.id))); + if (!member?.active || member.role !== user.role || member.role === 'guest') { + throw new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); + } + const [session] = await tx.select({ expires_at: webSessions.expires_at, + revoked_at: webSessions.revoked_at }).from(webSessions).where(and( + eq(webSessions.id, user.sid), eq(webSessions.user_id, user.id), + eq(webSessions.org_id, user.org_id))).for('share'); + const now = Date.now(); + if (!session || session.revoked_at || session.expires_at.getTime() <= now || user.exp * 1000 <= now) { + throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); + } + }; + return { actor, guard }; +} + +export function assertResourceSyncManager(actor: ModuleActor) { + if (actor.kind !== 'human' || !['owner', 'admin'].includes(actor.role) + || !['rest', 'ui'].includes(actor.source)) { + throw new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); + } +} diff --git a/apps/api/src/routes/app-resource-sync-management.ts b/apps/api/src/routes/app-resource-sync-management.ts new file mode 100644 index 00000000..00d5271b --- /dev/null +++ b/apps/api/src/routes/app-resource-sync-management.ts @@ -0,0 +1,159 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { AppError } from '../lib/app-errors.js'; +import { appResourceSyncChannelEnabled } from '../lib/app-resource-sync-channel.js'; +import { AppResourceSyncManagement } from '../lib/app-resource-sync-management.js'; +import { getAppRunRuntime } from '../lib/app-run-runtime.js'; +import { resourceSyncWebAuthority, ResourceSyncWebAuthenticationError } from '../lib/app-resource-sync-web-authority.js'; +import { assertOwnedResourceSyncRegistration, inspectResourceSyncBinding, + listResourceSyncBindings } from '../lib/app-resource-sync-status.js'; + +const MAX_MANAGEMENT_BODY_BYTES = 16_384; +const READ_DEADLINE_MS = 15_000; +const Id = z.string().uuid(); +async function body(c: Context, emptyOnly = false): Promise { + if (!emptyOnly && c.req.header('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') { + throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + } + const declared = Number(c.req.header('content-length') ?? 0); + if (!Number.isSafeInteger(declared) || declared < 0 || declared > MAX_MANAGEMENT_BODY_BYTES) { + throw new AppError('Private sync request too large', 'APP_ACTION_INVALID', 413); + } + const reader = c.req.raw.body?.getReader(); + if (!reader) { + if (emptyOnly) return null; + throw new AppError('JSON request required', 'APP_ACTION_INVALID', 400); + } + const chunks: Uint8Array[] = []; + let size = 0; + const deadline = Date.now() + READ_DEADLINE_MS; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new AppError('Private sync request timed out', 'APP_ACTION_INVALID', 400); + let timer: ReturnType | undefined; + const next = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new AppError('Private sync request timed out', 'APP_ACTION_INVALID', 400)), remaining); + }), + ]).finally(() => { if (timer) clearTimeout(timer); }); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_MANAGEMENT_BODY_BYTES) { + throw new AppError('Private sync request too large', 'APP_ACTION_INVALID', 413); + } + chunks.push(next.value); + } + } catch (error) { + void reader.cancel().catch(() => {}); + throw error; + } finally { reader.releaseLock(); } + if (emptyOnly) { + if (size !== 0) throw new SyntaxError('Unexpected body'); + return null; + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; +} + +function failure(c: Context, error: unknown) { + if (error instanceof AppError || error instanceof ResourceSyncWebAuthenticationError) return c.json({ error: error.message, code: error.code }, error.status); + if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError) { + return c.json({ error: 'Invalid private sync management request', code: 'VALIDATION_ERROR' }, 400); + } + console.error('[app-resource-sync-management] request failed'); + return c.json({ error: 'Private sync management request failed', code: 'INTERNAL_ERROR' }, 500); +} + +export function createAppResourceSyncManagementRoutes(options: { + management: () => Promise; +}) { + const routes = new Hono(); + routes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + if (!appResourceSyncChannelEnabled()) { + return c.json({ error: 'Private sync management unavailable', code: 'APP_RESOURCE_SYNC_DISABLED' }, 503); + } + // Authenticate before parsing bodies or looking up Runtime keys. Each handler + // authenticates again after body consumption and pins the final transaction SID. + try { await resourceSyncWebAuthority(c.req.header('authorization')); } + catch (error) { return failure(c, error); } + await next(); + }); + routes.post('/reviews/prepare', async (c) => { + try { + z.strictObject({}).parse(c.req.query()); + const input = await body(c); + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + return c.json({ review: await (await options.management()).prepareConsent(actor, input, guard) }); + } catch (error) { return failure(c, error); } + }); + routes.post('/bindings/activate', async (c) => { + try { + z.strictObject({}).parse(c.req.query()); + const input = await body(c); + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + return c.json({ binding: await (await options.management()).activateConsent(actor, input, guard) }, 201); + } catch (error) { return failure(c, error); } + }); + routes.get('/bindings', async (c) => { + try { + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + const entries = [...new URL(c.req.url).searchParams.entries()]; + if (new Set(entries.map(([key]) => key)).size !== entries.length) throw new SyntaxError('Duplicate query'); + return c.json(await listResourceSyncBindings(actor, Object.fromEntries(entries), guard)); + } catch (error) { return failure(c, error); } + }); + routes.get('/bindings/:bindingId', async (c) => { + try { + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + z.strictObject({}).parse(c.req.query()); + return c.json(await inspectResourceSyncBinding(actor, Id.parse(c.req.param('bindingId')), guard)); + } catch (error) { return failure(c, error); } + }); + // No-body operations reject a body/query rather than ignoring caller-supplied authority. + async function operation(c: Context) { + z.strictObject({}).parse(c.req.query()); + await body(c, true); + return resourceSyncWebAuthority(c.req.header('authorization')); + } + routes.post('/bindings/:bindingId/sessions', async (c) => { + try { + const { actor, guard } = await operation(c); + return c.json({ session: await (await options.management()).issueOperatorSession(actor, + Id.parse(c.req.param('bindingId')), guard) }, 201); + } catch (error) { return failure(c, error); } + }); + routes.post('/bindings/:bindingId/revoke', async (c) => { + try { + const { actor, guard } = await operation(c); + return c.json(await (await options.management()).revokeConsent(actor, Id.parse(c.req.param('bindingId')), guard)); + } catch (error) { return failure(c, error); } + }); + routes.post('/registrations/:registrationId/revoke', async (c) => { + try { + const { actor, guard } = await operation(c); + const id = Id.parse(c.req.param('registrationId')); + return c.json(await (await options.management()).revokeRegistration(actor, id, async (tx) => { + await assertOwnedResourceSyncRegistration(tx, actor, id); + await guard(tx); + })); + } catch (error) { return failure(c, error); } + }); + routes.post('/sessions/:sessionId/revoke', async (c) => { + try { + const { actor, guard } = await operation(c); + return c.json(await (await options.management()).revokeOperatorSession(actor, + Id.parse(c.req.param('sessionId')), guard)); + } catch (error) { return failure(c, error); } + }); + return routes; +} + +export const appResourceSyncManagementRoutes = createAppResourceSyncManagementRoutes({ + management: async () => new AppResourceSyncManagement((await getAppRunRuntime()).keys), +}); diff --git a/apps/api/test/app-resource-sync-management-http-db.test.ts b/apps/api/test/app-resource-sync-management-http-db.test.ts new file mode 100644 index 00000000..681b14f2 --- /dev/null +++ b/apps/api/test/app-resource-sync-management-http-db.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { ServerType } from '@hono/node-server'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!target || target !== process.env.DATABASE_URL) return false; + try { const u = new URL(target); return ['postgres:', 'postgresql:'].includes(u.protocol) + && u.hostname === '127.0.0.1' && u.port === '55435' + && /^\/gate_g_20260926_(?:management|root)(?:_v[0-9]+)?$/.test(u.pathname) + && !u.search && !u.hash; } catch { return false; } +})(); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`management-http:${purpose}`).digest('base64') } }); +const keyring = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('mgmt-enc'), receipt_signing: ring('mgmt-sign'), fingerprint: ring('mgmt-fp') }); +process.env.DEFT_APP_RUN_KEYRINGS = keyring; +after(async () => { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + await (await import('../src/lib/db.js')).closeDb(); +}); + +async function harness() { + const [{ db }, schema, drizzle, session, keysModule, routes, hono, serverModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/routes/app-resource-sync-management.js'), import('hono'), import('@hono/node-server'), + ]); + const keys = keysModule.parseEnvironmentAppRunKeyrings(keyring); + const fixture = await createReviewedResourceSyncFixture({ keys, clock: () => new Date() }); + const app = new hono.Hono(); + app.route('/manage', routes.createAppResourceSyncManagementRoutes({ management: async () => fixture.management })); + let server!: ServerType; + const base = await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + resolve(`http://127.0.0.1:${info.port}/manage`); + }); + }); + const token = async (id: string, orgId = fixture.org_id) => { + const [user] = await db.select().from(schema.users).where(drizzle.eq(schema.users.id, id)); + return session.createWebSession({ id, org_id: orgId, email: user!.email }); + }; + const owner = await token(fixture.owner_user_id); + const operator = await token(fixture.operator_user_id); + const call = async (path: string, method = 'GET', value?: unknown, bearer = owner.accessToken) => { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + return { status: response.status, body: await response.json() as any }; + }; + return { db, schema, ...drizzle, ...fixture, owner, operator, call, token, session, base, + close: async () => { await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); keys.destroy(); } }; +} + +test('private sync management rejects disabled rollout and non-web credentials over HTTP', async () => { + const [{ Hono }, { serve }, routes] = await Promise.all([import('hono'), import('@hono/node-server'), + import('../src/routes/app-resource-sync-management.js')]); + const app = new Hono(); + app.route('/manage', routes.createAppResourceSyncManagementRoutes({ management: async () => { throw Error('must not initialize'); } })); + let server!: ServerType; + const base = await new Promise(resolve => { + server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, info => resolve(`http://127.0.0.1:${info.port}`)); + }); + try { + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + const disabled = await fetch(`${base}/manage/bindings`); + assert.equal(disabled.status, 503); + assert.equal(disabled.headers.get('cache-control'), 'no-store'); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + for (const Authorization of ['', 'AppRuntime synthetic_runtime_token', 'Bearer synthetic_mcp_token']) { + const denied = await fetch(`${base}/manage/bindings`, { headers: { Authorization } }); + assert.equal(denied.status, 401); + assert.equal(denied.headers.get('cache-control'), 'no-store'); + } + } finally { process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + await new Promise(resolve => server.close(() => resolve())); } +}); + +test('private sync HTTP owner review activation, operator-only credential and strict request boundaries', { skip: !safe }, async () => { + const h = await harness(); + try { + const ownStatus = await h.call(`/bindings/${h.binding_id}`); + assert.equal(ownStatus.status, 200); + assert.equal(ownStatus.body.binding.resource_key, 'inbox'); + assert.equal(ownStatus.body.checkpoint.cursor_sequence, 0); + assert.equal(ownStatus.body.latest_run, null); + const readText = JSON.stringify(ownStatus.body); + for (const secret of ['cursor_hmac', 'cursor_ciphertext', 'session_token', 'token_hash', + 'provider_instance_id', 'reviewed_descriptor', 'safe_outcome', 'authorization_snapshot']) assert.ok(!readText.includes(secret)); + assert.equal((await h.call(`/bindings/${h.binding_id}/sessions`, 'POST')).status, 403); + const issued = await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken); + assert.equal(issued.status, 201); + assert.match(issued.body.session.session_token, /^[A-Za-z0-9_-]{40,}$/); + assert.equal((await h.call(`/sessions/${issued.body.session.session_id}/revoke`, 'POST')).status, 403); + assert.equal((await h.call(`/sessions/${issued.body.session.session_id}/revoke`, 'POST', undefined, h.operator.accessToken)).status, 200); + assert.equal((await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', { owner_user_id: h.owner_user_id }, h.operator.accessToken)).status, 400); + assert.equal((await h.call('/reviews/prepare', 'POST', { ...h.consent_request, owner_user_id: h.operator_user_id })).status, 400); + assert.equal((await h.call('/reviews/prepare', 'POST', h.consent_request, h.operator.accessToken)).status, 403); + assert.equal((await h.call(`/bindings/${h.binding_id}/revoke`, 'POST')).status, 200); + const review = await h.call('/reviews/prepare', 'POST', h.consent_request); + assert.equal(review.status, 200); + const activation = { ...h.consent_request, expected_review_digest: review.body.review.review_digest, accept_host_policy: true }; + assert.equal((await h.call('/bindings/activate', 'POST', { ...activation, expected_review_digest: `sha256:${'0'.repeat(64)}` })).status, 409); + const activated = await h.call('/bindings/activate', 'POST', activation); + assert.equal(activated.status, 201); + assert.equal((await h.call('/bindings/activate', 'POST', activation)).status, 409); + assert.equal((await h.call(`/registrations/${activated.body.binding.registration_id}/revoke`, 'POST')).status, 200); + assert.equal((await h.call(`/bindings/${activated.body.binding.binding_id}`)).body.binding.state, 'revoked'); + } finally { await h.close(); } +}); + +test('private sync HTTP current SID membership tenant and self-owner privacy with bounded pagination', { skip: !safe }, async () => { + const h = await harness(); + try { + const { db, schema: s, eq, and } = h; + assert.equal((await h.call(`/bindings/${h.binding_id}`, 'GET', undefined, h.operator.accessToken)).status, 403); + await db.update(s.orgMembers).set({ role: 'guest' }).where(and(eq(s.orgMembers.org_id, h.org_id), eq(s.orgMembers.user_id, h.operator_user_id))); + assert.equal((await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken)).status, 403); + await db.update(s.orgMembers).set({ role: 'admin' }).where(and(eq(s.orgMembers.org_id, h.org_id), eq(s.orgMembers.user_id, h.operator_user_id))); + assert.equal((await h.call(`/bindings/${h.binding_id}`, 'GET', undefined, h.operator.accessToken)).status, 403); + assert.deepEqual((await h.call('/bindings', 'GET', undefined, h.operator.accessToken)).body.bindings, []); + assert.equal((await h.call(`/registrations/${h.registration_id}/revoke`, 'POST', undefined, h.operator.accessToken)).status, 403); + assert.equal((await h.call(`/bindings/${h.binding_id}`)).body.binding.state, 'active', 'denied registration mutation rolls back'); + const foreignOrg = randomUUID(); + await db.insert(s.orgs).values({ id: foreignOrg, name: 'foreign', slug: `foreign-${foreignOrg}` }); + await db.insert(s.orgMembers).values({ org_id: foreignOrg, user_id: h.owner_user_id, role: 'owner', is_active: true }); + const foreign = await h.token(h.owner_user_id, foreignOrg); + assert.equal((await h.call(`/bindings/${h.binding_id}`, 'GET', undefined, foreign.accessToken)).status, 403); + assert.deepEqual((await h.call('/bindings', 'GET', undefined, foreign.accessToken)).body.bindings, []); + const jwt = (await import('jsonwebtoken')).default; + const { env } = await import('../src/lib/env.js'); + for (const token of [h.owner.refreshToken, jwt.sign({ id: h.owner_user_id, org_id: h.org_id, + email: 'synthetic@example.test', purpose: 'employee', sid: randomUUID(), jti: randomUUID() }, env.JWT_SECRET, { expiresIn: 60 }), + jwt.sign({ id: h.owner_user_id, org_id: h.org_id, email: 'synthetic@example.test', purpose: 'web-access', + sid: randomUUID(), jti: randomUUID() }, env.JWT_SECRET, { expiresIn: 60 })]) { + assert.equal((await h.call('/bindings', 'GET', undefined, token)).status, 401); + } + for (const query of ['?limit=0', '?limit=51', '?after=bad', '?owner_user_id=other', '?limit=1&limit=2']) { + assert.equal((await h.call(`/bindings${query}`)).status, 400); + } + await h.call(`/bindings/${h.binding_id}/revoke`, 'POST'); + const shortRequest = { ...h.consent_request, consent_expires_at: new Date(Date.now() + 3000).toISOString() }; + const shortReview = await h.management.prepareConsent(h.owner_actor, shortRequest); + const next = await h.management.activateConsent(h.owner_actor, { ...shortRequest, + expected_review_digest: shortReview.review_digest, accept_host_policy: true }); + const first = await h.call('/bindings?limit=1'); + const second = await h.call(`/bindings?limit=1&after=${first.body.next_after}`); + assert.equal(first.body.bindings.length, 1); assert.equal(second.body.bindings.length, 1); + assert.equal(second.body.next_after, null); + assert.deepEqual(new Set([first.body.bindings[0].binding_id, second.body.bindings[0].binding_id]), new Set([h.binding_id, next.binding_id])); + await new Promise(resolve => setTimeout(resolve, Math.max(0, new Date(shortRequest.consent_expires_at).getTime() - Date.now() + 10))); + assert.ok(new Date(shortRequest.consent_expires_at).getTime() <= Date.now()); + assert.equal((await h.call(`/bindings/${next.binding_id}`)).status, 200, 'expired consent still inspectable'); + assert.equal((await h.call(`/bindings/${next.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken)).status, 403); + await db.update(s.orgMembers).set({ role: 'member' }).where(and(eq(s.orgMembers.org_id, h.org_id), eq(s.orgMembers.user_id, h.owner_user_id))); + assert.equal((await h.call('/bindings')).status, 403, 'current role overrides JWT session identity'); + await db.update(s.orgMembers).set({ role: 'owner', is_active: false }).where(and(eq(s.orgMembers.org_id, h.org_id), eq(s.orgMembers.user_id, h.owner_user_id))); + assert.equal((await h.call('/bindings')).status, 401); + await db.update(s.orgMembers).set({ is_active: true }).where(and(eq(s.orgMembers.org_id, h.org_id), eq(s.orgMembers.user_id, h.owner_user_id))); + await db.update(s.webSessions).set({ expires_at: new Date(Date.now() - 1000) }).where(and(eq(s.webSessions.org_id, h.org_id), eq(s.webSessions.user_id, h.owner_user_id))); + assert.equal((await h.call('/bindings')).status, 401); + } finally { await h.close(); } +}); + +test('private sync HTTP revocation while waiting rolls back session issuance at final SID guard', { skip: !safe }, async () => { + const h = await harness(); + let release = () => {}; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM org_members WHERE org_id = ${h.org_id} + AND user_id = ${h.operator_user_id} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken); + let observedWait = false; + for (let i = 0; i < 100; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname=current_database() AND pid <> pg_backend_pid() AND wait_event_type='Lock' AND query LIKE '%org_members%'`); + if (result.rows[0]!.waiting > 0) { observedWait = true; break; } + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.ok(observedWait, 'actual HTTP request waited on member lock'); + await h.session.revokeWebSession(h.operator.refreshToken); + release(); await blocker; + assert.equal((await pending).status, 401); + const sessions = await h.db.select().from(h.schema.appRuntimeSessions).where(h.eq(h.schema.appRuntimeSessions.resource_binding_id, h.binding_id)); + assert.equal(sessions.length, 0, 'session insert rolls back after web revocation'); + const audits = await h.db.select().from(h.schema.auditLog).where(h.and(h.eq(h.schema.auditLog.org_id, h.org_id), + h.eq(h.schema.auditLog.action, 'app.resource_sync_session_issue'))); + assert.equal(audits.length, 0, 'issuance audit rolls back in the same transaction'); + } finally { release(); await h.close(); } +}); + +test('private sync HTTP status exposes generic Run metadata without private Run result authority', { skip: !safe }, async () => { + const h = await harness(); + try { + const { getAppRunRuntime } = await import('../src/lib/app-run-runtime.js'); + const runtime = await getAppRunRuntime(); + const admitted = await runtime.resourceSyncAdmission.admitDue({ org_id: h.org_id, resource_binding_id: h.binding_id }); + assert.equal(admitted.state, 'created'); + assert.ok('run_id' in admitted); + const canary = `private-provider-detail-${randomUUID()}`; + await h.db.update(h.schema.appRuns).set({ safe_outcome: { detail: canary } }) + .where(h.and(h.eq(h.schema.appRuns.org_id, h.org_id), h.eq(h.schema.appRuns.id, admitted.run_id))); + const status = await h.call(`/bindings/${h.binding_id}`); + assert.equal(status.status, 200); + assert.deepEqual(Object.keys(status.body.latest_run).sort(), ['created_at', 'receipt_id', 'run_id', 'state', 'terminal_at']); + assert.equal(status.body.latest_run.run_id, admitted.run_id); + assert.equal(status.body.latest_run.state, 'pending'); + assert.equal(status.body.latest_run.receipt_id, null); + assert.ok(!JSON.stringify(status.body).includes(canary)); + const { PostgresAppRunAuthorizer } = await import('../src/lib/app-run-authorization.js'); + const run = await runtime.repository.inspect(h.org_id, admitted.run_id); + assert.ok(run); + for (const action of ['inspect', 'result'] as const) { + assert.equal(await new PostgresAppRunAuthorizer().authorize({ action, org_id: h.org_id, + actor: { actor_type: 'human', user_id: h.owner_user_id }, run, required_authority_ref: null }), false); + } + } finally { await h.close(); } +}); From e39725a50aad49818489dce8aeecbee84af3444b Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:01:09 +0530 Subject: [PATCH 028/161] feat: add bounded owner-private synchronized resource reads --- apps/api/src/lib/app-resource-private-read.ts | 213 +++++++++++ .../test/app-resource-private-read-db.test.ts | 351 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 apps/api/src/lib/app-resource-private-read.ts create mode 100644 apps/api/test/app-resource-private-read-db.test.ts diff --git a/apps/api/src/lib/app-resource-private-read.ts b/apps/api/src/lib/app-resource-private-read.ts new file mode 100644 index 00000000..c0f7a6a7 --- /dev/null +++ b/apps/api/src/lib/app-resource-private-read.ts @@ -0,0 +1,213 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { and, asc, eq, gt, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { appResourceProjections, appSyncCheckpoints } from '@deft/db/schema'; +import { parseSyncPage } from '@deft/app-kit/experimental/resource-sync'; +import { AppRuntimeResourceRefV2Schema, canonicalCapabilityJson } from '@deft/shared'; +import type { ResourceRefV2 } from '@deft/shared'; +import type { AppRunKeyProvider } from './app-run-keyrings.js'; +import { PostgresAppRunRepository, type AppRunTransaction } from './app-run-repository.js'; +import { loadLiveResourceSyncBindingAuthority } from './app-resource-sync-authority.js'; +import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; + +export const APP_RESOURCE_PRIVATE_READ_LIMITS = Object.freeze({ items: 25, response_bytes: 1_048_576 }); +const uuid = z.string().uuid().transform((value) => value.toLowerCase()); +const subjectSchema = z.strictObject({ kind: z.literal('human'), org_id: uuid, user_id: uuid }); +const pageSchema = z.strictObject({ resource_binding_id: uuid, + limit: z.number().int().min(1).max(APP_RESOURCE_PRIVATE_READ_LIMITS.items).optional(), + cursor: z.string().min(1).max(2_048).optional() }); +const oneSchema = z.strictObject({ resource_binding_id: uuid, projection_id: uuid }); +const sequence = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const cursorSchema = z.strictObject({ version: z.literal(1), key_version: z.string().min(1).max(128), + org_id: uuid, resource_binding_id: uuid, checkpoint_id: uuid, + generation: sequence.refine((value) => value > 0), cursor_sequence: sequence, after: uuid }); +type Cursor = z.infer; +export type AppResourcePrivateReadSubject = z.input; +export type PrivateResourceRecord = Readonly<{ projection_id: string; ref: ResourceRefV2; + resource_type: string; label: string; revision: string; + data: Record; freshness: 'unknown' }>; +export type PrivateResourceCheckpoint = Readonly<{ generation: number; cursor_sequence: number; + last_applied_at: string | null; freshness: 'unknown' }>; +export type PrivateResourcePage = Readonly<{ items: readonly PrivateResourceRecord[]; + next_cursor: string | null; checkpoint: PrivateResourceCheckpoint }>; + +export class AppResourcePrivateReadError extends Error { + constructor(readonly code: 'APP_RESOURCE_PRIVATE_UNAVAILABLE' | 'APP_RESOURCE_PRIVATE_CURSOR_STALE' + | 'APP_RESOURCE_PRIVATE_INPUT_INVALID', readonly status: 400 | 404 | 409) { + super(code === 'APP_RESOURCE_PRIVATE_CURSOR_STALE' ? 'Private resource page changed; restart the read' + : code === 'APP_RESOURCE_PRIVATE_INPUT_INVALID' ? 'Invalid private resource read' + : 'Private resource unavailable'); + this.name = 'AppResourcePrivateReadError'; + } +} +const unavailable = () => new AppResourcePrivateReadError('APP_RESOURCE_PRIVATE_UNAVAILABLE', 404); +const invalid = () => new AppResourcePrivateReadError('APP_RESOURCE_PRIVATE_INPUT_INVALID', 400); + +/** Host-internal seam: callers must authenticate a current human session before + * constructing the subject. Neither a ResourceRef nor a Worker assertion grants access. */ +export class AppResourcePrivateReadService { + readonly #secrets: AppResourceSyncSecretService; + constructor(private readonly keys: AppRunKeyProvider, + private readonly clock: () => Date = () => new Date(), + private readonly repository: Pick = new PostgresAppRunRepository()) { + this.#secrets = new AppResourceSyncSecretService(keys); + } + + async listOwnerPrivateResourcePage(rawSubject: AppResourcePrivateReadSubject, + rawInput: z.input): Promise { + const subject = subjectSchema.safeParse(rawSubject); + const input = pageSchema.safeParse(rawInput); + if (!subject.success || !input.success) throw invalid(); + return this.#read(subject.data, input.data.resource_binding_id, async (tx, authority, checkpoint) => { + const cursor = input.data.cursor ? this.#openCursor(input.data.cursor) : null; + if (cursor && (cursor.org_id !== subject.data.org_id + || cursor.resource_binding_id !== authority.binding.id)) throw unavailable(); + if (cursor && (cursor.checkpoint_id !== checkpoint.id || cursor.generation !== checkpoint.generation + || cursor.cursor_sequence !== checkpoint.cursor_sequence)) { + throw new AppResourcePrivateReadError('APP_RESOURCE_PRIVATE_CURSOR_STALE', 409); + } + const limit = input.data.limit ?? APP_RESOURCE_PRIVATE_READ_LIMITS.items; + const rows = await tx.select().from(appResourceProjections).where(and( + ...this.#scope(subject.data.org_id, authority.binding.id, checkpoint), + cursor ? gt(appResourceProjections.id, cursor.after) : undefined, + )).orderBy(asc(appResourceProjections.id)).limit(limit + 1); + const metadata = this.#checkpoint(checkpoint); + const items: PrivateResourceRecord[] = []; + let nextCursor: string | null = null; + for (const [index, row] of rows.slice(0, limit).entries()) { + const item = this.#record(row, authority); + const candidateCursor = index + 1 < rows.length ? this.#sealCursor({ + org_id: subject.data.org_id, resource_binding_id: authority.binding.id, + checkpoint_id: checkpoint.id, generation: checkpoint.generation, + cursor_sequence: checkpoint.cursor_sequence, after: row.id, + }) : null; + const candidate = { items: [...items, item], next_cursor: candidateCursor, checkpoint: metadata }; + if (Buffer.byteLength(JSON.stringify(candidate), 'utf8') > APP_RESOURCE_PRIVATE_READ_LIMITS.response_bytes) { + if (items.length === 0) throw unavailable(); + // The preceding cursor already points to the last returned row; the + // oversized candidate will be considered first on the next page. + break; + } + items.push(item); + nextCursor = candidateCursor; + } + return { items, next_cursor: nextCursor, checkpoint: metadata }; + }); + } + + async getOwnerPrivateResource(rawSubject: AppResourcePrivateReadSubject, + rawInput: z.input): Promise> { + const subject = subjectSchema.safeParse(rawSubject); + const input = oneSchema.safeParse(rawInput); + if (!subject.success || !input.success) throw invalid(); + return this.#read(subject.data, input.data.resource_binding_id, async (tx, authority, checkpoint) => { + const [row] = await tx.select().from(appResourceProjections).where(and( + ...this.#scope(subject.data.org_id, authority.binding.id, checkpoint), + eq(appResourceProjections.id, input.data.projection_id), + )).limit(1); + if (!row) throw unavailable(); + return { item: this.#record(row, authority), checkpoint: this.#checkpoint(checkpoint) }; + }); + } + + async #read(subject: AppResourcePrivateReadSubject, bindingId: string, + read: (tx: AppRunTransaction, authority: Authority, checkpoint: Checkpoint) => Promise): Promise { + return this.repository.transaction(async (tx) => { + const authority = await loadLiveResourceSyncBindingAuthority(tx, { + org_id: subject.org_id, resource_binding_id: bindingId, clock: this.clock, + }); + if (!authority || authority.binding.owner_user_id !== subject.user_id) throw unavailable(); + await tx.execute(sql`SELECT id FROM app_sync_checkpoints WHERE org_id = ${subject.org_id} + AND resource_binding_id = ${bindingId} AND state = 'active' FOR SHARE`); + const checkpoints = await tx.select().from(appSyncCheckpoints).where(and( + eq(appSyncCheckpoints.org_id, subject.org_id), eq(appSyncCheckpoints.resource_binding_id, bindingId), + eq(appSyncCheckpoints.state, 'active'), + )).limit(2); + const checkpoint = checkpoints[0]; + if (!checkpoint || checkpoints.length !== 1) throw unavailable(); + const assertConsent = () => { + const now = this.clock(); + if (!(now instanceof Date) || !Number.isFinite(now.getTime()) + || !authority.binding.consent_expires_at || authority.binding.consent_expires_at <= now) throw unavailable(); + }; + assertConsent(); + const result = await read(tx, authority, checkpoint); + assertConsent(); + return result; + }); + } + + #scope(orgId: string, bindingId: string, checkpoint: Checkpoint) { + return [eq(appResourceProjections.org_id, orgId), eq(appResourceProjections.resource_binding_id, bindingId), + eq(appResourceProjections.checkpoint_id, checkpoint.id), + eq(appResourceProjections.generation, checkpoint.generation), eq(appResourceProjections.state, 'live')]; + } + + #checkpoint(checkpoint: Checkpoint): PrivateResourceCheckpoint { + // Settlement currently makes no provider freshness assertion. Its timestamp + // is a host observation, never evidence that the provider is current. + return { generation: checkpoint.generation, cursor_sequence: checkpoint.cursor_sequence, + last_applied_at: checkpoint.last_applied_at?.toISOString() ?? null, freshness: 'unknown' }; + } + + #record(row: typeof appResourceProjections.$inferSelect, authority: Authority): PrivateResourceRecord { + try { + const body = z.strictObject({ revision: z.string(), data: z.unknown() }).parse(this.#secrets.openJson({ + schema_version: row.body_envelope_version, algorithm: row.body_algorithm, + key_version: row.body_key_version, nonce_b64: row.body_nonce_b64, + ciphertext_b64: row.body_ciphertext_b64, auth_tag_b64: row.body_auth_tag_b64, + }, { org_id: row.org_id, resource_binding_id: row.resource_binding_id, + checkpoint_id: row.checkpoint_id, payload_kind: 'projection', generation: row.generation, + projection_id: row.id, slot: 'record' })); + const parsed = parseSyncPage(authority.descriptor, { + schema_version: 'deft.app_sync_request.v1', cursor: null, max_items: 1, + // The ID is only a parser placeholder; never decrypt the provider ID. + // Its minimum length cannot inflate a valid near-ceiling stored page. + }, { schema_version: 'deft.app_sync_page.v1', upserts: [{ id: 'x', ...body }], + tombstones: [], next_cursor: null, has_more: false }).upserts[0]!; + const label = (parsed.data[authority.descriptor.label_field] as string) + .replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim(); + const ref = AppRuntimeResourceRefV2Schema.parse({ schema_version: 'deft.resource_ref.v2', + provider: { kind: 'app_runtime', provider_instance_id: authority.registration.id }, + resource_type: authority.descriptor.resource_type, resource_id: row.id }); + return { projection_id: row.id, ref, resource_type: authority.descriptor.resource_type, + label, revision: parsed.revision, data: parsed.data, freshness: 'unknown' }; + } catch { throw unavailable(); } + } + + #sealCursor(value: Omit): string { + const key = this.keys.current('fingerprint'); + try { + const payload = Buffer.from(canonicalCapabilityJson(cursorSchema.parse({ ...value, + version: 1, key_version: key.key_id }))).toString('base64url'); + const mac = createHmac('sha256', key.key).update('deft.resource_private_read.cursor.v1\0') + .update(payload).digest('base64url'); + return `${payload}.${mac}`; + } finally { key.key.fill(0); } + } + + #openCursor(value: string): Cursor { + try { + const parts = value.split('.'); + if (parts.length !== 2 || !parts.every((part) => /^[A-Za-z0-9_-]+$/u.test(part))) throw unavailable(); + const payload = parts[0]!; + const bytes = Buffer.from(payload, 'base64url'); + if (bytes.toString('base64url') !== payload) throw unavailable(); + const cursor = cursorSchema.parse(JSON.parse(bytes.toString('utf8'))); + const key = this.keys.read('fingerprint', cursor.key_version); + if (!key) throw unavailable(); + try { + const mac = Buffer.from(parts[1]!, 'base64url'); + const expected = createHmac('sha256', key.key).update('deft.resource_private_read.cursor.v1\0') + .update(payload).digest(); + if (mac.length !== expected.length || mac.toString('base64url') !== parts[1] + || !timingSafeEqual(mac, expected)) throw unavailable(); + } finally { key.key.fill(0); } + return cursor; + } catch { throw unavailable(); } + } +} + +type Authority = NonNullable>>; +type Checkpoint = typeof appSyncCheckpoints.$inferSelect; diff --git a/apps/api/test/app-resource-private-read-db.test.ts b/apps/api/test/app-resource-private-read-db.test.ts new file mode 100644 index 00000000..3326415d --- /dev/null +++ b/apps/api/test/app-resource-private-read-db.test.ts @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import type { SyncDescriptorV1, SyncPageV1 } from '@deft/app-kit/experimental/resource-sync'; +import type { AppRunTransaction } from '../src/lib/app-run-repository.js'; + +const assigned = process.env.DATABASE_URL === process.env.DEFT_TEST_DATABASE_URL + && process.env.DATABASE_URL === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_private_read'; + +test('owner-private reads of actual reviewed and settled v5 resources', { skip: !assigned }, async (t) => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + const key = (purpose: string) => createHash('sha256').update(`private-read:${purpose}`).digest('base64'); + const ring = { schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'read-enc', keys: { 'read-enc': key('enc') } }, + receipt_signing: { current: 'read-sig', keys: { 'read-sig': key('sig') } }, + fingerprint: { current: 'read-fp', keys: { 'read-fp': key('fp') } } }; + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify(ring); + const [{ db, closeDb }, schema, { and, eq, sql }, fixture, keyrings, repositories, + secretModule, inputModule, syncSecretModule, storeModule, admissionModule, + runnerModule, channelModule, providerModule, queueModule, receiptModule, readModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('./fixtures/resource-sync-v5.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-repository.js'), import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-secret-repository.js'), import('../src/lib/app-resource-sync-secrets.js'), + import('../src/lib/app-resource-sync-store.js'), import('../src/lib/app-resource-sync-admission.js'), + import('../src/lib/app-run-attempt-runner.js'), import('../src/lib/app-resource-sync-channel.js'), + import('../src/lib/app-run-provider-executor.js'), import('../src/lib/app-run-scheduler.js'), + import('../src/lib/app-run-receipts.js'), import('../src/lib/app-resource-private-read.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify(ring)); + let checkedAt = new Date(); + const clock = () => new Date(checkedAt); + const repository = new repositories.PostgresAppRunRepository(); + const secrets = new secretModule.AppRunSecretService(keys); + const inputs = new inputModule.AppRunSecretRepository(secrets); + const syncSecrets = new syncSecretModule.AppResourceSyncSecretService(keys); + const receiptWriter = new receiptModule.PostgresAppRunReceiptWriter(secrets, inputs); + const makeRunner = (writer: typeof receiptWriter = receiptWriter) => new runnerModule.AppRunAttemptRunner(repository, inputs, secrets, + new providerModule.PinnedMcpAppRunProviderExecutor(), undefined, clock, 60_000, 20_000, + writer, undefined, + queueModule.postgresAppRunAttemptQueue, new storeModule.AppResourceSyncStore(syncSecrets, inputs)); + const runner = makeRunner(); + const admission = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + secrets, syncSecrets, runner, clock, () => true); + const channel = new channelModule.AppResourceSyncChannel(runner); + const reader = new readModule.AppResourcePrivateReadService(keys, clock); + type Fixture = Awaited>; + const subject = (owned: Fixture) => ({ kind: 'human' as const, + org_id: owned.org_id, user_id: owned.owner_user_id }); + const target = (owned: Fixture) => ({ resource_binding_id: owned.binding_id }); + const unavailable = (error: unknown) => { + assert.ok(error instanceof readModule.AppResourcePrivateReadError); + assert.equal(error.message, 'Private resource unavailable'); + assert.equal(error.status, 404); + return error.code === 'APP_RESOURCE_PRIVATE_UNAVAILABLE'; + }; + const settle = async (owned: Fixture, upserts: SyncPageV1['upserts'], + tombstones: SyncPageV1['tombstones'] = [], settleChannel = channel) => { + checkedAt = new Date(checkedAt.getTime() + 61_000); + const admitted = await admission.admitDue({ org_id: owned.org_id, resource_binding_id: owned.binding_id }); + assert.equal(admitted.state, 'created'); + const issued = await owned.management.issueOperatorSession(owned.operator_actor, owned.binding_id); + const base = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, session_id: issued.session_id, session_token: issued.session_token }; + const claim = await channel.claim({ ...base, max_claims: 1 }); + assert.ok(claim); + const attempt = { ...base, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + assert.ok(await channel.start(attempt)); + assert.ok(await settleChannel.complete({ ...attempt, status: 'returned', provider_succeeded: true, + page: { schema_version: 'deft.app_sync_page.v1', upserts, tombstones, + next_cursor: `provider-cursor-${randomUUID()}`, has_more: false } })); + }; + const fresh = async (descriptor?: SyncDescriptorV1) => { + const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock, descriptor }); + await settle(owned, [{ id: 'provider-private-id', revision: 'r1', data: { subject: 'Private label' } }]); + return owned; + }; + const waitForLock = async (marker: string) => { + for (let i = 0; i < 300; i++) { + const result = await db.execute(sql`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE application_name = ${marker} AND wait_event_type = 'Lock'`); + if (Number((result as { rows: Array<{ count: number }> }).rows[0]?.count) > 0) return; + await delay(10); + } + assert.fail('actual PostgreSQL lock wait was not observed'); + }; + const markedReader = (marker: string, readClock = clock) => { + class WaitingRepository extends repositories.PostgresAppRunRepository { + override transaction(work: (tx: AppRunTransaction) => Promise): Promise { + return super.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('application_name', ${marker}, true)`); + await tx.execute(sql`SET LOCAL lock_timeout = '8s'`); + return work(tx); + }); + } + } + return new readModule.AppResourcePrivateReadService(keys, readClock, new WaitingRepository()); + }; + try { + const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + await settle(owned, Array.from({ length: 31 }, (_, i) => ({ id: `provider-${i}`, revision: `r${i}`, + data: { subject: ` Private\tmessage ${i} ` } }))); + const first = await reader.listOwnerPrivateResourcePage(subject(owned), target(owned)); + await t.test('owner receives bounded normalized records and stable host UUID refs with unknown freshness', async () => { + assert.equal(first.items.length, 25); + assert.ok(first.next_cursor); + assert.equal(first.checkpoint.freshness, 'unknown'); + assert.equal(first.checkpoint.cursor_sequence, 1); + assert.ok(first.checkpoint.last_applied_at); + for (const item of first.items) { + assert.match(item.label, /^Private message \d+$/u); + assert.equal(item.ref.schema_version, 'deft.resource_ref.v2'); + assert.equal(item.ref.provider.kind, 'app_runtime'); + assert.equal(item.ref.provider.provider_instance_id, owned.registration_id); + assert.equal(item.ref.resource_id, item.projection_id); + assert.equal(item.ref.resource_type, 'email_message'); + assert.equal(item.freshness, 'unknown'); + assert.deepEqual((await reader.getOwnerPrivateResource(subject(owned), { + ...target(owned), projection_id: item.projection_id })).item, item); + } + const text = JSON.stringify(first); + for (const forbidden of ['provider-private-id', 'provider-cursor-', 'ciphertext', 'owner_user_id', + 'resource_id_hmac', 'provider_id', owned.owner_user_id, owned.operator_user_id]) { + assert.equal(text.includes(forbidden), false); + } + assert.equal(Buffer.byteLength(text) <= 1_048_576, true); + assert.deepEqual(await reader.listOwnerPrivateResourcePage(subject(owned), target(owned)), first); + }); + await t.test('keyset pagination returns every record exactly once and enforces a closed bounded input', async () => { + const second = await reader.listOwnerPrivateResourcePage(subject(owned), { + ...target(owned), cursor: first.next_cursor! }); + assert.equal(second.items.length, 6); + assert.equal(second.next_cursor, null); + const ids = [...first.items, ...second.items].map((item) => item.projection_id); + assert.equal(new Set(ids).size, 31); + assert.deepEqual(ids, [...ids].sort()); + for (const input of [{ ...target(owned), limit: 26 }, { ...target(owned), limit: 0 }, + { ...target(owned), offset: 2 }, { ...target(owned), resource_binding_id: 'bad' }]) { + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(owned), input), + (error: unknown) => (error as { code: string }).code === 'APP_RESOURCE_PRIVATE_INPUT_INVALID'); + } + }); + await t.test('foreign org, same-org nonowner, guessed row and cursor tampering disclose no private data', async () => { + await assert.rejects(reader.listOwnerPrivateResourcePage({ ...subject(owned), org_id: randomUUID() }, target(owned)), unavailable); + await assert.rejects(reader.listOwnerPrivateResourcePage({ ...subject(owned), user_id: owned.operator_user_id }, target(owned)), unavailable); + await assert.rejects(reader.getOwnerPrivateResource(subject(owned), { ...target(owned), projection_id: randomUUID() }), unavailable); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(owned), { ...target(owned), cursor: `${first.next_cursor!.slice(0, -2)}AA` }), unavailable); + const other = await fresh(); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(other), { ...target(other), cursor: first.next_cursor! }), unavailable); + await assert.rejects(reader.getOwnerPrivateResource(subject(other), { ...target(other), projection_id: first.items[0]!.projection_id }), unavailable); + }); + await t.test('settled tombstone removes the body and label; changed checkpoint invalidates prior cursor', async () => { + const old = first.items[0]!; + const index = Number(old.revision.slice(1)); + await settle(owned, [], [{ id: `provider-${index}`, revision: 'deleted' }]); + await assert.rejects(reader.getOwnerPrivateResource(subject(owned), { ...target(owned), projection_id: old.projection_id }), unavailable); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(owned), { ...target(owned), cursor: first.next_cursor! }), + (error: unknown) => (error as { code: string }).code === 'APP_RESOURCE_PRIVATE_CURSOR_STALE'); + const current = await reader.listOwnerPrivateResourcePage(subject(owned), target(owned)); + assert.equal(current.items.some((item) => item.projection_id === old.projection_id), false); + assert.equal(current.items.some((item) => item.label === old.label), false); + }); + for (const change of ['binding', 'registration', 'owner', 'operator', 'demoted', 'disabled', 'grant', 'consent'] as const) { + await t.test(`live authority denies ${change} revocation or expiry`, async () => { + const changed = await fresh(); + if (change === 'binding') await changed.management.revokeConsent(changed.owner_actor, changed.binding_id); + if (change === 'registration') await changed.management.revokeRegistration(changed.owner_actor, changed.registration_id); + if (change === 'owner' || change === 'operator') await db.update(schema.orgMembers).set({ is_active: false }).where(and( + eq(schema.orgMembers.org_id, changed.org_id), eq(schema.orgMembers.user_id, change === 'owner' ? changed.owner_user_id : changed.operator_user_id))); + if (change === 'demoted') await db.update(schema.orgMembers).set({ role: 'member' }).where(and( + eq(schema.orgMembers.org_id, changed.org_id), eq(schema.orgMembers.user_id, changed.owner_user_id))); + if (change === 'disabled') { + const [installation] = await db.select().from(schema.appInstallations) + .where(eq(schema.appInstallations.id, changed.installation_id)); + assert.ok(installation); + await (await import('../src/lib/app-service.js')).disableAppInstallation( + changed.owner_actor, changed.installation_id, installation.lifecycle_epoch); + } + if (change === 'grant') { + const [grant] = await db.select().from(schema.appGrantSnapshots).where(eq(schema.appGrantSnapshots.id, changed.grant_snapshot_id)); + assert.ok(grant); + const replacementId = randomUUID(); + await db.insert(schema.appGrantSnapshots).values({ ...grant, id: replacementId, supersedes_snapshot_id: grant.id }); + await db.update(schema.appInstallations).set({ active_grant_snapshot_id: replacementId, + grant_epoch: sql`${schema.appInstallations.grant_epoch} + 1` }) + .where(eq(schema.appInstallations.id, changed.installation_id)); + } + const expiryReader = change === 'consent' ? new readModule.AppResourcePrivateReadService(keys, + () => new Date(checkedAt.getTime() + 3_600_000)) : reader; + await assert.rejects(expiryReader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + }); + } + await t.test('missing encryption and cursor keys fail closed without fallback', async () => { + const changed = await fresh(); + const missingEncryption = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify({ ...ring, + run_encryption: { current: 'enc2', keys: { enc2: key('enc2') } } })); + const missingFingerprint = keyrings.parseEnvironmentAppRunKeyrings(JSON.stringify({ ...ring, + fingerprint: { current: 'fp2', keys: { fp2: key('fp2') } } })); + try { + await assert.rejects(new readModule.AppResourcePrivateReadService(missingEncryption, clock) + .listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + await assert.rejects(new readModule.AppResourcePrivateReadService(missingFingerprint, clock) + .listOwnerPrivateResourcePage(subject(owned), { ...target(owned), cursor: first.next_cursor! }), unavailable); + } finally { missingEncryption.destroy(); missingFingerprint.destroy(); } + }); + await t.test('consent expiring during decryption prevents the completed page from escaping', async () => { + const changed = await fresh(); + let clockReads = 0; + const lateReader = new readModule.AppResourcePrivateReadService(keys, + () => new Date(checkedAt.getTime() + (++clockReads >= 3 ? 3_600_000 : 0))); + await assert.rejects(lateReader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + assert.equal(clockReads, 3); + }); + await t.test('tampered ciphertext and authenticated descriptor-invalid plaintext fail the whole page', async () => { + const changed = await fresh(); + const [row] = await db.select().from(schema.appResourceProjections).where(eq(schema.appResourceProjections.resource_binding_id, changed.binding_id)); + assert.ok(row); + // Deliberate at-rest corruption injection under the retained-key test marker. + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL deft.app_resource_sync_rekey = 'on'`); + await tx.update(schema.appResourceProjections).set({ body_auth_tag_b64: Buffer.alloc(16, 42).toString('base64') }) + .where(eq(schema.appResourceProjections.id, row.id)); + }); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + const envelope = syncSecrets.sealJson({ revision: 'r2', data: { subject: 'Looks valid', unreviewed: 'SECRET' } }, { + org_id: changed.org_id, resource_binding_id: changed.binding_id, checkpoint_id: changed.checkpoint_id, + payload_kind: 'projection', projection_id: row.id, generation: row.generation, slot: 'record' }); + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL deft.app_resource_sync_rekey = 'on'`); + await tx.update(schema.appResourceProjections).set({ body_auth_tag_b64: envelope.auth_tag_b64, + body_ciphertext_b64: envelope.ciphertext_b64, body_nonce_b64: envelope.nonce_b64, + body_bytes: Buffer.byteLength(envelope.ciphertext_b64, 'base64') }).where(eq(schema.appResourceProjections.id, row.id)); + }); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + }); + await t.test('byte-capped keyset pages include every large record without silent skips', async () => { + const descriptor: SyncDescriptorV1 = { ...owned.descriptor, record_schema: { type: 'object', + properties: { subject: { type: 'string', maxLength: 200 }, + ...Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`field_${i}`, { type: 'string' as const, maxLength: 16_384 }])) }, + required: ['subject'], additionalProperties: false } }; + const big = await fixture.createReviewedResourceSyncFixture({ keys, clock, descriptor }); + for (let i = 0; i < 3; i++) await settle(big, [{ id: `large-${i}`, revision: 'r1', data: { + subject: `Large ${i}`, ...Object.fromEntries(Array.from({ length: 30 }, (_, f) => [`field_${f}`, 'x'.repeat(16_384)])), + } }]); + const page = await reader.listOwnerPrivateResourcePage(subject(big), target(big)); + assert.equal(page.items.length, 2); + assert.ok(page.next_cursor); + assert.ok(Buffer.byteLength(JSON.stringify(page)) <= 1_048_576); + const remaining = await reader.listOwnerPrivateResourcePage(subject(big), { ...target(big), cursor: page.next_cursor }); + assert.equal(remaining.items.length, 1); + assert.equal(remaining.next_cursor, null); + assert.equal(new Set([...page.items, ...remaining.items].map((item) => item.projection_id)).size, 3); + }); + await t.test('checkpoint lock wait rechecks consent after the actual database wait', async () => { + const changed = await fresh(); + const marker = `private-read-${randomUUID()}`; + class WaitingRepository extends repositories.PostgresAppRunRepository { + override transaction(work: (tx: AppRunTransaction) => Promise): Promise { + return super.transaction(async (tx) => { + await tx.execute(sql`SELECT set_config('application_name', ${marker}, true)`); + await tx.execute(sql`SET LOCAL lock_timeout = '8s'`); + return work(tx); + }); + } + } + let expired = false; + const waitingReader = new readModule.AppResourcePrivateReadService(keys, + () => new Date(checkedAt.getTime() + (expired ? 3_600_000 : 0)), new WaitingRepository()); + let unlock!: () => void; + let ready!: () => void; + const locked = new Promise((resolve) => { ready = resolve; }); + const release = new Promise((resolve) => { unlock = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.select().from(schema.appSyncCheckpoints).where(eq(schema.appSyncCheckpoints.id, changed.checkpoint_id)).for('update'); + ready(); + await release; + }); + await locked; + const pending = assert.rejects(waitingReader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + try { + let observed = false; + for (let i = 0; i < 300; i++) { + const result = await db.execute(sql`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE application_name = ${marker} AND wait_event_type = 'Lock'`); + if (Number((result as { rows: Array<{ count: number }> }).rows[0]?.count) > 0) { observed = true; break; } + await delay(10); + } + assert.ok(observed, 'actual PostgreSQL checkpoint lock wait observed'); + expired = true; + } finally { unlock(); await holder; } + await pending; + }); + await t.test('revocation committed during an observed member lock wait denies the reader', async () => { + const changed = await fresh(); + const marker = `read-revocation-${randomUUID()}`; + const waitingReader = markedReader(marker); + let unlock!: () => void; + let ready!: () => void; + const locked = new Promise((resolve) => { ready = resolve; }); + const release = new Promise((resolve) => { unlock = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.update(schema.orgMembers).set({ is_active: false }).where(and( + eq(schema.orgMembers.org_id, changed.org_id), eq(schema.orgMembers.user_id, changed.owner_user_id))); + ready(); + await release; + }); + await locked; + const pending = assert.rejects(waitingReader.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + try { await waitForLock(marker); } + finally { unlock(); await holder; } + await pending; + }); + await t.test('actual page settlement committed during checkpoint lock wait invalidates the old cursor', async () => { + const changed = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + await settle(changed, [{ id: 'first', revision: 'r1', data: { subject: 'First' } }, + { id: 'second', revision: 'r1', data: { subject: 'Second' } }]); + const prior = await reader.listOwnerPrivateResourcePage(subject(changed), { ...target(changed), limit: 1 }); + assert.ok(prior.next_cursor); + let unlock!: () => void; + let ready!: () => void; + const locked = new Promise((resolve) => { ready = resolve; }); + const release = new Promise((resolve) => { unlock = resolve; }); + const gatedWriter = { async write(tx: Parameters[0], + value: Parameters[1]) { + await receiptWriter.write(tx, value); + if (value.receipt_kind === 'attempt_terminal' && value.run.state === 'succeeded') { + ready(); + await release; + } + } }; + const holder = settle(changed, [{ id: 'third', revision: 'r1', data: { subject: 'Third' } }], [], + new channelModule.AppResourceSyncChannel(makeRunner(gatedWriter as typeof receiptWriter))); + await locked; + const marker = `read-settlement-${randomUUID()}`; + const pending = assert.rejects(markedReader(marker).listOwnerPrivateResourcePage(subject(changed), { + ...target(changed), cursor: prior.next_cursor, + }), (error: unknown) => (error as { code: string }).code === 'APP_RESOURCE_PRIVATE_CURSOR_STALE'); + try { await waitForLock(marker); } + finally { unlock(); await holder; } + await pending; + const current = await reader.listOwnerPrivateResourcePage(subject(changed), target(changed)); + assert.equal(current.items.length, 3); + assert.equal(current.checkpoint.cursor_sequence, 2); + }); + } finally { keys.destroy(); await closeDb(); } +}); From 4d50ecec4c01b8f36e029faaa258a9b5ffa284b7 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:02:30 +0530 Subject: [PATCH 029/161] Cover management HTTP body limits and credential redaction --- .../app-resource-sync-management-http-db.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/api/test/app-resource-sync-management-http-db.test.ts b/apps/api/test/app-resource-sync-management-http-db.test.ts index 681b14f2..e262d9f3 100644 --- a/apps/api/test/app-resource-sync-management-http-db.test.ts +++ b/apps/api/test/app-resource-sync-management-http-db.test.ts @@ -99,6 +99,19 @@ test('private sync HTTP owner review activation, operator-only credential and st const issued = await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken); assert.equal(issued.status, 201); assert.match(issued.body.session.session_token, /^[A-Za-z0-9_-]{40,}$/); + for (const path of ['/bindings', `/bindings/${h.binding_id}`]) { + const observed = await h.call(path); + assert.equal(observed.status, 200); + assert.ok(!JSON.stringify(observed.body).includes(issued.body.session.session_token)); + assert.ok(!JSON.stringify(observed.body).includes('session_token')); + } + assert.equal((await h.call('/reviews/prepare', 'POST', { oversized: 'x'.repeat(16_384) })).status, 413); + for (const [contentType, raw] of [['application/json', '{'], ['text/plain', '{}']]) { + const malformed = await fetch(`${h.base}/reviews/prepare`, { method: 'POST', + headers: { Authorization: `Bearer ${h.owner.accessToken}`, 'Content-Type': contentType! }, body: raw }); + assert.equal(malformed.status, 400); + assert.equal(malformed.headers.get('cache-control'), 'no-store'); + } assert.equal((await h.call(`/sessions/${issued.body.session.session_id}/revoke`, 'POST')).status, 403); assert.equal((await h.call(`/sessions/${issued.body.session.session_id}/revoke`, 'POST', undefined, h.operator.accessToken)).status, 200); assert.equal((await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', { owner_user_id: h.owner_user_id }, h.operator.accessToken)).status, 400); From ac98ee70fe1d7b1510ad38824b8323671cd38256 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:05:16 +0530 Subject: [PATCH 030/161] feat: schedule bounded private resource sync through host admission --- .../src/lib/app-resource-sync-admission.ts | 29 +- apps/api/src/lib/app-resource-sync-scanner.ts | 113 ++++++ apps/api/src/lib/env.ts | 5 + apps/api/src/lib/job-scheduler.ts | 2 + .../handlers/app-resource-sync-scan.ts | 13 + apps/api/src/workers/index.ts | 11 + apps/api/src/workers/types.ts | 2 + .../test/app-resource-sync-scanner-db.test.ts | 329 ++++++++++++++++++ .../fixtures/resource-sync-scan-process.ts | 23 ++ 9 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/lib/app-resource-sync-scanner.ts create mode 100644 apps/api/src/workers/handlers/app-resource-sync-scan.ts create mode 100644 apps/api/test/app-resource-sync-scanner-db.test.ts create mode 100644 apps/api/test/fixtures/resource-sync-scan-process.ts diff --git a/apps/api/src/lib/app-resource-sync-admission.ts b/apps/api/src/lib/app-resource-sync-admission.ts index 4be1b999..23db3c15 100644 --- a/apps/api/src/lib/app-resource-sync-admission.ts +++ b/apps/api/src/lib/app-resource-sync-admission.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { and, desc, eq } from 'drizzle-orm'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appRuns, appSyncCheckpoints, appSyncIntents } from '@deft/db/schema'; import { APP_RUN_CONTRACT_VERSIONS, APP_RUN_DEFAULT_ATTEMPT_LIMIT, @@ -20,6 +20,15 @@ const HostTargetSchema = z.strictObject({ org_id: z.string().uuid(), resource_binding_id: z.string().uuid() }); const unavailable = () => new AppError('Resource sync authority unavailable', 'APP_ACCESS_DENIED', 403); +const AdmissionLimitsSchema = z.strictObject({ + lock_timeout_ms: z.number().int().min(1).max(5_000), + statement_timeout_ms: z.number().int().min(1).max(10_000), + deadline_at: z.date(), +}); +export type ResourceSyncAdmissionLimits = z.infer & { + signal?: AbortSignal; +}; + export interface ResourceSyncAttemptScheduler { scheduleResourceSyncInTransaction(tx: AppRunTransaction, run: AppRunSafeView, now: Date): Promise; @@ -45,15 +54,29 @@ export class AppResourceSyncAdmissionService { private readonly enabled: () => boolean = () => false, ) {} - async admitDue(raw: unknown): Promise { + async admitDue(raw: unknown, limits?: ResourceSyncAdmissionLimits): Promise { if (!this.enabled()) throw new AppError('Resource sync is disabled', 'APP_FEATURE_DISABLED', 503); const target = HostTargetSchema.parse(raw); + const bounded = limits ? AdmissionLimitsSchema.parse({ lock_timeout_ms: limits.lock_timeout_ms, + statement_timeout_ms: limits.statement_timeout_ms, deadline_at: limits.deadline_at }) : undefined; + const assertWithinBudget = () => { + if (limits?.signal?.aborted || (bounded && bounded.deadline_at <= new Date())) { + throw new AppError('Resource sync admission budget expired', 'APP_ACCESS_DENIED', 403); + } + }; + assertWithinBudget(); return this.repository.transaction(async (tx) => { + if (bounded) { + await tx.execute(sql`SELECT set_config('lock_timeout', ${String(bounded.lock_timeout_ms)}, true), + set_config('statement_timeout', ${String(bounded.statement_timeout_ms)}, true)`); + } + assertWithinBudget(); // A new Run is not visible yet. Lock authority first, then checkpoint; // never take an existing Run lock while holding these later locks. const authority = await loadLiveResourceSyncBindingAuthority(tx, { ...target, clock: this.clock, }); + assertWithinBudget(); if (!authority) throw unavailable(); const { binding, installation, version, grant, registration } = authority; const [checkpoint] = await tx.select().from(appSyncCheckpoints).where(and( @@ -61,6 +84,7 @@ export class AppResourceSyncAdmissionService { eq(appSyncCheckpoints.resource_binding_id, binding.id), )).limit(1).for('update'); const now = this.clock(); + assertWithinBudget(); if (!checkpoint || checkpoint.state !== 'active' || !Number.isFinite(now.getTime()) || !binding.consent_expires_at || binding.consent_expires_at <= now) throw unavailable(); @@ -160,6 +184,7 @@ export class AppResourceSyncAdmissionService { generation: checkpoint.generation, cursor_sequence: checkpoint.cursor_sequence } }); const attemptId = await this.scheduler.scheduleResourceSyncInTransaction(tx, run, now); const completedAt = this.clock(); + assertWithinBudget(); if (!attemptId || !Number.isFinite(completedAt.getTime()) || inputExpiresAt <= completedAt || binding.consent_expires_at <= completedAt) throw unavailable(); return Object.freeze({ state: 'created', run_id: runId, attempt_id: attemptId }); diff --git a/apps/api/src/lib/app-resource-sync-scanner.ts b/apps/api/src/lib/app-resource-sync-scanner.ts new file mode 100644 index 00000000..ea4bdc48 --- /dev/null +++ b/apps/api/src/lib/app-resource-sync-scanner.ts @@ -0,0 +1,113 @@ +import { randomUUID } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; +import { and, asc, eq, gt, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { appResourceBindings, jobQueue } from '@deft/db/schema'; +import { db } from './db.js'; +import { isAppResourceSyncSchedulerEnabled } from './env.js'; +import type { AppResourceSyncAdmissionService } from './app-resource-sync-admission.js'; +import { QUEUE_NAMES } from './queues.js'; + +export const APP_RESOURCE_SYNC_SCAN_JOB = 'app-resource-sync-scan'; +export const APP_RESOURCE_SYNC_SCAN_CRON = 'cron:app-resource-sync-scan'; +export const APP_RESOURCE_SYNC_SCAN_INTERVAL_MS = 60_000; +export const APP_RESOURCE_SYNC_SCAN_LIMIT = 20; +export const APP_RESOURCE_SYNC_SCAN_BUDGET_MS = 20_000; +const CursorSchema = z.strictObject({ after_binding_id: z.string().uuid().nullable() }); +const initialCursor = { after_binding_id: null }; + +/** Queue metadata is a restart cursor, never provider/cursor/owner authority. + * Retention can discard this position after a prolonged disabled interval; + * restarting the pass remains safe because admission owns replay fencing. */ +export async function ensureAppResourceSyncScan(delayMs = APP_RESOURCE_SYNC_SCAN_INTERVAL_MS): Promise { + if (!isAppResourceSyncSchedulerEnabled()) return; + await db.execute(sql` + INSERT INTO job_queue (id, queue, name, data, status, max_attempts, run_at, cron_key) + VALUES (${randomUUID()}, ${QUEUE_NAMES.SCHEDULED_JOBS}, ${APP_RESOURCE_SYNC_SCAN_JOB}, + COALESCE((SELECT data FROM job_queue + WHERE queue = ${QUEUE_NAMES.SCHEDULED_JOBS} AND name = ${APP_RESOURCE_SYNC_SCAN_JOB} + AND cron_key = ${APP_RESOURCE_SYNC_SCAN_CRON} + ORDER BY created_at DESC, id DESC LIMIT 1), ${JSON.stringify(initialCursor)}::jsonb), + 'pending', 2, now() + (${Math.max(0, delayMs)} * interval '1 millisecond'), + ${APP_RESOURCE_SYNC_SCAN_CRON}) ON CONFLICT DO NOTHING + `); +} + +export type AppResourceSyncScanResult = Readonly<{ + state: 'disabled' | 'busy' | 'scanned'; + inspected: number; created: number; existing: number; blocked: number; not_due: number; rejected: number; + wrapped: boolean; +}>; +type Delivery = Readonly<{ id: string; lockToken: string; signal?: AbortSignal }>; + +/** One bounded host scan uses the existing Run admission and attempt queue. + * The advisory transaction holds no resource/Run/queue row locks. Admission + * uses a second connection, preserving member→App→binding→checkpoint order. + * No scan ever dispatches input, resets a cursor, or rearms a Run. */ +export async function scanAppResourceSyncBindings( + delivery: Delivery, + admission: Pick, +): Promise { + const result = { state: 'scanned' as AppResourceSyncScanResult['state'], inspected: 0, + created: 0, existing: 0, blocked: 0, not_due: 0, rejected: 0, wrapped: false }; + if (!isAppResourceSyncSchedulerEnabled()) return { ...result, state: 'disabled' }; + const startedAt = performance.now(); + const canContinue = () => !delivery.signal?.aborted && isAppResourceSyncSchedulerEnabled() + && performance.now() - startedAt < APP_RESOURCE_SYNC_SCAN_BUDGET_MS; + return db.transaction(async (lockTx) => { + await lockTx.execute(sql`SET LOCAL statement_timeout = '2000ms'`); + const lock = await lockTx.execute(sql`SELECT pg_try_advisory_xact_lock( + hashtextextended('deft.app_resource_sync.scan.v1', 0)) AS acquired`); + if (lock.rows[0]?.acquired !== true) return { ...result, state: 'busy' as const }; + const [job] = await lockTx.select({ data: jobQueue.data }).from(jobQueue).where(and( + eq(jobQueue.id, delivery.id), eq(jobQueue.name, APP_RESOURCE_SYNC_SCAN_JOB), + eq(jobQueue.queue, QUEUE_NAMES.SCHEDULED_JOBS), eq(jobQueue.cron_key, APP_RESOURCE_SYNC_SCAN_CRON), + eq(jobQueue.status, 'running'), eq(jobQueue.lock_token, delivery.lockToken), + sql`${jobQueue.lock_expires_at} > clock_timestamp()`, + )).limit(1); + if (!job) throw new Error('Resource sync scan lease unavailable'); + const cursor = CursorSchema.parse(job.data); + const bindings = await lockTx.select({ org_id: appResourceBindings.org_id, + resource_binding_id: appResourceBindings.id }).from(appResourceBindings).where(and( + eq(appResourceBindings.state, 'active'), + cursor.after_binding_id ? gt(appResourceBindings.id, cursor.after_binding_id) : undefined, + )).orderBy(asc(appResourceBindings.id)).limit(APP_RESOURCE_SYNC_SCAN_LIMIT + 1); + const saveCursor = async (after: string | null) => { + // A separate short transaction commits progress after every candidate. + // The scanner advisory transaction must never retain this queue lock. + const changed = await db.transaction(async (progressTx) => { + await progressTx.execute(sql`SET LOCAL lock_timeout = '500ms'`); + await progressTx.execute(sql`SET LOCAL statement_timeout = '2000ms'`); + return progressTx.update(jobQueue).set({ data: { after_binding_id: after } }).where(and( + eq(jobQueue.id, delivery.id), eq(jobQueue.status, 'running'), + eq(jobQueue.lock_token, delivery.lockToken), + sql`${jobQueue.lock_expires_at} > clock_timestamp()`, + )).returning({ id: jobQueue.id }); + }); + if (changed.length !== 1) throw new Error('Resource sync scan lease lost'); + }; + for (const target of bindings.slice(0, APP_RESOURCE_SYNC_SCAN_LIMIT)) { + if (!canContinue()) return result; + try { + const admitted = await admission.admitDue(target, { + lock_timeout_ms: 500, statement_timeout_ms: 2_000, + deadline_at: new Date(Date.now() + Math.max(1, + APP_RESOURCE_SYNC_SCAN_BUDGET_MS - (performance.now() - startedAt))), + signal: delivery.signal, + }); + result[admitted.state] += 1; + } catch { + // Denied/stale/expired/key-unavailable or contended bindings cannot + // starve later bindings. Never log a private target or raw DB error. + result.rejected += 1; + } + result.inspected += 1; + await saveCursor(target.resource_binding_id); + } + if (bindings.length <= APP_RESOURCE_SYNC_SCAN_LIMIT && canContinue()) { + await saveCursor(null); + result.wrapped = true; + } + return result; + }); +} diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 558f7cba..9004719b 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -134,6 +134,11 @@ export function isAppResourceSyncChannelEnabled(): boolean { return APPS_ENABLED && APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED && process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED === 'true'; } +// Unattended scheduling requires a separate explicit host opt-in. +export function isAppResourceSyncSchedulerEnabled(): boolean { + return isAppResourceSyncChannelEnabled() + && process.env.DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED === 'true'; +} // Track A automation is an independent, deny-by-default privileged plane. export const APP_AUTOMATIONS_ENABLED = process.env.DEFT_APP_AUTOMATIONS_ENABLED === 'true'; diff --git a/apps/api/src/lib/job-scheduler.ts b/apps/api/src/lib/job-scheduler.ts index 69339fe2..574fa2c4 100644 --- a/apps/api/src/lib/job-scheduler.ts +++ b/apps/api/src/lib/job-scheduler.ts @@ -1,6 +1,7 @@ // Cron job scheduler — registers repeatable jobs in Postgres job_queue import { ensureCronJob, QUEUE_NAMES } from './queues.js'; import { APP_AUTOMATIONS_ENABLED } from './env.js'; +import { ensureAppResourceSyncScan } from './app-resource-sync-scanner.js'; export async function initScheduler(): Promise { // Re-enqueue cron jobs on startup (idempotent — skips if already pending) @@ -35,6 +36,7 @@ export async function initScheduler(): Promise { 'cron:app-automation-scan', ); } + await ensureAppResourceSyncScan(0); console.log('[scheduler] Cron jobs registered'); } diff --git a/apps/api/src/workers/handlers/app-resource-sync-scan.ts b/apps/api/src/workers/handlers/app-resource-sync-scan.ts new file mode 100644 index 00000000..3cf117ce --- /dev/null +++ b/apps/api/src/workers/handlers/app-resource-sync-scan.ts @@ -0,0 +1,13 @@ +import { getAppRunRuntime } from '../../lib/app-run-runtime.js'; +import { isAppResourceSyncSchedulerEnabled } from '../../lib/env.js'; +import { scanAppResourceSyncBindings } from '../../lib/app-resource-sync-scanner.js'; +import type { JobData } from '../types.js'; + +export async function handleAppResourceSyncScan(job: JobData): Promise { + if (!isAppResourceSyncSchedulerEnabled()) return; + if (!job.lockToken) throw new Error('Resource sync scan requires a leased delivery'); + const runtime = await getAppRunRuntime(); + const result = await scanAppResourceSyncBindings({ id: job.id, + lockToken: job.lockToken, signal: job.signal }, runtime.resourceSyncAdmission); + console.info('[app-resource-sync] scan', result); +} diff --git a/apps/api/src/workers/index.ts b/apps/api/src/workers/index.ts index da2447f6..38280630 100644 --- a/apps/api/src/workers/index.ts +++ b/apps/api/src/workers/index.ts @@ -15,6 +15,7 @@ import { } from '../lib/queues.js'; import { sweepExpiredStagedAttachments } from '../lib/attachment-retention.js'; import { APP_AUTOMATIONS_ENABLED } from '../lib/env.js'; +import { APP_RESOURCE_SYNC_SCAN_JOB, ensureAppResourceSyncScan } from '../lib/app-resource-sync-scanner.js'; import type { JobHandler } from './types.js'; // ─── Cron re-enqueue delays ─── @@ -279,6 +280,10 @@ async function getAgentJobHandler(jobName: string): Promise { async function getScheduledJobHandler(jobName: string): Promise { switch (jobName) { + case 'app-resource-sync-scan': { + const mod = await import('./handlers/app-resource-sync-scan.js'); + return mod.handleAppResourceSyncScan; + } case 'app-automation-scan': { const mod = await import('./handlers/app-automation-scan.js'); return mod.handleAppAutomationScan; @@ -416,6 +421,7 @@ async function processDequeuedJob( name: job.name, data: job.data, attempts: job.attempts, + lockToken: job.lockToken, leaseExpiresAt: job.lockExpiresAt, signal, }; @@ -444,6 +450,10 @@ async function processDequeuedJob( }); if (settled) console.error(`[worker] Job ${job.name} failed:`, message); } finally { + if (job.name === APP_RESOURCE_SYNC_SCAN_JOB) { + try { await ensureAppResourceSyncScan(); } + catch { console.warn('[worker] Could not schedule next resource sync scan'); } + } // A terminally failed occurrence must not stop its recurring chain. If the // failure is retryable, the active-cron constraint leaves the retry as the // sole occurrence and this insert becomes a no-op. @@ -592,6 +602,7 @@ function trackBackground(promise: Promise): Promise { } async function reconcileRecurringJobs(): Promise { + await ensureAppResourceSyncScan(); await Promise.all(Object.entries(CRON_KEYS) .filter(([jobName]) => jobName !== 'agent-heartbeat' && (jobName !== 'app-automation-scan' || APP_AUTOMATIONS_ENABLED)) diff --git a/apps/api/src/workers/types.ts b/apps/api/src/workers/types.ts index e1d3fc62..f853f4b9 100644 --- a/apps/api/src/workers/types.ts +++ b/apps/api/src/workers/types.ts @@ -3,6 +3,8 @@ export type JobData = { name: string; data: Record; attempts: number; + /** Fences durable scan progress to the currently owned queue delivery. */ + lockToken?: string; /** Queue lease boundary for domain claims that must not outlive delivery. */ leaseExpiresAt?: Date; /** Cooperative cancellation; handlers must explicitly pass it to abort-aware I/O. */ diff --git a/apps/api/test/app-resource-sync-scanner-db.test.ts b/apps/api/test/app-resource-sync-scanner-db.test.ts new file mode 100644 index 00000000..a7531bb8 --- /dev/null +++ b/apps/api/test/app-resource-sync-scanner-db.test.ts @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const assigned = process.env.DATABASE_URL === process.env.DEFT_TEST_DATABASE_URL + && process.env.DATABASE_URL === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_scheduler'; + +test('bounded host resource sync scheduling through the durable worker queue', { skip: !assigned, timeout: 180_000 }, async (t) => { + process.env.DEFT_APPS_ENABLED = 'true'; + process.env.DEFT_APP_RUNS_ENABLED = 'true'; + process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + delete process.env.DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED; + const key = (purpose: string) => createHash('sha256').update(`scheduler:${purpose}`).digest('base64'); + process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'scan-enc', keys: { 'scan-enc': key('enc') } }, + receipt_signing: { current: 'scan-sig', keys: { 'scan-sig': key('sig') } }, + fingerprint: { current: 'scan-fp', keys: { 'scan-fp': key('fp') } } }); + const [{ db, closeDb }, schema, { and, eq, sql }, fixture, keyrings, repositories, + secretsModule, inputsModule, syncModule, admissionModule, runnerModule, providerModule, + attemptQueue, queue, scanner, workers, channelModule, storeModule, receiptModule, apps] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('./fixtures/resource-sync-v5.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/lib/app-run-repository.js'), import('../src/lib/app-run-secrets.js'), + import('../src/lib/app-run-secret-repository.js'), import('../src/lib/app-resource-sync-secrets.js'), + import('../src/lib/app-resource-sync-admission.js'), import('../src/lib/app-run-attempt-runner.js'), + import('../src/lib/app-run-provider-executor.js'), import('../src/lib/app-run-scheduler.js'), + import('../src/lib/queues.js'), import('../src/lib/app-resource-sync-scanner.js'), + import('../src/workers/index.js'), import('../src/lib/app-resource-sync-channel.js'), + import('../src/lib/app-resource-sync-store.js'), import('../src/lib/app-run-receipts.js'), + import('../src/lib/app-service.js'), + ]); + const keys = keyrings.parseEnvironmentAppRunKeyrings(process.env.DEFT_APP_RUN_KEYRINGS); + let checkedAt = new Date(); + const clock = () => new Date(checkedAt); + const repository = new repositories.PostgresAppRunRepository(); + const secrets = new secretsModule.AppRunSecretService(keys); + const inputs = new inputsModule.AppRunSecretRepository(secrets); + const syncSecrets = new syncModule.AppResourceSyncSecretService(keys); + const runner = new runnerModule.AppRunAttemptRunner(repository, inputs, secrets, + new providerModule.PinnedMcpAppRunProviderExecutor(), undefined, clock, 60_000, 20_000, + new receiptModule.PostgresAppRunReceiptWriter(secrets, inputs), undefined, + attemptQueue.postgresAppRunAttemptQueue, new storeModule.AppResourceSyncStore(syncSecrets, inputs)); + const admission = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + secrets, syncSecrets, runner, clock, () => true); + const channel = new channelModule.AppResourceSyncChannel(runner); + const fixtures: Awaited>[] = []; + const makeFixture = async () => { + const item = await fixture.createReviewedResourceSyncFixture({ keys, clock }); + fixtures.push(item); + return item; + }; + const scanJobs = () => db.select().from(schema.jobQueue) + .where(eq(schema.jobQueue.name, scanner.APP_RESOURCE_SYNC_SCAN_JOB)); + const claim = async () => { + await scanner.ensureAppResourceSyncScan(0); + await db.update(schema.jobQueue).set({ run_at: sql`now()` }).where(and( + eq(schema.jobQueue.name, scanner.APP_RESOURCE_SYNC_SCAN_JOB), eq(schema.jobQueue.status, 'pending'))); + const job = await queue.dequeueJob(queue.QUEUE_NAMES.SCHEDULED_JOBS, + { jobName: scanner.APP_RESOURCE_SYNC_SCAN_JOB, leaseMs: 60_000 }); + assert.ok(job); + return job; + }; + const runPage = async () => { + const job = await claim(); + const result = await scanner.scanAppResourceSyncBindings(job, admission); + assert.equal(await queue.completeJob(job.id, job.lockToken), true); + return result; + }; + const runCount = async (orgId: string) => (await db.select({ id: schema.appRuns.id }) + .from(schema.appRuns).where(eq(schema.appRuns.org_id, orgId))).length; + const revokeAll = async () => { + for (const item of fixtures) await item.management.revokeConsent(item.owner_actor, item.binding_id); + }; + try { + // Permit reruns only in this exact dedicated DB. Retain all source cursors, + // generations, intents and Runs; revoke prior synthetic consent instead of + // deleting the execution evidence. Queue position is operational metadata. + await db.update(schema.appResourceBindings).set({ state: 'revoked' }) + .where(eq(schema.appResourceBindings.state, 'active')); + await db.update(schema.jobQueue).set({ status: 'failed', completed_at: new Date(), + data: { after_binding_id: null }, lock_token: null, lock_expires_at: null }) + .where(eq(schema.jobQueue.name, scanner.APP_RESOURCE_SYNC_SCAN_JOB)); + const baselineJobCount = (await scanJobs()).length; + await t.test('scheduler is default-off and requires exact scheduler and channel gates', async () => { + await scanner.ensureAppResourceSyncScan(0); + assert.equal((await scanJobs()).length, baselineJobCount); + assert.equal((await scanner.scanAppResourceSyncBindings({ id: 'disabled', lockToken: 'disabled' }, + { admitDue: async () => { throw new Error('disabled scanner called admission'); } })).state, 'disabled'); + process.env.DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED = 'TRUE'; + await scanner.ensureAppResourceSyncScan(0); + assert.equal((await scanJobs()).length, baselineJobCount); + process.env.DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED = 'true'; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + await scanner.ensureAppResourceSyncScan(0); + assert.equal((await scanJobs()).length, baselineJobCount); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + assert.equal(typeof await workers._getScheduledJobHandlerForTest(scanner.APP_RESOURCE_SYNC_SCAN_JOB), 'function'); + }); + + await t.test('twenty-binding pages converge concurrent jobs and preserve restart scan fairness', async () => { + for (let i = 0; i < 21; i += 1) await makeFixture(); + const ordered = [...fixtures].sort((a, b) => a.binding_id.localeCompare(b.binding_id)); + await Promise.all([scanner.ensureAppResourceSyncScan(0), scanner.ensureAppResourceSyncScan(0)]); + assert.equal((await scanJobs()).filter((job) => job.status === 'pending').length, 1); + const job = await claim(); + let release!: () => void; + let entered!: () => void; + const ready = new Promise((resolve) => { entered = resolve; }); + const held = new Promise((resolve) => { release = resolve; }); + let first = true; + const started = performance.now(); + const primary = scanner.scanAppResourceSyncBindings(job, { async admitDue(target, limits) { + if (first) { first = false; entered(); await held; } + return admission.admitDue(target, limits); + } }); + await ready; + try { assert.equal((await scanner.scanAppResourceSyncBindings(job, admission)).state, 'busy'); } + finally { release(); } + const page = await primary; + assert.equal(page.inspected, 20); + assert.equal(page.created, 20); + assert.equal(page.wrapped, false); + const elapsedMs = performance.now() - started; + t.diagnostic(JSON.stringify({ measured_page_bindings: page.inspected, elapsed_ms: Math.round(elapsedMs), cadence_ms: scanner.APP_RESOURCE_SYNC_SCAN_INTERVAL_MS })); + assert.ok(elapsedMs < 20_000, 'measured 20-binding scan fits the provisional page budget'); + const [stored] = await db.select().from(schema.jobQueue).where(eq(schema.jobQueue.id, job.id)); + assert.deepEqual(stored?.data, { after_binding_id: ordered[19]!.binding_id }); + assert.equal(await queue.completeJob(job.id, job.lockToken), true); + await scanner.ensureAppResourceSyncScan(); + const pending = (await scanJobs()).find((row) => row.status === 'pending'); + assert.ok(pending); + assert.deepEqual(pending.data, stored?.data, 'new process/occurrence resumes durable queue position'); + assert.ok(pending.run_at.getTime() - pending.created_at.getTime() >= 59_000, + 'normal recurrence waits sixty seconds'); + await db.update(schema.jobQueue).set({ run_at: sql`now()` }).where(eq(schema.jobQueue.id, pending.id)); + const restarted = await promisify(execFile)(process.execPath, ['--import', 'tsx', + fileURLToPath(new URL('./fixtures/resource-sync-scan-process.ts', import.meta.url))], + { timeout: 60_000, windowsHide: true }); + const report = restarted.stdout.split(/\r?\n/).find((line) => line.startsWith('scheduler-restart:')); + assert.ok(report, 'fresh process reports its actual durable scan'); + const next = JSON.parse(report.slice('scheduler-restart:'.length)); + assert.equal(next.created, 1); + assert.equal(next.inspected, 1); + assert.equal(next.wrapped, true); + for (const item of fixtures) assert.equal(await runCount(item.org_id), 1); + const repeat = await runPage(); + assert.equal(repeat.created, 0); + assert.equal(repeat.existing, 20); + for (const item of fixtures) assert.equal(await runCount(item.org_id), 1); + // Finish the pass before isolating later fixture cohorts. + await runPage(); + await revokeAll(); + }); + + await t.test('revoked expired disabled owner-lost and operator-lost bindings cannot admit and cannot starve healthy work', async () => { + const revoked = await makeFixture(); + await revoked.management.revokeConsent(revoked.owner_actor, revoked.binding_id); + const expired = await makeFixture(); + checkedAt = new Date(checkedAt.getTime() + 60 * 60_000 + 1); + const disabled = await makeFixture(); + const [installation] = await db.select().from(schema.appInstallations) + .where(eq(schema.appInstallations.id, disabled.installation_id)); + assert.ok(installation); + await apps.disableAppInstallation(disabled.owner_actor, disabled.installation_id, installation.lifecycle_epoch); + const ownerLost = await makeFixture(); + const operatorLost = await makeFixture(); + for (const [item, userId] of [[ownerLost, ownerLost.owner_user_id], + [operatorLost, operatorLost.operator_user_id]] as const) { + await db.update(schema.orgMembers).set({ is_active: false }).where(and( + eq(schema.orgMembers.org_id, item.org_id), eq(schema.orgMembers.user_id, userId))); + } + const paused = await makeFixture(); + await db.update(schema.appSyncCheckpoints).set({ state: 'paused' }) + .where(eq(schema.appSyncCheckpoints.id, paused.checkpoint_id)); + const healthy = await makeFixture(); + const result = await runPage(); + assert.equal(result.created, 1); + assert.equal(result.rejected, 5); + assert.equal(result.wrapped, true); + for (const item of [revoked, expired, disabled, ownerLost, operatorLost, paused]) { + assert.equal(await runCount(item.org_id), 0); + } + assert.equal(await runCount(healthy.org_id), 1); + for (const [item, userId] of [[ownerLost, ownerLost.owner_user_id], + [operatorLost, operatorLost.operator_user_id]] as const) { + await db.update(schema.orgMembers).set({ is_active: true }).where(and( + eq(schema.orgMembers.org_id, item.org_id), eq(schema.orgMembers.user_id, userId))); + } + await revokeAll(); + }); + + await t.test('interrupted and terminally failed scan retains per-binding progress and isolates lock contention', async () => { + const cohort = await Promise.all([makeFixture(), makeFixture(), makeFixture()]); + cohort.sort((a, b) => a.binding_id.localeCompare(b.binding_id)); + let release!: () => void; + let entered!: () => void; + const ready = new Promise((resolve) => { entered = resolve; }); + const held = new Promise((resolve) => { release = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.select().from(schema.appSyncCheckpoints) + .where(eq(schema.appSyncCheckpoints.id, cohort[0]!.checkpoint_id)).for('update'); + entered(); await held; + }); + await ready; + const job = await claim(); + const abort = new AbortController(); + const started = performance.now(); + let page; + try { + page = await scanner.scanAppResourceSyncBindings({ ...job, signal: abort.signal }, { + async admitDue(target, limits) { + try { return await admission.admitDue(target, limits); } + finally { abort.abort(); } + }, + }); + } finally { release(); await holder; } + assert.equal(page.inspected, 1); + assert.equal(page.rejected, 1); + assert.ok(performance.now() - started < 5_000, 'locked binding is bounded by lock_timeout'); + const [stored] = await db.select().from(schema.jobQueue).where(eq(schema.jobQueue.id, job.id)); + assert.deepEqual(stored?.data, { after_binding_id: cohort[0]!.binding_id }); + assert.equal(await queue.failJob(job.id, job.lockToken, 'synthetic interruption', { terminal: true }), true); + assert.equal((await runPage()).created, 2, 'replacement continues after unhealthy binding'); + assert.equal(await runCount(cohort[0]!.org_id), 0); + assert.equal((await runPage()).created, 1, 'wrapped pass retries failed pre-admission work safely'); + for (const item of cohort) assert.equal(await runCount(item.org_id), 1); + await revokeAll(); + }); + + await t.test('lost queue lease and consent revoked after selection cannot admit work', async () => { + const owned = await makeFixture(); + const job = await claim(); + await assert.rejects(scanner.scanAppResourceSyncBindings({ ...job, lockToken: 'stale-token' }, + { admitDue: async () => { throw new Error('stale lease reached admission'); } }), + /Resource sync scan lease unavailable/); + const result = await scanner.scanAppResourceSyncBindings(job, { async admitDue(target, limits) { + await owned.management.revokeConsent(owned.owner_actor, owned.binding_id); + return admission.admitDue(target, limits); + } }); + assert.equal(result.inspected, 1); + assert.equal(result.rejected, 1); + assert.equal(await runCount(owned.org_id), 0); + assert.equal(await queue.completeJob(job.id, job.lockToken), true); + }); + + await t.test('admission deadline and cancellation roll back Run input intent attempt and queue together', async () => { + const owned = await makeFixture(); + const target = { org_id: owned.org_id, resource_binding_id: owned.binding_id }; + await assert.rejects(admission.admitDue(target, { lock_timeout_ms: 500, + statement_timeout_ms: 2_000, deadline_at: new Date(0) })); + const abort = new AbortController(); + const cancelled = new admissionModule.AppResourceSyncAdmissionService(repository, inputs, + secrets, syncSecrets, { async scheduleResourceSyncInTransaction(tx, run, now) { + const id = await runner.scheduleResourceSyncInTransaction(tx, run, now); + abort.abort(); + return id; + } }, clock, () => true); + await assert.rejects(cancelled.admitDue(target, { lock_timeout_ms: 500, + statement_timeout_ms: 2_000, deadline_at: new Date(Date.now() + 60_000), signal: abort.signal })); + for (const table of [schema.appRuns, schema.appSyncIntents, schema.appRunAttempts, + schema.appRunSecretPayloads, schema.jobQueue]) { + assert.equal((await db.select({ id: table.id }).from(table) + .where(eq(table.org_id, owned.org_id))).length, 0); + } + assert.equal((await runPage()).created, 1, 'safe pre-admission cancellation leaves no cursor intent'); + await revokeAll(); + }); + + await t.test('settled cursors obey interval and post-start unknown work never automatically creates another Run', async () => { + const success = await makeFixture(); + const uncertain = await makeFixture(); + assert.equal((await runPage()).created, 2); + for (const [item, succeeds] of [[success, true], [uncertain, false]] as const) { + const issued = await item.management.issueOperatorSession(item.operator_actor, item.binding_id); + const base = { schema_version: 'deft.app_runtime_channel.v2' as const, + audience: 'app_resource_sync' as const, session_id: issued.session_id, session_token: issued.session_token }; + const claimed = await channel.claim({ ...base, max_claims: 1 }); + assert.ok(claimed); + const attempt = { ...base, run_id: claimed.run_id, attempt_id: claimed.attempt_id, + claim_token: claimed.claim_token, sequence: claimed.sequence }; + assert.ok(await channel.start(attempt)); + assert.equal(await channel.start(attempt), null, 'one input release'); + const completed = await channel.complete({ ...attempt, + ...(succeeds ? { status: 'returned', provider_succeeded: true, page: { schema_version: 'deft.app_sync_page.v1' as const, + upserts: [], tombstones: [], next_cursor: 'next-private-cursor', has_more: false } } : { status: 'not_attempted', error_code: 'APP_RUN_PROVIDER_UNAVAILABLE' }) }); + assert.ok(completed); + } + const immediate = await runPage(); + assert.equal(immediate.not_due, 1); + assert.equal(immediate.blocked, 1); + checkedAt = new Date(checkedAt.getTime() + 61_000); + const due = await runPage(); + assert.equal(due.created, 1); + assert.equal(due.blocked, 1); + assert.equal(await runCount(success.org_id), 2); + assert.equal(await runCount(uncertain.org_id), 1); + const [unknown] = await db.select().from(schema.appRuns).where(eq(schema.appRuns.org_id, uncertain.org_id)); + assert.equal(unknown?.state, 'unknown_outcome'); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(eq(schema.appSyncCheckpoints.id, uncertain.checkpoint_id)); + assert.equal(checkpoint?.cursor_sequence, 0); + assert.equal(checkpoint?.generation, 1); + await revokeAll(); + }); + + await t.test('actual scheduled worker routes leased scan and shutdown gate stops recurrence', async () => { + const owned = await makeFixture(); + const job = await claim(); + await workers._processDequeuedJobForTest(queue.QUEUE_NAMES.SCHEDULED_JOBS, job); + assert.equal(await runCount(owned.org_id), 1); + const [finished] = await db.select().from(schema.jobQueue).where(eq(schema.jobQueue.id, job.id)); + assert.equal(finished?.status, 'completed'); + const next = await claim(); + process.env.DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED = 'false'; + await workers._processDequeuedJobForTest(queue.QUEUE_NAMES.SCHEDULED_JOBS, next); + assert.equal((await scanJobs()).filter((row) => ['pending', 'running'].includes(row.status)).length, 0); + await revokeAll(); + }); + } finally { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + keys.destroy(); + await closeDb(); + } +}); diff --git a/apps/api/test/fixtures/resource-sync-scan-process.ts b/apps/api/test/fixtures/resource-sync-scan-process.ts new file mode 100644 index 00000000..c82fd924 --- /dev/null +++ b/apps/api/test/fixtures/resource-sync-scan-process.ts @@ -0,0 +1,23 @@ +// Synthetic scheduler restart consumer. Separate process, real queue and runtime. +import { closeDb } from '../../src/lib/db.js'; +import { getAppRunRuntime, shutdownAppRunRuntime } from '../../src/lib/app-run-runtime.js'; +import { dequeueJob, completeJob, QUEUE_NAMES } from '../../src/lib/queues.js'; +import { APP_RESOURCE_SYNC_SCAN_JOB, scanAppResourceSyncBindings } from '../../src/lib/app-resource-sync-scanner.js'; + +if (process.env.DATABASE_URL !== process.env.DEFT_TEST_DATABASE_URL + || process.env.DATABASE_URL !== 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_scheduler') { + throw new Error('Scheduler restart fixture requires its dedicated synthetic database'); +} +try { + const job = await dequeueJob(QUEUE_NAMES.SCHEDULED_JOBS, + { jobName: APP_RESOURCE_SYNC_SCAN_JOB, leaseMs: 60_000 }); + if (!job) throw new Error('No due resource sync scan'); + const runtime = await getAppRunRuntime(); + const result = await scanAppResourceSyncBindings(job, runtime.resourceSyncAdmission); + if (!await completeJob(job.id, job.lockToken)) throw new Error('Lost restart fixture lease'); + console.log(`scheduler-restart:${JSON.stringify(result)}`); +} finally { + await shutdownAppRunRuntime(); + await closeDb(); +} +process.exit(0); From 975674d852f3a6d5be4b82d505b128ce32b08bc4 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:07:40 +0530 Subject: [PATCH 031/161] fix: fence private read delivery before final consent check --- apps/api/src/lib/app-resource-private-read.ts | 11 +++++-- .../test/app-resource-private-read-db.test.ts | 32 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/api/src/lib/app-resource-private-read.ts b/apps/api/src/lib/app-resource-private-read.ts index c0f7a6a7..de56736e 100644 --- a/apps/api/src/lib/app-resource-private-read.ts +++ b/apps/api/src/lib/app-resource-private-read.ts @@ -30,6 +30,7 @@ export type PrivateResourceCheckpoint = Readonly<{ generation: number; cursor_se last_applied_at: string | null; freshness: 'unknown' }>; export type PrivateResourcePage = Readonly<{ items: readonly PrivateResourceRecord[]; next_cursor: string | null; checkpoint: PrivateResourceCheckpoint }>; +export type AppResourcePrivateReadDeliveryGuard = (tx: AppRunTransaction) => Promise; export class AppResourcePrivateReadError extends Error { constructor(readonly code: 'APP_RESOURCE_PRIVATE_UNAVAILABLE' | 'APP_RESOURCE_PRIVATE_CURSOR_STALE' @@ -49,7 +50,8 @@ export class AppResourcePrivateReadService { readonly #secrets: AppResourceSyncSecretService; constructor(private readonly keys: AppRunKeyProvider, private readonly clock: () => Date = () => new Date(), - private readonly repository: Pick = new PostgresAppRunRepository()) { + private readonly repository: Pick = new PostgresAppRunRepository(), + private readonly deliveryGuard?: AppResourcePrivateReadDeliveryGuard) { this.#secrets = new AppResourceSyncSecretService(keys); } @@ -133,6 +135,9 @@ export class AppResourcePrivateReadService { }; assertConsent(); const result = await read(tx, authority, checkpoint); + // Host session/Experience checks belong inside these authority locks and + // before the last clock check: their own row locks may wait past consent. + await this.deliveryGuard?.(tx); assertConsent(); return result; }); @@ -160,10 +165,10 @@ export class AppResourcePrivateReadService { }, { org_id: row.org_id, resource_binding_id: row.resource_binding_id, checkpoint_id: row.checkpoint_id, payload_kind: 'projection', generation: row.generation, projection_id: row.id, slot: 'record' })); - const parsed = parseSyncPage(authority.descriptor, { - schema_version: 'deft.app_sync_request.v1', cursor: null, max_items: 1, // The ID is only a parser placeholder; never decrypt the provider ID. // Its minimum length cannot inflate a valid near-ceiling stored page. + const parsed = parseSyncPage(authority.descriptor, { + schema_version: 'deft.app_sync_request.v1', cursor: null, max_items: 1, }, { schema_version: 'deft.app_sync_page.v1', upserts: [{ id: 'x', ...body }], tombstones: [], next_cursor: null, has_more: false }).upserts[0]!; const label = (parsed.data[authority.descriptor.label_field] as string) diff --git a/apps/api/test/app-resource-private-read-db.test.ts b/apps/api/test/app-resource-private-read-db.test.ts index 3326415d..d44439c6 100644 --- a/apps/api/test/app-resource-private-read-db.test.ts +++ b/apps/api/test/app-resource-private-read-db.test.ts @@ -89,7 +89,8 @@ test('owner-private reads of actual reviewed and settled v5 resources', { skip: } assert.fail('actual PostgreSQL lock wait was not observed'); }; - const markedReader = (marker: string, readClock = clock) => { + const markedReader = (marker: string, readClock = clock, + guard?: (tx: AppRunTransaction) => Promise) => { class WaitingRepository extends repositories.PostgresAppRunRepository { override transaction(work: (tx: AppRunTransaction) => Promise): Promise { return super.transaction(async (tx) => { @@ -99,7 +100,7 @@ test('owner-private reads of actual reviewed and settled v5 resources', { skip: }); } } - return new readModule.AppResourcePrivateReadService(keys, readClock, new WaitingRepository()); + return new readModule.AppResourcePrivateReadService(keys, readClock, new WaitingRepository(), guard); }; try { const owned = await fixture.createReviewedResourceSyncFixture({ keys, clock }); @@ -347,5 +348,32 @@ test('owner-private reads of actual reviewed and settled v5 resources', { skip: assert.equal(current.items.length, 3); assert.equal(current.checkpoint.cursor_sequence, 2); }); + await t.test('final delivery guard lock wait cannot release a page after consent expires', async () => { + const changed = await fresh(); + const sid = randomUUID(); + await db.insert(schema.webSessions).values({ id: sid, org_id: changed.org_id, + user_id: changed.owner_user_id, refresh_token_hash: 'synthetic-private-read', + expires_at: new Date(checkedAt.getTime() + 86_400_000) }); + let unlock!: () => void; + let ready!: () => void; + const locked = new Promise((resolve) => { ready = resolve; }); + const release = new Promise((resolve) => { unlock = resolve; }); + const holder = db.transaction(async (tx) => { + await tx.select().from(schema.webSessions).where(eq(schema.webSessions.id, sid)).for('update'); + ready(); + await release; + }); + await locked; + let expired = false; + const marker = `read-final-guard-${randomUUID()}`; + const guarded = markedReader(marker, + () => new Date(checkedAt.getTime() + (expired ? 3_600_000 : 0)), async (tx) => { + await tx.select().from(schema.webSessions).where(eq(schema.webSessions.id, sid)).for('share'); + }); + const pending = assert.rejects(guarded.listOwnerPrivateResourcePage(subject(changed), target(changed)), unavailable); + try { await waitForLock(marker); expired = true; } + finally { unlock(); await holder; } + await pending; + }); } finally { keys.destroy(); await closeDb(); } }); From e353bb53be17b501f7fee9f914fea1f2aee4caa2 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:22:27 +0530 Subject: [PATCH 032/161] Fence private sync delivery by current human participant identity --- apps/api/src/lib/app-resource-private-read.ts | 4 +- .../src/lib/app-resource-sync-authority.ts | 28 +- .../src/lib/app-resource-sync-management.ts | 11 +- .../lib/app-resource-sync-web-authority.ts | 9 +- .../app-resource-private-read-http.test.ts | 274 ++++++++++++++++++ ...p-resource-sync-management-http-db.test.ts | 22 ++ 6 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 apps/api/test/app-resource-private-read-http.test.ts diff --git a/apps/api/src/lib/app-resource-private-read.ts b/apps/api/src/lib/app-resource-private-read.ts index de56736e..a952e1cb 100644 --- a/apps/api/src/lib/app-resource-private-read.ts +++ b/apps/api/src/lib/app-resource-private-read.ts @@ -7,7 +7,7 @@ import { AppRuntimeResourceRefV2Schema, canonicalCapabilityJson } from '@deft/sh import type { ResourceRefV2 } from '@deft/shared'; import type { AppRunKeyProvider } from './app-run-keyrings.js'; import { PostgresAppRunRepository, type AppRunTransaction } from './app-run-repository.js'; -import { loadLiveResourceSyncBindingAuthority } from './app-resource-sync-authority.js'; +import { loadLiveResourceSyncBindingAuthority, resourceSyncParticipantsAreHuman } from './app-resource-sync-authority.js'; import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; export const APP_RESOURCE_PRIVATE_READ_LIMITS = Object.freeze({ items: 25, response_bytes: 1_048_576 }); @@ -138,6 +138,8 @@ export class AppResourcePrivateReadService { // Host session/Experience checks belong inside these authority locks and // before the last clock check: their own row locks may wait past consent. await this.deliveryGuard?.(tx); + if (!await resourceSyncParticipantsAreHuman(tx, authority.binding.owner_user_id, + authority.registration.operator_user_id)) throw unavailable(); assertConsent(); return result; }); diff --git a/apps/api/src/lib/app-resource-sync-authority.ts b/apps/api/src/lib/app-resource-sync-authority.ts index 51072eb1..a79f76e8 100644 --- a/apps/api/src/lib/app-resource-sync-authority.ts +++ b/apps/api/src/lib/app-resource-sync-authority.ts @@ -1,7 +1,7 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, inArray, sql } from 'drizzle-orm'; import { appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, - capabilityProviderSnapshots, orgMembers, + capabilityProviderSnapshots, orgMembers, users, } from '@deft/db/schema'; import { parseSyncDescriptor, digestResourceSyncDescriptor } from '@deft/app-kit/experimental/resource-sync'; import { CapabilityProviderDiscoverySnapshotSchema } from '@deft/shared'; @@ -29,6 +29,15 @@ type BindingLocator = Readonly<{ org_id: string; resource_binding_id: string; cl type SessionLocator = Readonly<{ org_id: string; session_id: string; token_hash: string; clock: () => Date }>; +/** Participant kinds are read after waits without taking users locks after the + * established member/App locks. Callers supply IDs from locked authority rows. */ +export async function resourceSyncParticipantsAreHuman(tx: AppRunTransaction, + ownerUserId: string, operatorUserId: string): Promise { + const ids = [...new Set([ownerUserId, operatorUserId])]; + const rows = await tx.select({ id: users.id, kind: users.kind }).from(users).where(inArray(users.id, ids)); + return ids.every(id => rows.some(row => row.id === id && row.kind === 'human')); +} + function currentTime(clock: () => Date): Date | null { const checked = clock(); return checked instanceof Date && Number.isFinite(checked.getTime()) ? checked : null; @@ -73,14 +82,14 @@ export async function loadLiveResourceSyncBindingAuthority(tx: AppRunTransaction await tx.execute(sql`SELECT id FROM org_members WHERE org_id = ${input.org_id} AND user_id = ${userId} FOR SHARE`); } - const [owner] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) - .from(orgMembers).where(and(eq(orgMembers.org_id, input.org_id), + const [owner] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role, kind: users.kind }) + .from(orgMembers).innerJoin(users, eq(users.id, orgMembers.user_id)).where(and(eq(orgMembers.org_id, input.org_id), eq(orgMembers.user_id, locator.owner_user_id))).limit(1); - const [operator] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) - .from(orgMembers).where(and(eq(orgMembers.org_id, input.org_id), + const [operator] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role, kind: users.kind }) + .from(orgMembers).innerJoin(users, eq(users.id, orgMembers.user_id)).where(and(eq(orgMembers.org_id, input.org_id), eq(orgMembers.user_id, registrationLocator.operator_user_id))).limit(1); - if (!owner?.is_active || !['owner', 'admin'].includes(owner.role) - || !operator?.is_active || operator.role === 'guest') return null; + if (!owner?.is_active || owner.kind !== 'human' || !['owner', 'admin'].includes(owner.role) + || !operator?.is_active || operator.kind !== 'human' || operator.role === 'guest') return null; let reviewed: Reviewed; try { reviewed = await loadReviewedResourceSyncDescriptor(tx, input.org_id, locator.installation_id, locator.resource_key); } @@ -135,6 +144,7 @@ export async function loadLiveResourceSyncBindingAuthority(tx: AppRunTransaction try { if (!await validProviderSnapshot(providerSnapshot, registration, binding, reviewed.descriptor)) return null; } catch { return null; } + if (!await resourceSyncParticipantsAreHuman(tx, binding.owner_user_id, registration.operator_user_id)) return null; const checkedAt = currentTime(input.clock); if (!checkedAt || binding.consent_expires_at <= checkedAt) return null; return Object.freeze({ ...reviewed, registration, binding, provider_snapshot: providerSnapshot, @@ -163,6 +173,8 @@ export async function loadLiveResourceSyncAuthority(tx: AppRunTransaction, const [session] = await tx.select().from(appRuntimeSessions).where(and( eq(appRuntimeSessions.org_id, input.org_id), eq(appRuntimeSessions.id, input.session_id), eq(appRuntimeSessions.token_hash, input.token_hash))).limit(1); + if (!await resourceSyncParticipantsAreHuman(tx, bindingAuthority.binding.owner_user_id, + bindingAuthority.registration.operator_user_id)) return null; const checkedAt = currentTime(input.clock); if (!session || !checkedAt || session.audience !== 'app_resource_sync' || session.runtime_binding_id !== null diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index 5b1c4ef7..0f528249 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -3,7 +3,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, - appSyncCheckpoints, auditLog, orgMembers, + appSyncCheckpoints, auditLog, orgMembers, users, } from '@deft/db/schema'; import type { ModuleActor } from '@deft/shared/modules'; import type { AppRunKeyProvider } from './app-run-keyrings.js'; @@ -49,6 +49,8 @@ function operator(actor: ModuleActor): asserts actor is Human { async function assertManager(tx: Tx, actor: Human) { try { await assertCurrentModuleManagerWithExecutor(tx, actor); } catch (error) { if (isModuleError(error)) throw denied(); throw error; } + const [user] = await tx.select({ kind: users.kind }).from(users).where(eq(users.id, actor.actor_id)); + if (user?.kind !== 'human') throw denied(); } async function lockMembers(tx: Tx, orgId: string, userIds: readonly string[], mode: 'SHARE' | 'UPDATE') { for (const userId of [...new Set(userIds)].sort()) { @@ -69,10 +71,11 @@ export class AppResourceSyncManagement { activation: boolean) { await lockMembers(tx, actor.org_id, [actor.actor_id, input.operator_user_id], 'UPDATE'); await assertManager(tx, actor); - const [operatorMember] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role }) - .from(orgMembers).where(and(eq(orgMembers.org_id, actor.org_id), + const [operatorMember] = await tx.select({ is_active: orgMembers.is_active, role: orgMembers.role, kind: users.kind }) + .from(orgMembers).innerJoin(users, eq(users.id, orgMembers.user_id)) + .where(and(eq(orgMembers.org_id, actor.org_id), eq(orgMembers.user_id, input.operator_user_id))).limit(1); - if (!operatorMember?.is_active || operatorMember.role === 'guest') throw denied(); + if (!operatorMember?.is_active || operatorMember.role === 'guest' || operatorMember.kind !== 'human') throw denied(); if (activation) await tx.execute(sql`SELECT id FROM app_installations WHERE org_id = ${actor.org_id} AND id = ${input.installation_id} FOR UPDATE`); const reviewed = await loadReviewedResourceSyncDescriptor(tx, actor.org_id, diff --git a/apps/api/src/lib/app-resource-sync-web-authority.ts b/apps/api/src/lib/app-resource-sync-web-authority.ts index c6111688..cfedcc45 100644 --- a/apps/api/src/lib/app-resource-sync-web-authority.ts +++ b/apps/api/src/lib/app-resource-sync-web-authority.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from 'drizzle-orm'; -import { webSessions, orgMembers } from '@deft/db/schema'; +import { webSessions, orgMembers, users } from '@deft/db/schema'; import type { ModuleActor } from '@deft/shared/modules'; +import { db } from './db.js'; import { verifyWebAccess } from './web-sessions.js'; import { humanModuleActor } from './module-service.js'; import { AppError } from './app-errors.js'; @@ -19,6 +20,8 @@ export async function resourceSyncWebAuthority(authorization: string | undefined let user: Awaited>; try { user = await verifyWebAccess(match[1]!); } catch { throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); } + const [human] = await db.select({ kind: users.kind }).from(users).where(eq(users.id, user.id)); + if (human?.kind !== 'human') throw new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); const actor = humanModuleActor({ orgId: user.org_id, userId: user.id, role: user.role, source: 'rest' }); const guard: ResourceSyncManagementGuard = async (tx) => { @@ -36,6 +39,10 @@ export async function resourceSyncWebAuthority(authorization: string | undefined revoked_at: webSessions.revoked_at }).from(webSessions).where(and( eq(webSessions.id, user.sid), eq(webSessions.user_id, user.id), eq(webSessions.org_id, user.org_id))).for('share'); + // Recheck stored identity after any SID wait, without adding the reverse + // users lock edge against password reset's users -> member -> SID order. + const [currentHuman] = await tx.select({ kind: users.kind }).from(users).where(eq(users.id, user.id)); + if (currentHuman?.kind !== 'human') throw new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); const now = Date.now(); if (!session || session.revoked_at || session.expires_at.getTime() <= now || user.exp * 1000 <= now) { throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); diff --git a/apps/api/test/app-resource-private-read-http.test.ts b/apps/api/test/app-resource-private-read-http.test.ts new file mode 100644 index 00000000..83cb9803 --- /dev/null +++ b/apps/api/test/app-resource-private-read-http.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { AppRunTransaction } from '../src/lib/app-run-repository.js'; +import type { ServerType } from '@hono/node-server'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!target || target !== process.env.DATABASE_URL) return false; + try { const u = new URL(target); return ['postgres:', 'postgresql:'].includes(u.protocol) + && u.hostname === '127.0.0.1' && u.port === '55435' + && /^\/gate_g_20260926_(?:management|root)(?:_v[0-9]+)?$/.test(u.pathname) + && !u.search && !u.hash; } catch { return false; } +})(); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +// This HTTP profile shares its disposable DB/key material with management HTTP. +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`management-http:${purpose}`).digest('base64') } }); +process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('mgmt-enc'), receipt_signing: ring('mgmt-sign'), fingerprint: ring('mgmt-fp') }); +after(async () => { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + await (await import('../src/lib/db.js')).closeDb(); +}); + +async function harness(shortConsentMs?: number) { + const [{ db }, schema, drizzle, webSessions, runtimeModule, privateRoutes, channelRoutes, + managementRoutes, hono, serverModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-runtime.js'), + import('../src/routes/app-resource-private-read.js'), import('../src/routes/app-resource-sync-channel.js'), + import('../src/routes/app-resource-sync-management.js'), import('hono'), import('@hono/node-server'), + ]); + const runtime = await runtimeModule.getAppRunRuntime(); + const fixture = await createReviewedResourceSyncFixture({ keys: runtime.keys, clock: () => new Date() }); + const token = async (id: string, orgId = fixture.org_id) => { + const [user] = await db.select().from(schema.users).where(drizzle.eq(schema.users.id, id)); + return webSessions.createWebSession({ id, org_id: orgId, email: user!.email }); + }; + const owner = await token(fixture.owner_user_id); + const operator = await token(fixture.operator_user_id); + const app = new hono.Hono(); + app.route('/read', privateRoutes.appResourcePrivateReadRoutes); + app.route('/sync', channelRoutes.appResourceSyncChannelRoutes); + app.route('/manage', managementRoutes.appResourceSyncManagementRoutes); + let server!: ServerType; + const base = await new Promise(resolve => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, + info => resolve(`http://127.0.0.1:${info.port}`)); + }); + const call = async (path: string, auth = `Bearer ${owner.accessToken}`, method = 'GET', value?: unknown) => { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(auth ? { Authorization: auth } : {}), + ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + if (path.startsWith('/read/')) assert.equal(response.headers.get('pragma'), 'no-cache'); + return { status: response.status, body: await response.json() as any }; + }; + let bindingId = fixture.binding_id; + let expiresAt = fixture.consent_request.consent_expires_at; + if (shortConsentMs) { + await fixture.management.revokeConsent(fixture.owner_actor, fixture.binding_id); + const request = { ...fixture.consent_request, consent_expires_at: new Date(Date.now() + shortConsentMs).toISOString() }; + const review = await fixture.management.prepareConsent(fixture.owner_actor, request); + const activated = await fixture.management.activateConsent(fixture.owner_actor, { ...request, + expected_review_digest: review.review_digest, accept_host_policy: true }); + bindingId = activated.binding_id; + expiresAt = request.consent_expires_at; + } + const issued = await call(`/manage/bindings/${bindingId}/sessions`, `Bearer ${operator.accessToken}`, 'POST'); + assert.equal(issued.status, 201); + const session = issued.body.session; + const admitted = await runtime.resourceSyncAdmission.admitDue({ org_id: fixture.org_id, resource_binding_id: bindingId }); + assert.equal(admitted.state, 'created'); + const identity = { schema_version: 'deft.app_runtime_channel.v2', audience: 'app_resource_sync', session_id: session.session_id }; + const runtimeAuth = `AppRuntime ${session.session_token}`; + const claimed = await call('/sync/claim', runtimeAuth, 'POST', { ...identity, max_claims: 1 }); + assert.equal(claimed.status, 200); + const claim = claimed.body.claim; + assert.ok(claim); + const attempt = { ...identity, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + assert.equal((await call('/sync/start', runtimeAuth, 'POST', attempt)).status, 200); + const completed = await call('/sync/result', runtimeAuth, 'POST', { ...attempt, + status: 'returned', provider_succeeded: true, page: { schema_version: 'deft.app_sync_page.v1', + upserts: Array.from({ length: 3 }, (_, i) => ({ id: `provider-private-${i}`, revision: `r${i}`, + data: { subject: `Private HTTP message ${i}` } })), tombstones: [], + next_cursor: 'provider-private-cursor', has_more: false } }); + assert.equal(completed.status, 200); + assert.equal(completed.body.accepted, true); + const marker = `private-http-${fixture.org_id}`; + const originalTransaction = runtime.repository.transaction.bind(runtime.repository); + runtime.repository.transaction = (work: (tx: AppRunTransaction) => Promise) => originalTransaction(async tx => { + await tx.execute(drizzle.sql`SELECT set_config('application_name', ${marker}, true)`); + return work(tx); + }); + return { db, schema, ...drizzle, ...fixture, binding_id: bindingId, expires_at: expiresAt, marker, + owner, operator, runtime, webSessions, call, token, runtimeAuth, runtime_session: session, + close: () => { runtime.repository.transaction = originalTransaction; + return new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); } }; +} + +async function waitForLock(h: Awaited>, table: string) { + for (let i = 0; i < 250; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname=current_database() AND pid <> pg_backend_pid() AND wait_event_type='Lock' + AND application_name = ${h.marker} + AND query LIKE ${`%${table}%`}`); + if (result.rows[0]!.waiting > 0) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.fail(`actual HTTP request did not wait on ${table}`); +} + +test('private read HTTP returns settled owner records with signed pagination and private response headers', { skip: !safe }, async () => { + const h = await harness(); + try { + const path = `/read/bindings/${h.binding_id}/records`; + const first = await h.call(`${path}?limit=2`); + assert.equal(first.status, 200); + assert.equal(first.body.items.length, 2); + assert.equal(first.body.checkpoint.cursor_sequence, 1); + assert.equal(first.body.checkpoint.freshness, 'unknown'); + assert.ok(first.body.next_cursor); + const second = await h.call(`${path}?limit=2&cursor=${encodeURIComponent(first.body.next_cursor)}`); + assert.equal(second.status, 200); assert.equal(second.body.items.length, 1); + assert.equal(second.body.next_cursor, null); + const items = [...first.body.items, ...second.body.items]; + assert.equal(new Set(items.map(item => item.projection_id)).size, 3); + assert.deepEqual(new Set(items.map(item => item.data.subject)), new Set(['Private HTTP message 0', 'Private HTTP message 1', 'Private HTTP message 2'])); + for (const item of items) { + const detail = await h.call(`${path}/${item.projection_id}`); + assert.equal(detail.status, 200); + assert.deepEqual(detail.body.item, item); + assert.equal(item.ref.resource_id, item.projection_id); + } + const text = JSON.stringify(first.body); + for (const secret of ['provider-private-', 'cursor_hmac', 'ciphertext', 'body_nonce', + h.owner_user_id, h.operator_user_id, h.runtime_session.session_token]) assert.ok(!text.includes(secret)); + const status = await h.call(`/manage/bindings/${h.binding_id}`); + assert.equal(status.body.latest_run.state, 'succeeded'); + assert.ok(status.body.latest_run.receipt_id); + const receipts = await h.runtime.receiptReader.readVerified(h.org_id, status.body.latest_run.run_id); + assert.ok(receipts.some(receipt => receipt.receipt_kind === 'attempt_terminal')); + } finally { await h.close(); } +}); + +test('private read HTTP denies disabled rollout non-web foreign actors and query/cursor tampering', { skip: !safe }, async () => { + const h = await harness(); + try { + const path = `/read/bindings/${h.binding_id}/records`; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + assert.equal((await h.call(path)).status, 503); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + for (const auth of ['', h.runtimeAuth, `Bearer ${h.runtime_session.session_token}`, `Bearer ${h.owner.refreshToken}`, + 'Bearer synthetic-personal-mcp', 'Bearer synthetic-employee']) assert.equal((await h.call(path, auth)).status, 401); + assert.equal((await h.call(path, `Bearer ${h.operator.accessToken}`)).status, 404); + await h.db.update(h.schema.orgMembers).set({ role: 'admin' }).where(h.and( + h.eq(h.schema.orgMembers.org_id, h.org_id), h.eq(h.schema.orgMembers.user_id, h.operator_user_id))); + assert.equal((await h.call(path, `Bearer ${h.operator.accessToken}`)).status, 404); + const foreignOrg = randomUUID(); + await h.db.insert(h.schema.orgs).values({ id: foreignOrg, name: 'foreign', slug: `private-read-http-${foreignOrg}` }); + await h.db.insert(h.schema.orgMembers).values({ org_id: foreignOrg, user_id: h.owner_user_id, role: 'owner', is_active: true }); + const foreign = await h.token(h.owner_user_id, foreignOrg); + assert.equal((await h.call(path, `Bearer ${foreign.accessToken}`)).status, 404); + const page = await h.call(`${path}?limit=1`); + for (const query of ['?limit=0', '?limit=26', '?limit=1&limit=2', '?owner_user_id=other', '?cursor=']) { + assert.equal((await h.call(`${path}${query}`)).status, 400); + } + assert.equal((await h.call(`${path}?cursor=${page.body.next_cursor.slice(0, -2)}AA`)).status, 404); + assert.equal((await h.call(`${path}/${page.body.items[0].projection_id}?owner_user_id=other`)).status, 400); + assert.equal((await h.call(`${path}/${randomUUID()}`)).status, 404); + await h.management.revokeConsent(h.owner_actor, h.binding_id); + const revoked = await h.call(path); + assert.equal(revoked.status, 404); + assert.deepEqual(Object.keys(revoked.body).sort(), ['code', 'error']); + } finally { process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; await h.close(); } +}); + +test('private read HTTP discards decrypted records when exact web SID is revoked during a real authority lock wait', { skip: !safe }, async () => { + const h = await harness(); + let release = () => {}; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM org_members WHERE org_id = ${h.org_id} AND user_id = ${h.owner_user_id} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = h.call(`/read/bindings/${h.binding_id}/records`); + await waitForLock(h, 'org_members'); + await h.webSessions.revokeWebSession(h.owner.refreshToken); + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, 401); + assert.deepEqual(Object.keys(denied.body).sort(), ['code', 'error']); + assert.ok(!JSON.stringify(denied.body).includes('Private HTTP message')); + } finally { release(); await h.close(); } +}); + +test('private read HTTP rechecks consent after the final web SID lock wait', { skip: !safe }, async () => { + const h = await harness(8000); + let release = () => {}; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE org_id = ${h.org_id} AND user_id = ${h.owner_user_id} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = h.call(`/read/bindings/${h.binding_id}/records`); + await waitForLock(h, 'web_sessions'); + assert.ok(Date.now() < new Date(h.expires_at).getTime(), 'reader reached final SID wait before consent expiry'); + await new Promise(resolve => setTimeout(resolve, Math.max(0, new Date(h.expires_at).getTime() - Date.now() + 20))); + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, 404, 'completed decrypted data must not escape after consent expired during SID lock wait'); + assert.ok(!JSON.stringify(denied.body).includes('Private HTTP message')); + } finally { release(); await h.close(); } +}); + +test('private read HTTP rejects nonhuman owner and operator identity in current sync authority', { skip: !safe }, async () => { + const h = await harness(); + try { + const path = `/read/bindings/${h.binding_id}/records`; + await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, h.operator_user_id)); + assert.equal((await h.call(path)).status, 404, 'nonhuman selected operator ends current delivery authority'); + const { loadLiveResourceSyncAuthority } = await import('../src/lib/app-resource-sync-authority.js'); + const { hashAppResourceSyncToken } = await import('../src/lib/app-resource-sync-policy.js'); + assert.equal(await h.db.transaction(tx => loadLiveResourceSyncAuthority(tx, { + org_id: h.org_id, session_id: h.runtime_session.session_id, + token_hash: hashAppResourceSyncToken(h.runtime_session.session_token), clock: () => new Date(), + })), null, 'previously issued v2 session is fenced by current operator kind'); + await h.db.update(h.schema.users).set({ kind: 'human' }).where(h.eq(h.schema.users.id, h.operator_user_id)); + assert.equal((await h.call(path)).status, 200); + await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, h.owner_user_id)); + assert.equal((await h.call(path)).status, 403, 'web SID alone does not turn a stored agent into a human'); + const { AppResourcePrivateReadService } = await import('../src/lib/app-resource-private-read.js'); + await assert.rejects(new AppResourcePrivateReadService(h.runtime.keys).listOwnerPrivateResourcePage( + { kind: 'human', org_id: h.org_id, user_id: h.owner_user_id }, { resource_binding_id: h.binding_id }), + (error: unknown) => (error as { code?: string }).code === 'APP_RESOURCE_PRIVATE_UNAVAILABLE'); + } finally { await h.close(); } +}); + +test('private read HTTP rechecks participant kind after the final web SID lock wait', { skip: !safe }, async () => { + const h = await harness(); + let release = () => {}; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE org_id = ${h.org_id} AND user_id = ${h.owner_user_id} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = h.call(`/read/bindings/${h.binding_id}/records`); + await waitForLock(h, 'web_sessions'); + await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, h.operator_user_id)); + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, 404, 'operator becoming nonhuman during final SID wait must fence delivery'); + assert.ok(!JSON.stringify(denied.body).includes('Private HTTP message')); + } finally { release(); await h.close(); } +}); diff --git a/apps/api/test/app-resource-sync-management-http-db.test.ts b/apps/api/test/app-resource-sync-management-http-db.test.ts index e262d9f3..343972ae 100644 --- a/apps/api/test/app-resource-sync-management-http-db.test.ts +++ b/apps/api/test/app-resource-sync-management-http-db.test.ts @@ -243,3 +243,25 @@ test('private sync HTTP status exposes generic Run metadata without private Run } } finally { await h.close(); } }); + +test('private sync HTTP denies nonhuman accounts even with a valid web SID', { skip: !safe }, async () => { + const h = await harness(); + try { + const validSession = await h.management.issueOperatorSession(h.operator_actor, h.binding_id); + await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, h.operator_user_id)); + assert.equal((await h.call('/reviews/prepare', 'POST', h.consent_request)).status, 403, 'selected operator must be a stored human'); + await assert.rejects(h.management.issueOperatorSession(h.operator_actor, h.binding_id)); + const authority = await import('../src/lib/app-resource-sync-authority.js'); + const { hashAppResourceSyncToken } = await import('../src/lib/app-resource-sync-policy.js'); + assert.equal(await h.db.transaction(tx => authority.loadLiveResourceSyncAuthority(tx, { + org_id: h.org_id, session_id: validSession.session_id, token_hash: hashAppResourceSyncToken(validSession.session_token), clock: () => new Date() })), null); + const denied = await h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken); + assert.equal(denied.status, 403); + assert.ok(!JSON.stringify(denied.body).includes('session_token')); + for (const kind of ['agent', 'system'] as const) { + await h.db.update(h.schema.users).set({ kind }).where(h.eq(h.schema.users.id, h.owner_user_id)); + assert.equal((await h.call('/bindings')).status, 403); + assert.equal((await h.call(`/bindings/${h.binding_id}/revoke`, 'POST')).status, 403); + } + } finally { await h.close(); } +}); From d03675c03d781bc09b7eb1d3f265c5c6408f4b2f Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:27:05 +0530 Subject: [PATCH 033/161] Recheck consent deadlines after management session guards --- .../src/lib/app-resource-sync-management.ts | 12 +++- ...p-resource-sync-management-http-db.test.ts | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index 0f528249..4b43a3ca 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -26,10 +26,12 @@ import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, type Tx = Parameters[0]>[0]; export type ResourceSyncManagementGuard = (tx: Tx) => Promise; -async function managementTransaction(guard: ResourceSyncManagementGuard | undefined, operation: (tx: Tx) => Promise): Promise { +async function managementTransaction(guard: ResourceSyncManagementGuard | undefined, + operation: (tx: Tx) => Promise, assertFinal?: (result: T) => void): Promise { return db.transaction(async (tx) => { const result = await operation(tx); await guard?.(tx); + assertFinal?.(result); return result; }); } @@ -37,6 +39,10 @@ type Human = Extract; const Id = z.string().uuid(); const stale = () => new AppError('Private resource sync authority changed', 'APP_STALE', 409); const denied = () => new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); +function assertBeforeDeadline(deadline: Date, clock: () => Date) { + const now = clock(); + if (!(now instanceof Date) || !Number.isFinite(now.getTime()) || deadline <= now) throw stale(); +} const conflict = () => new AppError('Private resource sync already has current consent', 'APP_STATE_CONFLICT', 409); function reviewer(actor: ModuleActor): asserts actor is Human { @@ -182,7 +188,7 @@ export class AppResourceSyncManagement { checkpoint_id: checkpointId, app_version_id: version.id, grant_snapshot_id: grant.id, resource_key: descriptor.key, review_digest: review.review_digest }); - }); + }, () => assertBeforeDeadline(new Date(input.consent_expires_at), this.clock)); } async issueOperatorSession(actor: ModuleActor, bindingId: string, guard?: ResourceSyncManagementGuard) { @@ -217,7 +223,7 @@ export class AppResourceSyncManagement { audience: 'app_resource_sync', expires_at: expiresAt.toISOString() }, metadata: { source: actor.source } }); return expiresAt; - }); + }, (expiresAt) => assertBeforeDeadline(expiresAt, this.clock)); return Object.freeze({ session_id: sessionId, session_token: token, expires_at: issued }); } diff --git a/apps/api/test/app-resource-sync-management-http-db.test.ts b/apps/api/test/app-resource-sync-management-http-db.test.ts index 343972ae..0e0792d8 100644 --- a/apps/api/test/app-resource-sync-management-http-db.test.ts +++ b/apps/api/test/app-resource-sync-management-http-db.test.ts @@ -265,3 +265,60 @@ test('private sync HTTP denies nonhuman accounts even with a valid web SID', { s } } finally { await h.close(); } }); + +test('private sync HTTP deadline checks run after final web SID waits', { skip: !safe }, async () => { + for (const operation of ['session', 'activation'] as const) { + const h = await harness(); + let release = () => {}; + try { + await h.management.revokeConsent(h.owner_actor, h.binding_id); + const request = { ...h.consent_request, consent_expires_at: new Date(Date.now() + 8000).toISOString() }; + const review = await h.management.prepareConsent(h.owner_actor, request); + const activation = { ...request, expected_review_digest: review.review_digest, accept_host_policy: true }; + const binding = operation === 'session' ? await h.management.activateConsent(h.owner_actor, activation) : null; + const auditAction = operation === 'session' ? 'app.resource_sync_session_issue' : 'app.resource_sync_consent_activate'; + const before = await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org_id), h.eq(h.schema.auditLog.action, auditAction))); + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + let blockerPid = 0; + const blocker = h.db.transaction(async tx => { + blockerPid = (await tx.execute(h.sql<{ pid: number }>`SELECT pg_backend_pid() AS pid`)).rows[0]!.pid; + const userId = operation === 'session' ? h.operator_user_id : h.owner_user_id; + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE org_id = ${h.org_id} AND user_id = ${userId} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = binding + ? h.call(`/bindings/${binding.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken) + : h.call('/bindings/activate', 'POST', activation); + let waited = false; + for (let i = 0; i < 250; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname=current_database() AND ${blockerPid} = ANY(pg_blocking_pids(pid))`); + if (result.rows[0]!.waiting > 0) { waited = true; break; } + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.ok(waited, `${operation} waited on its exact web SID blocker`); + assert.ok(Date.now() < new Date(request.consent_expires_at).getTime()); + await new Promise(resolve => setTimeout(resolve, Math.max(0, new Date(request.consent_expires_at).getTime() - Date.now() + 20))); + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, 409, `${operation} cannot report success after consent expires during final SID wait`); + assert.ok(!JSON.stringify(denied.body).includes('session_token')); + const after = await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org_id), h.eq(h.schema.auditLog.action, auditAction))); + assert.equal(after.length, before.length, 'expired operation audit rolls back'); + if (binding) { + const sessions = await h.db.select().from(h.schema.appRuntimeSessions) + .where(h.eq(h.schema.appRuntimeSessions.resource_binding_id, binding.binding_id)); + assert.equal(sessions.length, 0); + } else { + const bindings = await h.db.select().from(h.schema.appResourceBindings).where(h.and( + h.eq(h.schema.appResourceBindings.org_id, h.org_id), h.eq(h.schema.appResourceBindings.state, 'active'))); + assert.equal(bindings.length, 0); + } + } finally { release(); await h.close(); } + } +}); From be9b4ca9e300ebf52bf971be2bd152b244b1c8df Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:26:07 +0530 Subject: [PATCH 034/161] test: prove private sync restore and process loss fencing --- .../test/app-resource-sync-restore-db.test.ts | 205 ++++++++++++++++++ .../fixtures/resource-sync-restore-child.ts | 189 ++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 apps/api/test/app-resource-sync-restore-db.test.ts create mode 100644 apps/api/test/fixtures/resource-sync-restore-child.ts diff --git a/apps/api/test/app-resource-sync-restore-db.test.ts b/apps/api/test/app-resource-sync-restore-db.test.ts new file mode 100644 index 00000000..a93a3f67 --- /dev/null +++ b/apps/api/test/app-resource-sync-restore-db.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { fork, execFile, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { setTimeout as delay } from 'node:timers/promises'; +import test from 'node:test'; +import pg from 'pg'; + +const sourceUrl = process.env.DEFT_TEST_DATABASE_URL; +const targetName = process.env.DEFT_SYNC_RESTORE_TARGET_DATABASE ?? 'gate_g_20260926_restore_target'; +const artifactRoot = process.env.DEFT_SYNC_RESTORE_ARTIFACT_DIR; +const assigned = sourceUrl === process.env.DATABASE_URL + && sourceUrl === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_restore_source' + && /^gate_g_20260926_restore_target(?:_[a-z0-9]{1,16})?$/.test(targetName) && artifactRoot; + +type Message = { phase: string; [key: string]: any }; +type Command = { action: string; state_path: string; ledger_path: string; pause?: string; revision?: number }; +function launch(command: Command, databaseUrl: string, configPath: string) { + const env: NodeJS.ProcessEnv = { ...process.env, DATABASE_URL: databaseUrl, DEFT_TEST_DATABASE_URL: databaseUrl, + DEFT_SYNC_RESTORE_CONFIG: configPath, TZ: 'UTC' }; + delete env.DEFT_APP_RUN_KEYRINGS; + const child = fork(fileURLToPath(new URL('./fixtures/resource-sync-restore-child.ts', import.meta.url)), [], { + execArgv: ['--import', 'tsx'], stdio: ['ignore', 'ignore', 'pipe', 'ipc'], windowsHide: true, env, + }); + const messages: Message[] = []; + child.on('message', (message) => messages.push(message as Message)); + let stderr = ''; + child.stderr?.on('data', (value) => { stderr = (stderr + value).slice(-4_000); }); + const exited = new Promise((resolve) => child.once('exit', () => resolve())); + child.send(command); + const wait = async (phase: string): Promise => { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const message = messages.find((item) => item.phase === phase || item.phase === 'error'); + if (message) { + if (message.phase === 'error' && phase !== 'error') throw new Error(JSON.stringify(message)); + return message; + } + if (child.exitCode !== null || child.signalCode !== null) throw new Error(`Child exited before ${phase}: ${stderr}`); + await delay(25); + } + throw new Error(`Child timed out before ${phase}: ${stderr}`); + }; + return { child, wait, exited }; +} +async function kill(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; +} +async function one(command: Command, databaseUrl: string, configPath: string, phase: string) { + const running = launch(command, databaseUrl, configPath); + try { + const value = await running.wait(phase); + await running.wait('done'); + await running.exited; + assert.equal(running.child.exitCode, 0); + return value; + } finally { await kill(running.child); } +} +async function rows(databaseUrl: string) { + const client = new pg.Client({ connectionString: databaseUrl }); + await client.connect(); + try { + const counts: Record = {}; + for (const table of ['app_resource_bindings', 'app_sync_checkpoints', 'app_sync_intents', + 'app_resource_projections', 'app_runs', 'app_run_attempts', 'app_run_secret_payloads', 'app_run_receipts']) { + counts[table] = (await client.query(`SELECT count(*)::int AS count FROM ${table}`)).rows[0].count; + } + return counts; + } finally { await client.end(); } +} +async function observations(path: string) { + return (await readFile(path, 'utf8')).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); +} +const digest = (value: Uint8Array | string) => createHash('sha256').update(value).digest('hex'); +async function waitUntil(iso: string) { + while (Date.now() < new Date(iso).getTime()) await delay(Math.min(1_000, new Date(iso).getTime() - Date.now())); +} +async function wslPg(tool: 'pg_dump' | 'pg_restore', args: string[]) { + const result = await promisify(execFile)('wsl', ['-d', 'Deft-CRM-Test', '--', + `/usr/lib/postgresql/16/bin/${tool}`, ...args], { timeout: 120_000, windowsHide: true }); + assert.equal(result.stderr.trim(), '', `${tool} emitted unexpected diagnostics`); +} + +test('private sync survives real process loss and DB plus keyring restore without unsafe replay', { + skip: !assigned, timeout: 480_000, +}, async (t) => { + const root = resolve(artifactRoot!); + assert.ok(!root.startsWith(resolve(import.meta.dirname, '..', '..', '..')), 'artifacts must stay outside repository'); + await mkdir(root, { recursive: true }); + const configPath = resolve(root, 'source-config.json'); + const restoredConfig = resolve(root, 'restored-config.json'); + const missingConfig = resolve(root, 'missing-key-config.json'); + const statePath = resolve(root, 'state.json'); + const ledgerPath = resolve(root, 'provider-observations.jsonl'); + const dumpPath = resolve(root, 'source.dump'); + assert.match(dumpPath, /^C:\\/i, 'WSL proof uses an explicit C drive artifact'); + const linuxDump = `/mnt/c/${dumpPath.slice(3).replaceAll('\\', '/')}`; + const config = { schema_version: 'deft.synthetic_restore_config.v1', flags: { + DEFT_APPS_ENABLED: 'true', DEFT_APP_RUNS_ENABLED: 'true', DEFT_APP_RUN_APP_ORIGIN_ENABLED: 'true', + DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED: 'true' }, keyring: JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: { current: 'restore-enc', keys: { 'restore-enc': Buffer.from(digest('restore-encryption'), 'hex').toString('base64') } }, + receipt_signing: { current: 'restore-sig', keys: { 'restore-sig': Buffer.from(digest('restore-signing'), 'hex').toString('base64') } }, + fingerprint: { current: 'restore-fp', keys: { 'restore-fp': Buffer.from(digest('restore-fingerprint'), 'hex').toString('base64') } } }) }; + await writeFile(configPath, JSON.stringify(config), { flag: 'wx' }); + const admin = new pg.Client({ connectionString: 'postgresql://gate_g_test@127.0.0.1:55435/postgres' }); + await admin.connect(); + try { assert.equal((await admin.query('SELECT 1 FROM pg_database WHERE datname=$1', [targetName])).rows.length, 0, + 'restore destination must be new; this proof never drops an existing DB'); } + finally { await admin.end(); } + const command = (action: string, extra: Partial = {}): Command => ({ action, + state_path: statePath, ledger_path: ledgerPath, ...extra }); + const children: ChildProcess[] = []; + t.after(async () => { await Promise.allSettled(children.map(kill)); }); + console.log('RESTORE_PHASE source settlement then process kill'); + const source = launch(command('bootstrap', { pause: 'after_commit', revision: 1 }), sourceUrl!, configPath); + children.push(source.child); + const sourceSettled = await source.wait('settled'); + assert.equal(sourceSettled.cursor_sequence, 1); + assert.equal(sourceSettled.terminal_receipts, 1); + await kill(source.child); + assert.equal((await observations(ledgerPath)).length, 1); + const sourceRows = await rows(sourceUrl!); + console.log('RESTORE_PHASE consistent dump and fresh target restore'); + await wslPg('pg_dump', ['-h', '127.0.0.1', '-p', '55435', '-U', 'gate_g_test', '-Fc', '--no-owner', '--no-acl', + '-f', linuxDump, 'gate_g_20260926_restore_source']); + const creator = new pg.Client({ connectionString: 'postgresql://gate_g_test@127.0.0.1:55435/postgres' }); + await creator.connect(); + try { await creator.query(`CREATE DATABASE ${targetName}`); } finally { await creator.end(); } + await wslPg('pg_restore', ['-h', '127.0.0.1', '-p', '55435', '-U', 'gate_g_test', '--exit-on-error', + '--no-owner', '--no-acl', '-d', targetName, linuxDump]); + await copyFile(configPath, restoredConfig); + assert.equal(digest(await readFile(configPath)), digest(await readFile(restoredConfig))); + const targetUrl = `postgresql://gate_g_test@127.0.0.1:55435/${targetName}`; + assert.deepEqual(await rows(targetUrl), sourceRows, 'all selected encrypted execution/projection counts survive restore'); + const bad = JSON.parse(config.keyring); + bad.run_encryption = { current: 'missing-original', keys: { 'missing-original': Buffer.alloc(32, 7).toString('base64') } }; + await writeFile(missingConfig, JSON.stringify({ ...config, keyring: JSON.stringify(bad) }), { flag: 'wx' }); + const missing = launch(command('verify'), targetUrl, missingConfig); + children.push(missing.child); + const missingError = await missing.wait('error'); + await missing.exited; + assert.equal(missingError.code, 'APP_RUN_KEY_VERSION_UNAVAILABLE'); + console.log('RESTORE_PHASE new process private reads receipts and exact callback replay'); + const restored = await one(command('verify'), targetUrl, restoredConfig, 'verified'); + assert.equal(restored.cursor_sequence, 1); + assert.equal(restored.projection_id, sourceSettled.projection_id); + assert.deepEqual(restored.ref, sourceSettled.ref); + assert.equal(restored.terminal_receipts, 1); + const replay = await one(command('replay'), targetUrl, restoredConfig, 'replayed'); + assert.equal(replay.cursor_sequence, 1); + assert.equal((await observations(ledgerPath)).length, 1); + assert.deepEqual(await rows(targetUrl), sourceRows, 'exact callback replay does not duplicate durable state'); + await waitUntil(new Date(Date.parse(sourceSettled.admitted_at) + 61_000).toISOString()); + console.log('RESTORE_PHASE newly due restored cursor settles once'); + const next = launch(command('next', { pause: 'after_commit', revision: 2 }), targetUrl, restoredConfig); + children.push(next.child); + const nextSettled = await next.wait('settled'); + await kill(next.child); + assert.equal(nextSettled.cursor_sequence, 2); + assert.equal(nextSettled.projection_id, sourceSettled.projection_id); + assert.deepEqual(nextSettled.ref, sourceSettled.ref); + assert.equal(nextSettled.terminal_receipts, 2); + assert.equal((await observations(ledgerPath)).length, 2); + const replayNext = await one(command('replay'), targetUrl, restoredConfig, 'replayed'); + assert.equal(replayNext.cursor_sequence, 2); + assert.equal((await observations(ledgerPath)).length, 2); + await waitUntil(new Date(Date.parse(nextSettled.admitted_at) + 61_000).toISOString()); + console.log('RESTORE_PHASE process killed after input release and fsynced source observation'); + const crashed = launch(command('next', { pause: 'after_observation', revision: 3 }), targetUrl, restoredConfig); + children.push(crashed.child); + const observed = await crashed.wait('observed'); + await kill(crashed.child); + assert.equal(observed.cursor_sequence, 2); + assert.equal((await observations(ledgerPath)).length, 3); + await waitUntil(new Date(Date.parse(observed.lease_expires_at) + 250).toISOString()); + const recovered = await one(command('recover'), targetUrl, restoredConfig, 'recovered'); + assert.equal(recovered.state, 'unknown_outcome'); + assert.equal(recovered.cursor_sequence, 2); + assert.equal(recovered.revision, 'revision-2'); + assert.equal(recovered.attempts, 1); + assert.equal(recovered.admission_blocked, true); + assert.equal((await observations(ledgerPath)).length, 3); + const revoked = await one(command('revoke'), targetUrl, restoredConfig, 'revoked'); + assert.equal(revoked.read_denied, true); + const ledger = await observations(ledgerPath); + assert.equal(new Set(ledger.map((item) => item.run_id)).size, 3); + const evidence = { schema_version: 'deft.gate_g.private_sync_restore.v1', + source_database: new URL(sourceUrl!).pathname.slice(1), restored_database: targetName, + source_rows: sourceRows, dump_sha256: digest(await readFile(dumpPath)), + restored_config_sha256: digest(await readFile(restoredConfig)), + missing_key_denied: missingError.code, source: sourceSettled, restored, replay, + newly_due: nextSettled, crash: { ...observed, ...recovered }, revoked, + observations: ledger, crash_windows: ['after_atomic_commit_before_ack', 'after_input_observation_before_commit'], + service_boundary_only: true }; + await writeFile(resolve(root, 'restore-evidence.json'), JSON.stringify(evidence, null, 2), { flag: 'wx' }); + t.diagnostic(JSON.stringify({ restored_private_projection: true, source_observations: ledger.length, + settled_runs: 2, unknown_runs: 1, unchanged_cursor_blocked: true })); +}); diff --git a/apps/api/test/fixtures/resource-sync-restore-child.ts b/apps/api/test/fixtures/resource-sync-restore-child.ts new file mode 100644 index 00000000..ab0c23e5 --- /dev/null +++ b/apps/api/test/fixtures/resource-sync-restore-child.ts @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { open, readFile } from 'node:fs/promises'; +import { z } from 'zod'; + +const databaseUrl = process.env.DATABASE_URL; +const allowed = /^gate_g_20260926_restore_(?:source|target(?:_[a-z0-9]{1,16})?)$/; +if (!process.send || databaseUrl !== process.env.DEFT_TEST_DATABASE_URL || !databaseUrl + || new URL(databaseUrl).hostname !== '127.0.0.1' || new URL(databaseUrl).port !== '55435' + || !allowed.test(new URL(databaseUrl).pathname.slice(1)) + || !process.env.DEFT_SYNC_RESTORE_CONFIG) throw new Error('Dedicated restore child profile required'); + +const config = z.strictObject({ schema_version: z.literal('deft.synthetic_restore_config.v1'), + keyring: z.string(), flags: z.strictObject({ DEFT_APPS_ENABLED: z.literal('true'), + DEFT_APP_RUNS_ENABLED: z.literal('true'), DEFT_APP_RUN_APP_ORIGIN_ENABLED: z.literal('true'), + DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED: z.literal('true') }) }) + .parse(JSON.parse(await readFile(process.env.DEFT_SYNC_RESTORE_CONFIG, 'utf8'))); +Object.assign(process.env, config.flags, { DEFT_APP_RUN_KEYRINGS: config.keyring }); + +type Command = { action: 'bootstrap' | 'next' | 'verify' | 'replay' | 'recover' | 'revoke'; + state_path: string; ledger_path: string; pause?: 'after_commit' | 'after_observation'; revision?: number }; +type Saved = { + org_id: string; owner_user_id: string; operator_user_id: string; binding_id: string; + checkpoint_id: string; foreign_org_id: string; foreign_user_id: string; + projection_id?: string; ref?: unknown; settled_runs: string[]; + latest?: { run_id: string; attempt_id: string; lease_expires_at: string; + starting_sequence: number; result: Record; admitted_at: string }; +}; +async function durableWrite(path: string, value: unknown, append = false) { + const file = await open(path, append ? 'a' : 'w'); + try { await file.write(`${JSON.stringify(value)}\n`); await file.sync(); } + finally { await file.close(); } +} +async function emit(value: Record) { + await new Promise((resolve, reject) => process.send!(value, (error: Error | null) => error ? reject(error) : resolve())); +} +async function pause() { await new Promise(() => { setInterval(() => {}, 1_000); }); } + +process.once('message', (raw: Command) => { + void run(raw).then(async () => { await emit({ phase: 'done' }); process.exit(0); }, async (error) => { + await emit({ phase: 'error', code: error?.code, message: error?.message ?? String(error), + cause: error?.cause?.message }); process.exit(1); + }); +}); + +async function run(command: Command) { + const [{ db, closeDb }, schema, { and, eq }, runtimeModule, readerModule, fixture, + managementModule, modules] = await Promise.all([ + import('../../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../../src/lib/app-run-runtime.js'), import('../../src/lib/app-resource-private-read.js'), + import('./resource-sync-v5.js'), import('../../src/lib/app-resource-sync-management.js'), + import('../../src/lib/module-service.js'), + ]); + try { + const runtime = await runtimeModule.getAppRunRuntime(); + const reader = new readerModule.AppResourcePrivateReadService(runtime.keys); + let state: Saved; + if (command.action === 'bootstrap') { + const owned = await fixture.createReviewedResourceSyncFixture({ keys: runtime.keys, clock: () => new Date() }); + const foreignOrg = randomUUID(); + const foreignUser = randomUUID(); + await db.insert(schema.orgs).values({ id: foreignOrg, name: 'Restore foreign workspace', slug: `restore-${randomUUID()}` }); + await db.insert(schema.users).values({ id: foreignUser, name: 'Restore foreign owner', email: `${foreignUser}@example.test` }); + await db.insert(schema.orgMembers).values({ id: randomUUID(), org_id: foreignOrg, + user_id: foreignUser, role: 'owner', is_active: true }); + state = { org_id: owned.org_id, owner_user_id: owned.owner_user_id, + operator_user_id: owned.operator_user_id, binding_id: owned.binding_id, + checkpoint_id: owned.checkpoint_id, foreign_org_id: foreignOrg, foreign_user_id: foreignUser, + settled_runs: [] }; + } else { state = JSON.parse(await readFile(command.state_path, 'utf8')); } + const subject = { kind: 'human' as const, org_id: state.org_id, user_id: state.owner_user_id }; + const target = { resource_binding_id: state.binding_id }; + const denied = (error: unknown) => (error as { code?: string }).code === 'APP_RESOURCE_PRIVATE_UNAVAILABLE'; + const verify = async () => { + const page = await reader.listOwnerPrivateResourcePage(subject, target); + assert.equal(page.items.length, 1); + if (state.projection_id) assert.equal(page.items[0]!.projection_id, state.projection_id); + if (state.ref) assert.deepEqual(page.items[0]!.ref, state.ref); + const one = await reader.getOwnerPrivateResource(subject, { ...target, projection_id: page.items[0]!.projection_id }); + assert.deepEqual(one.item, page.items[0]); + await assert.rejects(reader.listOwnerPrivateResourcePage({ ...subject, + user_id: state.operator_user_id }, target), denied); + await assert.rejects(reader.getOwnerPrivateResource({ kind: 'human', org_id: state.foreign_org_id, + user_id: state.foreign_user_id }, { ...target, projection_id: page.items[0]!.projection_id }), denied); + let terminalReceipts = 0; + for (const runId of state.settled_runs) { + const receipts = await runtime.receiptReader.readVerified(state.org_id, runId); + assert.ok(receipts.some((receipt) => receipt.verified && receipt.receipt_kind === 'attempt_terminal')); + terminalReceipts += receipts.filter((receipt) => receipt.verified && receipt.receipt_kind === 'attempt_terminal').length; + } + return { cursor_sequence: page.checkpoint.cursor_sequence, projection_id: page.items[0]!.projection_id, + ref: page.items[0]!.ref, revision: page.items[0]!.revision, terminal_receipts: terminalReceipts, + owner_read: true, foreign_denied: true, nonowner_denied: true }; + }; + if (command.action === 'verify') { await emit({ phase: 'verified', ...await verify() }); return; } + if (command.action === 'replay') { + assert.ok(state.latest); + assert.ok(await runtime.resourceSyncChannel.complete(state.latest.result)); + await emit({ phase: 'replayed', ...await verify() }); return; + } + if (command.action === 'recover') { + assert.ok(state.latest); + assert.equal(await runtime.attemptRunner.recoverRun(state.org_id, state.latest.run_id, state.latest.attempt_id), 1); + assert.equal(await runtime.attemptRunner.recoverRun(state.org_id, state.latest.run_id, state.latest.attempt_id), 0); + const [run] = await db.select().from(schema.appRuns).where(and(eq(schema.appRuns.org_id, state.org_id), + eq(schema.appRuns.id, state.latest.run_id))); + assert.equal(run?.state, 'unknown_outcome'); + const attempts = await db.select().from(schema.appRunAttempts).where(and( + eq(schema.appRunAttempts.org_id, state.org_id), eq(schema.appRunAttempts.run_id, state.latest.run_id))); + assert.equal(attempts.length, 1); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(eq(schema.appSyncCheckpoints.id, state.checkpoint_id)); + assert.equal(checkpoint?.cursor_sequence, state.latest.starting_sequence); + assert.equal(checkpoint?.generation, 1); + assert.equal(await runtime.secretRepository.readOutput(state.org_id, state.latest.run_id, state.latest.attempt_id), null); + for (let i = 0; i < 2; i += 1) assert.deepEqual(await runtime.resourceSyncAdmission.admitDue({ + org_id: state.org_id, resource_binding_id: state.binding_id }), + { state: 'blocked', reason: 'cursor_requires_recovery' }); + assert.equal(await runtime.resourceSyncChannel.complete(state.latest.result), null); + const receipts = await runtime.receiptReader.readVerified(state.org_id, state.latest.run_id); + assert.ok(receipts.some((receipt) => receipt.verified && receipt.receipt_kind === 'attempt_terminal')); + await emit({ phase: 'recovered', ...await verify(), state: run?.state, attempts: attempts.length, + admission_blocked: true, late_result_denied: true, retained_generation: checkpoint?.generation }); + return; + } + if (command.action === 'revoke') { + const manager = new managementModule.AppResourceSyncManagement(runtime.keys); + await manager.revokeConsent(modules.humanModuleActor({ orgId: state.org_id, + userId: state.owner_user_id, role: 'owner', source: 'rest' }), state.binding_id); + await assert.rejects(reader.listOwnerPrivateResourcePage(subject, target), denied); + await emit({ phase: 'revoked', read_denied: true }); return; + } + const admissions = await Promise.all([runtime.resourceSyncAdmission.admitDue({ org_id: state.org_id, + resource_binding_id: state.binding_id }), runtime.resourceSyncAdmission.admitDue({ org_id: state.org_id, + resource_binding_id: state.binding_id })]); + assert.deepEqual(admissions.map((item) => item.state).sort(), ['created', 'existing']); + const created = admissions.find((item) => item.state === 'created'); + assert.ok(created && created.state === 'created'); + assert.ok(admissions.every((item) => 'run_id' in item && item.run_id === created.run_id)); + const manager = new managementModule.AppResourceSyncManagement(runtime.keys); + const issued = await manager.issueOperatorSession(modules.humanModuleActor({ orgId: state.org_id, + userId: state.operator_user_id, role: 'member', source: 'rest' }), state.binding_id); + const base = { schema_version: 'deft.app_runtime_channel.v2' as const, audience: 'app_resource_sync' as const, + session_id: issued.session_id, session_token: issued.session_token }; + const claim = await runtime.resourceSyncChannel.claim({ ...base, max_claims: 1 }); + assert.ok(claim); + assert.equal(claim.run_id, created.run_id); + const attempt = { ...base, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + const started = await runtime.resourceSyncChannel.start(attempt); + assert.ok(started); + assert.equal(await runtime.resourceSyncChannel.start(attempt), null); + const [checkpoint] = await db.select().from(schema.appSyncCheckpoints) + .where(eq(schema.appSyncCheckpoints.id, state.checkpoint_id)); + assert.ok(checkpoint); + const [run] = await db.select().from(schema.appRuns).where(eq(schema.appRuns.id, created.run_id)); + assert.ok(run); + const revision = command.revision ?? 1; + const result = { ...attempt, status: 'returned', provider_succeeded: true, + page: { schema_version: 'deft.app_sync_page.v1', upserts: [{ id: 'restore-provider-record', + revision: `revision-${revision}`, data: { subject: `Synthetic restored record ${revision}` } }], + tombstones: [], next_cursor: `restore-cursor-${revision}`, has_more: false } }; + state.latest = { run_id: created.run_id, attempt_id: created.attempt_id, + lease_expires_at: claim.lease_expires_at, starting_sequence: checkpoint.cursor_sequence, + result, admitted_at: run.created_at.toISOString() }; + await durableWrite(command.ledger_path, { observation: 'synthetic_source_read', run_id: claim.run_id, + attempt_id: claim.attempt_id, process_id: process.pid, input_cursor: started.input.cursor }, true); + await durableWrite(command.state_path, state); + if (command.pause === 'after_observation') { + await emit({ phase: 'observed', run_id: claim.run_id, attempt_id: claim.attempt_id, + lease_expires_at: claim.lease_expires_at, cursor_sequence: checkpoint.cursor_sequence, process_id: process.pid }); + await pause(); + } + assert.ok(await runtime.resourceSyncChannel.complete(result)); + const page = await reader.listOwnerPrivateResourcePage(subject, target); + assert.equal(page.items.length, 1); + if (state.projection_id) assert.equal(page.items[0]!.projection_id, state.projection_id); + state.projection_id = page.items[0]!.projection_id; + state.ref = page.items[0]!.ref; + state.settled_runs.push(created.run_id); + await durableWrite(command.state_path, state); + await emit({ phase: 'settled', ...await verify(), run_id: created.run_id, + admitted_at: run.created_at.toISOString(), process_id: process.pid }); + if (command.pause === 'after_commit') await pause(); + } finally { + await runtimeModule.shutdownAppRunRuntime(); + await closeDb(); + } +} From 6e4188190d79079de353702fbceb553e25f726e2 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:36:42 +0530 Subject: [PATCH 035/161] test: require private sync boundaries and focused compatibility evidence --- apps/api/test/app-run-architecture.test.ts | 71 ++++- scripts/gate-g/README.md | 9 +- scripts/gate-g/check-evidence.test.mjs | 25 ++ scripts/gate-g/required-tests.json | 286 ++++++++++++++++++++- 4 files changed, 376 insertions(+), 15 deletions(-) diff --git a/apps/api/test/app-run-architecture.test.ts b/apps/api/test/app-run-architecture.test.ts index ecf0a6b0..24c0dc86 100644 --- a/apps/api/test/app-run-architecture.test.ts +++ b/apps/api/test/app-run-architecture.test.ts @@ -6,6 +6,22 @@ import test from 'node:test'; const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); +const runSecretBoundary = new Set([ + 'lib/app-run-keyrings.ts', 'lib/app-run-secrets.ts', 'lib/app-run-secret-repository.ts', +]); +// Sync uses its own domain-bound envelope and only these reviewed persistence/read +// boundaries transport it. This does not extend access to receipt signing material. +const syncEnvelopeBoundary = new Set([ + 'lib/app-resource-sync-secrets.ts', 'lib/app-resource-sync-store.ts', + 'lib/app-resource-sync-admission.ts', 'lib/app-resource-private-read.ts', +]); +function forbiddenSecretTokens(path: string, source: string): string[] { + return [...source.matchAll(/\b(?:ciphertext_b64|nonce_b64|auth_tag_b64|receipt_signing)\b/g)] + .filter(([token]) => !runSecretBoundary.has(path) + && !(token !== 'receipt_signing' && syncEnvelopeBoundary.has(path))) + .map(([token]) => token); +} + async function typescriptFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); const nested = await Promise.all(entries.map(async (entry) => { @@ -17,24 +33,21 @@ async function typescriptFiles(directory: string): Promise { } test('only the App Run secret boundary handles ciphertext and signing material', async () => { - const allowed = new Set([ - 'lib/app-run-keyrings.ts', - 'lib/app-run-secrets.ts', - 'lib/app-run-secret-repository.ts', - ]); - const forbidden = /\b(?:ciphertext_b64|nonce_b64|auth_tag_b64|receipt_signing)\b/g; const violations: string[] = []; for (const path of await typescriptFiles(sourceRoot)) { const sourcePath = relative(sourceRoot, path).replaceAll('\\', '/'); - if (allowed.has(sourcePath)) continue; const source = await readFile(path, 'utf8'); - for (const match of source.matchAll(forbidden)) { - violations.push(`${sourcePath}:${match.index ?? 0}:${match[0]}`); - } + for (const token of forbiddenSecretTokens(sourcePath, source)) violations.push(`${sourcePath}:${token}`); } assert.deepEqual(violations, []); + // A neighboring path and a signing token in an allowed sync file still fail. + assert.deepEqual(forbiddenSecretTokens('lib/app-resource-sync-unreviewed.ts', + 'ciphertext_b64 nonce_b64 auth_tag_b64 receipt_signing'), + ['ciphertext_b64', 'nonce_b64', 'auth_tag_b64', 'receipt_signing']); + assert.deepEqual(forbiddenSecretTokens('lib/app-resource-sync-store.ts', 'receipt_signing'), + ['receipt_signing']); }); test('App Run engine flag and key material stay confined to environment and Run composition', async () => { @@ -43,7 +56,10 @@ test('App Run engine flag and key material stay confined to environment and Run const sourcePath = relative(sourceRoot, path).replaceAll('\\', '/'); if (sourcePath.startsWith('lib/app-run-') || sourcePath === 'lib/env.ts') continue; const source = await readFile(path, 'utf8'); - if (/\b(?:AppRunSecretService|DEFT_APP_RUNS_ENABLED|APP_RUNS_ENABLED)\b/.test(source)) { + // Host sync admission receives the existing Run secret service solely to + // persist the same encrypted Run input/fingerprints; rollout flags stay composed. + if (/\b(?:DEFT_APP_RUNS_ENABLED|APP_RUNS_ENABLED)\b/.test(source) + || (sourcePath !== 'lib/app-resource-sync-admission.ts' && /\bAppRunSecretService\b/.test(source))) { consumers.push(sourcePath); } } @@ -137,15 +153,44 @@ test('only the MCP adapter calls the low-level client and governed execution is assert.doesNotMatch(executor, /mcpClientManager/); }); -test('Run submission has one repository writer behind the advisory-lock service', async () => { +test('Run submission is confined to the advisory-lock repository and checkpoint-locked host sync admission', async () => { const violations: string[] = []; for (const path of await typescriptFiles(sourceRoot)) { const sourcePath = relative(sourceRoot, path).replaceAll('\\', '/'); - if (sourcePath === 'lib/app-run-repository.ts') continue; + if (sourcePath === 'lib/app-run-repository.ts' + || sourcePath === 'lib/app-resource-sync-admission.ts') continue; const source = await readFile(path, 'utf8'); if (/\.insert\(appRuns\)/.test(source)) violations.push(sourcePath); } assert.deepEqual(violations, []); + const admission = await readFile(join(sourceRoot, 'lib/app-resource-sync-admission.ts'), 'utf8'); + const authority = await readFile(join(sourceRoot, 'lib/app-resource-sync-authority.ts'), 'utf8'); + const reviewed = await readFile(join(sourceRoot, 'lib/app-resource-sync-reviewed.ts'), 'utf8'); + const runtime = await readFile(join(sourceRoot, 'lib/app-run-runtime.ts'), 'utf8'); + // The second writer is the already-reviewed host sync entrance. It serializes + // on checkpoint rather than reversing the Run-before-checkpoint completion lock. + assert.match(admission, /HostTargetSchema = z\.strictObject\(\{ org_id: z\.string\(\)\.uuid\(\),\s*resource_binding_id: z\.string\(\)\.uuid\(\) \}\)/); + assert.match(admission, /if \(!this\.enabled\(\)\) throw/); + assert.match(runtime, /new AppResourceSyncAdmissionService\([\s\S]*?isAppResourceSyncChannelEnabled\)/); + const environment = await readFile(join(sourceRoot, 'lib/env.ts'), 'utf8'); + assert.match(environment, /function isAppResourceSyncChannelEnabled\(\): boolean \{\s*return APPS_ENABLED && APP_RUNS_ENABLED && APP_RUN_APP_ORIGIN_ENABLED\s*&& process\.env\.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED === 'true';\s*\}/); + const authorityLoad = admission.indexOf('await loadLiveResourceSyncBindingAuthority('); + const checkpointLock = admission.indexOf(".limit(1).for('update')", authorityLoad); + const runInsert = admission.indexOf('tx.insert(appRuns)'); + assert.ok(authorityLoad > 0 && checkpointLock > authorityLoad && runInsert > checkpointLock); + assert.match(authority, /SELECT id FROM org_members[\s\S]*?FOR SHARE/); + assert.ok(authority.indexOf('SELECT id FROM org_members') + < authority.indexOf('reviewed = await loadReviewedResourceSyncDescriptor')); + assert.match(reviewed, /from\(appInstallations\)[\s\S]*?\.for\('share'\)/); + assert.match(admission, /this\.repository\.transaction\(async \(tx\)/); + assert.match(admission, /state: 'existing', run_id: existing\.run_id/); + assert.match(admission, /eq\(appSyncIntents\.expected_cursor_sequence, checkpoint\.cursor_sequence\)/); + assert.match(admission, /this\.runInputs\.insertInput\(tx/); + assert.match(admission, /tx\.insert\(appSyncIntents\)/); + assert.match(admission, /scheduleResourceSyncInTransaction\(tx, run, now\)/); + assert.deepEqual([...admission.matchAll(/tx\.insert\((\w+)\)/g)].map((match) => match[1]), + ['appRuns', 'appSyncIntents']); + assert.doesNotMatch(admission, /\b(?:fetch|executeTool|executePinned|mcpClientManager|AppRunProviderExecutor|pgTable)\b/); }); test('the App Run approval bridge has one safe compatibility writer and no executor', async () => { diff --git a/scripts/gate-g/README.md b/scripts/gate-g/README.md index 11aecef4..954f55c6 100644 --- a/scripts/gate-g/README.md +++ b/scripts/gate-g/README.md @@ -5,7 +5,7 @@ These fixtures exercise proposed boundaries. They do not implement or certify th - `experiences/`: loopback browser egress and bounded interaction experiment. - `runtime/`: separate-process recovery experiment with independent synthetic host/provider ledgers. - `public/`: transaction/claim experiment on an explicitly assigned disposable PostgreSQL database. -- `required-tests.json`: reviewed inventory of 74 App Kit and 51 focused platform unit tests, six explicitly selected legacy-MCP cutover-on cases, one database ancestry case, eleven Runtime/public foundation cases, four reviewed Runtime journey/concurrency cases, four installed-consumer cases, and one production automation process/restore case. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. +- `required-tests.json`: reviewed inventory including 90 App Kit and 51 focused platform unit tests, the selected legacy-MCP cutover-on and ancestry profiles, Runtime/public/installed journeys, and bounded private sync, owner-read, scheduler, authenticated HTTP, and CRM compatibility profiles. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. - `verify-upgrade.mjs`: read-only retained-data fingerprints and schema snapshots for the assigned disposable PostgreSQL cluster. Capture a tracked predecessor before candidate upgrades, compare retained columns afterward, and compare candidate fresh/upgrade schemas separately. ## Capture and check test execution @@ -28,3 +28,10 @@ node --test scripts/gate-g/check-evidence.test.mjs The checker requires every inventoried case exactly once, exact source paths rooted in the current checkout, no failures/skips/todos/cancellations, matching execution counts, and a final runner summary. Failed or truncated output fails closed. Additional executed tests must also pass. The JSONL reporter omits test stdout and error details; retain a separate console log for diagnosis. This checks execution completeness, not authenticity: JSONL is not a signed attestation. Record the source revision, fixture hashes/diff, runtime versions, command, environment profile and reviewer alongside evidence. A rerun or source change requires a new evidence record. Required cases must never be removed merely to make a gate green. + +The private-reader and scheduler inventories name their nested boundary cases as +well as the parent test. A passing parent does not cover an omitted child. Use +the exact synthetic database guards described by each profile and matching +`DATABASE_URL`/`DEFT_TEST_DATABASE_URL`; a wrong target produces skipped tests and +must fail evidence checking. The HTTP profiles are owner-private host surfaces; +they do not certify an installed Experience broker, sharing, or public access. diff --git a/scripts/gate-g/check-evidence.test.mjs b/scripts/gate-g/check-evidence.test.mjs index eabda884..6d525088 100644 --- a/scripts/gate-g/check-evidence.test.mjs +++ b/scripts/gate-g/check-evidence.test.mjs @@ -56,3 +56,28 @@ test('checks actual Node reporter output, including a green runner with a skippe } } finally { rmSync(directory, { recursive: true, force: true }); } }); + +test('a passing database parent cannot conceal an omitted or skipped required boundary subtest', () => { + const directory = mkdtempSync(join(tmpdir(), 'deft-nested-evidence-check-')); + try { + const fixture = join(directory, 'database.test.mjs'); + const reporter = new URL('./acceptance-reporter.mjs', import.meta.url).href; + const inventory = { id: 'database', cases: [ + { file: fixture, name: 'database acceptance' }, + { file: fixture, name: 'final delivery lock wait' }, + ] }; + for (const mode of ['executed', 'skipped', 'omitted']) { + const child = mode === 'omitted' ? '' + : `await t.test('final delivery lock wait', { skip: ${mode === 'skipped'} }, () => {});`; + writeFileSync(fixture, `import test from 'node:test'; test('database acceptance', async t => { ${child} });`); + const childEnv = { ...process.env }; + delete childEnv.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ['--test', `--test-reporter=${reporter}`, fixture], { + encoding: 'utf8', env: childEnv, timeout: 30_000, + }); + assert.equal(result.status, 0, result.stderr); + const events = result.stdout.trim().split(/\r?\n/).map(JSON.parse); + assert.equal(checkEvidence(inventory, events).passed, mode === 'executed'); + } + } finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index e032b82d..9cc6e162 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -300,6 +300,70 @@ { "file": "packages/app-kit/test/experience-sdk.test.ts", "name": "SDK closes pending calls without leaking reusable credentials" + }, + { + "file": "packages/app-kit/test/resource-app-v5-cli.test.ts", + "name": "v5 CLI packs external sync-only source deterministically and rejects unknown executable fields" + }, + { + "file": "packages/app-kit/test/resource-app-v5.test.ts", + "name": "v5 sync-only App and Experience package are deterministic and only reviewable by the host" + }, + { + "file": "packages/app-kit/test/resource-app-v5.test.ts", + "name": "v5 mixed Runtime actions reference only v1; sync descriptors reference only v2" + }, + { + "file": "packages/app-kit/test/resource-app-v5.test.ts", + "name": "v5 rejects undeclared/oversized Experience resources and tampered package bytes" + }, + { + "file": "packages/app-kit/test/resource-sync-client.test.ts", + "name": "v2 SDK validates every reply and sends a sync-only bearer transcript" + }, + { + "file": "packages/app-kit/test/resource-sync-client.test.ts", + "name": "descriptor digest is canonical and start must match all claim correlation pins" + }, + { + "file": "packages/app-kit/test/resource-sync-client.test.ts", + "name": "SDK rejects wrong audience, malformed replies, replay substitution and invalid pages" + }, + { + "file": "packages/app-kit/test/resource-sync-client.test.ts", + "name": "transport guards URL, AbortSignal, deadline, response size and generic errors" + }, + { + "file": "packages/app-kit/test/resource-sync-packed.test.ts", + "name": "packed experimental sync subpath resolves from an offline external consumer" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "golden v1 page preserves only declared scalar data and stable canonical bytes" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "descriptor and request admit no authority, URL, owner, or executable envelope fields" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "descriptor requires a declared, required, bounded string label and closed scalar fields" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "cursor progression and UTF-8 byte bounds are exact" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "item count, duplicate IDs, exact resource identities and version fields are closed" + }, + { + "file": "packages/app-kit/test/resource-sync.test.ts", + "name": "canonical full-page 512 KiB ceiling accepts the boundary and rejects one byte more" + }, + { + "file": "packages/app-kit/test/runtime-client.test.ts", + "name": "Runtime client preserves the configured origin when its path starts with two slashes" } ], "id": "app-kit" @@ -428,7 +492,7 @@ "file": "apps/api/test/app-run-architecture.test.ts" }, { - "name": "Run submission has one repository writer behind the advisory-lock service", + "name": "Run submission is confined to the advisory-lock repository and checkpoint-locked host sync admission", "file": "apps/api/test/app-run-architecture.test.ts" }, { @@ -781,6 +845,226 @@ { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "partial interrupted initialization cannot be stamped as fresh" }, { "file": "packages/db/scripts/upgrade-ledger-db.test.ts", "name": "genuine untracked v0.2.0-preview.1 baseline remains adoptable" } ] + }, + { + "id": "resource-private-read", + "description": "Owner-private reader over real reviewed v5 Runtime settlement: all20 boundary subtests and parent required, including checkpoint/member/settlement/final-SID lock waits. Requires matching DATABASE_URL and DEFT_TEST_DATABASE_URL exactly postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_private_read. No public, sharing, Experience or whole Gate G claim.", + "cases": [ + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "owner receives bounded normalized records and stable host UUID refs with unknown freshness" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "keyset pagination returns every record exactly once and enforces a closed bounded input" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "foreign org, same-org nonowner, guessed row and cursor tampering disclose no private data" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "settled tombstone removes the body and label; changed checkpoint invalidates prior cursor" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies binding revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies registration revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies owner revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies operator revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies demoted revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies disabled revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies grant revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "live authority denies consent revocation or expiry" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "missing encryption and cursor keys fail closed without fallback" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "consent expiring during decryption prevents the completed page from escaping" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "tampered ciphertext and authenticated descriptor-invalid plaintext fail the whole page" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "byte-capped keyset pages include every large record without silent skips" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "checkpoint lock wait rechecks consent after the actual database wait" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "revocation committed during an observed member lock wait denies the reader" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "actual page settlement committed during checkpoint lock wait invalidates the old cursor" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "final delivery guard lock wait cannot release a page after consent expires" + }, + { + "file": "apps/api/test/app-resource-private-read-db.test.ts", + "name": "owner-private reads of actual reviewed and settled v5 resources" + } + ] + }, + { + "id": "resource-sync-scheduler", + "cases": [ + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "scheduler is default-off and requires exact scheduler and channel gates" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "twenty-binding pages converge concurrent jobs and preserve restart scan fairness" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "revoked expired disabled owner-lost and operator-lost bindings cannot admit and cannot starve healthy work" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "interrupted and terminally failed scan retains per-binding progress and isolates lock contention" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "lost queue lease and consent revoked after selection cannot admit work" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "admission deadline and cancellation roll back Run input intent attempt and queue together" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "settled cursors obey interval and post-start unknown work never automatically creates another Run" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "actual scheduled worker routes leased scan and shutdown gate stops recurrence" + }, + { + "file": "apps/api/test/app-resource-sync-scanner-db.test.ts", + "name": "bounded host resource sync scheduling through the durable worker queue" + } + ], + "description": "Bounded host scheduling with durable queue cursor, real process restart, live revocation and unchanged-cursor uncertainty fencing. Requires matching DATABASE_URL and DEFT_TEST_DATABASE_URL on loopback55435/gate_g_20260926_scheduler and TZ=UTC; all nested cases and the parent must execute." + }, + { + "id": "resource-sync-management-http", + "description": "Authenticated owner consent/operator sessions, bounded status, final SID/member/human-kind/deadline checks. All 7 cases required; matching loopback 55435 synthetic gate_g_20260926_management or gate_g_20260926_root database (optional _vN suffix).", + "cases": [ + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync management rejects disabled rollout and non-web credentials over HTTP" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP owner review activation, operator-only credential and strict request boundaries" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP current SID membership tenant and self-owner privacy with bounded pagination" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP revocation while waiting rolls back session issuance at final SID guard" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP status exposes generic Run metadata without private Run result authority" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP denies nonhuman accounts even with a valid web SID" + }, + { + "file": "apps/api/test/app-resource-sync-management-http-db.test.ts", + "name": "private sync HTTP deadline checks run after final web SID waits" + } + ] + }, + { + "id": "resource-private-read-http", + "description": "Actual settled private owner HTTP reads, cursor and credential denials, final SID/consent/participant-kind lock-wait fences. All6 cases required; matching loopback55435 synthetic gate_g_20260926_management or gate_g_20260926_root database (optional _vN suffix).", + "cases": [ + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP returns settled owner records with signed pagination and private response headers" + }, + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP denies disabled rollout non-web foreign actors and query/cursor tampering" + }, + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP discards decrypted records when exact web SID is revoked during a real authority lock wait" + }, + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP rechecks consent after the final web SID lock wait" + }, + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP rejects nonhuman owner and operator identity in current sync authority" + }, + { + "file": "apps/api/test/app-resource-private-read-http.test.ts", + "name": "private read HTTP rechecks participant kind after the final web SID lock wait" + } + ] + }, + { + "id": "resource-sync-restore", + "description": "Actual process loss, pg_dump and copied host keyring/config restore. Requires matching loopback 55435 gate_g_20260926_restore_source URLs, TZ=UTC, a nonexistent DEFT_SYNC_RESTORE_TARGET_DATABASE matching gate_g_20260926_restore_target with optional underscore suffix of 1-16 lowercase alphanumerics, and fresh outside-repository DEFT_SYNC_RESTORE_ARTIFACT_DIR. Does not certify broader restore or S04/S07 matrices.", + "cases": [ + { + "file": "apps/api/test/app-resource-sync-restore-db.test.ts", + "name": "private sync survives real process loss and DB plus keyring restore without unsafe replay" + } + ] + }, + { + "id": "crm-compatibility", + "description": "Existing base-to-reviewed-connected CRM lifecycle preserves canonical records, relations and Task links; current CRM HTTP/agent owner boundaries. Requires matching explicitly disposable DATABASE_URL and DEFT_TEST_DATABASE_URL; synthetic loopback regression database only. Package v0-v2 golden bytes remain required in app-kit.", + "cases": [ + { + "file": "apps/api/test/crm-public-lifecycle-db.test.ts", + "name": "CRM base installs records and links, then reviewed connected activation preserves them" + }, + { + "file": "apps/api/test/module-crm-foundation-db.test.ts", + "name": "CRM routes atomically create relationships and page live inverse records within owner boundaries" + } + ] } ], "schema_version": "deft.gate_g.test_inventory.v1" From 488d015d15f34b379cb6b3d2e70037c57169fdcd Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:43:48 +0530 Subject: [PATCH 036/161] Fence management participants after final web session waits --- .../src/lib/app-resource-sync-management.ts | 21 ++-- ...rce-sync-management-participant-db.test.ts | 119 ++++++++++++++++++ 2 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 apps/api/test/app-resource-sync-management-participant-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index 4b43a3ca..a6c2b7f3 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -17,7 +17,7 @@ import { AppResourceSyncSecretService } from './app-resource-sync-secrets.js'; import { createResourceSyncDiscoverySnapshot } from './app-resource-sync-discovery.js'; import { loadReviewedResourceSyncDescriptor } from './app-resource-sync-reviewed.js'; import { loadLiveResourceSyncBindingAuthority, - loadLiveResourceSyncAuthority } from './app-resource-sync-authority.js'; + loadLiveResourceSyncAuthority, resourceSyncParticipantsAreHuman } from './app-resource-sync-authority.js'; import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, AppResourceSyncConsentActivationSchema, AppResourceSyncConsentRequestSchema, assertResourceSyncConsentWindow, hashAppResourceSyncToken, @@ -27,11 +27,11 @@ type Tx = Parameters[0]>[0]; export type ResourceSyncManagementGuard = (tx: Tx) => Promise; async function managementTransaction(guard: ResourceSyncManagementGuard | undefined, - operation: (tx: Tx) => Promise, assertFinal?: (result: T) => void): Promise { + operation: (tx: Tx) => Promise, assertFinal?: (result: T, tx: Tx) => void | Promise): Promise { return db.transaction(async (tx) => { const result = await operation(tx); await guard?.(tx); - assertFinal?.(result); + await assertFinal?.(result, tx); return result; }); } @@ -188,7 +188,10 @@ export class AppResourceSyncManagement { checkpoint_id: checkpointId, app_version_id: version.id, grant_snapshot_id: grant.id, resource_key: descriptor.key, review_digest: review.review_digest }); - }, () => assertBeforeDeadline(new Date(input.consent_expires_at), this.clock)); + }, async (_result, tx) => { + if (!await resourceSyncParticipantsAreHuman(tx, actor.actor_id, input.operator_user_id)) throw denied(); + assertBeforeDeadline(new Date(input.consent_expires_at), this.clock); + }); } async issueOperatorSession(actor: ModuleActor, bindingId: string, guard?: ResourceSyncManagementGuard) { @@ -222,9 +225,13 @@ export class AppResourceSyncManagement { runtime_registration_id: live.registration.id, audience: 'app_resource_sync', expires_at: expiresAt.toISOString() }, metadata: { source: actor.source } }); - return expiresAt; - }, (expiresAt) => assertBeforeDeadline(expiresAt, this.clock)); - return Object.freeze({ session_id: sessionId, session_token: token, expires_at: issued }); + return { expiresAt, ownerUserId: live.binding.owner_user_id, + operatorUserId: live.registration.operator_user_id }; + }, async (result, tx) => { + if (!await resourceSyncParticipantsAreHuman(tx, result.ownerUserId, result.operatorUserId)) throw denied(); + assertBeforeDeadline(result.expiresAt, this.clock); + }); + return Object.freeze({ session_id: sessionId, session_token: token, expires_at: issued.expiresAt }); } /** The private owner can end consent without retaining a live App grant. */ diff --git a/apps/api/test/app-resource-sync-management-participant-db.test.ts b/apps/api/test/app-resource-sync-management-participant-db.test.ts new file mode 100644 index 00000000..9e5543c9 --- /dev/null +++ b/apps/api/test/app-resource-sync-management-participant-db.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { ServerType } from '@hono/node-server'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!target || target !== process.env.DATABASE_URL) return false; + try { const u = new URL(target); return ['postgres:', 'postgresql:'].includes(u.protocol) + && u.hostname === '127.0.0.1' && u.port === '55435' + && /^\/gate_g_20260926_scheduler$/.test(u.pathname) + && !u.search && !u.hash; } catch { return false; } +})(); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`management-http:${purpose}`).digest('base64') } }); +const keyring = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('mgmt-enc'), receipt_signing: ring('mgmt-sign'), fingerprint: ring('mgmt-fp') }); +process.env.DEFT_APP_RUN_KEYRINGS = keyring; +after(async () => { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + await (await import('../src/lib/db.js')).closeDb(); +}); + +async function harness() { + const [{ db }, schema, drizzle, session, keysModule, routes, hono, serverModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/routes/app-resource-sync-management.js'), import('hono'), import('@hono/node-server'), + ]); + const keys = keysModule.parseEnvironmentAppRunKeyrings(keyring); + const fixture = await createReviewedResourceSyncFixture({ keys, clock: () => new Date() }); + const app = new hono.Hono(); + app.route('/manage', routes.createAppResourceSyncManagementRoutes({ management: async () => fixture.management })); + let server!: ServerType; + const base = await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + resolve(`http://127.0.0.1:${info.port}/manage`); + }); + }); + const token = async (id: string, orgId = fixture.org_id) => { + const [user] = await db.select().from(schema.users).where(drizzle.eq(schema.users.id, id)); + return session.createWebSession({ id, org_id: orgId, email: user!.email }); + }; + const owner = await token(fixture.owner_user_id); + const operator = await token(fixture.operator_user_id); + const call = async (path: string, method = 'GET', value?: unknown, bearer = owner.accessToken) => { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + return { status: response.status, body: await response.json() as any }; + }; + return { db, schema, ...drizzle, ...fixture, owner, operator, call, token, session, base, + close: async () => { await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); keys.destroy(); } }; +} + +test('private sync management rejects other participant kind changes during final SID waits', { skip: !safe }, async (t) => { + for (const operation of ['session', 'activation'] as const) await t.test(operation, async () => { + const h = await harness(); + let release = () => {}; + try { + let activation: unknown; + if (operation === 'activation') { + await h.management.revokeConsent(h.owner_actor, h.binding_id); + const review = await h.management.prepareConsent(h.owner_actor, h.consent_request); + activation = { ...h.consent_request, expected_review_digest: review.review_digest, accept_host_policy: true }; + } + const auditAction = operation === 'session' ? 'app.resource_sync_session_issue' : 'app.resource_sync_consent_activate'; + const before = await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org_id), h.eq(h.schema.auditLog.action, auditAction))); + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + let blockerPid = 0; + const blocker = h.db.transaction(async tx => { + blockerPid = (await tx.execute(h.sql<{ pid: number }>`SELECT pg_backend_pid() AS pid`)).rows[0]!.pid; + const requester = operation === 'session' ? h.operator_user_id : h.owner_user_id; + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE org_id = ${h.org_id} AND user_id = ${requester} FOR UPDATE`); + acquired(); await released; + }); + await locked; + const pending = operation === 'session' + ? h.call(`/bindings/${h.binding_id}/sessions`, 'POST', undefined, h.operator.accessToken) + : h.call('/bindings/activate', 'POST', activation); + let waited = false; + for (let i = 0; i < 250; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname=current_database() AND ${blockerPid} = ANY(pg_blocking_pids(pid))`); + if (result.rows[0]!.waiting > 0) { waited = true; break; } + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.ok(waited, `${operation} waits on its exact requester SID blocker`); + const otherParticipant = operation === 'session' ? h.owner_user_id : h.operator_user_id; + await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, otherParticipant)); + release(); await blocker; + const response = await pending; + assert.ok(response.status === 403 || response.status === 409, + `${operation} must deny nonhuman other participant after SID wait; observed ${response.status}`); + assert.ok(!JSON.stringify(response.body).includes('session_token')); + const after = await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org_id), h.eq(h.schema.auditLog.action, auditAction))); + assert.equal(after.length, before.length, 'stale authority audit rolls back'); + if (operation === 'session') { + const sessions = await h.db.select().from(h.schema.appRuntimeSessions).where(h.eq(h.schema.appRuntimeSessions.resource_binding_id, h.binding_id)); + assert.equal(sessions.length, 0, 'no stale session remains'); + } else { + const bindings = await h.db.select().from(h.schema.appResourceBindings).where(h.and( + h.eq(h.schema.appResourceBindings.org_id, h.org_id), h.eq(h.schema.appResourceBindings.state, 'active'))); + assert.equal(bindings.length, 0, 'no stale active binding remains'); + } + } finally { release(); await h.close(); } + }); +}); \ No newline at end of file From 8fa4c7181cfde1636c748241bf77edf5963955b6 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:46:47 +0530 Subject: [PATCH 037/161] fix: bound private host requests before authentication --- .../middleware/app-resource-private-limits.ts | 19 ++ .../test/app-resource-private-limits.test.ts | 177 ++++++++++++++++++ scripts/gate-g/required-tests.json | 52 +++++ 3 files changed, 248 insertions(+) create mode 100644 apps/api/src/middleware/app-resource-private-limits.ts create mode 100644 apps/api/test/app-resource-private-limits.test.ts diff --git a/apps/api/src/middleware/app-resource-private-limits.ts b/apps/api/src/middleware/app-resource-private-limits.ts new file mode 100644 index 00000000..3b917f1a --- /dev/null +++ b/apps/api/src/middleware/app-resource-private-limits.ts @@ -0,0 +1,19 @@ +import { createAppResourceSyncLimits, type AppResourceSyncLimitsOptions } from './app-resource-sync-limits.js'; + +/** Independent pre-authentication budgets for host owner reads and management. + * The shared limiter uses the socket peer, never caller forwarding headers. + * Admitted work retains its concurrency slot until downstream unwinds, including + * after an abort, so cancelling a client cannot multiply ongoing database work. + */ +export function createAppResourcePrivateReadLimits(options: AppResourceSyncLimitsOptions = {}) { + return createAppResourceSyncLimits({ globalPerMinute: 600, peerPerMinute: 60, + globalConcurrent: 16, peerConcurrent: 4, maxPeerBuckets: 1024, ...options }); +} + +export function createAppResourceSyncManagementLimits(options: AppResourceSyncLimitsOptions = {}) { + return createAppResourceSyncLimits({ globalPerMinute: 120, peerPerMinute: 20, + globalConcurrent: 8, peerConcurrent: 2, maxPeerBuckets: 1024, ...options }); +} + +export const appResourcePrivateReadLimits = createAppResourcePrivateReadLimits(); +export const appResourceSyncManagementLimits = createAppResourceSyncManagementLimits(); diff --git a/apps/api/test/app-resource-private-limits.test.ts b/apps/api/test/app-resource-private-limits.test.ts new file mode 100644 index 00000000..28718910 --- /dev/null +++ b/apps/api/test/app-resource-private-limits.test.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { request as httpRequest, type Server } from 'node:http'; +import test from 'node:test'; +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { createAppResourcePrivateReadLimits, createAppResourceSyncManagementLimits } from '../src/middleware/app-resource-private-limits.js'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +async function listen(app: Hono) { + const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }) as Server; + if (!server.listening) await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return { url: `http://127.0.0.1:${address.port}`, close: async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } }; +} + +test('private host HTTP budgets use actual socket peers and reject spoofed forwarding headers before auth', async () => { + for (const factory of [createAppResourcePrivateReadLimits, createAppResourceSyncManagementLimits]) { + let now = 0; + let authReads = 0; + const app = new Hono(); + app.use('*', factory({ peerPerMinute: 2, globalPerMinute: 20, now: () => now })); + app.use('*', async (c, next) => { + authReads += 1; + if (c.req.header('authorization') !== 'test-current-human') return c.json({ error: 'unauthorized' }, 401); + await next(); + }); + app.get('/records', (c) => c.json({ ok: true })); + const host = await listen(app); + try { + assert.equal((await fetch(`${host.url}/records`)).status, 401); + assert.equal((await fetch(`${host.url}/records`, { headers: { authorization: 'test-current-human', + 'x-forwarded-for': '192.0.2.1', 'x-real-ip': '192.0.2.1' } })).status, 200); + const denied = await fetch(`${host.url}/records`, { headers: { authorization: 'test-current-human', + 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.8', forwarded: 'for=203.0.113.9' } }); + assert.equal(denied.status, 429); + assert.equal(denied.headers.get('retry-after'), '60'); + assert.deepEqual(await denied.json(), { error: 'Resource sync request failed', code: 'APP_RESOURCE_SYNC_FAILURE' }); + assert.equal(authReads, 2); + now = 60_000; + assert.equal((await fetch(`${host.url}/records`, { headers: { authorization: 'test-current-human' } })).status, 200); + assert.equal(authReads, 3); + } finally { await host.close(); } + } +}); + +test('private read and management HTTP budgets remain independent', async () => { + const app = new Hono(); + app.use('/read/*', createAppResourcePrivateReadLimits({ peerPerMinute: 1 })); + app.use('/manage/*', createAppResourceSyncManagementLimits({ peerPerMinute: 1 })); + app.get('*', (c) => c.json({ ok: true })); + const host = await listen(app); + try { + assert.equal((await fetch(`${host.url}/read/records`)).status, 200); + assert.equal((await fetch(`${host.url}/read/records`)).status, 429); + assert.equal((await fetch(`${host.url}/manage/status`)).status, 200); + assert.equal((await fetch(`${host.url}/manage/status`)).status, 429); + } finally { await host.close(); } +}); + +test('private HTTP budgets distinguish actual loopback socket addresses', async () => { + const app = new Hono(); + app.use('*', createAppResourcePrivateReadLimits({ peerPerMinute: 1, globalPerMinute: 10 })); + app.get('*', (c) => c.json({ ok: true })); + const host = await listen(app); + const from = (localAddress: string) => new Promise((resolve, reject) => { + const request = httpRequest(host.url, { localAddress, + headers: { 'x-forwarded-for': '203.0.113.99' } }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode!)); + }); + request.once('error', reject); + request.end(); + }); + try { + assert.equal(await from('127.0.0.1'), 200); + assert.equal(await from('127.0.0.1'), 429); + assert.equal(await from('127.0.0.2'), 200, 'actual second socket peer receives its own budget'); + assert.equal(await from('127.0.0.2'), 429); + } finally { await host.close(); } +}); + +test('private HTTP global budgets cannot be reset by trusted peer rotation', async () => { + const app = new Hono(); + // This mutable callback models a trusted transport adapter, not a request header. + let transportPeer = '192.0.2.1'; + app.use('*', createAppResourcePrivateReadLimits({ globalPerMinute: 2, peerPerMinute: 10, + peerAddress: () => transportPeer })); + app.get('*', (c) => c.json({ ok: true })); + const host = await listen(app); + try { + for (let index = 1; index <= 3; index += 1) { + transportPeer = `192.0.2.${index}`; + assert.equal((await fetch(host.url)).status, index <= 2 ? 200 : 429); + } + } finally { await host.close(); } +}); + +test('private HTTP missing and overflow peers share bounded budgets', async () => { + for (const peers of [[null, undefined, 'not-an-ip'], ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4']]) { + let transportPeer: string | null | undefined; + const app = new Hono(); + app.use('*', createAppResourceSyncManagementLimits({ globalPerMinute: 20, peerPerMinute: 2, + maxPeerBuckets: 1, peerAddress: () => transportPeer })); + app.get('*', (c) => c.json({ ok: true })); + const host = await listen(app); + try { + for (const [index, peer] of peers.entries()) { + transportPeer = peer; + assert.equal((await fetch(host.url)).status, index === peers.length - 1 ? 429 : 200); + } + } finally { await host.close(); } + } +}); + +test('private HTTP concurrency is global and remains charged until aborted work unwinds', { timeout: 10_000 }, async () => { + let transportPeer = '192.0.2.1'; + const entered = deferred(); + const aborted = deferred(); + const release = deferred(); + const completed = deferred(); + const app = new Hono(); + app.use('*', createAppResourcePrivateReadLimits({ globalConcurrent: 1, peerConcurrent: 1, + globalPerMinute: 50, peerPerMinute: 50, peerAddress: () => transportPeer })); + app.get('/blocked', async (c) => { + c.req.raw.signal.addEventListener('abort', aborted.resolve, { once: true }); + entered.resolve(); + await release.promise; + completed.resolve(); + return c.json({ ok: true }); + }); + app.get('/ready', (c) => c.json({ ok: true })); + const host = await listen(app); + const controller = new AbortController(); + const first = fetch(`${host.url}/blocked`, { signal: controller.signal }).catch((error: unknown) => error); + try { + await entered.promise; + transportPeer = '198.51.100.2'; + const blocked = await fetch(`${host.url}/ready`); + assert.equal(blocked.status, 503); + assert.equal(blocked.headers.get('retry-after'), '1'); + controller.abort(); + await first; + await aborted.promise; + assert.equal((await fetch(`${host.url}/ready`)).status, 503, 'aborted work still occupies the global slot'); + release.resolve(); + await completed.promise; + assert.equal((await fetch(`${host.url}/ready`)).status, 200); + } finally { release.resolve(); controller.abort(); await host.close(); } +}); + +test('private middleware rejects pre-aborted requests and releases thrown downstream work', async () => { + const app = new Hono(); + let entered = 0; + app.use('*', createAppResourceSyncManagementLimits({ globalConcurrent: 1, peerConcurrent: 1 })); + app.onError((_error, c) => c.json({ error: 'test failure' }, 500)); + app.get('/throw', () => { entered += 1; throw new Error('test-only downstream failure'); }); + app.get('/ready', (c) => { entered += 1; return c.json({ ok: true }); }); + const controller = new AbortController(); + controller.abort(); + assert.equal((await app.request('/ready', { signal: controller.signal })).status, 503); + assert.equal(entered, 0); + const host = await listen(app); + try { + assert.equal((await fetch(`${host.url}/throw`)).status, 500); + assert.equal((await fetch(`${host.url}/ready`)).status, 200); + assert.equal(entered, 2); + } finally { await host.close(); } +}); diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 9cc6e162..0bd57f53 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -1052,6 +1052,58 @@ } ] }, + { + "id": "resource-private-http-limits", + "description": "Real loopback HTTP socket-peer, global, overflow, independent owner-read/management and abort-unwind concurrency bounds before authentication; no database required.", + "cases": [ + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private host HTTP budgets use actual socket peers and reject spoofed forwarding headers before auth" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private read and management HTTP budgets remain independent" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private HTTP budgets distinguish actual loopback socket addresses" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private HTTP global budgets cannot be reset by trusted peer rotation" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private HTTP missing and overflow peers share bounded budgets" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private HTTP concurrency is global and remains charged until aborted work unwinds" + }, + { + "file": "apps/api/test/app-resource-private-limits.test.ts", + "name": "private middleware rejects pre-aborted requests and releases thrown downstream work" + } + ] + }, + { + "id": "resource-sync-management-participants", + "description": "Current other-participant human-kind recheck after actual final web SID waits for management activation/session issuance. All parent and child cases required; exact matching URLs postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_scheduler.", + "cases": [ + { + "file": "apps/api/test/app-resource-sync-management-participant-db.test.ts", + "name": "private sync management rejects other participant kind changes during final SID waits" + }, + { + "file": "apps/api/test/app-resource-sync-management-participant-db.test.ts", + "name": "session" + }, + { + "file": "apps/api/test/app-resource-sync-management-participant-db.test.ts", + "name": "activation" + } + ] + }, { "id": "crm-compatibility", "description": "Existing base-to-reviewed-connected CRM lifecycle preserves canonical records, relations and Task links; current CRM HTTP/agent owner boundaries. Requires matching explicitly disposable DATABASE_URL and DEFT_TEST_DATABASE_URL; synthetic loopback regression database only. Package v0-v2 golden bytes remain required in app-kit.", From d6367fc864c0cd3727e32d5afd39ed1bc0fd2d9c Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:48:38 +0530 Subject: [PATCH 038/161] test(apps): prove reviewed automation renewal and permanent revocation --- ...p-automation-renewal-acceptance-db.test.ts | 218 ++++++++++++++++++ .../test/fixtures/app-automation-renewal.ts | 129 +++++++++++ 2 files changed, 347 insertions(+) create mode 100644 apps/api/test/app-automation-renewal-acceptance-db.test.ts create mode 100644 apps/api/test/fixtures/app-automation-renewal.ts diff --git a/apps/api/test/app-automation-renewal-acceptance-db.test.ts b/apps/api/test/app-automation-renewal-acceptance-db.test.ts new file mode 100644 index 00000000..954fbb9b --- /dev/null +++ b/apps/api/test/app-automation-renewal-acceptance-db.test.ts @@ -0,0 +1,218 @@ +import './fixtures/app-run-enabled-env.js'; +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { and, eq } from 'drizzle-orm'; +import { appAutomationDefinitions, appAutomationFires, appRunReceipts, appRuns } from '@deft/db/schema'; +import { mcpClientManager } from '@deft/mcp'; +import { db, closeDb } from '../src/lib/db.js'; +import { AppError } from '../src/lib/app-errors.js'; +import { createReviewedAppAutomationDefinition, expireAppAutomationDefinition, + pauseAppAutomationDefinition, persistAppAutomationFire, resumeAppAutomationDefinition, + revokeAppAutomationDefinition } from '../src/lib/app-automation-definition-service.js'; +import { runAppAutomationFire, runAppAutomationScan } from '../src/lib/app-automation-runtime.js'; +import { completeJob, dequeueJob, QUEUE_NAMES } from '../src/lib/queues.js'; +import { handleAppRunAttempt } from '../src/lib/app-run-worker-handler.js'; +import { getAppRunRuntime, shutdownAppRunRuntime } from '../src/lib/app-run-runtime.js'; +import { createAutomationRenewalFixture } from './fixtures/app-automation-renewal.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +if (!target || target !== process.env.DATABASE_URL + || !/^postgresql:\/\/gate_g_test@127\.0\.0\.1:55435\/gate_g_20260926_automation_renewal(?:_v[0-9]+)?$/.test(target)) { + throw new Error('A02 requires the exact dedicated synthetic renewal database in both environment variables'); +} +process.env.DEFT_SELF_HOSTED = 'true'; +process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; +process.env.MCP_STDIO_ALLOWED_COMMANDS = process.execPath; +after(async () => { await shutdownAppRunRuntime(); await mcpClientManager.shutdown(); await closeDb(); }); + +type Fixture = Awaited>; +type Definition = Awaited>['definition']; +type QueueJob = NonNullable>>; +const minute = () => Math.floor(Date.now() / 60_000) * 60_000; +const stale = (error: unknown) => error instanceof AppError && error.code === 'APP_STALE'; +const jobData = (job: QueueJob) => ({ id: job.id, name: job.name, data: job.data, + attempts: job.attempts, leaseExpiresAt: job.lockExpiresAt }); +async function fireRows(f: Fixture, definition: Definition) { + return db.select().from(appAutomationFires).where(and(eq(appAutomationFires.org_id, f.orgId), + eq(appAutomationFires.definition_id, definition.id))); +} +async function fireJob(f: Fixture, definition: Definition) { + const job = await dequeueJob(QUEUE_NAMES.SCHEDULED_JOBS, { lockedBy: 'a02-acceptance', + orgId: f.orgId, jobName: 'app-automation-fire', dataMatch: { key: 'definition_id', value: definition.id } }); + assert.ok(job, 'actual scanner created a durable fire queue job'); + return job; +} +async function admit(f: Fixture, definition: Definition, scanAt: Date) { + await runAppAutomationScan(scanAt); + const job = await fireJob(f, definition); + await runAppAutomationFire(jobData(job), scanAt); + assert.equal(await completeJob(job.id, job.lockToken), true); + const [fire] = (await fireRows(f, definition)).filter(row => row.id === job.data.fire_id); + assert.equal(fire?.state, 'run_created'); + assert.ok(fire.app_run_id); + const runtime = await getAppRunRuntime(); + let attempt = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { lockedBy: 'a02-attempt', orgId: f.orgId, + jobName: 'app-run-attempt', dataMatch: { key: 'runId', value: fire.app_run_id } }); + if (!attempt) { + assert.ok(await runtime.attemptRunner.prepareAttempt(f.orgId, fire.app_run_id)); + attempt = await dequeueJob(QUEUE_NAMES.AGENT_JOBS, { lockedBy: 'a02-attempt-rearm', orgId: f.orgId, + jobName: 'app-run-attempt', dataMatch: { key: 'runId', value: fire.app_run_id } }); + } + assert.ok(attempt); + return { fire, attempt }; +} +async function deliver(f: Fixture, admitted: Awaited>) { + await handleAppRunAttempt(jobData(admitted.attempt)); + await handleAppRunAttempt({ ...jobData(admitted.attempt), id: `${admitted.attempt.id}:duplicate` }); + assert.equal(await completeJob(admitted.attempt.id, admitted.attempt.lockToken), true); + const [run] = await db.select().from(appRuns).where(and(eq(appRuns.org_id, f.orgId), eq(appRuns.id, admitted.fire.app_run_id!))); + assert.equal(run?.state, 'succeeded'); + const receipts = await db.select().from(appRunReceipts).where(and(eq(appRunReceipts.org_id, f.orgId), + eq(appRunReceipts.run_id, admitted.fire.app_run_id!))); + assert.equal(receipts.length, 1); + const runtime = await getAppRunRuntime(); + const verified = await runtime.receiptReader.readVerified(f.orgId, admitted.fire.app_run_id!); + assert.equal(verified.length, 1); + assert.equal(verified[0]!.verified, true); + return receipts[0]!; +} +function occurrence(f: Fixture, definition: Definition, scheduledAt: Date, expectedEpoch = definition.definition_epoch) { + return { organization_id: f.orgId, definition_id: definition.id, expected_epoch: expectedEpoch, + logical_local_date: scheduledAt.toISOString().slice(0, 10), + resolution: { kind: 'resolved' as const, resolved_at_utc: scheduledAt } }; +} + +test('A02 reviewed renewal creates new authority while expired definitions and old queued fires stay inert', async () => { + const f = await createAutomationRenewalFixture(); + const base = minute(); + const oldDue = new Date(base - 2 * 60_000); + const old = await f.create(oldDue, new Date(base - 4 * 60_000), 180); + await runAppAutomationScan(new Date(oldDue.getTime() + 10_000)); + const queuedOld = await fireJob(f, old.definition); + const expired = await expireAppAutomationDefinition(f.actor, { definition_id: old.definition.id, + expected_epoch: old.definition.definition_epoch }, { now: () => new Date(base - 60_000) }); + assert.equal(expired.state, 'expired'); + assert.equal(expired.definition_epoch, 2); + assert.ok(expired.valid_until.getTime() <= Date.now(), 'old approval window has actually elapsed'); + await assert.rejects(resumeAppAutomationDefinition(f.actor, { definition_id: expired.id, expected_epoch: 2 }), stale); + await assert.rejects(pauseAppAutomationDefinition(f.actor, { definition_id: expired.id, expected_epoch: 1 }), stale); + await assert.rejects(persistAppAutomationFire(occurrence(f, old.definition, oldDue)), stale); + const newDue = new Date(base); + const newInput = f.inputFor(newDue); + await assert.rejects(createReviewedAppAutomationDefinition(f.actor, { ...newInput, + expected_review_digest: old.review.review_digest, accept_code_owned_policy: true }), stale); + const renewed = await f.create(newDue, new Date(newDue.getTime() - 10_000)); + assert.notEqual(renewed.definition.id, old.definition.id); + assert.notEqual(renewed.definition.definition_digest, old.definition.definition_digest); + assert.notEqual(renewed.review.review_digest, old.review.review_digest); + assert.equal(renewed.definition.definition_epoch, 1); + assert.equal(renewed.definition.approved_by_user_id, f.userId); + await runAppAutomationFire(jobData(queuedOld)); + assert.equal(await completeJob(queuedOld.id, queuedOld.lockToken), true); + const [oldFire] = await fireRows(f, old.definition); + assert.equal(oldFire?.state, 'skipped'); + assert.equal(oldFire.terminal_reason, 'definition_ineligible'); + assert.equal(oldFire.app_run_id, null); + assert.equal((await f.effects()).length, 0); + const admitted = await admit(f, renewed.definition, new Date()); + const receipt = await deliver(f, admitted); + await runAppAutomationFire(jobData(queuedOld)); + await runAppAutomationScan(new Date()); + assert.equal((await f.effects()).length, 1); + assert.equal((await fireRows(f, old.definition)).length, 1); + assert.equal((await fireRows(f, renewed.definition)).length, 1); + const [stillExpired] = await db.select().from(appAutomationDefinitions).where(eq(appAutomationDefinitions.id, expired.id)); + assert.equal(stillExpired?.state, 'expired'); + console.log('A02_RENEWAL', JSON.stringify({ org_id: f.orgId, old_definition: expired.id, + new_definition: renewed.definition.id, old_fire: oldFire.state, effects: 1, + receipt_id: receipt.id, outbox: f.outboxRoot })); +}); + +test('A02 permanent revocation blocks already admitted work and cannot be resumed with any epoch', async () => { + const f = await createAutomationRenewalFixture(); + const base = minute(); + const due = new Date(base - 60_000); + const original = await f.create(due, new Date(base - 3 * 60_000)); + const admitted = await admit(f, original.definition, new Date()); + const revoked = await revokeAppAutomationDefinition(f.actor, { definition_id: original.definition.id, expected_epoch: 1 }); + assert.equal(revoked.state, 'revoked'); + assert.equal(revoked.definition_epoch, 2); + assert.ok(revoked.revoked_at); + for (const epoch of [1, 2, 3]) { + await assert.rejects(resumeAppAutomationDefinition(f.actor, { definition_id: revoked.id, expected_epoch: epoch }), stale); + await assert.rejects(pauseAppAutomationDefinition(f.actor, { definition_id: revoked.id, expected_epoch: epoch }), stale); + await assert.rejects(persistAppAutomationFire(occurrence(f, revoked, due, epoch)), stale); + } + await handleAppRunAttempt(jobData(admitted.attempt)); + await handleAppRunAttempt({ ...jobData(admitted.attempt), id: `${admitted.attempt.id}:replayed` }); + assert.equal(await completeJob(admitted.attempt.id, admitted.attempt.lockToken), true); + await runAppAutomationScan(new Date(due.getTime() + 24 * 60 * 60_000 + 10_000)); + assert.equal((await f.effects()).length, 0); + assert.equal((await fireRows(f, revoked)).length, 1); + const receipts = await db.select().from(appRunReceipts).where(eq(appRunReceipts.org_id, f.orgId)); + assert.equal(receipts.length, 0); + const [run] = await db.select().from(appRuns).where(eq(appRuns.id, admitted.fire.app_run_id!)); + assert.notEqual(run?.state, 'succeeded'); + console.log('A02_REVOCATION', JSON.stringify({ org_id: f.orgId, definition_id: revoked.id, + epoch: revoked.definition_epoch, admitted_run_state: run?.state, effects: 0, receipts: 0, outbox: f.outboxRoot })); +}); + +test('A02 pause resume rejects stale epochs and missed occurrences without forbidden backfill', async () => { + const f = await createAutomationRenewalFixture(); + const base = minute(); + const due = new Date(base - 2 * 60_000); + const original = await f.create(due, new Date(due.getTime() - 2 * 24 * 60 * 60_000)); + const paused = await pauseAppAutomationDefinition(f.actor, { definition_id: original.definition.id, expected_epoch: 1 }, + { now: () => new Date(due.getTime() - 30_000) }); + await runAppAutomationScan(new Date(due.getTime() + 10_000)); + assert.equal((await fireRows(f, paused)).length, 0); + await assert.rejects(resumeAppAutomationDefinition(f.actor, { definition_id: paused.id, expected_epoch: 1 }), stale); + const resumed = await resumeAppAutomationDefinition(f.actor, { definition_id: paused.id, expected_epoch: 2 }, + { now: () => new Date(due.getTime() + 30_000) }); + assert.equal(resumed.definition_epoch, 3); + for (const epoch of [1, 2, 3]) { + await assert.rejects(persistAppAutomationFire(occurrence(f, resumed, due, epoch)), stale); + } + await runAppAutomationScan(new Date()); + assert.equal((await fireRows(f, resumed)).length, 0, 'resuming inside catch-up window never backfills pre-resume occurrence'); + assert.equal((await f.effects()).length, 0); + const nextDue = new Date(due.getTime() + 24 * 60 * 60_000); + const admitted = await admit(f, resumed, new Date(nextDue.getTime() + 10_000)); + assert.equal(admitted.fire.definition_epoch, 3); + assert.equal(admitted.fire.logical_local_date, nextDue.toISOString().slice(0, 10)); + await deliver(f, admitted); + assert.equal((await f.effects()).length, 1); + assert.equal((await fireRows(f, resumed)).length, 1); + await revokeAppAutomationDefinition(f.actor, { definition_id: resumed.id, expected_epoch: 3 }); + console.log('A02_PAUSE_RESUME', JSON.stringify({ org_id: f.orgId, definition_id: resumed.id, + resumed_epoch: 3, missed_fires: 0, next_due_fires: 1, effects: 1, outbox: f.outboxRoot })); +}); +test('A02 queued fires from an earlier definition epoch cannot execute after pause and resume', async () => { + const f = await createAutomationRenewalFixture(); + const base = minute(); + const due = new Date(base - 60_000); + const original = await f.create(due, new Date(base - 3 * 60_000)); + await runAppAutomationScan(new Date()); + const oldJob = await fireJob(f, original.definition); + assert.equal(oldJob.data.definition_epoch, 1); + const paused = await pauseAppAutomationDefinition(f.actor, { definition_id: original.definition.id, expected_epoch: 1 }); + const resumed = await resumeAppAutomationDefinition(f.actor, { definition_id: paused.id, expected_epoch: 2 }); + assert.equal(resumed.state, 'active'); + assert.equal(resumed.definition_epoch, 3); + await assert.rejects(revokeAppAutomationDefinition(f.actor, { definition_id: resumed.id, expected_epoch: 1 }), stale); + await runAppAutomationFire(jobData(oldJob)); + await runAppAutomationFire({ ...jobData(oldJob), id: `${oldJob.id}:replayed` }); + assert.equal(await completeJob(oldJob.id, oldJob.lockToken), true); + await runAppAutomationScan(new Date()); + const rows = await fireRows(f, resumed); + assert.equal(rows.length, 1); + assert.equal(rows[0]!.definition_epoch, 1); + assert.equal(rows[0]!.state, 'skipped'); + assert.equal(rows[0]!.terminal_reason, 'definition_ineligible'); + assert.equal(rows[0]!.app_run_id, null); + assert.equal((await db.select().from(appRuns).where(eq(appRuns.org_id, f.orgId))).length, 0); + assert.equal((await db.select().from(appRunReceipts).where(eq(appRunReceipts.org_id, f.orgId))).length, 0); + assert.equal((await f.effects()).length, 0); + console.log('A02_STALE_DELIVERY', JSON.stringify({ org_id: f.orgId, definition_id: resumed.id, + current_epoch: 3, queued_epoch: 1, old_fire: rows[0]!.state, runs: 0, effects: 0, outbox: f.outboxRoot })); +}); \ No newline at end of file diff --git a/apps/api/test/fixtures/app-automation-renewal.ts b/apps/api/test/fixtures/app-automation-renewal.ts new file mode 100644 index 00000000..bbf3b09c --- /dev/null +++ b/apps/api/test/fixtures/app-automation-renewal.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { and, eq } from 'drizzle-orm'; +import { appActionBindings, appGrantSnapshots, appModuleBindings, appVersions, mcpConnections, moduleVersions, orgMembers, orgs, users } from '@deft/db/schema'; +import { RESOURCE_CONTRACT_VERSIONS } from '@deft/shared'; +import { db } from '../../src/lib/db.js'; +import { CapabilityService } from '../../src/lib/capability-service.js'; +import { activateAppInstallation, stageAppPackage } from '../../src/lib/app-service.js'; +import { activateConnectedAppInstallation, prepareConnectedAppReview } from '../../src/lib/app-review-service.js'; +import { createModuleRecord, humanModuleActor } from '../../src/lib/module-service.js'; +import { replaceResourceRelation } from '../../src/lib/resource-relation-service.js'; +import { createReviewedAppAutomationDefinition, prepareAppAutomationDefinitionReview } from '../../src/lib/app-automation-definition-service.js'; +import { digestAppGrantValue } from '../../src/lib/app-grant-service.js'; +import { buildPhase5DependencyAppPackage, buildTrackAAutomatedConnectedAppPackage } from './phase5-connected-app-package.js'; + +/** Synthetic local provider and real reviewed Protocol v2 authority. No persisted + * authority rows are fabricated and no production lifecycle state is patched. */ +export async function createAutomationRenewalFixture() { + const outboxRoot = await mkdtemp(resolve(tmpdir(), 'deft-a02-renewal-')); + const orgId = randomUUID(); + const userId = randomUUID(); + const email = `a02-live-${randomUUID()}@example.test`; + await db.insert(orgs).values({ id: orgId, name: 'A02 live authority', slug: `a02-live-${randomUUID()}` }); + await db.insert(users).values({ id: userId, name: 'A02 live owner', email }); + await db.insert(orgMembers).values({ id: randomUUID(), org_id: orgId, user_id: userId, role: 'owner', is_active: true }); + const actor = humanModuleActor({ orgId, userId, role: 'owner', source: 'rest' }); + const dependency = await buildPhase5DependencyAppPackage(); + const dependencyInstallation = await stageAppPackage(actor, dependency.json); + await activateAppInstallation(actor, dependencyInstallation.id, dependencyInstallation.package_digest); + const built = await buildTrackAAutomatedConnectedAppPackage(); + const staged = await stageAppPackage(actor, built.json); + const [version] = await db.select().from(appVersions).where(eq(appVersions.id, staged.version_id)).limit(1); + assert.ok(version?.requested_grant_snapshot_id); + const [requested] = await db.select().from(appGrantSnapshots) + .where(eq(appGrantSnapshots.id, version.requested_grant_snapshot_id)).limit(1); + assert.ok(requested); + const connectionId = randomUUID(); + const providerRoot = resolve(import.meta.dirname, '..', '..', '..', '..', 'examples', 'app-platform-sandbox-email-provider'); + await db.insert(mcpConnections).values({ + id: connectionId, org_id: orgId, name: 'A02 synthetic mail', slug: `a02-mail-${randomUUID()}`, + server_url: null, transport: 'stdio', stdio_command: process.execPath, + stdio_args: [resolve(providerRoot, 'server.mjs'), '--outbox-file', resolve(outboxRoot, 'effects.jsonl')], + auth_type: 'none', is_active: true, created_by: userId, + }); + const capability = new CapabilityService(); + const reviewRequest = { + app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: staged.lifecycle_epoch, + expected_grant_epoch: staged.grant_epoch, + connector_selections: [{ connector_requirement_key: 'mail_provider', mcp_connection_id: connectionId }], + }; + const review = await prepareConnectedAppReview(actor, staged.id, reviewRequest, capability); + await activateConnectedAppInstallation(actor, staged.id, { + ...reviewRequest, expected_review_digest: review.review_digest, accept_host_policy: true, + }, capability); + const campaignBinding = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and(eq(appModuleBindings.org_id, orgId), eq(appModuleBindings.app_installation_id, staged.id))).limit(1); + const contactBinding = await db.select({ binding: appModuleBindings, version: moduleVersions }) + .from(appModuleBindings).innerJoin(moduleVersions, and( + eq(moduleVersions.org_id, appModuleBindings.org_id), + eq(moduleVersions.installation_id, appModuleBindings.module_installation_id), + eq(moduleVersions.id, appModuleBindings.module_version_id), + )).where(and(eq(appModuleBindings.org_id, orgId), eq(appModuleBindings.app_installation_id, dependencyInstallation.id))).limit(1); + assert.ok(campaignBinding[0] && contactBinding[0]); + const contact = await createModuleRecord(actor, { + module_id: 'org.deft.reference.resource-contacts', collection_key: 'contacts', + data: { name: 'A02 contact', email: 'a02@example.test' }, relations: {}, + expected_manifest_digest: contactBinding[0].version.manifest_digest, + idempotency_key: `a02-contact-${randomUUID()}`, + }); + const campaign = await createModuleRecord(actor, { + module_id: 'org.deft.reference.resource-campaigns', collection_key: 'campaigns', + data: { name: 'A02 campaign', subject: 'A02 proof', body: 'One daily action.', status: 'ready' }, relations: {}, + expected_manifest_digest: campaignBinding[0].version.manifest_digest, + idempotency_key: `a02-campaign-${randomUUID()}`, + }); + assert.ok(contact.record && campaign.record); + const placementRef = { schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module' as const, provider_instance_id: campaignBinding[0].binding.module_installation_id }, + resource_type: 'campaigns', resource_id: campaign.record.id }; + const selectedRef = { schema_version: RESOURCE_CONTRACT_VERSIONS.ref, + provider: { kind: 'module' as const, provider_instance_id: contactBinding[0].binding.module_installation_id }, + resource_type: 'contacts', resource_id: contact.record.id }; + await replaceResourceRelation(actor, { schema_version: RESOURCE_CONTRACT_VERSIONS.relation, + source: placementRef, relation_key: 'contacts', refs: [selectedRef], + expected_revision: 0, idempotency_key: `a02-relation-${randomUUID()}` }); + const [binding] = await db.select().from(appActionBindings).where(and( + eq(appActionBindings.org_id, orgId), eq(appActionBindings.app_installation_id, staged.id), + eq(appActionBindings.action_key, 'send_campaign_email'), + )).limit(1); + assert.ok(binding); + const inputFor = (scheduledAt: Date, validitySeconds = 30 * 24 * 60 * 60) => ({ + app_installation_id: staged.id, app_version_id: version.id, action_binding_id: binding.id, + automation_request_key: 'daily_campaign_send', + placement: { resource_ref: placementRef, revision: String(campaign.record!.revision), + content_digest: digestAppGrantValue(campaign.record!.data) }, + selected: { resource_ref: selectedRef, revision: String(contact.record!.revision), + content_digest: digestAppGrantValue(contact.record!.data) }, + local_time: scheduledAt.toISOString().slice(11, 16), timezone: 'UTC', + validity_seconds: validitySeconds, max_org_runs_per_utc_day: 100, max_pending_org_fires: 25, + } as const); + const create = async (scheduledAt: Date, approvedAt: Date, validitySeconds?: number) => { + const input = inputFor(scheduledAt, validitySeconds); + const review = await prepareAppAutomationDefinitionReview(actor, input); + const definition = await createReviewedAppAutomationDefinition(actor, { + ...input, expected_review_digest: review.review_digest, accept_code_owned_policy: true, + }, { now: () => approvedAt }); + return { definition, input, review, scheduledAt }; + }; + const effects = async (): Promise>> => { + try { + const raw = await readFile(resolve(outboxRoot, 'effects.jsonl'), 'utf8'); + return raw.trim().split('\n').filter(Boolean).map((line) => JSON.parse(line) as Record); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + }; + return { orgId, userId, actor, staged, connectionId, outboxRoot, inputFor, create, effects }; +} \ No newline at end of file From 1e88c1144a4633ffdd669131834a908fa304a07a Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:51:26 +0530 Subject: [PATCH 039/161] test: require reviewed automation renewal acceptance cases --- scripts/gate-g/required-tests.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 0bd57f53..cca27236 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -1104,6 +1104,28 @@ } ] }, + { + "id": "automation-renewal-acceptance", + "description": "A02 actual reviewed renewal, permanent revocation, pause/resume epoch and queued-fire fencing. All four cases required; the fixture throws before execution unless DATABASE_URL equals DEFT_TEST_DATABASE_URL and matches postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_automation_renewal with optional _vN suffix. This is the focused renewal profile, not all automation or Gate G acceptance.", + "cases": [ + { + "file": "apps/api/test/app-automation-renewal-acceptance-db.test.ts", + "name": "A02 reviewed renewal creates new authority while expired definitions and old queued fires stay inert" + }, + { + "file": "apps/api/test/app-automation-renewal-acceptance-db.test.ts", + "name": "A02 permanent revocation blocks already admitted work and cannot be resumed with any epoch" + }, + { + "file": "apps/api/test/app-automation-renewal-acceptance-db.test.ts", + "name": "A02 pause resume rejects stale epochs and missed occurrences without forbidden backfill" + }, + { + "file": "apps/api/test/app-automation-renewal-acceptance-db.test.ts", + "name": "A02 queued fires from an earlier definition epoch cannot execute after pause and resume" + } + ] + }, { "id": "crm-compatibility", "description": "Existing base-to-reviewed-connected CRM lifecycle preserves canonical records, relations and Task links; current CRM HTTP/agent owner boundaries. Requires matching explicitly disposable DATABASE_URL and DEFT_TEST_DATABASE_URL; synthetic loopback regression database only. Package v0-v2 golden bytes remain required in app-kit.", From 2b7951a55b5d27c576441ac7c9f6fd70928b1ec9 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:05:52 +0530 Subject: [PATCH 040/161] feat(apps): expose guarded owner-private resource management --- .env.example | 5 + Dockerfile | 2 + apps/api/src/index.ts | 9 ++ .../src/routes/app-resource-private-read.ts | 69 +++++++++ .../app/(app)/settings/apps/apps-client.tsx | 8 +- .../settings/apps/private-resources/page.tsx | 8 ++ .../private-resources-client.tsx | 135 ++++++++++++++++++ apps/web/src/lib/apps.test.ts | 15 ++ apps/web/src/lib/apps.ts | 23 ++- apps/web/src/lib/feature-flags.ts | 1 + docker-compose.yml | 6 + 11 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/routes/app-resource-private-read.ts create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/page.tsx create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/private-resources-client.tsx diff --git a/.env.example b/.env.example index 3d3452bd..c7139be6 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,11 @@ NEXT_PUBLIC_FEATURE_HUDDLES=false # beta. Enable the API and bake the public UI flag into the same build. DEFT_APPS_ENABLED=false NEXT_PUBLIC_FEATURE_APPS=false +# Private resource sync remains a separate experimental opt-in. The web flag +# must be baked into a source build; it does not grant read or sync authority. +NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC=false +DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED=false +DEFT_APP_RESOURCE_SYNC_SCHEDULER_ENABLED=false DEFT_APP_DEVELOPER_PAIRING_ENABLED=false # The App Run engine can decrypt and drain accepted work only when this exact # opt-in and valid purpose-separated keyrings are supplied. diff --git a/Dockerfile b/Dockerfile index 64be4d4f..d34abfa6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ ARG NEXT_PUBLIC_API_URL=__DEFT_API_URL__ ARG NEXT_PUBLIC_WS_URL=__DEFT_WS_URL__ ARG NEXT_PUBLIC_FEATURE_HUDDLES=false ARG NEXT_PUBLIC_FEATURE_APPS=false +ARG NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC=false ARG NEXT_PUBLIC_DEFT_SELF_HOSTED=false ARG DEFT_RELEASE_VERSION=0.3.0-preview.14 ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL @@ -37,6 +38,7 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL ENV NEXT_PUBLIC_FEATURE_HUDDLES=$NEXT_PUBLIC_FEATURE_HUDDLES ENV NEXT_PUBLIC_FEATURE_APPS=$NEXT_PUBLIC_FEATURE_APPS +ENV NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC=$NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC ENV NEXT_PUBLIC_DEFT_SELF_HOSTED=$NEXT_PUBLIC_DEFT_SELF_HOSTED ENV DEFT_RELEASE_VERSION=$DEFT_RELEASE_VERSION diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 76afa6bd..3154f03e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -78,6 +78,9 @@ import { appRuntimeChannelRoutes } from './routes/app-runtime-channel.js'; import { appResourceSyncChannelRoutes } from './routes/app-resource-sync-channel.js'; import { appResourceSyncLimits } from './middleware/app-resource-sync-limits.js'; import { appRuntimeManagementRoutes } from './routes/app-runtime-management.js'; +import { appResourceSyncManagementRoutes } from './routes/app-resource-sync-management.js'; +import { appResourcePrivateReadRoutes } from './routes/app-resource-private-read.js'; +import { appResourcePrivateReadLimits, appResourceSyncManagementLimits } from './middleware/app-resource-private-limits.js'; import { appRuntimeReviewRoutes } from './routes/app-runtime-review.js'; import { appRuntimeActionRoutes } from './routes/app-runtime-actions.js'; import { appExperienceRoutes } from './routes/app-experiences.js'; @@ -201,6 +204,12 @@ if (APPS_ENABLED) { app.route('/api/app-runtime/channel', appRuntimeChannelRoutes); app.use('/api/app-resource-sync/channel/*', appResourceSyncLimits); app.route('/api/app-resource-sync/channel', appResourceSyncChannelRoutes); + // Owner controls and private reads verify a live web SID themselves. Employee + // and Runtime credentials must never reach these human-only surfaces. + app.use('/api/app-resource-sync-management/*', appResourceSyncManagementLimits); + app.route('/api/app-resource-sync-management', appResourceSyncManagementRoutes); + app.use('/api/app-resource-private/*', appResourcePrivateReadLimits); + app.route('/api/app-resource-private', appResourcePrivateReadRoutes); } if (APPS_ENABLED && process.env.DEFT_APP_PUBLIC_INGRESS_ENABLED === 'true') { app.route('/api/public/apps', createAppPublicRoutes(new AppPublicClaimService({ enabled: true }))); diff --git a/apps/api/src/routes/app-resource-private-read.ts b/apps/api/src/routes/app-resource-private-read.ts new file mode 100644 index 00000000..45636312 --- /dev/null +++ b/apps/api/src/routes/app-resource-private-read.ts @@ -0,0 +1,69 @@ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import { AppError } from '../lib/app-errors.js'; +import { AppResourcePrivateReadService, AppResourcePrivateReadError } from '../lib/app-resource-private-read.js'; +import { resourceSyncWebAuthority, ResourceSyncWebAuthenticationError } from '../lib/app-resource-sync-web-authority.js'; +import { getAppRunRuntime } from '../lib/app-run-runtime.js'; +import { isAppResourceSyncChannelEnabled } from '../lib/env.js'; + +const id = z.string().uuid(); +const pageQuery = z.strictObject({ + limit: z.coerce.number().int().min(1).max(25).optional(), + cursor: z.string().min(1).max(2048).optional(), +}); + +/** The web subject comes from a verified session; refs and path IDs confer no access. */ +export const appResourcePrivateReadRoutes = new Hono(); +appResourcePrivateReadRoutes.use('*', async (c, next) => { + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + if (!isAppResourceSyncChannelEnabled()) { + return c.json({ error: 'Private resources are unavailable', code: 'APP_RESOURCE_SYNC_DISABLED' }, 503); + } + await next(); +}); + +async function reader(c: Context) { + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + if (actor.kind !== 'human') throw new AppError('Private resource access denied', 'APP_ACCESS_DENIED', 403); + const runtime = await getAppRunRuntime(); + // Run the web guard inside the reader's authority transaction, before its + // final consent deadline check. A session lock wait must not outlive consent. + const service = new AppResourcePrivateReadService(runtime.keys, () => new Date(), runtime.repository, guard); + return { service, subject: { kind: 'human' as const, org_id: actor.org_id, user_id: actor.actor_id } }; +} + +function failure(c: Context, error: unknown) { + if (error instanceof AppResourcePrivateReadError || error instanceof AppError || error instanceof ResourceSyncWebAuthenticationError) { + return c.json({ error: error.message, code: error.code }, error.status); + } + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid private resource request', code: 'APP_RESOURCE_PRIVATE_INPUT_INVALID' }, 400); + } + return c.json({ error: 'Private resources are unavailable', code: 'APP_RESOURCE_PRIVATE_FAILURE' }, 500); +} + +appResourcePrivateReadRoutes.get('/bindings/:bindingId/records', async (c) => { + try { + const queries = c.req.queries(); + if (Object.values(queries).some((values) => values.length !== 1)) { + return c.json({ error: 'Invalid private resource request', code: 'APP_RESOURCE_PRIVATE_INPUT_INVALID' }, 400); + } + const query = pageQuery.parse(Object.fromEntries(Object.entries(queries).map(([key, values]) => [key, values[0]]))); + const bindingId = id.parse(c.req.param('bindingId')); + const { service, subject } = await reader(c); + return c.json(await service.listOwnerPrivateResourcePage(subject, { resource_binding_id: bindingId, ...query })); + } catch (error) { return failure(c, error); } +}); + +appResourcePrivateReadRoutes.get('/bindings/:bindingId/records/:projectionId', async (c) => { + try { + z.strictObject({}).parse(c.req.query()); + const bindingId = id.parse(c.req.param('bindingId')); + const projectionId = id.parse(c.req.param('projectionId')); + const { service, subject } = await reader(c); + return c.json(await service.getOwnerPrivateResource(subject, { + resource_binding_id: bindingId, projection_id: projectionId, + })); + } catch (error) { return failure(c, error); } +}); diff --git a/apps/web/src/app/(app)/settings/apps/apps-client.tsx b/apps/web/src/app/(app)/settings/apps/apps-client.tsx index 902e4881..91c68c52 100644 --- a/apps/web/src/app/(app)/settings/apps/apps-client.tsx +++ b/apps/web/src/app/(app)/settings/apps/apps-client.tsx @@ -8,6 +8,7 @@ import { EmptyState } from '@/components/empty-state'; import { ConnectedAppManagement } from '@/components/apps/connected-app-management'; import { useSetPageContext } from '@/components/app-header-context'; import { api } from '@/lib/api'; +import { APP_RESOURCE_SYNC_ENABLED } from '@/lib/feature-flags'; import { useAuth } from '@/lib/auth-context'; import { APP_PACKAGE_MAX_BYTES, appApiError, canEnableAppWithoutReview, canStageConnectedUpgrade, isConnectedAppManifest, normalizeAppInspection, type AppInspection, type AppInstallation } from '@/lib/apps'; import { refreshApps, useAppRealtime, useApps } from '@/hooks/use-apps'; @@ -134,6 +135,7 @@ export function AppsClient({ selectedId }: { selectedId?: string } = {}) { void inspect(event)} className="sr-only" aria-label="Choose a Deft App package" />
+ {canManage && APP_RESOURCE_SYNC_ENABLED && Your private App resources} {message &&
{message.text}
} {pairing &&

One-time Codex pairing

Enter this only in the CLI prompt. It expires at {new Date(pairing.expires_at).toLocaleTimeString()} and cannot be replayed.

@@ -169,7 +171,8 @@ function InspectionCard({ pending, upgradeTarget, busy, onCancel, onStage }: { p const connectedManifest = isConnectedAppManifest(pending.manifest) ? pending.manifest : null; const connected = Boolean(connectedManifest); const runtime = pending.manifest.compatibility.app_protocol === '3' - || pending.manifest.compatibility.app_protocol === '4'; + || pending.manifest.compatibility.app_protocol === '4' + || pending.manifest.compatibility.app_protocol === '5'; return

Review {pending.manifest.name}{upgradeTarget ? ' upgrade' : ''}

{pending.manifest.description ?? 'Declarative workspace App.'}

@@ -183,7 +186,8 @@ function InspectionCard({ pending, upgradeTarget, busy, onCancel, onStage }: { p function AppCard({ app, canManage, busy, onActivate, onEnable, onDisable, onChooseUpgrade }: { app: AppInstallation; canManage: boolean; busy: boolean; onActivate: () => void; onEnable: () => void; onDisable: () => void; onChooseUpgrade: () => void }) { const connected = isConnectedAppManifest(app.manifest); const runtime = app.manifest.compatibility.app_protocol === '3' - || app.manifest.compatibility.app_protocol === '4'; + || app.manifest.compatibility.app_protocol === '4' + || app.manifest.compatibility.app_protocol === '5'; const showConnectedManagement = connected || canStageConnectedUpgrade(app); const tone = app.state === 'active' ? 'var(--status-green)' : app.state === 'disabled' ? 'var(--outline)' : 'var(--status-amber)'; return
diff --git a/apps/web/src/app/(app)/settings/apps/private-resources/page.tsx b/apps/web/src/app/(app)/settings/apps/private-resources/page.tsx new file mode 100644 index 00000000..968f0fa5 --- /dev/null +++ b/apps/web/src/app/(app)/settings/apps/private-resources/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from 'next/navigation'; +import { APP_RESOURCE_SYNC_ENABLED } from '@/lib/feature-flags'; +import { PrivateResourcesClient } from './private-resources-client'; + +export default function PrivateResourcesPage() { + if (!APP_RESOURCE_SYNC_ENABLED) notFound(); + return ; +} diff --git a/apps/web/src/app/(app)/settings/apps/private-resources/private-resources-client.tsx b/apps/web/src/app/(app)/settings/apps/private-resources/private-resources-client.tsx new file mode 100644 index 00000000..ec7b4a9a --- /dev/null +++ b/apps/web/src/app/(app)/settings/apps/private-resources/private-resources-client.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { Loader2, LockKeyhole, RefreshCw } from 'lucide-react'; +import { PageHeader } from '@/components/page-header'; +import { useSetPageContext } from '@/components/app-header-context'; +import { api } from '@/lib/api'; +import { appApiError } from '@/lib/apps'; +import { useAuth } from '@/lib/auth-context'; +import { useApps } from '@/hooks/use-apps'; + +type Binding = { binding_id: string; installation_id: string; resource_key: string; state: string; consent_expires_at: string }; +type Status = { binding: Binding; checkpoint: { state: string; retained_record_count: number; last_applied_at: string | null } | null; latest_run: { state: string; terminal_at: string | null } | null }; +type RecordPage = { items: Array<{ projection_id: string; label: string; data: Record }>; next_cursor: string | null }; +const management = '/api/app-resource-sync-management'; +async function result(response: Response): Promise { + if (!response.ok) throw new Error(await appApiError(response, 'This private resource is unavailable.')); + return response.json() as Promise; +} + +export function PrivateResourcesClient() { + const { user, sessionCacheScope } = useAuth(); + useSetPageContext(Private App resources, []); + if (!sessionCacheScope || !user) return null; + if (user.role !== 'owner' && user.role !== 'admin') return

Private App resource management is available to workspace owners and admins.

; + return ; +} + +function PrivateResourceWorkspace() { + const { apps } = useApps(); + const [bindings, setBindings] = useState([]); + const [after, setAfter] = useState(null); + const [selected, setSelected] = useState(null); + const [page, setPage] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [revokeReview, setRevokeReview] = useState(false); + const [consentExpired, setConsentExpired] = useState(false); + const generation = useRef(0); + const clearContent = useCallback(() => { generation.current += 1; setPage(null); setBusy(false); }, []); + + const loadBindings = useCallback(async (cursor?: string) => { + const request = ++generation.current; + setBusy(true); setError(null); setPage(null); setSelected(null); setRevokeReview(false); + try { + const body = await result<{ bindings: Binding[]; next_after: string | null }>(await api.get(`${management}/bindings?limit=25${cursor ? `&after=${encodeURIComponent(cursor)}` : ''}`)); + if (request !== generation.current) return; + setBindings(body.bindings); setAfter(body.next_after); + } catch (reason) { + if (request === generation.current) { setBindings([]); setAfter(null); setError(reason instanceof Error ? reason.message : 'Unable to load resources.'); } + } finally { if (request === generation.current) setBusy(false); } + }, []); + useEffect(() => { + void loadBindings(); + const hide = () => { if (document.hidden) clearContent(); else void loadBindings(); }; + document.addEventListener('visibilitychange', hide); + return () => { generation.current += 1; document.removeEventListener('visibilitychange', hide); }; + }, [loadBindings, clearContent]); + useEffect(() => { + if (!selected) return; + const expiresAt = new Date(selected.binding.consent_expires_at).getTime(); + let timer: ReturnType; + setConsentExpired(false); + const check = () => { + const remaining = expiresAt - Date.now(); + if (!Number.isFinite(remaining) || remaining <= 0) { + clearContent(); setConsentExpired(true); return; + } + timer = setTimeout(check, Math.min(remaining, 2_147_483_647)); + }; + check(); + return () => clearTimeout(timer); + }, [selected, clearContent]); + + const open = async (binding: Binding) => { + const request = ++generation.current; + setBusy(true); setError(null); setPage(null); setSelected(null); setRevokeReview(false); + try { + const status = await result(await api.get(`${management}/bindings/${encodeURIComponent(binding.binding_id)}`)); + if (request === generation.current) setSelected(status); + } catch (reason) { if (request === generation.current) setError(reason instanceof Error ? reason.message : 'Unable to load status.'); } + finally { if (request === generation.current) setBusy(false); } + }; + const read = async (cursor?: string) => { + if (!selected) return; + const expiresAt = new Date(selected.binding.consent_expires_at).getTime(); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + clearContent(); setConsentExpired(true); return; + } + const request = ++generation.current; + setBusy(true); setError(null); setPage(null); + try { + const body = await result(await api.get(`/api/app-resource-private/bindings/${encodeURIComponent(selected.binding.binding_id)}/records?limit=10${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`)); + if (request === generation.current) { + if (expiresAt <= Date.now()) { clearContent(); setConsentExpired(true); } + else setPage(body); + } + } catch (reason) { if (request === generation.current) setError(reason instanceof Error ? reason.message : 'Unable to read records.'); } + finally { if (request === generation.current) setBusy(false); } + }; + const revoke = async () => { + if (!selected) return; + const request = ++generation.current; + setBusy(true); setPage(null); setError(null); setRevokeReview(false); + try { + await result(await api.post(`${management}/bindings/${encodeURIComponent(selected.binding.binding_id)}/revoke`)); + if (request === generation.current) await loadBindings(); + } catch (reason) { if (request === generation.current) setError(reason instanceof Error ? reason.message : 'Unable to revoke access.'); } + finally { if (request === generation.current) setBusy(false); } + }; + const readable = !consentExpired && selected?.binding.state === 'active' && new Date(selected.binding.consent_expires_at).getTime() > Date.now(); + return
+ +
+
← Apps
+

Only resources you explicitly connected are shown here. Reading them does not share them with your workspace.

+ {error &&

{error}

} + {busy &&
Loading…
} + {!busy && !error && bindings.length === 0 &&

You have no private App connections. A connection appears here after you review and activate its consent.

} +
+
{bindings.map((binding) => )}{after && }
+ {selected &&
+

{selected.binding.resource_key}

+
Saved records
{selected.checkpoint?.retained_record_count ?? 0}
Latest sync
{selected.latest_run?.state.replaceAll('_', ' ') ?? 'Not started'}
Last saved update
{selected.checkpoint?.last_applied_at ? new Date(selected.checkpoint.last_applied_at).toLocaleString() : 'No update saved yet'}
+

Saved records may differ from the source. Source freshness has not been verified.

+ {consentExpired &&

Consent has expired. Review a new connection before reading more records.

} +
{selected.binding.state === 'active' && }
+ {revokeReview &&

Revoke this connection? Future syncs and private reads will be denied. Previously delivered copies cannot be recalled.

} + {page &&
{page.items.length === 0 ?

No saved records are available.

: page.items.map((item) =>
{item.label}
{Object.entries(item.data).map(([key, value]) =>
{key}
{typeof value === 'string' ? value : JSON.stringify(value)}
)}
)}{page.next_cursor && }
} +
} +
+
+
; +} diff --git a/apps/web/src/lib/apps.test.ts b/apps/web/src/lib/apps.test.ts index 8f566cfe..d1a61698 100644 --- a/apps/web/src/lib/apps.test.ts +++ b/apps/web/src/lib/apps.test.ts @@ -164,6 +164,21 @@ test('App installation normalization preserves v0 and expanded connected manifes } }); +test('resource App normalization preserves Protocol 5 and never enables v0 activation', () => { + const base = installation('0'); + const manifest = { ...base.manifest, schema_version: '5', compatibility: { app_protocol: '5' }, + runtime_requirements: [{ key: 'provider', protocol_version: 'deft.app_runtime_channel.v2' }], + private_capabilities: [], runtime_actions: [], experiences: [], public_actions: [], + sync_descriptors: [{ key: 'inbox', requested_visibility: 'user_private' }] }; + const app = normalizeAppInstallation({ ...base, manifest }); + assert.equal(app.manifest.schema_version, '5'); + assert.equal(isConnectedAppManifest(app.manifest), false); + assert.equal(canEnableAppWithoutReview({ ...app, state: 'disabled' }), false); + if (app.manifest.schema_version === '5') assert.equal(app.manifest.sync_descriptors[0].key, 'inbox'); + assert.throws(() => normalizeAppInstallation({ ...base, manifest: { ...manifest, sync_descriptors: null } })); + assert.throws(() => normalizeAppInstallation({ ...base, manifest: { ...manifest, schema_version: '4' } })); +}); + test('Protocol v0 active and disabled installations expose connected upgrade review', () => { const v0 = installation('0') as unknown as Parameters[0]; const v1 = installation('1') as unknown as Parameters[0]; diff --git a/apps/web/src/lib/apps.ts b/apps/web/src/lib/apps.ts index ed10f1eb..502c01b1 100644 --- a/apps/web/src/lib/apps.ts +++ b/apps/web/src/lib/apps.ts @@ -91,11 +91,16 @@ export type AppInstalledManifestV4 = Omit & { + schema_version: '5'; compatibility: { app_protocol: '5' }; + sync_descriptors: Record[]; +}; + export type AppManifest = AppManifestV0 | AppManifestV1 | AppManifestV2 - | AppRuntimeManifestV3 | AppInstalledManifestV4; + | AppRuntimeManifestV3 | AppInstalledManifestV4 | AppResourceManifestV5; export type ConnectedAppManifest = AppManifestV1 | AppManifestV2; export type AppPackageFormat = 'deft.app.package.v0' | 'deft.app.package.v1' - | 'deft.app.package.v2' | 'deft.app.package.v3' | 'deft.app.package.v4'; + | 'deft.app.package.v2' | 'deft.app.package.v3' | 'deft.app.package.v4' | 'deft.app.package.v5'; export function isConnectedAppManifest(manifest: AppManifest): manifest is ConnectedAppManifest { return manifest.compatibility.app_protocol === '1' || manifest.compatibility.app_protocol === '2'; @@ -416,7 +421,8 @@ function packageFormat(value: unknown): AppPackageFormat { && value !== 'deft.app.package.v1' && value !== 'deft.app.package.v2' && value !== 'deft.app.package.v3' - && value !== 'deft.app.package.v4') { + && value !== 'deft.app.package.v4' + && value !== 'deft.app.package.v5') { throw new Error('Invalid App package format.'); } return value; @@ -510,7 +516,7 @@ function normalizeManifest(value: unknown): AppManifest { const compatibility = object(row.compatibility, 'App compatibility'); const protocol = compatibility.app_protocol; if (protocol !== '0' && protocol !== '1' && protocol !== '2' - && protocol !== '3' && protocol !== '4') throw new Error('Unsupported App protocol.'); + && protocol !== '3' && protocol !== '4' && protocol !== '5') throw new Error('Unsupported App protocol.'); if (row.schema_version !== protocol) throw new Error('App manifest protocol and schema do not match.'); const base: AppManifestBase = { id: stringValue(row.id, 'App identity'), @@ -537,7 +543,7 @@ function normalizeManifest(value: unknown): AppManifest { } : {}), }; if (protocol === '0') return { ...base, schema_version: '0', compatibility: { app_protocol: '0' } }; - if (protocol === '3' || protocol === '4') { + if (protocol === '3' || protocol === '4' || protocol === '5') { const runtime = { ...base, runtime_requirements: recordArray(row.runtime_requirements, 'Runtime requirements'), @@ -545,7 +551,7 @@ function normalizeManifest(value: unknown): AppManifest { runtime_actions: recordArray(row.runtime_actions, 'Runtime actions'), }; if (protocol === '3') return { ...runtime, schema_version: '3', compatibility: { app_protocol: '3' } }; - return { ...runtime, schema_version: '4', compatibility: { app_protocol: '4' }, + const installed = { ...runtime, experiences: recordArray(row.experiences, 'App Experiences').map((item) => { if (item.bridge_version !== 'deft.experience_bridge.v1' || item.renderer_version !== 'deft.trusted_renderer.v1') { @@ -555,10 +561,13 @@ function normalizeManifest(value: unknown): AppManifest { label: stringValue(item.label, 'Experience label'), artifact_path: stringValue(item.artifact_path, 'Experience path'), artifact_digest: stringValue(item.artifact_digest, 'Experience digest'), - bridge_version: item.bridge_version, renderer_version: item.renderer_version }; + bridge_version: item.bridge_version as 'deft.experience_bridge.v1', renderer_version: item.renderer_version as 'deft.trusted_renderer.v1' }; }), public_actions: recordArray(row.public_actions, 'public actions'), }; + if (protocol === '4') return { ...installed, schema_version: '4', compatibility: { app_protocol: '4' } }; + return { ...installed, schema_version: '5', compatibility: { app_protocol: '5' }, + sync_descriptors: recordArray(row.sync_descriptors, 'App sync descriptors') }; } const connected = { ...base, diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/feature-flags.ts index d0db7b34..331f2920 100644 --- a/apps/web/src/lib/feature-flags.ts +++ b/apps/web/src/lib/feature-flags.ts @@ -1,2 +1,3 @@ export const HUDDLES_ENABLED = process.env.NEXT_PUBLIC_FEATURE_HUDDLES === 'true'; export const APPS_ENABLED = process.env.NEXT_PUBLIC_FEATURE_APPS === 'true'; +export const APP_RESOURCE_SYNC_ENABLED = APPS_ENABLED && process.env.NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC === 'true'; diff --git a/docker-compose.yml b/docker-compose.yml index 46542fb9..f962e0da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} NEXT_PUBLIC_DEFT_SELF_HOSTED: ${NEXT_PUBLIC_DEFT_SELF_HOSTED:-true} ports: - '${DEFT_WEB_PORT:-3000}:3000' @@ -24,6 +25,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} DEFT_APPS_ENABLED: ${DEFT_APPS_ENABLED:-false} DEFT_APP_DEVELOPER_PAIRING_ENABLED: ${DEFT_APP_DEVELOPER_PAIRING_ENABLED:-false} DEFT_MCP_PRIVATE_ORIGIN_ALLOWLIST: ${DEFT_MCP_PRIVATE_ORIGIN_ALLOWLIST:-} @@ -46,6 +48,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} NEXT_PUBLIC_DEFT_SELF_HOSTED: ${NEXT_PUBLIC_DEFT_SELF_HOSTED:-true} env_file: .env environment: @@ -66,6 +69,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} NEXT_PUBLIC_DEFT_SELF_HOSTED: ${NEXT_PUBLIC_DEFT_SELF_HOSTED:-true} env_file: .env environment: @@ -86,6 +90,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} NEXT_PUBLIC_DEFT_SELF_HOSTED: ${NEXT_PUBLIC_DEFT_SELF_HOSTED:-true} env_file: .env environment: @@ -110,6 +115,7 @@ services: NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-http://localhost:3001} NEXT_PUBLIC_FEATURE_HUDDLES: ${NEXT_PUBLIC_FEATURE_HUDDLES:-false} NEXT_PUBLIC_FEATURE_APPS: ${NEXT_PUBLIC_FEATURE_APPS:-false} + NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC: ${NEXT_PUBLIC_FEATURE_APP_RESOURCE_SYNC:-false} NEXT_PUBLIC_DEFT_SELF_HOSTED: ${NEXT_PUBLIC_DEFT_SELF_HOSTED:-true} env_file: .env environment: From aa647a2a7994b555a7bec2f0e08db9f44a7ee6c4 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:08:33 +0530 Subject: [PATCH 041/161] Isolate automation scan item failures across tenant pages --- apps/api/src/lib/app-automation-runtime.ts | 6 +- apps/api/src/lib/app-automation-scanner.ts | 113 +++++++++++------- apps/api/test/app-automation-scanner.test.ts | 119 +++++++++++++++++++ 3 files changed, 193 insertions(+), 45 deletions(-) diff --git a/apps/api/src/lib/app-automation-runtime.ts b/apps/api/src/lib/app-automation-runtime.ts index ef75aa9b..39063ab3 100644 --- a/apps/api/src/lib/app-automation-runtime.ts +++ b/apps/api/src/lib/app-automation-runtime.ts @@ -31,7 +31,7 @@ const AppAutomationFireJobSchema = z.strictObject({ export async function runAppAutomationScan(now = new Date()): Promise { if (!APP_AUTOMATIONS_ENABLED) return; - await scanAppAutomations({ + const result = await scanAppAutomations({ listEligibleDefinitions: (eligibleAt, limit, after) => ( listEligibleAppAutomationDefinitionsWithExecutor(db, { eligible_at: eligibleAt, @@ -121,6 +121,10 @@ export async function runAppAutomationScan(now = new Date()): Promise { if (!charged) throw new Error('Failed queue delivery changed before its attempt was charged'); }), }, now); + if (Object.values(result.errors).some(count => count > 0)) { + // One bounded aggregate warning; never emit raw tenant/provider errors. + console.warn('[app-automations] scan item failures', result); + } } export async function runAppAutomationFire(job: JobData, now = new Date()): Promise { diff --git a/apps/api/src/lib/app-automation-scanner.ts b/apps/api/src/lib/app-automation-scanner.ts index 2c424668..8715d141 100644 --- a/apps/api/src/lib/app-automation-scanner.ts +++ b/apps/api/src/lib/app-automation-scanner.ts @@ -52,10 +52,17 @@ export type AppAutomationScanResult = Readonly<{ pending: number; skipped: number; recovered: number; + errors: Readonly<{ definitions: number; occurrences: number; expired_claims: number; deliveries: number }>; }>; +function propagateCancellation(error: unknown): void { + if (error instanceof Error && error.name === 'AbortError') throw error; +} + /** Reconcile schedule truth into the durable fire ledger. Queue rows only - * deliver pending fire IDs and never determine whether an occurrence exists. */ + * deliver pending fire IDs and never determine whether an occurrence exists. + * Item failures leave durable identity intact for the next pass. Page-list + * failures remain fatal: there is no safe continuation without a known page. */ export async function scanAppAutomations( port: AppAutomationScannerPort, now = new Date(), @@ -65,18 +72,29 @@ export async function scanAppAutomations( let pending = 0; let skipped = 0; let recovered = 0; + const errors = { definitions: 0, occurrences: 0, expired_claims: 0, deliveries: 0 }; const deliver = async (fire: AppAutomationFireRow): Promise => { - await port.deliverFire(fire, now); + try { await port.deliverFire(fire, now); } + catch (error) { + propagateCancellation(error); + errors.deliveries += 1; + } }; let fireAfter: AppAutomationFireScanCursor | undefined; do { const expired = await port.listExpiredClaims(now, APP_AUTOMATION_SCAN_LIMIT, fireAfter); for (const fire of expired) { - const reconciled = await port.reconcileExpiredClaim(fire, now); - if (reconciled?.state === 'pending') await deliver(reconciled); + let reconciled: AppAutomationFireRow | null; + try { reconciled = await port.reconcileExpiredClaim(fire, now); } + catch (error) { + propagateCancellation(error); + errors.expired_claims += 1; + continue; + } if (reconciled) recovered += 1; + if (reconciled?.state === 'pending') await deliver(reconciled); } const last = expired.at(-1); fireAfter = expired.length === APP_AUTOMATION_SCAN_LIMIT && last @@ -92,48 +110,55 @@ export async function scanAppAutomations( const eligibleAfter = definition.state_changed_at > definition.valid_from ? definition.state_changed_at : definition.valid_from; - const dates = listAppAutomationLogicalDates({ - eligible_after: eligibleAfter, - now, - timezone: definition.timezone, - }); - for (const logicalLocalDate of dates) { - const occurrence = resolveAppAutomationOccurrence({ - logical_local_date: logicalLocalDate, - local_time: definition.local_time, - timezone: definition.timezone, - }); - const decision = classifyAppAutomationOccurrence({ - occurrence, - now, - eligible_after: eligibleAfter, - eligible_before: definition.valid_until, - catch_up_window_minutes: 15, + let dates: string[]; + try { + dates = listAppAutomationLogicalDates({ + eligible_after: eligibleAfter, now, timezone: definition.timezone, }); - if (decision.kind === 'future' || decision.kind === 'not_eligible') continue; + } catch (error) { + propagateCancellation(error); + errors.definitions += 1; + continue; + } + for (const logicalLocalDate of dates) { + try { + const occurrence = resolveAppAutomationOccurrence({ + logical_local_date: logicalLocalDate, + local_time: definition.local_time, + timezone: definition.timezone, + }); + const decision = classifyAppAutomationOccurrence({ + occurrence, now, eligible_after: eligibleAfter, + eligible_before: definition.valid_until, catch_up_window_minutes: 15, + }); + if (decision.kind === 'future' || decision.kind === 'not_eligible') continue; - let fire = await port.ensureFire({ - organization_id: definition.org_id, - definition_id: definition.id, - expected_epoch: definition.definition_epoch, - logical_local_date: logicalLocalDate, - resolution: occurrence.resolution, - ...(decision.kind === 'skipped' ? { terminal_reason: decision.reason } : {}), - }, now); - if (!fire) continue; - if (fire.state === 'claimed' - && fire.claim_token - && fire.lease_expires_at - && fire.lease_expires_at <= now) { - fire = await port.recoverFire(fire, now); + let fire = await port.ensureFire({ + organization_id: definition.org_id, + definition_id: definition.id, + expected_epoch: definition.definition_epoch, + logical_local_date: logicalLocalDate, + resolution: occurrence.resolution, + ...(decision.kind === 'skipped' ? { terminal_reason: decision.reason } : {}), + }, now); if (!fire) continue; - } - occurrences += 1; - if (fire.state === 'pending') { - pending += 1; - await deliver(fire); - } else if (fire.state === 'skipped') { - skipped += 1; + if (fire.state === 'claimed' + && fire.claim_token + && fire.lease_expires_at + && fire.lease_expires_at <= now) { + fire = await port.recoverFire(fire, now); + if (!fire) continue; + } + occurrences += 1; + if (fire.state === 'pending') { + pending += 1; + await deliver(fire); + } else if (fire.state === 'skipped') { + skipped += 1; + } + } catch (error) { + propagateCancellation(error); + errors.occurrences += 1; } } } @@ -143,5 +168,5 @@ export async function scanAppAutomations( : undefined; } while (after); - return { definitions: definitionCount, occurrences, pending, skipped, recovered }; + return { definitions: definitionCount, occurrences, pending, skipped, recovered, errors }; } diff --git a/apps/api/test/app-automation-scanner.test.ts b/apps/api/test/app-automation-scanner.test.ts index d52d9444..55880e50 100644 --- a/apps/api/test/app-automation-scanner.test.ts +++ b/apps/api/test/app-automation-scanner.test.ts @@ -78,6 +78,7 @@ test('scanner persists old misfires before enqueuing the one catch-up occurrence assert.deepEqual(enqueued, ['fire-2026-09-01']); assert.deepEqual(result, { definitions: 1, occurrences: 2, pending: 1, skipped: 1, recovered: 0, + errors: { definitions: 0, occurrences: 0, expired_claims: 0, deliveries: 0 }, }); }); @@ -122,6 +123,7 @@ test('eligible definitions page without starving later tenants', async () => { assert.equal(result.definitions, 101); assert.equal(pages, 2); + assert.equal(result.errors.occurrences, 0, 'no occurrence is eligible at the resume boundary'); }); test('scanner recovers an expired domain claim even when its old queue row is gone', async () => { @@ -210,3 +212,120 @@ test('delivery delegates terminal queue recovery through one atomic port operati assert.equal(charges, 1); }); + +test('scanner isolates recurring delivery failures and reaches later tenants across keyset pages', async () => { + const definitions = Array.from({ length: 101 }, (_, i) => definition({ + id: `definition-${String(i).padStart(3, '0')}`, org_id: `org-${String(i).padStart(3, '0')}`, + valid_from: new Date('2026-09-01T03:00:00Z'), state_changed_at: new Date('2026-09-01T03:00:00Z'), + })); + const ledger = new Map(); + const delivered: string[] = []; + let pages = 0; + const port = scannerPort({ + listEligibleDefinitions: async (_now, limit, after) => { + pages++; + const start = after ? definitions.findIndex(row => row.id === after.definition_id) + 1 : 0; + return definitions.slice(start, start + limit); + }, + ensureFire: async input => { + const identity = `${input.definition_id}:${input.expected_epoch}:${input.logical_local_date}`; + if (!ledger.has(identity)) ledger.set(identity, fire(input, { id: identity })); + return ledger.get(identity)!; + }, + deliverFire: async row => { + if (row.definition_id === definitions[0]!.id || row.definition_id === definitions[99]!.id) throw Error('private synthetic queue detail'); + delivered.push(row.definition_id); + }, + }); + for (let retry = 0; retry < 3; retry++) { + const result = await scanAppAutomations(port, new Date('2026-09-01T04:10:00Z')); + assert.equal(result.definitions, 101); + assert.equal(result.errors.deliveries, 2); + assert.ok(!JSON.stringify(result).includes('private synthetic queue detail')); + } + assert.equal(pages, 6); + assert.equal(ledger.size, 101, 'retry preserves occurrence identity rather than manufacturing replacement work'); + assert.equal(delivered.length, 99 * 3); + assert.equal(delivered.filter(id => id === definitions[100]!.id).length, 3, 'last tenant reached on every scan'); +}); + +test('scanner isolates expired claim failures across pages before serving eligible definitions', async () => { + const expired = Array.from({ length: 101 }, (_, i) => fire({ + organization_id: `org-${i}`, definition_id: `definition-${i}`, expected_epoch: 1, + logical_local_date: '2026-09-01', resolution: { kind: 'resolved', resolved_at_utc: new Date('2026-09-01T04:00:00Z') }, + }, { id: `expired-${i}`, state: 'claimed' })); + let pages = 0; + const delivered: string[] = []; + const result = await scanAppAutomations(scannerPort({ + listExpiredClaims: async (_now, limit, after) => { + pages++; + const start = after ? expired.findIndex(row => row.id === after.fire_id) + 1 : 0; + return expired.slice(start, start + limit); + }, + reconcileExpiredClaim: async row => { + if (row.id === expired[0]!.id) throw Error('synthetic reconciliation failure'); + return { ...row, state: 'pending' }; + }, + listEligibleDefinitions: async () => [definition({ valid_from: new Date('2026-09-01T03:00:00Z'), state_changed_at: new Date('2026-09-01T03:00:00Z') })], + ensureFire: async input => fire(input, { id: 'new-healthy-fire' }), + deliverFire: async row => { + if (row.id === expired[1]!.id) throw Error('synthetic queue failure'); + delivered.push(row.id); + }, + }), new Date('2026-09-01T04:10:00Z')); + assert.equal(pages, 2); + assert.equal(result.recovered, 100, 'successful reconciliation remains observed when queue delivery fails'); + assert.deepEqual(result.errors, { definitions: 0, occurrences: 0, expired_claims: 1, deliveries: 1 }); + assert.ok(delivered.includes(expired[100]!.id)); + assert.ok(delivered.includes('new-healthy-fire')); +}); + +test('scanner isolates malformed definitions and occurrence persistence or recovery errors without widening eligibility', async () => { + const delivered: string[] = []; + const result = await scanAppAutomations(scannerPort({ + listEligibleDefinitions: async () => [definition({ id: 'malformed', timezone: 'invalid-zone' }), + definition({ id: 'persistence' }), definition({ id: 'recovery', valid_from: new Date('2026-09-01T03:00:00Z'), state_changed_at: new Date('2026-09-01T03:00:00Z') }), + definition({ id: 'denied' }), definition({ id: 'healthy' })], + ensureFire: async input => { + if (input.definition_id === 'denied') return null; + if (input.definition_id === 'persistence' && input.logical_local_date === '2026-08-31') throw Error('synthetic persistence failure'); + return fire(input, { id: `${input.definition_id}:${input.logical_local_date}`, + ...(input.definition_id === 'recovery' ? { state: 'claimed', claim_token: 'expired', lease_expires_at: new Date('2026-09-01T04:00:00Z') } : {}) }); + }, + recoverFire: async () => { throw Error('synthetic recovery failure'); }, + deliverFire: async row => { delivered.push(row.id); }, + }), new Date('2026-09-01T04:10:00Z')); + assert.deepEqual(result.errors, { definitions: 1, occurrences: 2, expired_claims: 0, deliveries: 0 }); + assert.deepEqual(delivered, ['persistence:2026-09-01', 'healthy:2026-09-01']); + assert.equal(result.skipped, 1, 'healthy older occurrence remains a misfire rather than a new effect'); +}); + +test('scanner keeps catalog listing failures visible rather than advancing an unknown page', async () => { + const outage = new Error('synthetic listing outage'); + await assert.rejects(scanAppAutomations(scannerPort({ listExpiredClaims: async () => { throw outage; } })), error => error === outage); + await assert.rejects(scanAppAutomations(scannerPort({ listEligibleDefinitions: async () => { throw outage; } })), error => error === outage); +}); +test('scanner propagates explicit cancellation from item work without starting later writes', async () => { + const cancelled = new Error('scan cancelled'); + cancelled.name = 'AbortError'; + for (const stage of ['reconcile', 'ensure', 'recover', 'deliver'] as const) { + const visited: string[] = []; + const claimed = fire({ organization_id: 'org-1', definition_id: 'first', expected_epoch: 1, + logical_local_date: '2026-09-01', resolution: { kind: 'resolved', resolved_at_utc: new Date('2026-09-01T04:00:00Z') } }, + { state: 'claimed', claim_token: 'expired', lease_expires_at: new Date('2026-09-01T04:00:00Z') }); + await assert.rejects(scanAppAutomations(scannerPort({ + listExpiredClaims: async () => stage === 'reconcile' ? [claimed] : [], + reconcileExpiredClaim: async () => { throw cancelled; }, + listEligibleDefinitions: async () => ['first', 'later'].map(id => definition({ id, + valid_from: new Date('2026-09-01T03:00:00Z'), state_changed_at: new Date('2026-09-01T03:00:00Z') })), + ensureFire: async input => { + visited.push(input.definition_id); + if (stage === 'ensure') throw cancelled; + return stage === 'recover' ? claimed : fire(input); + }, + recoverFire: async () => { throw cancelled; }, + deliverFire: async () => { throw cancelled; }, + }), new Date('2026-09-01T04:10:00Z')), error => error === cancelled); + assert.ok(!visited.includes('later')); + } +}); From 2fa00a600f48b278267122bb1beff0dd2648da33 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:09:25 +0530 Subject: [PATCH 042/161] test: require automation scanner fault isolation cases --- scripts/gate-g/README.md | 2 +- scripts/gate-g/required-tests.json | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/gate-g/README.md b/scripts/gate-g/README.md index 954f55c6..f1885687 100644 --- a/scripts/gate-g/README.md +++ b/scripts/gate-g/README.md @@ -5,7 +5,7 @@ These fixtures exercise proposed boundaries. They do not implement or certify th - `experiences/`: loopback browser egress and bounded interaction experiment. - `runtime/`: separate-process recovery experiment with independent synthetic host/provider ledgers. - `public/`: transaction/claim experiment on an explicitly assigned disposable PostgreSQL database. -- `required-tests.json`: reviewed inventory including 90 App Kit and 51 focused platform unit tests, the selected legacy-MCP cutover-on and ancestry profiles, Runtime/public/installed journeys, and bounded private sync, owner-read, scheduler, authenticated HTTP, and CRM compatibility profiles. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. +- `required-tests.json`: reviewed inventory including 90 App Kit and 56 focused platform unit tests, the selected legacy-MCP cutover-on and ancestry profiles, Runtime/public/installed journeys, and bounded private sync, owner-read, scheduler, authenticated HTTP, and CRM compatibility profiles. This is not the complete Gate G matrix; remaining database, browser, recovery, and compound profiles require separate evidence. - `verify-upgrade.mjs`: read-only retained-data fingerprints and schema snapshots for the assigned disposable PostgreSQL cluster. Capture a tracked predecessor before candidate upgrades, compare retained columns afterward, and compare candidate fresh/upgrade schemas separately. ## Capture and check test execution diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index cca27236..991d5a81 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -447,6 +447,26 @@ "name": "delivery delegates terminal queue recovery through one atomic port operation", "file": "apps/api/test/app-automation-scanner.test.ts" }, + { + "name": "scanner isolates recurring delivery failures and reaches later tenants across keyset pages", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "scanner isolates expired claim failures across pages before serving eligible definitions", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "scanner isolates malformed definitions and occurrence persistence or recovery errors without widening eligibility", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "scanner keeps catalog listing failures visible rather than advancing an unknown page", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, + { + "name": "scanner propagates explicit cancellation from item work without starting later writes", + "file": "apps/api/test/app-automation-scanner.test.ts" + }, { "name": "daily wall-clock resolution is exact in a fixed-offset zone", "file": "apps/api/test/app-automation-schedule.test.ts" From 5d2d18849e02381f3b33441e67ea7954c1ee57de Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:12:15 +0530 Subject: [PATCH 043/161] test: attest the isolated automation capacity mode --- apps/api/test/apps-connected-grants-db.test.ts | 9 +++++++++ scripts/gate-g/required-tests.json | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/api/test/apps-connected-grants-db.test.ts b/apps/api/test/apps-connected-grants-db.test.ts index 760b7481..4e84cc9f 100644 --- a/apps/api/test/apps-connected-grants-db.test.ts +++ b/apps/api/test/apps-connected-grants-db.test.ts @@ -106,6 +106,13 @@ import { databaseCompleteAppRunTestKeyringFixture } from './fixtures/app-run-tes const DATABASE_URL = process.env.DEFT_TEST_DATABASE_URL ?? (process.env.CI === 'true' ? process.env.DATABASE_URL : undefined); +// Capacity uses fixed organization IDs and global row-count assertions. Require +// its explicitly assigned fresh synthetic database before any fixture writes. +if (process.env.DEFT_PREVIEW_CAPACITY_PROOF === 'true' + && (DATABASE_URL !== process.env.DATABASE_URL + || !/^postgresql:\/\/gate_g_test@127\.0\.0\.1:55435\/gate_g_20260926_capacity_test(?:_v[0-9]+)?$/.test(DATABASE_URL ?? ''))) { + throw new Error('A05 capacity proof requires matching URLs for the assigned fresh synthetic capacity database'); +} if (!DATABASE_URL) throw new Error('Connected App grant DB tests require DEFT_TEST_DATABASE_URL'); if (process.env.CI !== 'true' && !/(?:test|ci|acceptance|phase5)/i.test(new URL(DATABASE_URL).pathname)) { throw new Error('Connected App grant DB tests require an explicitly disposable database'); @@ -914,6 +921,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', assert.equal(new Set(managedIds).size, managedIds.length, 'management cursor pages do not duplicate definitions'); if (capacityMode) { + await t.test('A05 capacity mode pages 402 definitions across two organizations within frozen bounds', async () => { const currentDue = await Promise.all(Array.from({ length: 23 }, (_value, index) => ( createDefinition(1, 100, new Date(approvedAt.getTime() + index + 1)) ))); @@ -1087,6 +1095,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', org_b_persist_enqueue_ms_max: scanMs, org_b_fire_id: orgBJob.data.fire_id, })); + }); return; } diff --git a/scripts/gate-g/required-tests.json b/scripts/gate-g/required-tests.json index 991d5a81..3127b9ad 100644 --- a/scripts/gate-g/required-tests.json +++ b/scripts/gate-g/required-tests.json @@ -1146,6 +1146,20 @@ } ] }, + { + "id": "automation-capacity-mode", + "description": "Existing A05 capacity branch attested by a unique required child plus its unchanged parent: 402 definitions/202 active across two healthy orgs, 29-day history, management and scan bounds. Requires DEFT_PREVIEW_CAPACITY_PROOF=true, matching exact postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_capacity_test URLs (optional _vN), and a freshly initialized empty assigned database. Ordinary mode cannot satisfy the child. Does not certify unhealthy DB lock fairness or all A05.", + "cases": [ + { + "file": "apps/api/test/apps-connected-grants-db.test.ts", + "name": "Protocol v2 review and automation lifecycle converge on one governed Run" + }, + { + "file": "apps/api/test/apps-connected-grants-db.test.ts", + "name": "A05 capacity mode pages 402 definitions across two organizations within frozen bounds" + } + ] + }, { "id": "crm-compatibility", "description": "Existing base-to-reviewed-connected CRM lifecycle preserves canonical records, relations and Task links; current CRM HTTP/agent owner boundaries. Requires matching explicitly disposable DATABASE_URL and DEFT_TEST_DATABASE_URL; synthetic loopback regression database only. Package v0-v2 golden bytes remain required in app-kit.", From 4d304a16b43b77c007dc9f084ced009cedcdc2b7 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:16:16 +0530 Subject: [PATCH 044/161] test: close capacity mode provider clients on cleanup --- apps/api/test/apps-connected-grants-db.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/test/apps-connected-grants-db.test.ts b/apps/api/test/apps-connected-grants-db.test.ts index 4e84cc9f..728cb6ec 100644 --- a/apps/api/test/apps-connected-grants-db.test.ts +++ b/apps/api/test/apps-connected-grants-db.test.ts @@ -921,7 +921,8 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', assert.equal(new Set(managedIds).size, managedIds.length, 'management cursor pages do not duplicate definitions'); if (capacityMode) { - await t.test('A05 capacity mode pages 402 definitions across two organizations within frozen bounds', async () => { + await t.test('A05 capacity mode pages 402 definitions across two organizations within frozen bounds', async (capacity) => { + capacity.after(async () => (await import('@deft/mcp')).mcpClientManager.disconnect(connectionId)); const currentDue = await Promise.all(Array.from({ length: 23 }, (_value, index) => ( createDefinition(1, 100, new Date(approvedAt.getTime() + index + 1)) ))); @@ -954,6 +955,7 @@ test('Protocol v2 review and automation lifecycle converge on one governed Run', eq(appGrantSnapshots.org_id, orgB), eq(appGrantSnapshots.id, versionB!.requested_grant_snapshot_id!), )); const connectionB = randomUUID(); + capacity.after(async () => (await import('@deft/mcp')).mcpClientManager.disconnect(connectionB)); await db.insert(mcpConnections).values({ id: connectionB, org_id: orgB, From 2c74a3f07846b2523a04a941a12b83658b5b0af4 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:51:15 +0530 Subject: [PATCH 045/161] feat: expose reviewed private sync setup context --- .../src/lib/app-resource-sync-management.ts | 71 ++++++- apps/api/src/lib/app-resource-sync-policy.ts | 18 +- .../api/src/lib/app-resource-sync-reviewed.ts | 8 +- .../routes/app-resource-sync-management.ts | 9 + .../app-resource-sync-setup-http-db.test.ts | 184 ++++++++++++++++++ 5 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 apps/api/test/app-resource-sync-setup-http-db.test.ts diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index a6c2b7f3..e971cece 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -1,6 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { and, eq, inArray, sql } from 'drizzle-orm'; import { z } from 'zod'; +import { digestResourceSyncDescriptor } from '@deft/app-kit/experimental/resource-sync'; import { appResourceBindings, appRuntimeRegistrations, appRuntimeSessions, appSyncCheckpoints, auditLog, orgMembers, users, @@ -18,7 +19,8 @@ import { createResourceSyncDiscoverySnapshot } from './app-resource-sync-discove import { loadReviewedResourceSyncDescriptor } from './app-resource-sync-reviewed.js'; import { loadLiveResourceSyncBindingAuthority, loadLiveResourceSyncAuthority, resourceSyncParticipantsAreHuman } from './app-resource-sync-authority.js'; -import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, +import { APP_RESOURCE_SYNC_HOST_POLICY, APP_RESOURCE_SYNC_SESSION_MS, APP_RESOURCE_SYNC_MAX_CONSENT_MS, + APP_RESOURCE_SYNC_LIMIT_BOUNDS, AppResourceSyncConsentActivationSchema, AppResourceSyncConsentRequestSchema, assertResourceSyncConsentWindow, hashAppResourceSyncToken, type AppResourceSyncConsentRequest } from './app-resource-sync-policy.js'; @@ -73,6 +75,73 @@ export class AppResourceSyncManagement { this.#secrets = new AppResourceSyncSecretService(keys); } + /** Discovery conveys no private read or Runtime authority. The only operator + * offered by this initial setup surface is the current manager-owner. */ + async setupContext(actor: ModuleActor, value: unknown, guard?: ResourceSyncManagementGuard) { + reviewer(actor); + const { installation_id } = z.strictObject({ installation_id: Id }).parse(value); + return db.transaction(async (tx) => { + await assertManager(tx, actor); + const reviewed = await loadReviewedResourceSyncDescriptor(tx, actor.org_id, installation_id); + const { installation, version, grant } = reviewed; + const bindings = await tx.select({ binding_id: appResourceBindings.id, + resource_key: appResourceBindings.resource_key, state: appResourceBindings.state, + operator_user_id: appRuntimeRegistrations.operator_user_id, + registration_state: appRuntimeRegistrations.state, + consent_expires_at: appResourceBindings.consent_expires_at }) + .from(appResourceBindings).innerJoin(appRuntimeRegistrations, and( + eq(appRuntimeRegistrations.org_id, appResourceBindings.org_id), + eq(appRuntimeRegistrations.id, appResourceBindings.runtime_registration_id))) + .where(and(eq(appResourceBindings.org_id, actor.org_id), + eq(appResourceBindings.app_installation_id, installation.id), + eq(appResourceBindings.grant_snapshot_id, grant.id), + eq(appResourceBindings.owner_user_id, actor.actor_id), + inArray(appResourceBindings.state, ['active', 'disabled']))) + .limit(8).for('share'); + const descriptors = await Promise.all(reviewed.descriptors.map(async (descriptor) => ({ + resource_key: descriptor.key, resource_type: descriptor.resource_type, + visibility: descriptor.requested_visibility, + descriptor_digest: await digestResourceSyncDescriptor(descriptor), + }))); + await guard?.(tx); + if (!await resourceSyncParticipantsAreHuman(tx, actor.actor_id, actor.actor_id)) throw denied(); + const now = this.clock(); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + return { schema_version: 'deft.app_resource_sync_setup.v1' as const, + org_id: actor.org_id, owner_user_id: actor.actor_id, operator_user_id: actor.actor_id, + installation_id: installation.id, app_version_id: version.id, + host_policy: APP_RESOURCE_SYNC_HOST_POLICY, + host_limits: { max_consent_ms: APP_RESOURCE_SYNC_MAX_CONSENT_MS, + session_ms: APP_RESOURCE_SYNC_SESSION_MS, limits: APP_RESOURCE_SYNC_LIMIT_BOUNDS }, + descriptors: descriptors.map((descriptor) => { + const binding = bindings.find((item) => item.resource_key === descriptor.resource_key); + const consent_request: AppResourceSyncConsentRequest = { + installation_id: installation.id, resource_key: descriptor.resource_key, + operator_user_id: actor.actor_id, expected_app_version_id: version.id, + expected_package_digest: version.package_digest, + expected_grant_snapshot_digest: grant.snapshot_digest, + expected_lifecycle_epoch: installation.lifecycle_epoch, + expected_grant_epoch: installation.grant_epoch, + consent_expires_at: expiresAt, limits: { + max_records_per_page: 100, max_page_bytes: 524_288, + max_retained_records: 10_000, max_retained_bytes: 104_857_600, + min_interval_seconds: 300, + }, + }; + return { ...descriptor, consent_request, + existing_binding: binding ? { binding_id: binding.binding_id, + state: binding.state as 'active' | 'disabled', + consent_expires_at: binding.consent_expires_at?.toISOString() ?? null, + can_issue_session: binding.operator_user_id === actor.actor_id + && binding.registration_state === 'active' && binding.state === 'active' + && binding.consent_expires_at !== null && binding.consent_expires_at > now, + requires_revoke: binding.state !== 'active' || !binding.consent_expires_at + || binding.consent_expires_at <= now } : null }; + }), + }; + }); + } + async #reviewContext(tx: Tx, actor: Human, input: AppResourceSyncConsentRequest, activation: boolean) { await lockMembers(tx, actor.org_id, [actor.actor_id, input.operator_user_id], 'UPDATE'); diff --git a/apps/api/src/lib/app-resource-sync-policy.ts b/apps/api/src/lib/app-resource-sync-policy.ts index 21c1f241..71b1d10e 100644 --- a/apps/api/src/lib/app-resource-sync-policy.ts +++ b/apps/api/src/lib/app-resource-sync-policy.ts @@ -10,6 +10,13 @@ export const APP_RESOURCE_SYNC_HOST_POLICY = Object.freeze({ } as const); export const APP_RESOURCE_SYNC_MAX_CONSENT_MS = 90 * 24 * 60 * 60 * 1_000; export const APP_RESOURCE_SYNC_SESSION_MS = 15 * 60 * 1_000; +export const APP_RESOURCE_SYNC_LIMIT_BOUNDS = Object.freeze({ + max_records_per_page: { min: 1, max: 100 }, + max_page_bytes: { min: 1, max: 524_288 }, + max_retained_records: { min: 1, max: 100_000 }, + max_retained_bytes: { min: 1, max: 1_073_741_824 }, + min_interval_seconds: { min: 60, max: 86_400 }, +}); const identity = z.string().uuid(); const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); @@ -17,12 +24,13 @@ const epoch = z.number().int().min(0).max(2_147_483_647); const resourceKey = z.string().min(1).max(48).regex(/^[a-z][a-z0-9_]*$/u) .refine((value) => !['constructor', 'prototype', '__proto__'].includes(value)); +const bounded = (range: { min: number; max: number }) => z.number().int().min(range.min).max(range.max); export const AppResourceSyncConsentLimitsSchema = z.strictObject({ - max_records_per_page: z.number().int().min(1).max(100), - max_page_bytes: z.number().int().min(1).max(524_288), - max_retained_records: z.number().int().min(1).max(100_000), - max_retained_bytes: z.number().int().min(1).max(1_073_741_824), - min_interval_seconds: z.number().int().min(60).max(86_400), + max_records_per_page: bounded(APP_RESOURCE_SYNC_LIMIT_BOUNDS.max_records_per_page), + max_page_bytes: bounded(APP_RESOURCE_SYNC_LIMIT_BOUNDS.max_page_bytes), + max_retained_records: bounded(APP_RESOURCE_SYNC_LIMIT_BOUNDS.max_retained_records), + max_retained_bytes: bounded(APP_RESOURCE_SYNC_LIMIT_BOUNDS.max_retained_bytes), + min_interval_seconds: bounded(APP_RESOURCE_SYNC_LIMIT_BOUNDS.min_interval_seconds), }); export const AppResourceSyncConsentRequestSchema = z.strictObject({ diff --git a/apps/api/src/lib/app-resource-sync-reviewed.ts b/apps/api/src/lib/app-resource-sync-reviewed.ts index a3dd6509..1c7f797d 100644 --- a/apps/api/src/lib/app-resource-sync-reviewed.ts +++ b/apps/api/src/lib/app-resource-sync-reviewed.ts @@ -13,7 +13,7 @@ const stale = () => new AppError('Reviewed App resource authority changed', 'APP * owner/operator before this reader, and separately check resource consent, * registration, session and Run intent. A reviewed App never grants a read. */ export async function loadReviewedResourceSyncDescriptor( - tx: AppRunTransaction, orgId: string, installationId: string, resourceKey: string, + tx: AppRunTransaction, orgId: string, installationId: string, resourceKey?: string, ) { const [installation] = await tx.select().from(appInstallations).where(and( eq(appInstallations.org_id, orgId), eq(appInstallations.id, installationId), @@ -30,7 +30,8 @@ export async function loadReviewedResourceSyncDescriptor( let manifest: ReturnType; try { manifest = parseResourceAppManifest(version.manifest); } catch { throw stale(); } - const descriptor = manifest.sync_descriptors.find((item) => item.key === resourceKey); + const descriptor = resourceKey === undefined ? manifest.sync_descriptors[0] + : manifest.sync_descriptors.find((item) => item.key === resourceKey); if (!descriptor) throw stale(); const [grant] = await tx.select().from(appGrantSnapshots).where(and( eq(appGrantSnapshots.org_id, orgId), eq(appGrantSnapshots.app_installation_id, installation.id), @@ -72,5 +73,6 @@ export async function loadReviewedResourceSyncDescriptor( classification, review_digest: stored.review_digest }; if (digestAppGrantValue(expected) !== grant.snapshot_digest) throw stale(); const descriptorDigest = await digestResourceSyncDescriptor(descriptor); - return { installation, version, grant, descriptor, descriptor_digest: descriptorDigest }; + return { installation, version, grant, descriptor, descriptor_digest: descriptorDigest, + descriptors: manifest.sync_descriptors }; } diff --git a/apps/api/src/routes/app-resource-sync-management.ts b/apps/api/src/routes/app-resource-sync-management.ts index 00d5271b..e781d7a8 100644 --- a/apps/api/src/routes/app-resource-sync-management.ts +++ b/apps/api/src/routes/app-resource-sync-management.ts @@ -84,6 +84,15 @@ export function createAppResourceSyncManagementRoutes(options: { catch (error) { return failure(c, error); } await next(); }); + routes.get('/setup', async (c) => { + try { + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization')); + const entries = [...new URL(c.req.url).searchParams.entries()]; + if (new Set(entries.map(([key]) => key)).size !== entries.length) throw new SyntaxError('Duplicate query'); + return c.json({ setup: await (await options.management()).setupContext(actor, + Object.fromEntries(entries), guard) }); + } catch (error) { return failure(c, error); } + }); routes.post('/reviews/prepare', async (c) => { try { z.strictObject({}).parse(c.req.query()); diff --git a/apps/api/test/app-resource-sync-setup-http-db.test.ts b/apps/api/test/app-resource-sync-setup-http-db.test.ts new file mode 100644 index 00000000..9b5ac2d3 --- /dev/null +++ b/apps/api/test/app-resource-sync-setup-http-db.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { ServerType } from '@hono/node-server'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!target || target !== process.env.DATABASE_URL) return false; + try { const u = new URL(target); return ['postgres:', 'postgresql:'].includes(u.protocol) + && u.hostname === '127.0.0.1' && u.port === '55435' + && /^\/gate_g_20260926_setup_test$/.test(u.pathname) + && !u.search && !u.hash; } catch { return false; } +})(); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`management-http:${purpose}`).digest('base64') } }); +const keyring = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('mgmt-enc'), receipt_signing: ring('mgmt-sign'), fingerprint: ring('mgmt-fp') }); +process.env.DEFT_APP_RUN_KEYRINGS = keyring; +after(async () => { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + await (await import('../src/lib/db.js')).closeDb(); +}); + +async function harness() { + const [{ db }, schema, drizzle, session, keysModule, routes, hono, serverModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-keyrings.js'), + import('../src/routes/app-resource-sync-management.js'), import('hono'), import('@hono/node-server'), + ]); + const keys = keysModule.parseEnvironmentAppRunKeyrings(keyring); + const fixture = await createReviewedResourceSyncFixture({ keys, clock: () => new Date() }); + const app = new hono.Hono(); + app.route('/manage', routes.createAppResourceSyncManagementRoutes({ management: async () => fixture.management })); + let server!: ServerType; + const base = await new Promise((resolve) => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, (info) => { + resolve(`http://127.0.0.1:${info.port}/manage`); + }); + }); + const token = async (id: string, orgId = fixture.org_id) => { + const [user] = await db.select().from(schema.users).where(drizzle.eq(schema.users.id, id)); + return session.createWebSession({ id, org_id: orgId, email: user!.email }); + }; + const owner = await token(fixture.owner_user_id); + const operator = await token(fixture.operator_user_id); + const call = async (path: string, method = 'GET', value?: unknown, bearer = owner.accessToken) => { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), + ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + return { status: response.status, body: await response.json() as any }; + }; + return { db, schema, ...drizzle, ...fixture, owner, operator, call, token, session, base, + close: async () => { await new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); keys.destroy(); } }; +} + +test('setup HTTP publishes self consent pins, recovers activation loss and requires expired consent revocation', { skip: !safe }, async () => { + const h = await harness(); + try { + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'false'; + const path = `/setup?installation_id=${h.consent_request.installation_id}`; + const result = await h.call(path); + assert.equal(result.status, 200); + const setup = result.body.setup; + assert.equal(setup.schema_version, 'deft.app_resource_sync_setup.v1'); + assert.equal(setup.owner_user_id, h.owner_user_id); + assert.equal(setup.operator_user_id, h.owner_user_id); + assert.equal(setup.descriptors.length, 1); + const descriptor = setup.descriptors[0]; + assert.equal(descriptor.visibility, 'user_private'); + assert.equal(descriptor.existing_binding.binding_id, h.binding_id); + assert.equal(descriptor.existing_binding.requires_revoke, false); + assert.equal(descriptor.existing_binding.can_issue_session, false, 'another operator credential is never offered'); + const request = descriptor.consent_request; + assert.equal(request.operator_user_id, h.owner_user_id); + assert.equal(request.expected_grant_snapshot_digest, h.consent_request.expected_grant_snapshot_digest); + for (const [key, value] of Object.entries(request.limits)) { + assert.ok(Number(value) >= setup.host_limits.limits[key].min); + assert.ok(Number(value) <= setup.host_limits.limits[key].max); + } + for (const secret of ['canonical_snapshot', 'record_schema', 'cursor_hmac', 'session_token', 'token_hash', 'provider_snapshot_id']) { + assert.ok(!JSON.stringify(setup).includes(secret)); + } + assert.equal((await h.call(`/bindings/${h.binding_id}/revoke`, 'POST')).status, 200); + const shortRequest = { ...request, consent_expires_at: new Date(Date.now() + 3000).toISOString() }; + const shortReview = await h.call('/reviews/prepare', 'POST', shortRequest); + assert.equal(shortReview.status, 200); + const shortActivation = await h.call('/bindings/activate', 'POST', { ...shortRequest, + expected_review_digest: shortReview.body.review.review_digest, accept_host_policy: true }); + assert.equal(shortActivation.status, 201); + await new Promise(resolve => setTimeout(resolve, + Math.max(0, new Date(shortRequest.consent_expires_at).getTime() - Date.now() + 20))); + assert.equal((await h.call(path)).body.setup.descriptors[0].existing_binding.requires_revoke, true); + const review = await h.call('/reviews/prepare', 'POST', request); + assert.equal(review.status, 200); + const activation = { ...request, expected_review_digest: review.body.review.review_digest, accept_host_policy: true }; + assert.equal((await h.call('/bindings/activate', 'POST', activation)).status, 409); + assert.equal((await h.call(`/bindings/${shortActivation.body.binding.binding_id}/revoke`, 'POST')).status, 200); + assert.equal((await h.call(path)).body.setup.descriptors[0].existing_binding, null); + const activated = await h.call('/bindings/activate', 'POST', activation); + assert.equal(activated.status, 201); + assert.equal((await h.call('/bindings/activate', 'POST', activation)).status, 409); + const recovered = (await h.call(path)).body.setup.descriptors[0].existing_binding; + assert.equal(recovered.binding_id, activated.body.binding.binding_id); + assert.equal(recovered.can_issue_session, true); + assert.equal((await h.call(`/bindings/${recovered.binding_id}/sessions`, 'POST')).status, 201); + const stale = { ...request, expected_grant_epoch: request.expected_grant_epoch + 1 }; + assert.equal((await h.call('/reviews/prepare', 'POST', stale)).status, 409); + const apps = await import('../src/lib/app-service.js'); + await apps.disableAppInstallation(h.owner_actor, request.installation_id, request.expected_lifecycle_epoch); + assert.equal((await h.call('/reviews/prepare', 'POST', request)).status, 409, + 'actual authority change invalidates previously discovered pins'); + assert.equal((await h.call('/bindings/activate', 'POST', activation)).status, 409); + assert.equal((await h.call(path)).status, 409); + } finally { await h.close(); } +}); + +test('setup HTTP denies non-manager/foreign identity and validates exact query under default-off', { skip: !safe }, async () => { + const h = await harness(); + try { + const path = `/setup?installation_id=${h.consent_request.installation_id}`; + assert.equal((await h.call(path, 'GET', undefined, h.operator.accessToken)).status, 403); + for (const suffix of ['&owner_user_id=' + h.owner_user_id, '&installation_id=' + h.consent_request.installation_id]) { + assert.equal((await h.call(path + suffix)).status, 400); + } + assert.equal((await h.call('/setup')).status, 400); + assert.equal((await h.call(`/setup?installation_id=${randomUUID()}`)).status, 409); + const foreignOrg = randomUUID(); + await h.db.insert(h.schema.orgs).values({ id: foreignOrg, name: 'foreign', slug: `foreign-${foreignOrg}` }); + await h.db.insert(h.schema.orgMembers).values({ org_id: foreignOrg, user_id: h.owner_user_id, role: 'owner', is_active: true }); + const foreign = await h.token(h.owner_user_id, foreignOrg); + assert.equal((await h.call(path, 'GET', undefined, foreign.accessToken)).status, 409); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + assert.equal((await h.call(path)).status, 503); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + await h.db.update(h.schema.orgMembers).set({ role: 'guest' }).where(h.and( + h.eq(h.schema.orgMembers.org_id, h.org_id), h.eq(h.schema.orgMembers.user_id, h.owner_user_id))); + assert.equal((await h.call(path)).status, 403); + } finally { process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; await h.close(); } +}); + +test('setup HTTP rechecks SID expiry and human kind after a real final SID lock wait', { skip: !safe }, async () => { + for (const change of ['expiry', 'kind'] as const) { + const h = await harness(); + let release = () => {}; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + let blockerPid = 0; + const blocker = h.db.transaction(async tx => { + blockerPid = (await tx.execute(h.sql<{ pid: number }>`SELECT pg_backend_pid() AS pid`)).rows[0]!.pid; + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE org_id = ${h.org_id} + AND user_id = ${h.owner_user_id} FOR UPDATE`); + acquired(); await released; + if (change === 'expiry') await tx.update(h.schema.webSessions).set({ expires_at: new Date(Date.now() - 1000) }) + .where(h.and(h.eq(h.schema.webSessions.org_id, h.org_id), h.eq(h.schema.webSessions.user_id, h.owner_user_id))); + }); + await locked; + const pending = h.call(`/setup?installation_id=${h.consent_request.installation_id}`); + let waited = false; + for (let i = 0; i < 250; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting + FROM pg_stat_activity WHERE datname=current_database() AND ${blockerPid} = ANY(pg_blocking_pids(pid))`); + if (result.rows[0]!.waiting > 0) { waited = true; break; } + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.ok(waited, 'setup request waited on exact SID lock'); + if (change === 'kind') await h.db.update(h.schema.users).set({ kind: 'agent' }) + .where(h.eq(h.schema.users.id, h.owner_user_id)); + release(); await blocker; + const result = await pending; + assert.equal(result.status, change === 'expiry' ? 401 : 403); + assert.equal(result.body.setup, undefined); + } finally { release(); await h.close(); } + } +}); + From 27c788283b1ec3410bf8c2a6e740a8c15ae39ca0 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:59:49 +0530 Subject: [PATCH 046/161] feat(resources): resolve owner-private runtime display safely --- apps/api/src/lib/app-resource-private-read.ts | 40 ++- .../lib/app-resource-sync-web-authority.ts | 7 +- .../src/lib/app-runtime-resource-display.ts | 27 ++ apps/api/src/lib/native-resource-service.ts | 8 +- apps/api/src/routes/resources.ts | 2 +- .../test/app-resource-resolve-http.test.ts | 273 ++++++++++++++++++ ...esource-authorization-architecture.test.ts | 6 +- 7 files changed, 356 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/lib/app-runtime-resource-display.ts create mode 100644 apps/api/test/app-resource-resolve-http.test.ts diff --git a/apps/api/src/lib/app-resource-private-read.ts b/apps/api/src/lib/app-resource-private-read.ts index a952e1cb..83cbdf4e 100644 --- a/apps/api/src/lib/app-resource-private-read.ts +++ b/apps/api/src/lib/app-resource-private-read.ts @@ -1,7 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { and, asc, eq, gt, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { appResourceProjections, appSyncCheckpoints } from '@deft/db/schema'; +import { appResourceBindings, appResourceProjections, appSyncCheckpoints } from '@deft/db/schema'; import { parseSyncPage } from '@deft/app-kit/experimental/resource-sync'; import { AppRuntimeResourceRefV2Schema, canonicalCapabilityJson } from '@deft/shared'; import type { ResourceRefV2 } from '@deft/shared'; @@ -113,9 +113,45 @@ export class AppResourcePrivateReadService { }); } - async #read(subject: AppResourcePrivateReadSubject, bindingId: string, + /** Canonical host display only. Locators nominate authority; they never grant it. */ + async resolveOwnerPrivateDisplay(rawSubject: AppResourcePrivateReadSubject, + rawRef: unknown): Promise> { + const subject = subjectSchema.safeParse(rawSubject); + const ref = AppRuntimeResourceRefV2Schema.safeParse(rawRef); + if (!subject.success) throw invalid(); + if (!ref.success || !uuid.safeParse(ref.data.resource_id).success + || !uuid.safeParse(ref.data.provider.provider_instance_id).success) throw unavailable(); + return this.#read(subject.data, async tx => { + const [locator] = await tx.select({ binding_id: appResourceBindings.id }) + .from(appResourceProjections).innerJoin(appResourceBindings, and( + eq(appResourceBindings.org_id, appResourceProjections.org_id), + eq(appResourceBindings.id, appResourceProjections.resource_binding_id))) + .where(and(eq(appResourceProjections.org_id, subject.data.org_id), + eq(appResourceProjections.id, ref.data.resource_id), + eq(appResourceBindings.runtime_registration_id, ref.data.provider.provider_instance_id), + eq(appResourceBindings.resource_family, ref.data.resource_type), + eq(appResourceBindings.owner_user_id, subject.data.user_id))).limit(1); + if (!locator) throw unavailable(); + return locator.binding_id; + }, async (tx, authority, checkpoint) => { + // Recheck the exact locator against locked, live authority after any wait. + if (authority.registration.id !== ref.data.provider.provider_instance_id.toLowerCase() + || authority.descriptor.resource_type !== ref.data.resource_type) throw unavailable(); + const [row] = await tx.select().from(appResourceProjections).where(and( + ...this.#scope(subject.data.org_id, authority.binding.id, checkpoint), + eq(appResourceProjections.id, ref.data.resource_id), + )).limit(1); + if (!row) throw unavailable(); + // Do not export provider revision, arbitrary fields, cursor, or checkpoint. + return { label: this.#record(row, authority).label }; + }); + } + + async #read(subject: AppResourcePrivateReadSubject, + bindingLocator: string | ((tx: AppRunTransaction) => Promise), read: (tx: AppRunTransaction, authority: Authority, checkpoint: Checkpoint) => Promise): Promise { return this.repository.transaction(async (tx) => { + const bindingId = typeof bindingLocator === 'string' ? bindingLocator : await bindingLocator(tx); const authority = await loadLiveResourceSyncBindingAuthority(tx, { org_id: subject.org_id, resource_binding_id: bindingId, clock: this.clock, }); diff --git a/apps/api/src/lib/app-resource-sync-web-authority.ts b/apps/api/src/lib/app-resource-sync-web-authority.ts index cfedcc45..10a57e49 100644 --- a/apps/api/src/lib/app-resource-sync-web-authority.ts +++ b/apps/api/src/lib/app-resource-sync-web-authority.ts @@ -14,12 +14,17 @@ export class ResourceSyncWebAuthenticationError extends Error { /** Only the exact web-access bearer purpose is accepted; context-injected human, * Employee, personal MCP, app developer and Runtime identities confer no authority. */ -export async function resourceSyncWebAuthority(authorization: string | undefined) { +export async function resourceSyncWebAuthority(authorization: string | undefined, + expectedSession?: Readonly<{ org_id: string; user_id: string; sid: string }>) { const match = /^Bearer ([^\s]+)$/u.exec(authorization ?? ''); if (!match) throw new ResourceSyncWebAuthenticationError('Web authentication required'); let user: Awaited>; try { user = await verifyWebAccess(match[1]!); } catch { throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); } + if (expectedSession && (user.org_id !== expectedSession.org_id + || user.id !== expectedSession.user_id || user.sid !== expectedSession.sid)) { + throw new ResourceSyncWebAuthenticationError('Invalid or expired web session'); + } const [human] = await db.select({ kind: users.kind }).from(users).where(eq(users.id, user.id)); if (human?.kind !== 'human') throw new AppError('Private resource sync access denied', 'APP_ACCESS_DENIED', 403); const actor = humanModuleActor({ orgId: user.org_id, userId: user.id, diff --git a/apps/api/src/lib/app-runtime-resource-display.ts b/apps/api/src/lib/app-runtime-resource-display.ts new file mode 100644 index 00000000..8c14405b --- /dev/null +++ b/apps/api/src/lib/app-runtime-resource-display.ts @@ -0,0 +1,27 @@ +import type { ResourceRefV2 } from '@deft/shared/resources-v2'; +import { AppError } from './app-errors.js'; +import { AppResourcePrivateReadError, AppResourcePrivateReadService } from './app-resource-private-read.js'; +import { getAppRunRuntime } from './app-run-runtime.js'; +import { resourceSyncWebAuthority, ResourceSyncWebAuthenticationError } from './app-resource-sync-web-authority.js'; +import { isAppResourceSyncChannelEnabled } from './env.js'; +import type { NativeResourceWebCaller } from './native-resource-service.js'; +import { ResourceAuthorizationError } from './resource-authorization.js'; + +/** Closed host adapter, never selected from App-supplied code or a provider URL. */ +export async function resolveAppRuntimeDisplay(caller: NativeResourceWebCaller, ref: ResourceRefV2, + authorization: string | undefined): Promise | null> { + if (!isAppResourceSyncChannelEnabled()) return null; + try { + const { actor, guard } = await resourceSyncWebAuthority(authorization, caller); + const runtime = await getAppRunRuntime(); + const reader = new AppResourcePrivateReadService(runtime.keys, () => new Date(), runtime.repository, guard); + return await reader.resolveOwnerPrivateDisplay({ kind: 'human', org_id: actor.org_id, + user_id: actor.actor_id }, ref); + } catch (error) { + if (error instanceof AppResourcePrivateReadError && error.code === 'APP_RESOURCE_PRIVATE_UNAVAILABLE') return null; + if (error instanceof ResourceSyncWebAuthenticationError || error instanceof AppError) { + throw new ResourceAuthorizationError('Resource access denied', 'RESOURCE_ACCESS_DENIED', 403); + } + throw error; + } +} diff --git a/apps/api/src/lib/native-resource-service.ts b/apps/api/src/lib/native-resource-service.ts index 7c34b092..a602fb9e 100644 --- a/apps/api/src/lib/native-resource-service.ts +++ b/apps/api/src/lib/native-resource-service.ts @@ -10,6 +10,7 @@ import type { NativeResourceDisplay, NativeResourceSubject } from './native-reso import { resolveNativeMessageDisplay, resolveNativeWikiDisplay, resolveNativeNoteDisplay } from './native-content-projections.js'; import { resolveNativeCalendarDisplay, resolveNativeFileDisplay } from './native-calendar-file-projections.js'; import { resolveNativePersonDisplay, resolveNativeTeamDisplay } from './native-directory-projections.js'; +import { resolveAppRuntimeDisplay } from './app-runtime-resource-display.js'; const callerSchema = z.strictObject({ org_id: ResourceHostOrganizationIdSchema, user_id: ResourceOpaqueIdSchema, sid: z.string().uuid() }); @@ -58,7 +59,8 @@ async function nativeDisplay(subject: NativeResourceSubject, ref: ResourceRefV2) /** Web reads do not confer an Experience grant or a Runtime viewer credential. */ export class NativeResourceService { - async resolve(callerValue: NativeResourceWebCaller, refValue: unknown): Promise { + async resolve(callerValue: NativeResourceWebCaller, refValue: unknown, + authorization?: string): Promise { const caller = callerSchema.safeParse(callerValue); if (!caller.success) throw denied(); const parsed = ResourceRefV2Schema.safeParse(refValue); @@ -68,7 +70,9 @@ export class NativeResourceService { const ref = parsed.data; try { const subject = await liveSubject(caller.data); - const display = await nativeDisplay(subject, ref); + const display: NativeResourceDisplay | null = ref.provider.kind === 'app_runtime' + ? await resolveAppRuntimeDisplay(caller.data, ref, authorization) + : await nativeDisplay(subject, ref); const current = await liveSubject(caller.data); // A role change during a private-team read cannot retain the old role's result. if (current.role !== subject.role) throw denied(); diff --git a/apps/api/src/routes/resources.ts b/apps/api/src/routes/resources.ts index 246d8179..5ec46830 100644 --- a/apps/api/src/routes/resources.ts +++ b/apps/api/src/routes/resources.ts @@ -27,7 +27,7 @@ resourceRoutes.get('/resolve', async (c) => { } try { return c.json(await nativeResourceService.resolve( - { org_id: user.org_id, user_id: user.id, sid: user.sid }, ref)); + { org_id: user.org_id, user_id: user.id, sid: user.sid }, ref, c.req.header('authorization'))); } catch (error) { if (error instanceof ResourceAuthorizationError) { return c.json({ error: error.message, code: error.code }, error.status); diff --git a/apps/api/test/app-resource-resolve-http.test.ts b/apps/api/test/app-resource-resolve-http.test.ts new file mode 100644 index 00000000..f9d7c8e4 --- /dev/null +++ b/apps/api/test/app-resource-resolve-http.test.ts @@ -0,0 +1,273 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { AppRunTransaction } from '../src/lib/app-run-repository.js'; +import type { ServerType } from '@hono/node-server'; +import { createReviewedResourceSyncFixture } from './fixtures/resource-sync-v5.js'; + +const target = process.env.DEFT_TEST_DATABASE_URL; +const safe = (() => { + if (!target || target !== process.env.DATABASE_URL) return false; + try { const u = new URL(target); return ['postgres:', 'postgresql:'].includes(u.protocol) + && u.hostname === '127.0.0.1' && u.port === '55435' + && /^\/gate_g_20260926_resource_resolve_test$/.test(u.pathname) + && !u.search && !u.hash; } catch { return false; } +})(); +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +// Deterministic keys are confined to the explicitly named disposable test database. +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`management-http:${purpose}`).digest('base64') } }); +process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('mgmt-enc'), receipt_signing: ring('mgmt-sign'), fingerprint: ring('mgmt-fp') }); +after(async () => { + await (await import('../src/lib/app-run-runtime.js')).shutdownAppRunRuntime(); + await (await import('../src/lib/db.js')).closeDb(); +}); + +async function harness(shortConsentMs?: number) { + const [{ db }, schema, drizzle, webSessions, runtimeModule, privateRoutes, channelRoutes, + managementRoutes, hono, serverModule] = await Promise.all([ + import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('../src/lib/web-sessions.js'), import('../src/lib/app-run-runtime.js'), + import('../src/routes/resources.js'), import('../src/routes/app-resource-sync-channel.js'), + import('../src/routes/app-resource-sync-management.js'), import('hono'), import('@hono/node-server'), + ]); + const runtime = await runtimeModule.getAppRunRuntime(); + const fixture = await createReviewedResourceSyncFixture({ keys: runtime.keys, clock: () => new Date(), + descriptor: { schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', runtime_requirement_key: 'provider', + resource_type: 'email_message', requested_visibility: 'user_private', label_field: 'subject', + record_schema: { type: 'object', properties: { subject: { type: 'string', maxLength: 200 }, + body: { type: 'string', maxLength: 200 } }, required: ['subject', 'body'], additionalProperties: false } } }); + const token = async (id: string, orgId = fixture.org_id) => { + const [user] = await db.select().from(schema.users).where(drizzle.eq(schema.users.id, id)); + return webSessions.createWebSession({ id, org_id: orgId, email: user!.email }); + }; + const owner = await token(fixture.owner_user_id); + const operator = await token(fixture.operator_user_id); + const app = new hono.Hono(); + const { authMiddleware } = await import('../src/middleware/auth.js'); + app.use('/resolve', async (c, next) => { + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + await next(); + }); + app.use('/resolve', authMiddleware); + app.route('/', privateRoutes.resourceRoutes); + app.route('/sync', channelRoutes.appResourceSyncChannelRoutes); + app.route('/manage', managementRoutes.appResourceSyncManagementRoutes); + let server!: ServerType; + const base = await new Promise(resolve => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, + info => resolve(`http://127.0.0.1:${info.port}`)); + }); + const call = async (path: string, auth = `Bearer ${owner.accessToken}`, method = 'GET', value?: unknown) => { + const response = await fetch(`${base}${path}`, { method, + headers: { ...(auth ? { Authorization: auth } : {}), + ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + if (path.startsWith('/resolve')) assert.equal(response.headers.get('pragma'), 'no-cache'); + return { status: response.status, body: await response.json() as any }; + }; + let bindingId = fixture.binding_id; + let registrationId = fixture.registration_id; + let expiresAt = fixture.consent_request.consent_expires_at; + if (shortConsentMs) { + await fixture.management.revokeConsent(fixture.owner_actor, fixture.binding_id); + const request = { ...fixture.consent_request, consent_expires_at: new Date(Date.now() + shortConsentMs).toISOString() }; + const review = await fixture.management.prepareConsent(fixture.owner_actor, request); + const activated = await fixture.management.activateConsent(fixture.owner_actor, { ...request, + expected_review_digest: review.review_digest, accept_host_policy: true }); + bindingId = activated.binding_id; + registrationId = activated.registration_id; + expiresAt = request.consent_expires_at; + } + const issued = await call(`/manage/bindings/${bindingId}/sessions`, `Bearer ${operator.accessToken}`, 'POST'); + assert.equal(issued.status, 201); + const session = issued.body.session; + const admitted = await runtime.resourceSyncAdmission.admitDue({ org_id: fixture.org_id, resource_binding_id: bindingId }); + assert.equal(admitted.state, 'created'); + const identity = { schema_version: 'deft.app_runtime_channel.v2', audience: 'app_resource_sync', session_id: session.session_id }; + const runtimeAuth = `AppRuntime ${session.session_token}`; + const claimed = await call('/sync/claim', runtimeAuth, 'POST', { ...identity, max_claims: 1 }); + assert.equal(claimed.status, 200); + const claim = claimed.body.claim; + assert.ok(claim); + const attempt = { ...identity, run_id: claim.run_id, attempt_id: claim.attempt_id, + claim_token: claim.claim_token, sequence: claim.sequence }; + assert.equal((await call('/sync/start', runtimeAuth, 'POST', attempt)).status, 200); + const completed = await call('/sync/result', runtimeAuth, 'POST', { ...attempt, + status: 'returned', provider_succeeded: true, page: { schema_version: 'deft.app_sync_page.v1', + upserts: Array.from({ length: 3 }, (_, i) => ({ id: `provider-private-${i}`, revision: `r${i}`, + data: { subject: `Private HTTP message ${i}`, body: `Secret body ${i}` } })), tombstones: [], + next_cursor: 'provider-private-cursor', has_more: false } }); + assert.equal(completed.status, 200); + assert.equal(completed.body.accepted, true); + const marker = `private-http-${fixture.org_id}`; + const originalTransaction = runtime.repository.transaction.bind(runtime.repository); + runtime.repository.transaction = (work: (tx: AppRunTransaction) => Promise) => originalTransaction(async tx => { + await tx.execute(drizzle.sql`SELECT set_config('application_name', ${marker}, true)`); + return work(tx); + }); + return { db, schema, ...drizzle, ...fixture, binding_id: bindingId, registration_id: registrationId, expires_at: expiresAt, marker, + owner, operator, runtime, webSessions, call, token, runtimeAuth, runtime_session: session, + close: () => { runtime.repository.transaction = originalTransaction; + return new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())); } }; +} + +async function waitForLock(h: Awaited>, table: string) { + for (let i = 0; i < 250; i++) { + const result = await h.db.execute(h.sql<{ waiting: number }>`SELECT count(*)::int AS waiting FROM pg_stat_activity + WHERE datname=current_database() AND pid <> pg_backend_pid() AND wait_event_type='Lock' + AND application_name = ${h.marker} + AND query LIKE ${`%${table}%`}`); + if (result.rows[0]!.waiting > 0) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.fail(`actual HTTP request did not wait on ${table}`); +} + +async function reference(h: Awaited>) { + const [projection] = await h.db.select().from(h.schema.appResourceProjections) + .where(h.eq(h.schema.appResourceProjections.resource_binding_id, h.binding_id)); + return { schema_version: 'deft.resource_ref.v2', provider: { kind: 'app_runtime', + provider_instance_id: h.registration_id }, resource_type: 'email_message', resource_id: projection!.id }; +} +const resolvePath = (ref: unknown) => `/resolve?ref=${encodeURIComponent(JSON.stringify(ref))}`; + +test('canonical resolver returns only the owner safe display from a settled private resource', { skip: !safe }, async () => { + const h = await harness(); + try { + const ref = await reference(h); + const result = await h.call(resolvePath(ref)); + assert.equal(result.status, 200); + assert.equal(result.body.state, 'available'); + assert.deepEqual(result.body.ref, ref); + assert.deepEqual(Object.keys(result.body.resource).sort(), ['label', 'ref', 'schema_version']); + assert.match(result.body.resource.label, /^Private HTTP message [0-2]$/); + const serialized = JSON.stringify(result.body); + for (const secret of ['Secret body', 'provider-private', 'cursor', 'ciphertext', 'data', + h.binding_id, h.owner_user_id, h.operator_user_id, h.runtime_session.session_token]) { + assert.ok(!serialized.includes(secret), `safe projection leaked ${secret}`); + } + } finally { await h.close(); } +}); + +test('canonical private locator denies foreign and mismatched authority without distinguishing existence', { skip: !safe }, async () => { + const h = await harness(); + try { + const ref = await reference(h); + const unavailable = async (locator = ref, auth?: string) => { + const result = await h.call(resolvePath(locator), auth); + assert.deepEqual(result, { status: 200, body: { schema_version: 'deft.resource_resolve.v2', + ref: locator, state: 'unavailable' } }); + }; + await unavailable(ref, `Bearer ${h.operator.accessToken}`); + await h.db.update(h.schema.orgMembers).set({ role: 'admin' }).where(h.and( + h.eq(h.schema.orgMembers.org_id, h.org_id), h.eq(h.schema.orgMembers.user_id, h.operator_user_id))); + const admin = await h.token(h.operator_user_id); + await unavailable(ref, `Bearer ${admin.accessToken}`); + const foreignOrg = randomUUID(); + await h.db.insert(h.schema.orgs).values({ id: foreignOrg, name: 'foreign', slug: `resolve-${foreignOrg}` }); + await h.db.insert(h.schema.orgMembers).values({ org_id: foreignOrg, user_id: h.owner_user_id, role: 'owner', is_active: true }); + const foreign = await h.token(h.owner_user_id, foreignOrg); + await unavailable(ref, `Bearer ${foreign.accessToken}`); + await unavailable({ ...ref, resource_type: 'other_resource' }); + await unavailable({ ...ref, resource_id: randomUUID() }); + await unavailable({ ...ref, resource_id: 'not-a-host-uuid' }); + await unavailable({ ...ref, provider: { ...ref.provider, provider_instance_id: randomUUID() } }); + await unavailable({ ...ref, provider: { ...ref.provider, provider_instance_id: 'not-a-host-uuid' } }); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + await unavailable(); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + assert.equal((await h.call(resolvePath(ref))).body.state, 'available'); + await h.management.revokeConsent(h.owner_actor, h.binding_id); + await unavailable(); + } finally { process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; await h.close(); } +}); + +test('canonical private resolver fences durable tombstones and paused checkpoints', { skip: !safe }, async () => { + const h = await harness(); + try { + const ref = await reference(h); + // Inject durable record state, never authority, through the DB's enforced transition. + await h.db.update(h.schema.appResourceProjections).set({ state: 'tombstone', + tombstoned_at: new Date(), applied_sequence: 2, body_bytes: 0, body_envelope_version: null, + body_algorithm: null, body_key_version: null, body_nonce_b64: null, + body_ciphertext_b64: null, body_auth_tag_b64: null }) + .where(h.eq(h.schema.appResourceProjections.id, ref.resource_id)); + assert.equal((await h.call(resolvePath(ref))).body.state, 'unavailable'); + const [other] = await h.db.select().from(h.schema.appResourceProjections).where(h.and( + h.eq(h.schema.appResourceProjections.resource_binding_id, h.binding_id), + h.eq(h.schema.appResourceProjections.state, 'live'))); + const otherRef = { ...ref, resource_id: other!.id }; + assert.equal((await h.call(resolvePath(otherRef))).body.state, 'available'); + await h.db.update(h.schema.appSyncCheckpoints).set({ state: 'paused' }) + .where(h.eq(h.schema.appSyncCheckpoints.id, h.checkpoint_id)); + assert.equal((await h.call(resolvePath(otherRef))).body.state, 'unavailable'); + } finally { await h.close(); } +}); + +test('canonical resolver rejects a different current SID and bearer purpose', { skip: !safe }, async () => { + const h = await harness(); + try { + const ref = await reference(h); + for (const auth of ['', h.runtimeAuth, `Bearer ${h.runtime_session.session_token}`, + `Bearer ${h.owner.refreshToken}`, 'Bearer synthetic-personal-mcp']) { + // The authenticated route harness rejects non-web bearer purposes before resolution. + const denied = await h.call(resolvePath(ref), auth); + assert.equal(denied.status, 401); + assert.ok(!JSON.stringify(denied.body).includes('Private HTTP')); + } + const current = await h.webSessions.verifyWebAccess(h.owner.accessToken); + const other = await h.token(h.owner_user_id); + const { NativeResourceService } = await import('../src/lib/native-resource-service.js'); + await assert.rejects(new NativeResourceService().resolve({ org_id: h.org_id, user_id: h.owner_user_id, + sid: current.sid }, ref, `Bearer ${other.accessToken}`), + (error: unknown) => (error as { code: string }).code === 'RESOURCE_ACCESS_DENIED'); + } finally { await h.close(); } +}); + +for (const race of ['sid-revoked', 'sid-expired', 'consent-expired', 'operator-nonhuman'] as const) { + test(`canonical resolver discards private display after actual SID lock wait: ${race}`, { skip: !safe }, async () => { + const h = await harness(race === 'consent-expired' ? 10_000 : undefined); + let release = () => {}; + try { + const ref = await reference(h); + const current = await h.webSessions.verifyWebAccess(h.owner.accessToken); + const expiresAt = new Date(Date.now() + 3_000); + if (race === 'sid-expired') await h.db.update(h.schema.webSessions).set({ expires_at: expiresAt }) + .where(h.eq(h.schema.webSessions.id, current.sid)); + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE id = ${current.sid} FOR UPDATE`); + acquired(); await released; + if (race === 'sid-revoked') await tx.update(h.schema.webSessions).set({ revoked_at: new Date() }) + .where(h.eq(h.schema.webSessions.id, current.sid)); + }); + await locked; + const pending = h.call(resolvePath(ref)); + await waitForLock(h, 'web_sessions'); + if (race === 'operator-nonhuman') await h.db.update(h.schema.users).set({ kind: 'agent' }) + .where(h.eq(h.schema.users.id, h.operator_user_id)); + if (race === 'sid-expired' || race === 'consent-expired') { + const deadline = race === 'sid-expired' ? expiresAt.getTime() : new Date(h.expires_at).getTime(); + assert.ok(Date.now() < deadline, 'request reached actual lock before expiry'); + await new Promise(resolve => setTimeout(resolve, deadline - Date.now() + 30)); + } + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, race.startsWith('sid-') ? 403 : 200); + if (!race.startsWith('sid-')) assert.equal(denied.body.state, 'unavailable'); + assert.ok(!JSON.stringify(denied.body).includes('Private HTTP message')); + assert.ok(!JSON.stringify(denied.body).includes('Secret body')); + } finally { release(); await h.close(); } + }); +} + + diff --git a/apps/api/test/resource-authorization-architecture.test.ts b/apps/api/test/resource-authorization-architecture.test.ts index 1ebdbbd5..eb9ab71b 100644 --- a/apps/api/test/resource-authorization-architecture.test.ts +++ b/apps/api/test/resource-authorization-architecture.test.ts @@ -32,9 +32,13 @@ test('Phase 4 Resource authorization stays closed and owns no external effect pa const routeConsumers: string[] = []; const generalizedResourceRoutes: string[] = []; + const consumesV1Resolver = (source: string) => /ResourceAuthorizationService|resourceAuthorizationService|ResourceRefV1/.test(source); + assert.equal(consumesV1Resolver("import { ResourceAuthorizationError } from '../lib/resource-authorization.js';"), false); + assert.equal(consumesV1Resolver('resourceAuthorizationService.resolve(context, ref)'), true); for (const path of await typescriptFiles(join(apiSourceRoot, 'routes'))) { const source = await readFile(path, 'utf8'); - if (/resource-authorization|ResourceAuthorizationService|ResourceRefV1/.test(source)) { + // Sharing the error type with v2 does not make a route a v1 resolver consumer. + if (consumesV1Resolver(source)) { routeConsumers.push(relative(apiSourceRoot, path).replaceAll('\\', '/')); } if (/['"]\/api\/resources(?:\/|['"])/.test(source)) { From d9800b24910802ff15de6bb014405e7ab29dd028 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:01:07 +0530 Subject: [PATCH 047/161] fix: avoid joined row locks during sync setup discovery --- .../src/lib/app-resource-sync-management.ts | 4 ++- .../app-resource-sync-setup-http-db.test.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/api/src/lib/app-resource-sync-management.ts b/apps/api/src/lib/app-resource-sync-management.ts index e971cece..11bcd717 100644 --- a/apps/api/src/lib/app-resource-sync-management.ts +++ b/apps/api/src/lib/app-resource-sync-management.ts @@ -97,7 +97,9 @@ export class AppResourceSyncManagement { eq(appResourceBindings.grant_snapshot_id, grant.id), eq(appResourceBindings.owner_user_id, actor.actor_id), inArray(appResourceBindings.state, ['active', 'disabled']))) - .limit(8).for('share'); + // Advisory locator only: registration/binding authority is rechecked by + // every operation. Joined rowmarks can lock binding before registration. + .limit(8); const descriptors = await Promise.all(reviewed.descriptors.map(async (descriptor) => ({ resource_key: descriptor.key, resource_type: descriptor.resource_type, visibility: descriptor.requested_visibility, diff --git a/apps/api/test/app-resource-sync-setup-http-db.test.ts b/apps/api/test/app-resource-sync-setup-http-db.test.ts index 9b5ac2d3..31d71f57 100644 --- a/apps/api/test/app-resource-sync-setup-http-db.test.ts +++ b/apps/api/test/app-resource-sync-setup-http-db.test.ts @@ -182,3 +182,31 @@ test('setup HTTP rechecks SID expiry and human kind after a real final SID lock } }); +test('setup discovery does not wait on a registration write or lock its binding first', { skip: !safe }, async () => { + const h = await harness(); + let release = () => {}; + let pending: ReturnType | undefined; + let blocker: Promise | undefined; + try { + let acquired!: () => void; + const locked = new Promise(resolve => { acquired = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM app_runtime_registrations WHERE org_id = ${h.org_id} + AND id = ${h.registration_id} FOR UPDATE`); + acquired(); await released; + // A registration-first writer must not encounter a binding lock acquired + // by discovery while discovery waits for this registration. + await tx.execute(h.sql`SELECT id FROM app_resource_bindings WHERE org_id = ${h.org_id} + AND id = ${h.binding_id} FOR UPDATE NOWAIT`); + }); + await locked; + pending = h.call(`/setup?installation_id=${h.consent_request.installation_id}`); + const result = await Promise.race([pending, new Promise(resolve => setTimeout(() => resolve(null), 1500))]); + release(); + await blocker; + assert.ok(result, 'advisory setup discovery must not wait for registration writes'); + assert.equal(result.status, 200); + } finally { release(); await blocker?.catch(() => {}); await pending?.catch(() => {}); await h.close(); } +}); + From 4e3a33ff395ec0191577744922476ac3aff679b3 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:01:39 +0530 Subject: [PATCH 048/161] fix(automations): bound scanner database work and settle cancellation --- .../lib/app-automation-definition-service.ts | 36 ++- apps/api/src/lib/app-automation-runtime.ts | 75 +++--- apps/api/src/lib/app-automation-scan-db.ts | 104 ++++++++ apps/api/src/lib/app-automation-scanner.ts | 37 +-- apps/api/src/lib/db.ts | 3 +- .../workers/handlers/app-automation-scan.ts | 4 +- apps/api/src/workers/index.ts | 6 + .../app-automation-scan-limits-db.test.ts | 231 ++++++++++++++++++ apps/api/test/app-automation-scanner.test.ts | 27 ++ 9 files changed, 469 insertions(+), 54 deletions(-) create mode 100644 apps/api/src/lib/app-automation-scan-db.ts create mode 100644 apps/api/test/app-automation-scan-limits-db.test.ts diff --git a/apps/api/src/lib/app-automation-definition-service.ts b/apps/api/src/lib/app-automation-definition-service.ts index e9d0976d..39fedd2e 100644 --- a/apps/api/src/lib/app-automation-definition-service.ts +++ b/apps/api/src/lib/app-automation-definition-service.ts @@ -771,7 +771,10 @@ export async function persistAppAutomationFire( | Readonly<{ kind: 'dst_gap' }>; terminal_reason?: 'dst_gap' | 'misfire_skipped'; }>, - options: Readonly<{ now?: () => Date }> = {}, + options: Readonly<{ + now?: () => Date; + executor?: Parameters[0]>[0]; + }> = {}, ): Promise { const logicalLocalDate = LogicalLocalDateSchema.parse(input.logical_local_date); if ((input.resolution.kind === 'dst_gap') !== (input.terminal_reason === 'dst_gap')) { @@ -780,7 +783,7 @@ export async function persistAppAutomationFire( if (input.terminal_reason === 'misfire_skipped' && input.resolution.kind !== 'resolved') { invalid('Misfire skips require a resolved UTC occurrence'); } - return db.transaction(async (tx) => { + const persist = async (tx: Parameters[0]>[0]) => { const definition = await getAppAutomationDefinitionWithExecutor( tx, input.organization_id, @@ -791,7 +794,7 @@ export async function persistAppAutomationFire( if (definition.state !== 'active' || definition.definition_epoch !== input.expected_epoch) { stale('App automation definition is not eligible for this fire'); } - const now = (options.now ?? (() => new Date()))(); + let now = (options.now ?? (() => new Date()))(); if (now < definition.valid_from || now >= definition.valid_until) { stale('App automation definition is outside its approved validity window'); } @@ -823,6 +826,18 @@ export async function persistAppAutomationFire( || decision.kind === 'future' || decision.kind === 'not_eligible' ) stale('App automation occurrence is not eligible for the requested fire state'); + const refreshPolicyTime = () => { + now = (options.now ?? (() => new Date()))(); + const currentDecision = classifyAppAutomationOccurrence({ + occurrence: canonicalOccurrence, now, eligible_after: eligibleAfter, + eligible_before: definition.valid_until, catch_up_window_minutes: 15, + }); + if (now < definition.valid_from || now >= definition.valid_until + || currentDecision.kind !== decision.kind + || (currentDecision.kind === 'skipped' && currentDecision.reason !== input.terminal_reason)) { + stale('App automation occurrence changed while waiting for persistence'); + } + }; const fireIdentity = digestAppAutomationFireIdentity({ organization_id: input.organization_id, definition_id: definition.id, @@ -836,17 +851,20 @@ export async function persistAppAutomationFire( fire_identity: fireIdentity, }); if (existing) { + refreshPolicyTime(); if (decision.kind === 'skipped' && decision.reason === 'misfire_skipped' && existing.state === 'pending' && existing.attempt_count === 0) { - return await terminalizeUnclaimedAppAutomationFireMisfireWithExecutor(tx, { + const terminalized = await terminalizeUnclaimedAppAutomationFireMisfireWithExecutor(tx, { organization_id: input.organization_id, definition_id: definition.id, fire_id: existing.id, expected_epoch: definition.definition_epoch, terminal_at: now, }) ?? existing; + refreshPolicyTime(); + return terminalized; } return existing; } @@ -864,7 +882,10 @@ export async function persistAppAutomationFire( stale('App automation pending-fire budget is exhausted'); } } - return insertAppAutomationFireIdempotentlyWithExecutor(tx, { + // Lock acquisition and budget reads can cross expiry or the catch-up edge. + // Re-evaluate policy after those waits rather than persisting stale time. + refreshPolicyTime(); + const created = await insertAppAutomationFireIdempotentlyWithExecutor(tx, { id: randomUUID(), org_id: input.organization_id, definition_id: definition.id, @@ -886,7 +907,10 @@ export async function persistAppAutomationFire( created_at: now, updated_at: now, }); - }); + refreshPolicyTime(); + return created; + }; + return options.executor ? persist(options.executor) : db.transaction(persist); } export function digestAppAutomationFireIdentity(input: Readonly<{ diff --git a/apps/api/src/lib/app-automation-runtime.ts b/apps/api/src/lib/app-automation-runtime.ts index 39063ab3..e167a3a4 100644 --- a/apps/api/src/lib/app-automation-runtime.ts +++ b/apps/api/src/lib/app-automation-runtime.ts @@ -15,7 +15,8 @@ import { terminalizeAppAutomationFireDefinitionIneligibleWithExecutor, terminalizeUnclaimedAppAutomationFireMisfireWithExecutor, } from './app-automation-repository.js'; -import { scanAppAutomations } from './app-automation-scanner.js'; +import { scanAppAutomations, type AppAutomationScannerPort } from './app-automation-scanner.js'; +import { appAutomationScanDatabase, type AppAutomationScanTransaction } from './app-automation-scan-db.js'; import { db } from './db.js'; import { APP_AUTOMATIONS_ENABLED } from './env.js'; import { isAppError } from './app-errors.js'; @@ -29,25 +30,29 @@ const AppAutomationFireJobSchema = z.strictObject({ definition_epoch: z.number().int().min(1), }); -export async function runAppAutomationScan(now = new Date()): Promise { +export async function runAppAutomationScan(now = new Date(), signal?: AbortSignal): Promise { if (!APP_AUTOMATIONS_ENABLED) return; - const result = await scanAppAutomations({ - listEligibleDefinitions: (eligibleAt, limit, after) => ( - listEligibleAppAutomationDefinitionsWithExecutor(db, { - eligible_at: eligibleAt, - limit, - after, - }) - ), - listExpiredClaims: (scanAt, limit, after) => ( - listExpiredClaimedAppAutomationFiresWithExecutor(db, { now: scanAt, limit, after }) - ), - reconcileExpiredClaim: (fire, recoveredAt) => db.transaction(async (tx) => { + const started = performance.now(); + const currentTime = () => new Date(now.getTime() + performance.now() - started); + const transaction = (run: (tx: AppAutomationScanTransaction) => Promise) => ( + appAutomationScanDatabase().transaction(run, signal) + ); + const reconcileExpiredClaim: AppAutomationScannerPort['reconcileExpiredClaim'] = (fire) => ( + transaction(async (tx) => { const definition = await getAppAutomationDefinitionWithExecutor( tx, fire.org_id, fire.definition_id, + { lock: true }, + ); + // Read policy time only after both mutable rows are locked. Reusing this + // path for occurrence recovery avoids passing time captured before a wait. + const currentFire = await getAppAutomationFireWithExecutor( + tx, fire.org_id, fire.definition_id, fire.id, { lock: true }, ); + if (!currentFire || currentFire.state !== 'claimed' + || currentFire.claim_token !== fire.claim_token) return null; + const recoveredAt = currentTime(); if (!definition || definition.state !== 'active' || definition.definition_epoch !== fire.definition_epoch @@ -63,7 +68,7 @@ export async function runAppAutomationScan(now = new Date()): Promise { terminal_at: recoveredAt, }); } - return recoverExpiredAppAutomationFireClaimWithExecutor(tx, { + const recovered = await recoverExpiredAppAutomationFireClaimWithExecutor(tx, { organization_id: fire.org_id, definition_id: fire.definition_id, fire_id: fire.id, @@ -71,10 +76,27 @@ export async function runAppAutomationScan(now = new Date()): Promise { expected_claim_token: fire.claim_token!, recovered_at: recoveredAt, }); - }), - ensureFire: async (input, createdAt) => { + if (currentTime() >= definition.valid_until) { + throw new Error('App automation definition expired during claim recovery'); + } + return recovered; + }) + ); + const result = await scanAppAutomations({ + listEligibleDefinitions: (eligibleAt, limit, after) => ( + transaction(tx => listEligibleAppAutomationDefinitionsWithExecutor(tx, { + eligible_at: eligibleAt, + limit, + after, + })) + ), + listExpiredClaims: (scanAt, limit, after) => ( + transaction(tx => listExpiredClaimedAppAutomationFiresWithExecutor(tx, { now: scanAt, limit, after })) + ), + reconcileExpiredClaim, + ensureFire: async (input) => { try { - return await persistAppAutomationFire(input, { now: () => createdAt }); + return await transaction(tx => persistAppAutomationFire(input, { now: currentTime, executor: tx })); } catch (error) { if (isAppError(error) && (error.code === 'APP_STALE' || error.code === 'APP_NOT_FOUND')) { return null; @@ -82,17 +104,8 @@ export async function runAppAutomationScan(now = new Date()): Promise { throw error; } }, - recoverFire: (fire, recoveredAt) => db.transaction((tx) => ( - recoverExpiredAppAutomationFireClaimWithExecutor(tx, { - organization_id: fire.org_id, - definition_id: fire.definition_id, - fire_id: fire.id, - expected_epoch: fire.definition_epoch, - expected_claim_token: fire.claim_token!, - recovered_at: recoveredAt, - }) - )), - deliverFire: (fire, chargedAt) => db.transaction(async (tx) => { + recoverFire: reconcileExpiredClaim, + deliverFire: (fire) => transaction(async (tx) => { const delivery = await enqueueOrRearmFailed( QUEUE_NAMES.SCHEDULED_JOBS, 'app-automation-fire', @@ -116,11 +129,11 @@ export async function runAppAutomationScan(now = new Date()): Promise { fire_id: fire.id, expected_epoch: fire.definition_epoch, expected_attempt_count: fire.attempt_count, - charged_at: chargedAt, + charged_at: currentTime(), }); if (!charged) throw new Error('Failed queue delivery changed before its attempt was charged'); }), - }, now); + }, now, { signal, now: currentTime }); if (Object.values(result.errors).some(count => count > 0)) { // One bounded aggregate warning; never emit raw tenant/provider errors. console.warn('[app-automations] scan item failures', result); diff --git a/apps/api/src/lib/app-automation-scan-db.ts b/apps/api/src/lib/app-automation-scan-db.ts new file mode 100644 index 00000000..6484ac6f --- /dev/null +++ b/apps/api/src/lib/app-automation-scan-db.ts @@ -0,0 +1,104 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import pg from 'pg'; +import * as schema from '@deft/db/schema'; +import { env } from './env.js'; + +export const APP_AUTOMATION_SCAN_DB_LIMITS = Object.freeze({ + connections: 2, + acquisition_ms: 1_000, + lock_ms: 250, + statement_ms: 2_000, + operation_ms: 3_000, +}); + +type ScanDatabase = ReturnType>; +export type AppAutomationScanTransaction = Parameters[0]>[0]; + +/** Separate capacity and server-enforced SQL limits for scheduler reconciliation. + * Cancellation waits for the bounded active statement and ROLLBACK; no rejected + * client-side race may release a slot while database work is still running. */ +export function createAppAutomationScanDatabase(connectionString: string) { + const pool = new pg.Pool({ + connectionString, + max: APP_AUTOMATION_SCAN_DB_LIMITS.connections, + connectionTimeoutMillis: APP_AUTOMATION_SCAN_DB_LIMITS.acquisition_ms, + statement_timeout: APP_AUTOMATION_SCAN_DB_LIMITS.statement_ms, + lock_timeout: APP_AUTOMATION_SCAN_DB_LIMITS.lock_ms, + application_name: 'deft-app-automation-scanner', + }); + return { + close: () => pool.end(), + async transaction( + run: (tx: AppAutomationScanTransaction) => Promise, + signal?: AbortSignal, + ): Promise { + const deadline = performance.now() + APP_AUTOMATION_SCAN_DB_LIMITS.operation_ms; + const check = () => { + signal?.throwIfAborted(); + if (performance.now() >= deadline) throw new Error('App automation database operation timed out'); + }; + check(); + // pg removes timed-out pending acquisitions from its queue. We always + // await acquisition settlement, including cancellation while queued. + const client = await pool.connect().catch((error: unknown) => { + signal?.throwIfAborted(); + throw error; + }); + let broken = false; + let settled = false; + try { + check(); + const guarded = new Proxy(client, { + get(target, property, receiver) { + if (property !== 'query') return Reflect.get(target, property, receiver); + return async (query: string | pg.QueryConfig, values?: unknown[]) => { + const text = typeof query === 'string' ? query : query.text; + // Rollback must remain possible after abort/deadline/SQL errors. + if (text.toLowerCase() === 'rollback') { + try { const result = await client.query(query, values); settled = true; return result; } + catch (error) { broken = true; throw error; } + } + check(); + if (text.toLowerCase() !== 'begin') { + const remaining = Math.max(1, Math.floor(deadline - performance.now())); + await client.query("SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $2, true)", [ + String(Math.min(APP_AUTOMATION_SCAN_DB_LIMITS.statement_ms, remaining)), + String(Math.min(APP_AUTOMATION_SCAN_DB_LIMITS.lock_ms, remaining)), + ]); + check(); + } + const result = await client.query(query, values); + if (text.toLowerCase() === 'commit') settled = true; + // COMMIT has already settled and cannot be undone. All earlier + // boundaries, including immediately before COMMIT, check abort. + if (text.toLowerCase() !== 'commit') check(); + return result; + }; + }, + }); + return await drizzle(guarded, { schema }).transaction(run); + } catch (error) { + signal?.throwIfAborted(); + throw error; + } finally { + // BEGIN can succeed immediately before cancellation, outside Drizzle's + // transaction callback. Cover that path as well before reusing the slot. + if (!settled) { + try { await client.query('ROLLBACK'); } + catch { broken = true; } + } + client.release(broken); + } + }, + }; +} + +let scanner: ReturnType | undefined; +export function appAutomationScanDatabase() { + return scanner ??= createAppAutomationScanDatabase(env.DATABASE_URL); +} + +export async function closeAppAutomationScanDatabase(): Promise { + if (scanner) await scanner.close(); + scanner = undefined; +} diff --git a/apps/api/src/lib/app-automation-scanner.ts b/apps/api/src/lib/app-automation-scanner.ts index 8715d141..ac2f0728 100644 --- a/apps/api/src/lib/app-automation-scanner.ts +++ b/apps/api/src/lib/app-automation-scanner.ts @@ -55,7 +55,8 @@ export type AppAutomationScanResult = Readonly<{ errors: Readonly<{ definitions: number; occurrences: number; expired_claims: number; deliveries: number }>; }>; -function propagateCancellation(error: unknown): void { +function propagateCancellation(error: unknown, signal?: AbortSignal): void { + signal?.throwIfAborted(); if (error instanceof Error && error.name === 'AbortError') throw error; } @@ -66,7 +67,13 @@ function propagateCancellation(error: unknown): void { export async function scanAppAutomations( port: AppAutomationScannerPort, now = new Date(), + options: Readonly<{ signal?: AbortSignal; now?: () => Date }> = {}, ): Promise { + const currentTime = () => { + options.signal?.throwIfAborted(); + return options.now?.() ?? now; + }; + currentTime(); let definitionCount = 0; let occurrences = 0; let pending = 0; @@ -75,21 +82,21 @@ export async function scanAppAutomations( const errors = { definitions: 0, occurrences: 0, expired_claims: 0, deliveries: 0 }; const deliver = async (fire: AppAutomationFireRow): Promise => { - try { await port.deliverFire(fire, now); } + try { await port.deliverFire(fire, currentTime()); } catch (error) { - propagateCancellation(error); + propagateCancellation(error, options.signal); errors.deliveries += 1; } }; let fireAfter: AppAutomationFireScanCursor | undefined; do { - const expired = await port.listExpiredClaims(now, APP_AUTOMATION_SCAN_LIMIT, fireAfter); + const expired = await port.listExpiredClaims(currentTime(), APP_AUTOMATION_SCAN_LIMIT, fireAfter); for (const fire of expired) { let reconciled: AppAutomationFireRow | null; - try { reconciled = await port.reconcileExpiredClaim(fire, now); } + try { reconciled = await port.reconcileExpiredClaim(fire, currentTime()); } catch (error) { - propagateCancellation(error); + propagateCancellation(error, options.signal); errors.expired_claims += 1; continue; } @@ -104,7 +111,7 @@ export async function scanAppAutomations( let after: AppAutomationDefinitionScanCursor | undefined; do { - const definitions = await port.listEligibleDefinitions(now, APP_AUTOMATION_SCAN_LIMIT, after); + const definitions = await port.listEligibleDefinitions(currentTime(), APP_AUTOMATION_SCAN_LIMIT, after); definitionCount += definitions.length; for (const definition of definitions) { const eligibleAfter = definition.state_changed_at > definition.valid_from @@ -113,22 +120,23 @@ export async function scanAppAutomations( let dates: string[]; try { dates = listAppAutomationLogicalDates({ - eligible_after: eligibleAfter, now, timezone: definition.timezone, + eligible_after: eligibleAfter, now: currentTime(), timezone: definition.timezone, }); } catch (error) { - propagateCancellation(error); + propagateCancellation(error, options.signal); errors.definitions += 1; continue; } for (const logicalLocalDate of dates) { try { + const occurrenceTime = currentTime(); const occurrence = resolveAppAutomationOccurrence({ logical_local_date: logicalLocalDate, local_time: definition.local_time, timezone: definition.timezone, }); const decision = classifyAppAutomationOccurrence({ - occurrence, now, eligible_after: eligibleAfter, + occurrence, now: occurrenceTime, eligible_after: eligibleAfter, eligible_before: definition.valid_until, catch_up_window_minutes: 15, }); if (decision.kind === 'future' || decision.kind === 'not_eligible') continue; @@ -140,13 +148,13 @@ export async function scanAppAutomations( logical_local_date: logicalLocalDate, resolution: occurrence.resolution, ...(decision.kind === 'skipped' ? { terminal_reason: decision.reason } : {}), - }, now); + }, currentTime()); if (!fire) continue; if (fire.state === 'claimed' && fire.claim_token && fire.lease_expires_at - && fire.lease_expires_at <= now) { - fire = await port.recoverFire(fire, now); + && fire.lease_expires_at <= currentTime()) { + fire = await port.recoverFire(fire, currentTime()); if (!fire) continue; } occurrences += 1; @@ -157,7 +165,7 @@ export async function scanAppAutomations( skipped += 1; } } catch (error) { - propagateCancellation(error); + propagateCancellation(error, options.signal); errors.occurrences += 1; } } @@ -168,5 +176,6 @@ export async function scanAppAutomations( : undefined; } while (after); + currentTime(); return { definitions: definitionCount, occurrences, pending, skipped, recovered, errors }; } diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index abacb3ea..4889d4da 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -2,6 +2,7 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from '@deft/db/schema'; import { env } from './env.js'; +import { closeAppAutomationScanDatabase } from './app-automation-scan-db.js'; const { Pool } = pg; @@ -43,5 +44,5 @@ export async function withDbAdvisoryLock( /** Drain the shared PostgreSQL pool during process shutdown. */ export async function closeDb(): Promise { - await Promise.all([pool.end(), advisoryPool.end()]); + await Promise.all([pool.end(), advisoryPool.end(), closeAppAutomationScanDatabase()]); } diff --git a/apps/api/src/workers/handlers/app-automation-scan.ts b/apps/api/src/workers/handlers/app-automation-scan.ts index ef9661fa..11f2ba7a 100644 --- a/apps/api/src/workers/handlers/app-automation-scan.ts +++ b/apps/api/src/workers/handlers/app-automation-scan.ts @@ -1,6 +1,6 @@ import { runAppAutomationScan } from '../../lib/app-automation-runtime.js'; import type { JobData } from '../types.js'; -export async function handleAppAutomationScan(_job: JobData): Promise { - await runAppAutomationScan(); +export async function handleAppAutomationScan(job: JobData): Promise { + await runAppAutomationScan(new Date(), job.signal); } diff --git a/apps/api/src/workers/index.ts b/apps/api/src/workers/index.ts index 38280630..fc0c74b0 100644 --- a/apps/api/src/workers/index.ts +++ b/apps/api/src/workers/index.ts @@ -180,6 +180,12 @@ async function runClaimedWork( } finally { if (timeout) clearTimeout(timeout); clearInterval(renewal); + // Scanner SQL has server-enforced limits and cooperative transaction + // rollback. Keep its worker slot until that bounded rollback has settled; + // otherwise a timed-out scan could overlap with its replacement. + if (jobs.length > 0 && jobs.every(job => job.name === 'app-automation-scan')) { + await settled; + } } } diff --git a/apps/api/test/app-automation-scan-limits-db.test.ts b/apps/api/test/app-automation-scan-limits-db.test.ts new file mode 100644 index 00000000..469c8b60 --- /dev/null +++ b/apps/api/test/app-automation-scan-limits-db.test.ts @@ -0,0 +1,231 @@ +import './fixtures/app-run-enabled-env.js'; +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import pg from 'pg'; +import { mcpClientManager } from '@deft/mcp'; +import { sql } from 'drizzle-orm'; +import { createAppAutomationScanDatabase } from '../src/lib/app-automation-scan-db.js'; +import { persistAppAutomationFire } from '../src/lib/app-automation-definition-service.js'; +import { claimAppAutomationFireWithExecutor } from '../src/lib/app-automation-repository.js'; +import { runAppAutomationScan } from '../src/lib/app-automation-runtime.js'; +import { createAutomationRenewalFixture } from './fixtures/app-automation-renewal.js'; +import { handleAppAutomationScan } from '../src/workers/handlers/app-automation-scan.js'; +import { db, closeDb } from '../src/lib/db.js'; +import { enqueue, dequeueJob, type QueueName } from '../src/lib/queues.js'; +import { _processDequeuedJobForTest, getWorkerStatus } from '../src/workers/index.js'; + +const url = process.env.DATABASE_URL; +const safe = url === process.env.DEFT_TEST_DATABASE_URL + && url === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_a05_limits_test'; +process.env.DEFT_SELF_HOSTED = 'true'; +process.env.DEFT_MCP_ENABLE_UNSAFE_STDIO = 'true'; +process.env.MCP_STDIO_ALLOWED_COMMANDS = process.execPath; +after(async () => { await mcpClientManager.shutdown(); await closeDb(); }); + +test('A05 worker ordinary Error abort settles a held catalog lock without subsequent scan work', { skip: !safe }, async () => { + const locker = new pg.Client({ connectionString: url }); + await locker.connect(); + await locker.query('BEGIN'); + await locker.query('LOCK TABLE app_automation_fires IN ACCESS EXCLUSIVE MODE'); + const controller = new AbortController(); + const reason = new Error('worker lease lost'); + const abort = setTimeout(() => controller.abort(reason), 60); + const watchdog = setTimeout(() => { void locker.query('ROLLBACK'); }, 2500); + const start = performance.now(); + let failure: unknown; + try { + await handleAppAutomationScan({ id: 'a05-test', name: 'app-automation-scan', data: {}, attempts: 1, signal: controller.signal }); + } catch (error) { failure = error; } + finally { + clearTimeout(abort); + clearTimeout(watchdog); + await locker.query('ROLLBACK'); + await locker.end(); + } + assert.equal(failure, reason, 'actual handler must preserve worker abort reason'); + assert.ok(performance.now() - start < 1500, 'must settle within frozen unhealthy catalog bound'); +}); + +test('A05 claim recovery refreshes definition expiry after waiting for the exact fire lock', { skip: !safe }, async () => { + const fixture = await createAutomationRenewalFixture(); + const scheduledAt = new Date('2035-01-02T10:00:00Z'); + const { definition } = await fixture.create(scheduledAt, new Date('2035-01-02T09:59:59Z'), 2); + const fire = await persistAppAutomationFire({ + organization_id: fixture.orgId, definition_id: definition.id, expected_epoch: definition.definition_epoch, + logical_local_date: '2035-01-02', resolution: { kind: 'resolved', resolved_at_utc: scheduledAt }, + }, { now: () => scheduledAt }); + const claimed = await db.transaction(tx => claimAppAutomationFireWithExecutor(tx, { + organization_id: fixture.orgId, definition_id: definition.id, fire_id: fire.id, + expected_epoch: definition.definition_epoch, claim_owner: 'a05-recovery', claim_token: crypto.randomUUID(), + claimed_at: scheduledAt, lease_expires_at: new Date('2035-01-02T10:00:00.800Z'), + })); + assert.ok(claimed); + const locker = new pg.Client({ connectionString: url }); + await locker.connect(); + await locker.query('BEGIN'); + await locker.query('SELECT id FROM app_automation_fires WHERE id = $1 FOR UPDATE', [fire.id]); + const release = setTimeout(() => { void locker.query('ROLLBACK'); }, 150); + try { + await runAppAutomationScan(new Date('2035-01-02T10:00:00.900Z')); + const result = await locker.query('SELECT state, terminal_reason FROM app_automation_fires WHERE id = $1', [fire.id]); + assert.deepEqual(result.rows, [{ state: 'skipped', terminal_reason: 'definition_ineligible' }]); + const deliveries = await locker.query("SELECT count(*)::int AS count FROM job_queue WHERE name = 'app-automation-fire' AND data->>'fire_id' = $1", [fire.id]); + assert.equal(deliveries.rows[0].count, 0); + } finally { clearTimeout(release); await locker.query('ROLLBACK'); await locker.end(); } +}); + +test('A05 actual worker timeout and lease loss retain the scanner slot until SQL rollback settles', { skip: !safe }, async () => { + const scanner = createAppAutomationScanDatabase(url!); + const observer = new pg.Client({ connectionString: url }); + await observer.connect(); + const queue = `a05-worker-limits:${crypto.randomUUID()}` as QueueName; + await observer.query('CREATE TABLE IF NOT EXISTS a05_worker_abort_probe (value integer)'); + await observer.query('TRUNCATE a05_worker_abort_probe'); + try { + await scanner.transaction(tx => tx.execute(sql`SELECT 1`)); + for (const mode of ['timeout', 'lease loss'] as const) { + await enqueue(queue, 'app-automation-scan', {}, { maxAttempts: 3 }); + const job = await dequeueJob(queue, { leaseMs: 10_000 }); + assert.ok(job); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + let aborted!: () => void; + const abortObserved = new Promise(resolve => { aborted = resolve; }); + let originalReason: unknown; + let handlerError: unknown; + let handlerSettled = false; + let workerSettled = false; + const work = _processDequeuedJobForTest(queue, job, { + timeoutMs: mode === 'timeout' ? 150 : 2000, + leaseMs: 10_000, renewIntervalMs: 30, recurrence: null, + resolveHandler: async () => async runtimeJob => { + runtimeJob.signal!.addEventListener('abort', () => { + originalReason = runtimeJob.signal!.reason; + aborted(); + }, { once: true }); + try { + await scanner.transaction(async tx => { + entered(); + await tx.execute(sql`INSERT INTO a05_worker_abort_probe SELECT 1 FROM pg_sleep(0.5)`); + }, runtimeJob.signal); + } catch (error) { handlerError = error; throw error; } + finally { handlerSettled = true; } + }, + }).finally(() => { workerSettled = true; }); + await started; + if (mode === 'lease loss') { + await observer.query('UPDATE job_queue SET lock_token = $1 WHERE id = $2', [crypto.randomUUID(), job.id]); + } + await abortObserved; + await new Promise(resolve => setTimeout(resolve, 30)); + assert.equal(handlerSettled, false, 'statement remains active until its server response'); + assert.equal(workerSettled, false, 'worker must await transaction rollback before returning'); + assert.equal(getWorkerStatus().inFlight, 1, 'underlying scanner remains tracked while settling'); + await work; + assert.equal(handlerSettled, true); + assert.equal(handlerError, originalReason, 'rollback preserves the worker cancellation reason'); + assert.ok(originalReason instanceof Error); + assert.match(originalReason.message, mode === 'timeout' ? /timed out/ : /lost its job lease/); + assert.equal(getWorkerStatus().inFlight, 0); + assert.equal((await observer.query('SELECT count(*)::int AS count FROM a05_worker_abort_probe')).rows[0].count, 0); + } + } finally { + await observer.query('DELETE FROM job_queue WHERE queue = $1', [queue]); + await observer.query('DROP TABLE a05_worker_abort_probe'); + await scanner.close(); + await observer.end(); + } +}); + +test('A05 persistence refreshes expiry and catch-up policy after real pending-budget lock waits', { skip: !safe }, async () => { + const fixture = await createAutomationRenewalFixture(); + const scanner = createAppAutomationScanDatabase(url!); + const locker = new pg.Client({ connectionString: url }); + await locker.connect(); + try { + const scheduledAt = new Date('2035-01-01T10:00:00Z'); + for (const boundary of ['expiry', 'catch-up'] as const) { + const { definition } = await fixture.create(scheduledAt, new Date('2035-01-01T09:59:59Z'), boundary === 'expiry' ? 2 : 3600); + await locker.query('BEGIN'); + await locker.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`deft.app_automation.pending_budget:${fixture.orgId}`]); + let now = scheduledAt; + const release = setTimeout(() => { + now = boundary === 'expiry' ? new Date('2035-01-01T10:00:01Z') : new Date('2035-01-01T10:15:00.001Z'); + void locker.query('ROLLBACK'); + }, 100); + try { + await assert.rejects(scanner.transaction(tx => persistAppAutomationFire({ + organization_id: fixture.orgId, definition_id: definition.id, expected_epoch: definition.definition_epoch, + logical_local_date: '2035-01-01', resolution: { kind: 'resolved', resolved_at_utc: scheduledAt }, + }, { executor: tx, now: () => now })), /changed while waiting for persistence/); + const fires = await locker.query('SELECT count(*)::int AS count FROM app_automation_fires WHERE definition_id = $1', [definition.id]); + assert.equal(fires.rows[0].count, 0, `${boundary} must not leave a pending fire after the wait`); + } finally { clearTimeout(release); await locker.query('ROLLBACK'); } + } + } finally { await scanner.close(); await locker.end(); } +}); + +test('A05 scanner SQL timeout settles before reuse and does not change ordinary pool limits', { skip: !safe }, async () => { + const scanner = createAppAutomationScanDatabase(url!); + const ordinary = new pg.Client({ connectionString: url }); + await ordinary.connect(); + try { + const start = performance.now(); + await assert.rejects(scanner.transaction(tx => tx.execute(sql`SELECT pg_sleep(10)`))); + assert.ok(performance.now() - start < 3000); + const result = await scanner.transaction(tx => tx.execute(sql`SELECT 7 AS value`)); + assert.equal(result.rows[0]?.value, 7); + assert.equal((await ordinary.query('SHOW statement_timeout')).rows[0].statement_timeout, '0'); + assert.equal((await ordinary.query('SHOW lock_timeout')).rows[0].lock_timeout, '0'); + } finally { await scanner.close(); await ordinary.end(); } +}); + +test('A05 scanner saturation bounds acquisition while unrelated database work progresses', { skip: !safe }, async () => { + const scanner = createAppAutomationScanDatabase(url!); + const ordinary = new pg.Client({ connectionString: url }); + await ordinary.connect(); + let entered = 0; + let bothEntered!: () => void; + const occupied = new Promise(resolve => { bothEntered = resolve; }); + const occupy = () => scanner.transaction(async tx => { + if (++entered === 2) bothEntered(); + await tx.execute(sql`SELECT pg_sleep(1.8)`); + }); + const holders = [occupy(), occupy()]; + try { + await occupied; + const start = performance.now(); + await ordinary.query('SELECT 1'); + assert.ok(performance.now() - start < 1000, 'ordinary connection remains independent'); + await assert.rejects(scanner.transaction(tx => tx.execute(sql`SELECT 1`)), /timeout/); + assert.ok(performance.now() - start < 1500, 'queued borrower is removed by pg acquisition timeout'); + await Promise.all(holders); + await scanner.transaction(tx => tx.execute(sql`SELECT 1`)); + } finally { await Promise.allSettled(holders); await scanner.close(); await ordinary.end(); } +}); + +test('A05 ordinary Error abort rolls back in-flight SQL before releasing scanner slot', { skip: !safe }, async () => { + const scanner = createAppAutomationScanDatabase(url!); + const observer = new pg.Client({ connectionString: url }); + await observer.connect(); + await observer.query('CREATE TABLE IF NOT EXISTS a05_scan_abort_probe (value integer)'); + await observer.query('TRUNCATE a05_scan_abort_probe'); + const controller = new AbortController(); + const reason = new Error('lease renewal failed'); + let timer: ReturnType | undefined; + try { + await assert.rejects(scanner.transaction(async tx => { + timer = setTimeout(() => controller.abort(reason), 60); + await tx.execute(sql`INSERT INTO a05_scan_abort_probe SELECT 1 FROM pg_sleep(0.2)`); + assert.fail('aborted SQL must not start subsequent callback work'); + }, controller.signal), error => error === reason); + assert.equal((await observer.query('SELECT count(*)::int AS count FROM a05_scan_abort_probe')).rows[0].count, 0); + await scanner.transaction(tx => tx.execute(sql`INSERT INTO a05_scan_abort_probe VALUES (2)`)); + assert.deepEqual((await observer.query('SELECT value FROM a05_scan_abort_probe')).rows, [{ value: 2 }]); + } finally { + clearTimeout(timer); + await observer.query('DROP TABLE a05_scan_abort_probe'); + await scanner.close(); + await observer.end(); + } +}); diff --git a/apps/api/test/app-automation-scanner.test.ts b/apps/api/test/app-automation-scanner.test.ts index 55880e50..21e59d51 100644 --- a/apps/api/test/app-automation-scanner.test.ts +++ b/apps/api/test/app-automation-scanner.test.ts @@ -329,3 +329,30 @@ test('scanner propagates explicit cancellation from item work without starting l assert.ok(!visited.includes('later')); } }); + +test('scanner propagates worker signals with arbitrary reasons at every awaited item boundary', async () => { + for (const reason of [new Error('worker lease lost'), 'host shutdown']) { + for (const stage of ['reconcile', 'ensure', 'recover', 'deliver'] as const) { + const controller = new AbortController(); + const visited: string[] = []; + const abort = (): never => { controller.abort(reason); throw new Error('underlying I/O settled'); }; + const claimed = fire({ organization_id: 'org-1', definition_id: 'first', expected_epoch: 1, + logical_local_date: '2026-09-01', resolution: { kind: 'resolved', resolved_at_utc: new Date('2026-09-01T04:00:00Z') } }, + { state: 'claimed', claim_token: 'expired', lease_expires_at: new Date('2026-09-01T04:00:00Z') }); + await assert.rejects(scanAppAutomations(scannerPort({ + listExpiredClaims: async () => stage === 'reconcile' ? [claimed] : [], + reconcileExpiredClaim: async () => abort(), + listEligibleDefinitions: async () => ['first', 'later'].map(id => definition({ id, + valid_from: new Date('2026-09-01T03:00:00Z'), state_changed_at: new Date('2026-09-01T03:00:00Z') })), + ensureFire: async input => { + visited.push(input.definition_id); + if (stage === 'ensure') abort(); + return stage === 'recover' ? claimed : fire(input); + }, + recoverFire: async () => abort(), + deliverFire: async () => abort(), + }), new Date('2026-09-01T04:10:00Z'), { signal: controller.signal }), error => error === reason); + assert.ok(!visited.includes('later')); + } + } +}); From 105180e06eff3e4125fc22e6c68eae5d69436287 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:50:34 +0530 Subject: [PATCH 049/161] feat: review and activate sync-only Apps over web v2 --- apps/api/src/lib/app-runtime-review.ts | 94 +++++- apps/api/src/routes/app-runtime-review.ts | 64 +++- .../test/app-runtime-review-web-http.test.ts | 294 ++++++++++++++++++ 3 files changed, 425 insertions(+), 27 deletions(-) create mode 100644 apps/api/test/app-runtime-review-web-http.test.ts diff --git a/apps/api/src/lib/app-runtime-review.ts b/apps/api/src/lib/app-runtime-review.ts index 40a59358..96a69112 100644 --- a/apps/api/src/lib/app-runtime-review.ts +++ b/apps/api/src/lib/app-runtime-review.ts @@ -22,6 +22,18 @@ export const RuntimeAppReviewRequestSchema = z.strictObject({ export const RuntimeAppActivateRequestSchema = RuntimeAppReviewRequestSchema.extend({ expected_review_digest: AppDigestSchema, accept_host_policy: z.literal(true), }); +type ReviewTransaction = Parameters[0]>[0]; +export type RuntimeAppReviewOptions = Readonly<{ + guard?: (tx: ReviewTransaction) => Promise; + assertAdmission?: (manifest: RuntimeAppManifest | DeftAppManifestV5) => void; +}>; +export const RuntimeAppReviewContextSchema = z.strictObject({ + schema_version: z.literal('deft.app_runtime_review_context.v1'), + installation_id: z.string(), app_version_id: z.string(), + protocol_version: z.enum(['3', '4', '5']), state: z.enum(['staged', 'disabled', 'active']), + review_request: RuntimeAppReviewRequestSchema.nullable(), + current_activation: z.strictObject({ grant_snapshot_id: z.string(), review_digest: AppDigestSchema }).nullable(), +}); export function runtimeActionDescriptors(manifest: Pick) { return manifest.runtime_actions.map((action) => { @@ -53,21 +65,18 @@ export function buildResourceAppReviewedAuthority(manifest: DeftAppManifestV5, p }; } -async function reviewContext(tx: Executor, actor: ModuleActor, installationId: string, - request: z.infer) { +async function loadReviewAuthority(tx: Executor, actor: ModuleActor, installationId: string, + versionId: string, allowActive = false) { await assertCurrentModuleManagerWithExecutor(tx, actor); const [installation] = await tx.select().from(appInstallations).where(and( eq(appInstallations.org_id, actor.org_id), eq(appInstallations.id, installationId), )).limit(1).for('update'); - if (!installation || !['staged', 'disabled'].includes(installation.state) - || installation.lifecycle_epoch !== request.expected_lifecycle_epoch - || installation.grant_epoch !== request.expected_grant_epoch) throw stale(); + if (!installation || !['staged', 'disabled', ...(allowActive ? ['active'] : [])].includes(installation.state)) throw stale(); const [version] = await tx.select().from(appVersions).where(and( eq(appVersions.org_id, actor.org_id), eq(appVersions.installation_id, installationId), - eq(appVersions.id, request.app_version_id), + eq(appVersions.id, versionId), )).limit(1).for('share'); if (!version || !['3', '4', '5'].includes(version.protocol_version) || !['staged', 'active'].includes(version.state) - || version.package_digest !== request.expected_package_digest || (installation.active_version_id && installation.active_version_id !== version.id)) throw stale(); const manifest = version.protocol_version === '5' ? parseResourceAppManifest(version.manifest) : parseRuntimeAppManifest(version.manifest); @@ -79,8 +88,7 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s const expected = buildRequestedAppGrantProjection({ organization_id: actor.org_id, app_installation_id: installationId, app_version_id: version.id, manifest, manifest_digest: version.manifest_digest, package_digest: version.package_digest }); - if (!requested || requested.snapshot_digest !== request.expected_requested_snapshot_digest - || requested.snapshot_digest !== expected.snapshot_digest + if (!requested || requested.snapshot_digest !== expected.snapshot_digest || digestAppGrantValue(requested.canonical_snapshot) !== expected.snapshot_digest) throw stale(); const authority = manifest.schema_version === '5' ? buildResourceAppReviewedAuthority(manifest, { lineage_key: installation.lineage_key, @@ -89,21 +97,76 @@ async function reviewContext(tx: Executor, actor: ModuleActor, installationId: s package_digest: version.package_digest, manifest_digest: version.manifest_digest, runtime_actions: runtimeActionDescriptors(manifest), ...(manifest.schema_version === '4' ? { modules: manifest.modules, experiences: manifest.experiences, public_actions: manifest.public_actions } : {}) }; + return { installation, version, requested, manifest, authority }; +} + +async function reviewContext(tx: Executor, actor: ModuleActor, installationId: string, + request: z.infer) { + const context = await loadReviewAuthority(tx, actor, installationId, request.app_version_id); + const { installation, version, requested, authority } = context; + if (installation.lifecycle_epoch !== request.expected_lifecycle_epoch + || installation.grant_epoch !== request.expected_grant_epoch + || version.package_digest !== request.expected_package_digest + || requested.snapshot_digest !== request.expected_requested_snapshot_digest) throw stale(); const review = { ...request, installation_id: installationId, organization_id: actor.org_id, authority, requested_snapshot_id: requested.id }; - return { installation, version, requested, authority, review: { ...review, review_digest: digestAppGrantValue(review) } }; + return { ...context, review: { ...review, review_digest: digestAppGrantValue(review) } }; } -export async function prepareRuntimeAppReview(actor: ModuleActor, installationId: string, raw: unknown) { +/** Pins are nominated by the host; every subsequent review rechecks them. */ +export async function getRuntimeAppReviewContext(actor: ModuleActor, installationId: string, + versionId: string, options: RuntimeAppReviewOptions = {}) { + return db.transaction(async tx => { + const context = await loadReviewAuthority(tx, actor, installationId, versionId, true); + options.assertAdmission?.(context.manifest); + const { installation, version, requested } = context; + let currentActivation: { grant_snapshot_id: string; review_digest: string } | null = null; + if (installation.state === 'active') { + if (installation.active_version_id !== version.id || version.state !== 'active' + || installation.active_grant_snapshot_kind !== 'effective') throw stale(); + const [grant] = await tx.select().from(appGrantSnapshots).where(and( + eq(appGrantSnapshots.org_id, actor.org_id), eq(appGrantSnapshots.app_installation_id, installationId), + eq(appGrantSnapshots.app_version_id, version.id), eq(appGrantSnapshots.snapshot_kind, 'effective'), + eq(appGrantSnapshots.id, installation.active_grant_snapshot_id ?? ''), + )).limit(1); + const digest = AppDigestSchema.safeParse(grant?.canonical_snapshot.review_digest); + if (!grant || !digest.success || grant.package_digest !== version.package_digest + || grant.manifest_digest !== version.manifest_digest + || digestAppGrantValue(grant.canonical_snapshot) !== grant.snapshot_digest) throw stale(); + currentActivation = { grant_snapshot_id: grant.id, review_digest: digest.data }; + } + const result = RuntimeAppReviewContextSchema.parse({ schema_version: 'deft.app_runtime_review_context.v1', + installation_id: installation.id, app_version_id: version.id, protocol_version: version.protocol_version, + state: installation.state, review_request: installation.state === 'active' ? null : { + app_version_id: version.id, expected_package_digest: version.package_digest, + expected_requested_snapshot_digest: requested.snapshot_digest, + expected_lifecycle_epoch: installation.lifecycle_epoch, expected_grant_epoch: installation.grant_epoch, + }, current_activation: currentActivation }); + await options.guard?.(tx); + options.assertAdmission?.(context.manifest); + return result; + }); +} + +export async function prepareRuntimeAppReview(actor: ModuleActor, installationId: string, raw: unknown, + options: RuntimeAppReviewOptions = {}) { const request = RuntimeAppReviewRequestSchema.parse(raw); - return db.transaction(async (tx) => (await reviewContext(tx, actor, installationId, request)).review); + return db.transaction(async (tx) => { + const context = await reviewContext(tx, actor, installationId, request); + options.assertAdmission?.(context.manifest); + await options.guard?.(tx); + options.assertAdmission?.(context.manifest); + return context.review; + }); } -export async function activateRuntimeApp(actor: ModuleActor, installationId: string, raw: unknown) { +export async function activateRuntimeApp(actor: ModuleActor, installationId: string, raw: unknown, + options: RuntimeAppReviewOptions = {}) { const { expected_review_digest, accept_host_policy: _accept, ...request } = RuntimeAppActivateRequestSchema.parse(raw); const postCommit: ModuleLifecyclePostCommit[] = []; const activated = await db.transaction(async (tx) => { const context = await reviewContext(tx, actor, installationId, request); + options.assertAdmission?.(context.manifest); if (context.review.review_digest !== expected_review_digest) throw stale(); const manifest = context.version.protocol_version === '5' ? parseResourceAppManifest(context.version.manifest) : parseRuntimeAppManifest(context.version.manifest); @@ -124,7 +187,8 @@ export async function activateRuntimeApp(actor: ModuleActor, installationId: str const owned = await tx.select().from(appModuleBindings).where(and(eq(appModuleBindings.org_id, actor.org_id), eq(appModuleBindings.app_installation_id, installationId), eq(appModuleBindings.app_version_id, context.version.id), eq(appModuleBindings.ownership, 'app'))); - for (const binding of owned) await tx.update(moduleInstallations).set({ is_enabled: true }).where(and( + for (const binding of owned) await tx.update(moduleInstallations).set({ is_enabled: true, + disabled_at: null, updated_by_actor_type: actor.kind, updated_by_actor_id: actor.actor_id }).where(and( eq(moduleInstallations.org_id, actor.org_id), eq(moduleInstallations.id, binding.module_installation_id), eq(moduleInstallations.is_deleted, false))); } @@ -166,6 +230,8 @@ export async function activateRuntimeApp(actor: ModuleActor, installationId: str before_state: { state: context.installation.state }, after_state: { state: 'active', grant_snapshot_id: effectiveId, review_digest: expected_review_digest }, metadata: { source: actor.source } }); + await options.guard?.(tx); + options.assertAdmission?.(context.manifest); return { installation, grant_snapshot_id: effectiveId }; }); for (const effect of postCommit) effect.emit(); diff --git a/apps/api/src/routes/app-runtime-review.ts b/apps/api/src/routes/app-runtime-review.ts index 4084312d..cdf91213 100644 --- a/apps/api/src/routes/app-runtime-review.ts +++ b/apps/api/src/routes/app-runtime-review.ts @@ -1,36 +1,74 @@ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { z } from 'zod'; import type { AuthUser } from '../middleware/auth.js'; -import { humanModuleActor } from '../lib/module-service.js'; import { appRuntimeChannelEnabled } from '../lib/app-runtime-channel.js'; -import { activateRuntimeApp, prepareRuntimeAppReview } from '../lib/app-runtime-review.js'; +import { activateRuntimeApp, prepareRuntimeAppReview, getRuntimeAppReviewContext, + type RuntimeAppReviewOptions } from '../lib/app-runtime-review.js'; import { isAppError } from '../lib/app-errors.js'; import { isModuleError } from '../lib/module-errors.js'; +import { isAppResourceSyncChannelEnabled } from '../lib/env.js'; +import { resourceSyncWebAuthority, ResourceSyncWebAuthenticationError } from '../lib/app-resource-sync-web-authority.js'; + +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/); +class RuntimeReviewChannelDisabledError extends Error { + readonly code = 'APP_RUNTIME_DISABLED'; + readonly status = 503; +} +const assertAdmission: NonNullable = manifest => { + // Preserve existing v1 declaration review. The additive v2 path grants no + // Runtime action support, including for action-bearing protocol 5 Apps. + if (appRuntimeChannelEnabled()) return; + if (isAppResourceSyncChannelEnabled() && manifest.schema_version === '5' + && manifest.runtime_actions.length === 0 && manifest.sync_descriptors.length > 0) return; + throw new RuntimeReviewChannelDisabledError('Runtime review channel unavailable'); +}; +async function authority(c: Context) { + const user = c.get('user') as AuthUser | undefined; + if (!user?.sid) throw new ResourceSyncWebAuthenticationError('Web authentication required'); + const { actor, guard } = await resourceSyncWebAuthority(c.req.header('authorization'), + { org_id: user.org_id, user_id: user.id, sid: user.sid }); + return { actor, options: { guard, assertAdmission } }; +} +function failure(c: Context, error: unknown) { + if (isAppError(error) || isModuleError(error) || error instanceof ResourceSyncWebAuthenticationError + || error instanceof RuntimeReviewChannelDisabledError) { + return c.json({ error: error.message, code: error.code }, error.status); + } + if (error instanceof z.ZodError || error instanceof SyntaxError) return c.json({ error: 'Invalid review request', code: 'VALIDATION_ERROR' }, 400); + return c.json({ error: 'Runtime review failed', code: 'INTERNAL_ERROR' }, 500); +} /** Mounted behind the normal authenticated human API middleware. */ export const appRuntimeReviewRoutes = new Hono(); appRuntimeReviewRoutes.use('*', async (c, next) => { c.header('Cache-Control', 'no-store'); - if (!appRuntimeChannelEnabled()) return c.json({ error: 'Runtime unavailable', code: 'APP_RUNTIME_DISABLED' }, 503); + c.header('Pragma', 'no-cache'); + if (!appRuntimeChannelEnabled() && !isAppResourceSyncChannelEnabled()) return c.json({ error: 'Runtime unavailable', code: 'APP_RUNTIME_DISABLED' }, 503); await next(); }); appRuntimeReviewRoutes.use('*', bodyLimit({ maxSize: 8192 })); +appRuntimeReviewRoutes.get('/:installationId/context', async c => { + try { + const queries = c.req.queries(); + if (Object.values(queries).some(values => values.length !== 1)) throw new SyntaxError(); + const query = z.strictObject({ app_version_id: Id }).parse(c.req.query()); + const { actor, options } = await authority(c); + return c.json(await getRuntimeAppReviewContext(actor, Id.parse(c.req.param('installationId')), + query.app_version_id, options)); + } catch (error) { return failure(c, error); } +}); for (const operation of ['review', 'activate'] as const) { appRuntimeReviewRoutes.post(`/:installationId/${operation}`, async (c) => { - const user = c.get('user') as AuthUser | undefined; - if (!user) return c.json({ error: 'Authentication required', code: 'APP_ACCESS_DENIED' }, 401); try { - const actor = humanModuleActor({ orgId: user.org_id, userId: user.id, role: user.role ?? 'member', source: 'rest' }); - const id = z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/).parse(c.req.param('installationId')); + const { actor, options } = await authority(c); + const id = Id.parse(c.req.param('installationId')); const body: unknown = await c.req.json(); - const result = operation === 'review' ? await prepareRuntimeAppReview(actor, id, body) - : await activateRuntimeApp(actor, id, body); + const result = operation === 'review' ? await prepareRuntimeAppReview(actor, id, body, options) + : await activateRuntimeApp(actor, id, body, options); return c.json(result); } catch (error) { - if (isAppError(error) || isModuleError(error)) return c.json({ error: error.message, code: error.code }, error.status); - if (error instanceof z.ZodError || error instanceof SyntaxError) return c.json({ error: 'Invalid review request', code: 'VALIDATION_ERROR' }, 400); - return c.json({ error: 'Runtime review failed', code: 'INTERNAL_ERROR' }, 500); + return failure(c, error); } }); } diff --git a/apps/api/test/app-runtime-review-web-http.test.ts b/apps/api/test/app-runtime-review-web-http.test.ts new file mode 100644 index 00000000..1f58b036 --- /dev/null +++ b/apps/api/test/app-runtime-review-web-http.test.ts @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import test, { after } from 'node:test'; +import type { ServerType } from '@hono/node-server'; + +const safe = process.env.DATABASE_URL === process.env.DEFT_TEST_DATABASE_URL + && process.env.DATABASE_URL === 'postgresql://gate_g_test@127.0.0.1:55435/gate_g_20260926_v5_activation_test'; +process.env.DEFT_APPS_ENABLED = 'true'; +process.env.DEFT_APP_RUNS_ENABLED = 'true'; +process.env.DEFT_APP_RUN_APP_ORIGIN_ENABLED = 'true'; +process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'false'; +process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; +const ring = (purpose: string) => ({ current: purpose, + keys: { [purpose]: createHash('sha256').update(`activation-http:${purpose}`).digest('base64') } }); +process.env.DEFT_APP_RUN_KEYRINGS = JSON.stringify({ schema_version: 'deft.app_run_keyring.v1', + run_encryption: ring('act-enc'), receipt_signing: ring('act-sig'), fingerprint: ring('act-fp') }); +let server: ServerType | undefined; +let base: string; +after(async () => { + server?.closeAllConnections(); + if (server) await new Promise((resolve, reject) => server!.close(e => e ? reject(e) : resolve())); + if (safe) await (await import('../src/lib/db.js')).closeDb(); +}); + +async function fixture(protocol: '3' | '4' | '5' = '5', mixed = false, withModule = false) { + const [{ app }, { db }, schema, drizzle, kit, sessions, serverModule] = await Promise.all([ + import('../src/index.js'), import('../src/lib/db.js'), import('@deft/db/schema'), import('drizzle-orm'), + import('@deft/app-kit'), import('../src/lib/web-sessions.js'), import('@hono/node-server'), + ]); + if (!server) base = await new Promise(resolve => { + server = serverModule.serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, info => resolve(`http://127.0.0.1:${info.port}`)); + }); + const org = randomUUID(); const owner = randomUUID(); const peer = randomUUID(); + const suffix = randomUUID().replaceAll('-', ''); + await db.insert(schema.orgs).values({ id: org, name: 'Activation HTTP', slug: `activation-${suffix}` }); + await db.insert(schema.users).values([{ id: owner, name: 'Owner', email: `${owner}@example.test` }, + { id: peer, name: 'Member', email: `${peer}@example.test` }]); + await db.insert(schema.orgMembers).values([{ org_id: org, user_id: owner, role: 'owner', is_active: true }, + { org_id: org, user_id: peer, role: 'member', is_active: true }]); + const token = (id = owner, orgId = org) => sessions.createWebSession({ id, org_id: orgId, email: `${id}@example.test` }); + const web = await token(); const memberWeb = await token(peer); + const call = async (path: string, value?: unknown, auth = web.accessToken) => { + const response = await fetch(`${base}${path}`, { method: value === undefined ? 'GET' : 'POST', + headers: { Authorization: `Bearer ${auth}`, ...(value === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(value === undefined ? {} : { body: JSON.stringify(value) }) }); + return { status: response.status, body: await response.json() as any, + cache: response.headers.get('cache-control') }; + }; + const shape = { type: 'object' as const, properties: { subject: { type: 'string' as const, maxLength: 200 } }, + required: ['subject'], additionalProperties: false as const }; + const actions = protocol !== '5' || mixed; + const moduleManifest = { schema_version: '1', id: `community.example.items.a${suffix}`, slug: `items-${suffix}`, + version: '1.0.0', name: 'Items', collections: [{ key: 'items', name: 'Items', singular_name: 'Item', + fields: [{ key: 'title', label: 'Title', type: 'text', required: true }], + views: [{ key: 'all', name: 'All', type: 'table', fields: ['title'] }], + search: { title_field: 'title', subtitle_fields: [], fields: ['title'] } }], + navigation: { default_collection: 'items', default_view: 'all' } }; + const artifacts = withModule ? [await kit.prepareModuleArtifact({ path: 'modules/items/deft.module.json', manifest: moduleManifest })] : []; + const manifest = { schema_version: protocol, id: `community.example.activation.a${suffix}`, version: '1.0.0', + name: 'Activation fixture', license: 'AGPL-3.0-only', compatibility: { app_protocol: protocol }, + runtime_requirements: [ + ...(protocol === '5' ? [{ key: 'sync', protocol_version: 'deft.app_runtime_channel.v2' }] : []), + ...(actions ? [{ key: 'actions', protocol_version: 'deft.app_runtime_channel.v1' }] : []), + ], + private_capabilities: actions ? [{ key: 'message', version: '1', input_schema: shape, output_schema: shape }] : [], + runtime_actions: actions ? [{ key: 'send_message', label: 'Send message', capability_key: 'message', runtime_requirement_key: 'actions' }] : [], + modules: artifacts.map(artifact => ({ module_id: moduleManifest.id, + version: '1.0.0', manifest_path: artifact.path, manifest_digest: artifact.digest })), + navigation: [], + ...(protocol !== '3' ? { experiences: [], public_actions: [] } : {}), + ...(protocol === '5' ? { sync_descriptors: [{ schema_version: 'deft.app_sync_descriptor.v1', key: 'inbox', + runtime_requirement_key: 'sync', resource_type: 'email_message', requested_visibility: 'user_private', + record_schema: shape, label_field: 'subject' }] } : {}), + }; + const pkg = await kit.buildDeftAppPackage({ manifest, artifacts }); + const staged = await call('/api/apps/stage', JSON.parse(pkg.json)); + assert.equal(staged.status, 201, JSON.stringify(staged.body)); + const installed = staged.body.app; + const path = `/api/app-runtime-review/${installed.id}`; + const context = () => call(`${path}/context?app_version_id=${installed.version_id}`); + return { db, schema, ...drizzle, sessions, org, owner, peer, web, memberWeb, token, call, installed, path, context }; +} + +test('staged sync-only v5 activates over v2-only HTTP using typed current pins', { skip: !safe }, async () => { + const h = await fixture(); + const context = await h.context(); + assert.equal(context.status, 200); + assert.equal(context.cache, 'no-store'); + assert.equal(context.body.schema_version, 'deft.app_runtime_review_context.v1'); + const input = context.body.review_request; + const review = await h.call(`${h.path}/review`, input); + assert.equal(review.status, 200, JSON.stringify(review.body)); + const activated = await h.call(`${h.path}/activate`, { ...input, expected_review_digest: review.body.review_digest, accept_host_policy: true }); + assert.equal(activated.status, 200, JSON.stringify(activated.body)); + const current = await h.context(); + assert.equal(current.body.state, 'active'); + assert.equal(current.body.review_request, null); + assert.equal(current.body.current_activation.review_digest, review.body.review_digest); + for (const table of [h.schema.appResourceBindings, h.schema.appRuntimeRegistrations, h.schema.appRuntimeSessions, + h.schema.appSyncCheckpoints, h.schema.appRuns]) { + assert.equal((await h.db.select({ id: table.id }).from(table).where(h.eq(table.org_id, h.org))).length, 0); + } +}); + +test('runtime review discovery and activation enforce pins purpose tenant and default-off gates', { skip: !safe }, async () => { + const h = await fixture(); + const context = await h.context(); + const input = context.body.review_request; + const jwt = (await import('jsonwebtoken')).default; + const { env } = await import('../src/lib/env.js'); + const caller = await h.sessions.verifyWebAccess(h.web.accessToken); + const signed = (purpose: string, sid = caller.sid) => jwt.sign({ id: h.owner, org_id: h.org, + email: `${h.owner}@example.test`, purpose, sid, jti: randomUUID() }, env.JWT_SECRET, { expiresIn: 60 }); + for (const auth of [h.web.refreshToken, signed('personal-mcp'), signed('employee'), signed('developer'), + signed('web-access', randomUUID()), 'AppRuntime invalid']) { + assert.equal((await h.call(`${h.path}/context?app_version_id=${h.installed.version_id}`, undefined, auth)).status, 401); + assert.equal((await h.call(`${h.path}/review`, input, auth)).status, 401); + assert.equal((await h.call(`${h.path}/activate`, { ...input, + expected_review_digest: `sha256:${'0'.repeat(64)}`, accept_host_policy: true }, auth)).status, 401); + } + assert.equal((await h.call(`${h.path}/review`, input, h.memberWeb.accessToken)).status, 403); + const foreignOrg = randomUUID(); + await h.db.insert(h.schema.orgs).values({ id: foreignOrg, name: 'Other org', slug: foreignOrg }); + await h.db.insert(h.schema.orgMembers).values({ org_id: foreignOrg, user_id: h.owner, role: 'owner', is_active: true }); + const foreign = await h.token(h.owner, foreignOrg); + assert.equal((await h.call(`${h.path}/review`, input, foreign.accessToken)).status, 409); + for (const query of ['', `?app_version_id=${h.installed.version_id}&extra=yes`, + `?app_version_id=${h.installed.version_id}&app_version_id=${h.installed.version_id}`]) { + assert.equal((await h.call(`${h.path}/context${query}`)).status, 400); + } + assert.equal((await h.call(`${h.path}/context?app_version_id=${randomUUID()}`)).status, 409); + for (const change of [{ expected_package_digest: `sha256:${'0'.repeat(64)}` }, + { expected_requested_snapshot_digest: `sha256:${'0'.repeat(64)}` }, + { expected_lifecycle_epoch: input.expected_lifecycle_epoch + 1 }, + { expected_grant_epoch: input.expected_grant_epoch + 1 }, { app_version_id: randomUUID() }]) { + assert.equal((await h.call(`${h.path}/review`, { ...input, ...change })).status, 409); + } + assert.equal((await h.call(`${h.path}/activate`, { ...input, + expected_review_digest: `sha256:${'0'.repeat(64)}`, accept_host_policy: true })).status, 409); + assert.equal((await h.call(`${h.path}/review`, { ...input, authority: {} })).status, 400); + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + try { assert.equal((await h.context()).status, 503); assert.equal((await h.call(`${h.path}/review`, input)).status, 503); } + finally { process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; } +}); + +test('disabled sync-only App requires fresh pins and explicit admin acceptance', { skip: !safe }, async () => { + const h = await fixture('5', false, true); + const input = (await h.context()).body.review_request; + const review = await h.call(`${h.path}/review`, input); + const original = { ...input, expected_review_digest: review.body.review_digest, accept_host_policy: true }; + const activated = await h.call(`${h.path}/activate`, original); + assert.equal(activated.status, 200); + const disabled = await h.call(`/api/apps/${h.installed.id}/disable`, + { expected_lifecycle_epoch: activated.body.installation.lifecycle_epoch }); + assert.equal(disabled.status, 200, JSON.stringify(disabled.body)); + await h.db.update(h.schema.orgMembers).set({ role: 'admin' }).where(h.and( + h.eq(h.schema.orgMembers.org_id, h.org), h.eq(h.schema.orgMembers.user_id, h.owner))); + const next = await h.context(); + assert.equal(next.status, 200); + assert.equal(next.body.state, 'disabled'); + assert.equal(next.body.current_activation, null); + assert.ok(next.body.review_request.expected_lifecycle_epoch > input.expected_lifecycle_epoch); + assert.equal((await h.call(`${h.path}/activate`, original)).status, 409); + const prepared = await h.call(`${h.path}/review`, next.body.review_request); + assert.equal(prepared.status, 200); + assert.notEqual(prepared.body.review_digest, review.body.review_digest); + const accepted = await h.call(`${h.path}/activate`, { ...next.body.review_request, + expected_review_digest: prepared.body.review_digest, accept_host_policy: true }); + assert.equal(accepted.status, 200, JSON.stringify(accepted.body)); + assert.notEqual(accepted.body.grant_snapshot_id, activated.body.grant_snapshot_id); + assert.equal((await h.db.select().from(h.schema.appModuleBindings).where(h.eq(h.schema.appModuleBindings.org_id, h.org))).length, 1); + const [module] = await h.db.select().from(h.schema.moduleInstallations).where(h.eq(h.schema.moduleInstallations.org_id, h.org)); + assert.equal(module!.is_enabled, true); + assert.equal(module!.disabled_at, null); + assert.equal((await h.context()).body.current_activation.review_digest, prepared.body.review_digest); +}); + +test('v3 v4 and mixed v5 preserve v1 admission without acquiring v5 action support', { skip: !safe }, async () => { + for (const protocol of ['3', '4', '5'] as const) { + const h = await fixture(protocol, true); + assert.equal((await h.context()).status, 503, `${protocol} must not use the sync-only exception`); + process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'true'; + try { + const input = (await h.context()).body.review_request; + const review = await h.call(`${h.path}/review`, input); + assert.equal(review.status, 200); + const accepted = { ...input, expected_review_digest: review.body.review_digest, accept_host_policy: true }; + const result = await h.call(`${h.path}/activate`, accepted); + assert.equal(result.status, 200, JSON.stringify(result.body)); + const { loadReviewedRuntimeAction } = await import('../src/lib/app-runtime-review.js'); + const load = () => h.db.transaction(tx => loadReviewedRuntimeAction(tx, h.org, h.installed.id, 'send_message')); + if (protocol === '5') await assert.rejects(load()); else assert.ok((await load()).action); + } finally { process.env.DEFT_APP_RUNTIME_CHANNEL_ENABLED = 'false'; } + } +}); + +test('concurrent accepts create one grant and lost response is recovered by exact current digest', { skip: !safe }, async () => { + const h = await fixture('5', false, true); + const input = (await h.context()).body.review_request; + const review = await h.call(`${h.path}/review`, input); + const value = { ...input, expected_review_digest: review.body.review_digest, accept_host_policy: true }; + const outcomes = await Promise.all([h.call(`${h.path}/activate`, value), h.call(`${h.path}/activate`, value)]); + assert.deepEqual(outcomes.map(item => item.status).sort(), [200, 409]); + const current = (await h.context()).body; + assert.equal(current.current_activation.review_digest, review.body.review_digest); + assert.equal(current.review_request, null); + const grants = await h.db.select().from(h.schema.appGrantSnapshots).where(h.and( + h.eq(h.schema.appGrantSnapshots.org_id, h.org), h.eq(h.schema.appGrantSnapshots.snapshot_kind, 'effective'))); + assert.equal(grants.length, 1); + assert.equal((await h.db.select().from(h.schema.appModuleBindings).where(h.eq(h.schema.appModuleBindings.org_id, h.org))).length, 1); + const [installation] = await h.db.select().from(h.schema.appInstallations).where(h.eq(h.schema.appInstallations.id, h.installed.id)); + assert.equal(installation!.lifecycle_epoch, input.expected_lifecycle_epoch + 1); + assert.equal(installation!.grant_epoch, input.expected_grant_epoch + 1); + assert.equal((await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org), h.eq(h.schema.auditLog.action, 'app.runtime.review_activate')))).length, 1); +}); + +async function waitForSidLock(h: Awaited>) { + for (let i = 0; i < 200; i++) { + const result = await h.db.execute(h.sql<{ count: number }>`SELECT count(*)::int AS count FROM pg_stat_activity + WHERE datname=current_database() AND wait_event_type='Lock' AND query ILIKE '%web_sessions%' + AND query ILIKE '%for share%'`); + if (result.rows[0]!.count > 0) return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.fail('request never reached final SID lock'); +} + +for (const operation of ['context', 'review', 'activate'] as const) { + for (const race of ['revoke', 'expire', 'token', 'kind', 'flag'] as const) { + test(`web ${operation} rejects ${race} at final SID lock and activation rolls back modules`, { skip: !safe }, async () => { + const h = await fixture('5', false, operation === 'activate'); + const input = (await h.context()).body.review_request; + const review = await h.call(`${h.path}/review`, input); + const value = operation === 'activate' ? { ...input, expected_review_digest: review.body.review_digest, accept_host_policy: true } : input; + const caller = await h.sessions.verifyWebAccess(h.web.accessToken); + const expiry = new Date(Date.now() + 1_200); + if (race === 'expire') await h.db.update(h.schema.webSessions).set({ expires_at: expiry }).where(h.eq(h.schema.webSessions.id, caller.sid)); + let release!: () => void; let entered!: () => void; + const held = new Promise(resolve => { entered = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const blocker = h.db.transaction(async tx => { + await tx.execute(h.sql`SELECT id FROM web_sessions WHERE id = ${caller.sid} FOR UPDATE`); + entered(); await released; + if (race === 'revoke') await tx.update(h.schema.webSessions).set({ revoked_at: new Date() }).where(h.eq(h.schema.webSessions.id, caller.sid)); + }); + let pending: Promise>> | undefined; + try { + await held; + let auth = h.web.accessToken; + const tokenExpiry = Math.floor(Date.now() / 1000) + 3; + if (race === 'token') { + const jwt = (await import('jsonwebtoken')).default; + const { env } = await import('../src/lib/env.js'); + auth = jwt.sign({ id: h.owner, org_id: h.org, email: `${h.owner}@example.test`, + sid: caller.sid, jti: randomUUID(), purpose: 'web-access', exp: tokenExpiry }, env.JWT_SECRET); + } + pending = operation === 'context' + ? h.call(`${h.path}/context?app_version_id=${h.installed.version_id}`, undefined, auth) + : h.call(`${h.path}/${operation}`, value, auth); + await waitForSidLock(h); + if (race === 'kind') await h.db.update(h.schema.users).set({ kind: 'agent' }).where(h.eq(h.schema.users.id, h.owner)); + if (race === 'flag') process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'false'; + if (race === 'expire') { + assert.ok(Date.now() < expiry.getTime()); + await new Promise(resolve => setTimeout(resolve, expiry.getTime() - Date.now() + 30)); + } + if (race === 'token') { + assert.ok(Date.now() < tokenExpiry * 1000); + await new Promise(resolve => setTimeout(resolve, tokenExpiry * 1000 - Date.now() + 30)); + } + release(); await blocker; + const denied = await pending; + assert.equal(denied.status, race === 'kind' ? 403 : race === 'flag' ? 503 : 401, JSON.stringify(denied.body)); + const [installation] = await h.db.select().from(h.schema.appInstallations).where(h.eq(h.schema.appInstallations.id, h.installed.id)); + assert.equal(installation!.state, 'staged'); + assert.equal(installation!.lifecycle_epoch, input.expected_lifecycle_epoch); + assert.equal(installation!.grant_epoch, input.expected_grant_epoch); + const [version] = await h.db.select().from(h.schema.appVersions).where(h.eq(h.schema.appVersions.id, input.app_version_id)); + assert.equal(version!.state, 'staged'); + for (const table of [h.schema.appModuleBindings, h.schema.moduleInstallations]) { + assert.equal((await h.db.select({ id: table.id }).from(table).where(h.eq(table.org_id, h.org))).length, 0); + } + assert.equal((await h.db.select().from(h.schema.appGrantSnapshots).where(h.and( + h.eq(h.schema.appGrantSnapshots.org_id, h.org), h.eq(h.schema.appGrantSnapshots.snapshot_kind, 'effective')))).length, 0); + assert.equal((await h.db.select().from(h.schema.auditLog).where(h.and( + h.eq(h.schema.auditLog.org_id, h.org), h.eq(h.schema.auditLog.action, 'app.runtime.review_activate')))).length, 0); + } finally { + release(); await blocker; await pending; + process.env.DEFT_APP_RESOURCE_SYNC_CHANNEL_ENABLED = 'true'; + } + }); + } +} From 652e53101d01989f3aa511f20bc0dd70be67495b Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:58:41 +0530 Subject: [PATCH 050/161] feat(apps): connect private resources through reviewed browser setup --- .../app/(app)/settings/apps/apps-client.tsx | 3 + .../[bindingId]/operator-session-client.tsx | 95 ++++++++++ .../operator/[bindingId]/page.tsx | 10 ++ .../private-resource-setup.tsx | 163 ++++++++++++++++++ .../private-resources-client.tsx | 8 +- .../private-resources.module.css | 5 + .../components/apps/resource-app-review.tsx | 91 ++++++++++ 7 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/operator/[bindingId]/operator-session-client.tsx create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/operator/[bindingId]/page.tsx create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/private-resource-setup.tsx create mode 100644 apps/web/src/app/(app)/settings/apps/private-resources/private-resources.module.css create mode 100644 apps/web/src/components/apps/resource-app-review.tsx diff --git a/apps/web/src/app/(app)/settings/apps/apps-client.tsx b/apps/web/src/app/(app)/settings/apps/apps-client.tsx index 91c68c52..c5bef7c4 100644 --- a/apps/web/src/app/(app)/settings/apps/apps-client.tsx +++ b/apps/web/src/app/(app)/settings/apps/apps-client.tsx @@ -6,6 +6,7 @@ import { AppWindow, Check, Copy, FileUp, KeyRound, Loader2, Power, ShieldCheck, import { PageHeader } from '@/components/page-header'; import { EmptyState } from '@/components/empty-state'; import { ConnectedAppManagement } from '@/components/apps/connected-app-management'; +import { ResourceAppReview } from '@/components/apps/resource-app-review'; import { useSetPageContext } from '@/components/app-header-context'; import { api } from '@/lib/api'; import { APP_RESOURCE_SYNC_ENABLED } from '@/lib/feature-flags'; @@ -199,8 +200,10 @@ function AppCard({ app, canManage, busy, onActivate, onEnable, onDisable, onChoo {app.state === 'active' ? 'Reviewed Runtime App' : 'Owner review required before use'} {app.state === 'active' && app.manifest.schema_version === '4' && app.manifest.experiences.map((experience) => Open {experience.label})} + {canManage && APP_RESOURCE_SYNC_ENABLED && app.state === 'active' && app.manifest.schema_version === '5' && app.manifest.sync_descriptors.length > 0 && Connect private resources} {canManage && app.state === 'active' && }
} + {canManage && APP_RESOURCE_SYNC_ENABLED && ['staged', 'disabled'].includes(app.state) && app.manifest.schema_version === '5' && app.manifest.runtime_actions.length === 0 && app.manifest.sync_descriptors.length > 0 && } {showConnectedManagement && }
; } diff --git a/apps/web/src/app/(app)/settings/apps/private-resources/operator/[bindingId]/operator-session-client.tsx b/apps/web/src/app/(app)/settings/apps/private-resources/operator/[bindingId]/operator-session-client.tsx new file mode 100644 index 00000000..e617f96f --- /dev/null +++ b/apps/web/src/app/(app)/settings/apps/private-resources/operator/[bindingId]/operator-session-client.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { PageHeader } from '@/components/page-header'; +import { useAuth } from '@/lib/auth-context'; +import { api } from '@/lib/api'; +import { appApiError } from '@/lib/apps'; +import { useSetPageContext } from '@/components/app-header-context'; +import styles from '../../private-resources.module.css'; + +type Session = { session_id: string; session_token: string; expires_at: string }; +const management = '/api/app-resource-sync-management'; +const channelUrl = `${(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001').replace(/\/$/, '')}/api/app-resource-sync/channel`; + +export function OperatorSessionClient({ bindingId }: { bindingId: string }) { + const { user, sessionCacheScope } = useAuth(); + useSetPageContext(Private resource operator, []); + if (!user || !sessionCacheScope) return null; + return ; +} + +function OperatorSession({ bindingId }: { bindingId: string }) { + const [session, setSession] = useState | null>(null); + const [credential, setCredential] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const generation = useRef(0); + const issuing = useRef(null); + const clearCredential = useCallback(() => { + generation.current += 1; setCredential(null); setBusy(false); + }, []); + useEffect(() => { + const hide = () => { if (document.hidden) { clearCredential(); setNotice(issuing.current ? 'Credential delivery was interrupted. A session may remain active for up to 15 minutes. Revoke the connection from Private resources to invalidate all its sessions.' : 'The credential was hidden. Its session remains valid until expiry or revocation.'); } }; + document.addEventListener('visibilitychange', hide); + window.addEventListener('pagehide', clearCredential); + return () => { generation.current += 1; document.removeEventListener('visibilitychange', hide); window.removeEventListener('pagehide', clearCredential); }; + }, [clearCredential]); + useEffect(() => { + if (!session) return; + let timer: ReturnType; + const check = () => { + const remaining = new Date(session.expires_at).getTime() - Date.now(); + if (!Number.isFinite(remaining) || remaining <= 0) { clearCredential(); setSession(null); setNotice('This operator session has expired.'); return; } + timer = setTimeout(check, Math.min(remaining, 2_147_483_647)); + }; + check(); + return () => clearTimeout(timer); + }, [session, clearCredential]); + const issue = async () => { + const request = ++generation.current; + issuing.current = request; + setBusy(true); setCredential(null); setError(null); setNotice(null); + try { + const response = await api.post(`${management}/bindings/${encodeURIComponent(bindingId)}/sessions`); + if (!response.ok) throw new Error(await appApiError(response, 'Unable to issue an operator credential.')); + const body = await response.json() as { session: Session }; + if (request !== generation.current || document.hidden) return; + if (!Number.isFinite(new Date(body.session.expires_at).getTime()) || new Date(body.session.expires_at).getTime() <= Date.now()) { setNotice('The operator session expired before it arrived. Request a new credential.'); return; } + setSession({ session_id: body.session.session_id, expires_at: body.session.expires_at }); + setCredential(JSON.stringify({ session_id: body.session.session_id, session_token: body.session.session_token }, null, 2)); + } catch (reason) { if (request === generation.current) { setError(reason instanceof Error ? reason.message : 'Unable to issue credential.'); setNotice('If issuance reached the server, an unreceived session may remain active for up to 15 minutes. Revoke the connection from Private resources to invalidate all its sessions.'); } } + finally { if (issuing.current === request) issuing.current = null; if (request === generation.current) setBusy(false); } + }; + const revoke = async () => { + if (!session) return; + const request = ++generation.current; + setBusy(true); setCredential(null); setError(null); + try { + const response = await api.post(`${management}/sessions/${encodeURIComponent(session.session_id)}/revoke`); + if (!response.ok) throw new Error(await appApiError(response, 'Unable to revoke this operator session.')); + if (request === generation.current) { setSession(null); setNotice('Operator session revoked. The connection remains available for a new session.'); } + } catch (reason) { if (request === generation.current) setError(reason instanceof Error ? reason.message : 'Unable to revoke session.'); } + finally { if (request === generation.current) setBusy(false); } + }; + return
+ +
+ ← Private resources +
+

Only the assigned operator can issue this connection’s credential. Give it directly to the provider process you run. It permits the reviewed sync for this connection.

+

The credential is shown once and is cleared when you leave or hide this page. It lasts at most 15 minutes, capped by consent expiry. Hiding it does not revoke the session.

+

If delivery is interrupted, revoke the connection from Private resources to invalidate any session you did not receive.

+ + {error &&

{error}

} + {notice &&

{notice}

} + {busy &&

Updating operator session…

} + {session &&

Session expires {new Date(session.expires_at).toLocaleString()}.

} + {credential &&