From d0fd82bd12289d0276a2b3abe5d6f79eaf657ccb Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 14 Sep 2026 15:48:01 -0700 Subject: [PATCH] feat(core): define event subscriptions and handler envelope contract --- CHANGELOG.md | 1 + docs/event-subscriptions.md | 163 +++++++++++++++++++++ docs/verification/event-contract.md | 92 ++++++++++++ packages/core/README.md | 23 +++ packages/core/package.json | 5 + packages/core/src/events/contract.test.ts | 167 ++++++++++++++++++++++ packages/core/src/events/event.ts | 29 ++++ packages/core/src/events/index.ts | 4 + packages/core/src/events/subscription.ts | 27 ++++ packages/core/src/events/types.ts | 42 ++++++ packages/core/src/events/validation.ts | 100 +++++++++++++ packages/core/src/index.ts | 1 + 12 files changed, 654 insertions(+) create mode 100644 docs/event-subscriptions.md create mode 100644 docs/verification/event-contract.md create mode 100644 packages/core/src/events/contract.test.ts create mode 100644 packages/core/src/events/event.ts create mode 100644 packages/core/src/events/index.ts create mode 100644 packages/core/src/events/subscription.ts create mode 100644 packages/core/src/events/types.ts create mode 100644 packages/core/src/events/validation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8356ff..0bdd3474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- `@relayfile/adapter-core/events` now provides versioned subscription and handler-event contracts with browser-safe validation against existing adapter event catalogs, preserving logical event identity separately from transport delivery IDs. - `@relayfile/adapter-github` now exports a cursor-resumable pull-index convergence primitive that backfills `headRef` with one GitHub list request per invocation and no per-record metadata, file, or diff fetches. - `@relayfile/adapter-linear` now materializes `/linear/issues/by-project//.json` aliases so project-scoped consumers can avoid mounting the full issue tree. The path mapper and generated `LAYOUT.md` contract expose the new lookup; existing mounts must resync to materialize and discover it. - `@relayfile/adapter-ramp` now provides read-only Ramp finance materialization with canonical bill, purchase-order, item-receipt, vendor-agreement, transaction, reimbursement, receipt, vendor, transfer, repayment, dimension, and accounting paths; stable indexes and aliases; a generated `LAYOUT.md` contract; Hookdeck-aware inbound declarations; webhook normalization and signature verification; and digest-visible lifecycle classification. Existing consumers must resync to materialize and discover the new canonical paths and layout contract. diff --git a/docs/event-subscriptions.md b/docs/event-subscriptions.md new file mode 100644 index 00000000..565602a0 --- /dev/null +++ b/docs/event-subscriptions.md @@ -0,0 +1,163 @@ +# Event subscriptions and handler input + +`@relayfile/adapter-core/events` defines the versioned data exchanged between an +event source and a handler runtime. It supplies browser-safe TypeScript types, +constructors, and parsers. It does not start listeners or flow runs. + +Subscriptions belong before execution: an event that does not match creates no +run. A handler can therefore concentrate on work, without treating a filter +mismatch as cancellation or a successful empty run. + +## Declare a subscription + +```ts +import { defineEventSubscription } from '@relayfile/adapter-core/events'; + +export const source = defineEventSubscription({ + provider: 'linear', + connectionId: 'conn_linear_team', + eventTypes: ['issue.create', 'issue.update'], + pathPrefixes: ['/linear/issues'], +}); +``` + +The constructor adds `schema: 'relayfile.event-subscription/1'` and returns a +validated, immutable snapshot. `parseEventSubscription(unknown)` reads the +serialized form, including its schema discriminator. Unknown providers, event +names, schema versions, or selector fields are rejected rather than ignored. + +The v1 selector contract is: + +- `provider` and `connectionId` must both match the event exactly. The connection + ID is a host-resolved reference, never a credential or proof of access. +- `eventTypes` is a nonempty, duplicate-free OR list of exact adapter event + names. For Linear, use `issue.create`, not `linear.issue.create` or `issue.*`. +- `pathPrefixes`, when present, is a nonempty, duplicate-free OR list. At least + one affected event path must equal a prefix or lie beneath it at a `/` segment + boundary. `/linear/issues` matches `/linear/issues/ENG-42__issue-42.json`, but + not `/linear/issues-archive/ENG-42__issue-42.json`. `/` includes every path. +- Omitting `pathPrefixes` selects all paths for the specified connection and + event types. An empty list is rejected; it does not mean all paths. + +Paths must be concrete absolute Relayfile paths. Globs, unresolved `{templates}`, +traversal, empty segments, and trailing slashes other than `/` are rejected. +Use adapter-owned path helpers for record paths; do not concatenate provider +resource paths. An adapter's published resource roots, such as `/linear/issues`, +can be used as literal subtree selectors. + +These are matching semantics for the host to implement. Neither subscription +parsing nor event parsing performs matching. + +## Preserve the event delivered to the handler + +At an adapter-aware ingestion boundary, construct an event using the existing +logical identity and adapter-owned path mapping: + +```ts +import { createAdapterEvent } from '@relayfile/adapter-core/events'; +import { linearIssuePath } from '@relayfile/adapter-linear/path-mapper'; + +const event = createAdapterEvent({ + id: 'upstream-logical-event-123', + provider: 'linear', + eventType: 'issue.create', + workspaceId: 'workspace_123', + connectionId: 'conn_linear_team', + deliveryId: 'transport-attempt-456', + occurredAt: '2026-09-14T19:00:00.000Z', + paths: [linearIssuePath('issue-42', 'ENG-42')], + payload: { id: 'issue-42', identifier: 'ENG-42', title: 'Fix sign-in' }, +}); +``` + +This example's helper produces `/linear/issues/ENG-42__issue-42.json`. It uses +the canonical issue record, not an alias or a legacy `metadata.json` path. The +provider path helper runs at the adapter boundary; the generic events module +itself imports no provider adapter. + +The constructor adds `schema: 'relayfile.adapter-event/1'`. +`parseAdapterEvent(unknown)` validates the serialized envelope. Both preserve +and freeze a JSON snapshot; neither fetches a fresher provider record. + +| Field | Contract | +| --- | --- | +| `id` | Existing logical event identity, stable across redelivery. The constructor never mints one. | +| `provider`, `eventType` | Canonical provider and exact event name from the existing trigger catalog. | +| `workspaceId`, `connectionId` | Context the host must verify against its authenticated binding. | +| `deliveryId` | Optional transport attempt identity; separate from logical event identity. | +| `occurredAt` | Canonical UTC ISO timestamp, including milliseconds and `Z`. | +| `paths` | Nonempty, duplicate-free concrete affected paths supplied by adapter-owned mapping. | +| `payload` | Preserved finite, acyclic JSON data; provider-specific payload schemas remain separate. | + +These parsers validate shape, catalog names, and JSON data. They do not validate +provider payload semantics, authenticate an event, authorize a workspace or +connection, match a subscription, or deduplicate delivery. A parsed event remains +untrusted until the host completes those checks. + +## Runtime obligations before execution + +The Cloud or CLI host owns the executable binding: subscription identity and +revision, authenticated workspace, connection, handler identity, and executable +version. Those deployment fields are outside the portable subscription selector. + +Before activating a subscription, the host must resolve the connection, verify +its provider and workspace ownership, check required authentication and connection +scope, and confirm that the bound handler can execute. At delivery, it must: + +1. Authenticate the ingress and verify the event's workspace and connection + against that binding. Do not trust those IDs merely because parsing succeeded. +2. Resolve the active subscription revision and apply its selectors. No match + means no run. A stale or unauthorized binding must not invoke the handler. +3. Claim the logical event for the subscription durably before dispatch. Dedupe + by subscription identity plus logical event identity, within the verified + workspace/connection boundary; a transport retry must not create another run. + Record the matched subscription revision with that claim so retries execute + the original binding rather than silently switching handlers or selectors. +4. Persist the accepted event snapshot with the run and pass that same snapshot + to the handler on execution and recovery. Keep external-effect idempotency + separate from delivery deduplication. + +An explicit replay or subscription replacement needs its own host policy. A new +transport `deliveryId` or a changed timestamp is not permission to duplicate work. + +## Reuse adapter metadata + +The events module validates against `KNOWN_TRIGGER_CATALOG` from +`@relayfile/adapter-core/triggers`; it introduces no second provider inventory. +Connection setup can also consume the existing `/inbound`, `/scope-keys`, and +`/writeback-paths` catalogs. Canonical resource paths remain adapter-owned. + +The scope-key catalog lists supported connection filter **names**. It does not +describe their value types, discover selectable teams or channels, or grant OAuth +permissions. Consumers must use the adapter's connection/configuration contract +and authenticated discovery for those values. Do not infer a complete connection +form or accept arbitrary filter values from a scope-key name alone. + +## Cloud and Flows rollout + +The existing Cloud agent `onEvent` envelope requires an explicit bridge. Map its +resource data into `payload`, preserve the exact catalog `eventType`, and carry +verified workspace/connection context and adapter-produced paths into this +envelope. Do not copy a Cloud ID blindly: where Cloud falls back to a timestamp +for identity, retries do not have a sufficient stable dedupe key. Preserve the +upstream logical ID, or derive it at ingress under the existing adapter-owned +logical-key policy before constructing this event. Transport identity belongs in +`deliveryId` when available. + +This package adds the contract, not that bridge. Flows' existing `.on(source, +handler)` surface still needs an implementation that binds this subscription to +an executable handler. This document does not introduce a new working Flows API +or claim that Cloud event execution is complete. + +Release sequence: + +1. Merge this adapter-core change, then publish `core` through the repository's + publish workflow. Feature PRs leave package versions unchanged. +2. Update Flows to the published core version and implement the event binding. + A one-off CLI run must accept a supplied event through the same handler path; + live listening additionally requires an authenticated subscription runtime. +3. Wire Cloud delivery to that handler contract, retaining host authentication, + matching, durable deduplication, and the accepted event snapshot. +4. Update the builder to consume published metadata and generate the supported + subscription declaration plus handler function. Verify the authenticated CLI + and Cloud paths before describing the generated result as runnable end to end. diff --git a/docs/verification/event-contract.md b/docs/verification/event-contract.md new file mode 100644 index 00000000..b12ed8bd --- /dev/null +++ b/docs/verification/event-contract.md @@ -0,0 +1,92 @@ +# Event contract verification + +Working tree based on adapter main `0ce581f5`. Package versions are unchanged. + +## Repository gate + +Command (from repository root, with local test-server access): + +```sh +PATH="/tmp/flows-ci-toolchain/node_modules/.bin:$PATH" npm_config_cache=/tmp/relayfile-event-npm-cache npx turbo build typecheck test > /tmp/relayfile-event-gate.log 2>&1 +``` + +Exit code: 0. Full captured output is retained locally at +`/tmp/relayfile-event-gate.log` (16,086 lines). The final output excerpt is: + +```text + + Tasks: 157 successful, 157 total +Cached: 0 cached, 157 total + Time: 39.36s + +``` + +This includes the new public-export tests, existing adapter tests and catalog +checks. The new tests cover every catalog event, serialized declarations, +immutable snapshots, required provenance, identity preservation, concrete paths, +and finite acyclic JSON. TypeScript compilation also checks provider/event +mismatch examples. No test gate or existing test was modified. + +## Packed consumer and browser bundle + +Commands: + +```sh +mkdir -p /tmp/relayfile-event-pack +PATH="/tmp/flows-ci-toolchain/node_modules/.bin:$PATH" npm pack --workspace @relayfile/adapter-core --ignore-scripts --pack-destination /tmp/relayfile-event-pack --json > /tmp/relayfile-event-pack.json +./node_modules/.bin/esbuild packages/core/src/events/index.ts --bundle --platform=browser --format=esm --outfile=/tmp/relayfile-event-pack/events.browser.mjs +``` + +Exit code: 0. Captured bundler output: + +```text + ../../../tmp/relayfile-event-pack/events.browser.mjs 20.5kb + +⚡ Done in 14ms +``` + +The tarball was unpacked as the sole package in a fresh consumer's node_modules: + +```sh +mkdir -p /tmp/relayfile-event-consumer/node_modules/@relayfile/adapter-core +tar -xzf /tmp/relayfile-event-pack/relayfile-adapter-core-0.5.24.tgz -C /tmp/relayfile-event-consumer/node_modules/@relayfile/adapter-core --strip-components=1 +cp /tmp/relayfile-event-pack/events.browser.mjs /tmp/relayfile-event-consumer/events.browser.mjs +``` + +Exit code: 0; no output. The consumer script was: + +```js +import assert from 'node:assert/strict'; +import * as packed from '@relayfile/adapter-core/events'; +import * as browser from './events.browser.mjs'; + +const input = { + id: 'stable-upstream-event', provider: 'linear', eventType: 'issue.create', + workspaceId: 'workspace', connectionId: 'connection', + occurredAt: '2026-09-14T00:00:00.000Z', + paths: ['/linear/issues/ENG-42__issue-42.json'], payload: { title: 'Example' }, +}; +const selector = { provider: 'linear', connectionId: 'connection', eventTypes: ['issue.create'] }; +assert.deepEqual(packed.createAdapterEvent(input), browser.createAdapterEvent(input)); +assert.deepEqual(packed.defineEventSubscription(selector), browser.defineEventSubscription(selector)); +assert.equal(packed.parseAdapterEvent(JSON.parse(JSON.stringify(packed.createAdapterEvent(input)))).id, input.id); +assert.throws(() => packed.parseEventSubscription({ ...packed.defineEventSubscription(selector), labels: ['ready'] })); +console.log('Packed package export and browser bundle agree; serialized identity preserved; unsupported selectors rejected.'); +``` + +Command: + +```sh +node /tmp/relayfile-event-consumer/check.mjs +``` + +Captured output: + +```text +Packed package export and browser bundle agree; serialized identity preserved; unsupported selectors rejected. +``` + +Exit code: 0. The bundled module was executed under Node for comparison; this is +not a browser UI test. No credentials, live provider events, subscription runtime, +Cloud deployment, or authored Flows handler bridge were exercised. Those consumer +changes follow the adapter-core release as described in `docs/event-subscriptions.md`. diff --git a/packages/core/README.md b/packages/core/README.md index 9c6b1093..01fbed5f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -74,6 +74,29 @@ const adapter = new SchemaAdapter({ }); ``` +## Event subscriptions + +`@relayfile/adapter-core/events` exports versioned subscription declarations and +handler event envelopes with browser-safe validation: + +```ts +import { defineEventSubscription } from '@relayfile/adapter-core/events'; + +const source = defineEventSubscription({ + provider: 'linear', + connectionId: 'conn_linear_team', + eventTypes: ['issue.create', 'issue.update'], + pathPrefixes: ['/linear/issues'], +}); +``` + +Event names come from the existing trigger catalog. `createAdapterEvent` and +`parseAdapterEvent` preserve the upstream logical event ID and payload; parsing +does not authenticate, match, or deduplicate. The host performs those checks +before invoking a handler, so unmatched events create no run. The Cloud/Flows +execution bridge is a separate consumer change. See the +[contract and rollout](../../docs/event-subscriptions.md). + ## What It Generates - `adapter.generated.ts`: static mapping logic for path resolution and writeback matching diff --git a/packages/core/package.json b/packages/core/package.json index b580c729..066921df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,11 @@ "import": "./dist/src/inbound/index.js", "default": "./dist/src/inbound/index.js" }, + "./events": { + "types": "./dist/src/events/index.d.ts", + "import": "./dist/src/events/index.js", + "default": "./dist/src/events/index.js" + }, "./package.json": "./package.json" }, "bin": { diff --git a/packages/core/src/events/contract.test.ts b/packages/core/src/events/contract.test.ts new file mode 100644 index 00000000..2de08028 --- /dev/null +++ b/packages/core/src/events/contract.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ADAPTER_EVENT_SCHEMA, + EVENT_SUBSCRIPTION_SCHEMA, + createAdapterEvent, + defineEventSubscription, + parseAdapterEvent, + parseEventSubscription, + type AdapterEventInput, +} from "@relayfile/adapter-core/events"; +import { KNOWN_TRIGGER_CATALOG } from "@relayfile/adapter-core/triggers"; + +function subscription() { + return { schema: EVENT_SUBSCRIPTION_SCHEMA, provider: "linear", eventTypes: ["issue.create"], connectionId: "linear-connection" }; +} + +function event(): AdapterEventInput<"linear"> { + return { + id: "v1:provider-delivery-id:sha256:upstream-logical-identity", + provider: "linear", + eventType: "issue.create", + workspaceId: "workspace-1", + connectionId: "linear-connection", + deliveryId: "transport-delivery-1", + occurredAt: "2026-09-14T12:34:56.789Z", + paths: ["/linear/issues/eng-123__issue-id.json"], + payload: { id: "issue-id", title: "Fix the build" }, + }; +} + +function serializedEvent() { + return { ...event(), schema: ADAPTER_EVENT_SCHEMA }; +} + +test("subscription and event parsers use every provider's existing trigger catalog", () => { + for (const [provider, eventTypes] of Object.entries(KNOWN_TRIGGER_CATALOG)) { + const parsed = parseEventSubscription({ ...subscription(), provider, eventTypes }); + assert.deepEqual(parsed.eventTypes, eventTypes); + for (const eventType of eventTypes) { + const parsedEvent = parseAdapterEvent({ ...serializedEvent(), provider, eventType }); + assert.equal(parsedEvent.provider, provider); + assert.equal(parsedEvent.eventType, eventType); + } + } + for (const provider of ["missing-provider", "toString", "__proto__"]) { + assert.throws(() => parseEventSubscription({ ...subscription(), provider }), /Unknown event provider/); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), provider }), /Unknown event provider/); + } + for (const eventType of ["issues.labeled", "linear.issue.create", "issue.*"]) { + assert.throws(() => parseEventSubscription({ ...subscription(), eventTypes: [eventType] }), /Unknown linear event type/); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), eventType }), /Unknown linear event type/); + } +}); + +test("serialized declarations reject unknown versions and unsupported selectors", () => { + assert.throws(() => parseEventSubscription({ ...subscription(), schema: "relayfile.event-subscription/2" }), /schema/); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), schema: "relayfile.adapter-event/2" }), /schema/); + for (const selector of ["filter", "scope", "labels", "providerConfigKey"]) { + assert.throws(() => parseEventSubscription({ ...subscription(), [selector]: { labels: ["ready"] } }), /not supported/); + } + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), accepted: true }), /not supported/); + for (const eventTypes of [[], ["issue.create", "issue.create"], "issue.create"]) { + assert.throws(() => parseEventSubscription({ ...subscription(), eventTypes }), /eventTypes/); + } +}); + +test("definitions are independent immutable snapshots of mutable inputs", () => { + const input = { provider: "linear" as const, eventTypes: ["issue.create" as const], connectionId: "connection-1", pathPrefixes: ["/linear/issues"] }; + const source = defineEventSubscription(input); + input.connectionId = "other-connection"; + input.eventTypes.length = 0; + input.pathPrefixes[0] = "/other"; + assert.equal(source.connectionId, "connection-1"); + assert.deepEqual(source.eventTypes, ["issue.create"]); + assert.deepEqual(source.pathPrefixes, ["/linear/issues"]); + assert.ok(Object.isFrozen(source)); + assert.ok(Object.isFrozen(source.eventTypes)); + assert.ok(Object.isFrozen(source.pathPrefixes)); + + const payload = { title: "before", nested: { labels: ["ready"] } }; + const paths = ["/linear/issues/issue.json"]; + const delivered = createAdapterEvent({ ...event(), paths, payload }); + payload.title = "after"; + payload.nested.labels.push("changed"); + paths[0] = "/other"; + assert.deepEqual(delivered.payload, { title: "before", nested: { labels: ["ready"] } }); + assert.deepEqual(delivered.paths, ["/linear/issues/issue.json"]); + assert.ok(Object.isFrozen(delivered)); + assert.ok(Object.isFrozen(delivered.paths)); + assert.ok(Object.isFrozen(delivered.payload)); + assert.ok(Object.isFrozen((delivered.payload as typeof payload).nested.labels)); +}); + +test("envelopes require provenance and preserve logical and transport identity exactly", () => { + const input = event(); + const delivered = createAdapterEvent(input); + assert.equal(delivered.id, input.id); + assert.equal(delivered.deliveryId, input.deliveryId); + assert.equal(delivered.occurredAt, input.occurredAt); + assert.equal(delivered.workspaceId, input.workspaceId); + assert.equal(delivered.connectionId, input.connectionId); + assert.deepEqual(parseAdapterEvent(JSON.parse(JSON.stringify(delivered))), delivered); + const { deliveryId: _deliveryId, ...withoutDelivery } = input; + assert.equal(Object.hasOwn(createAdapterEvent(withoutDelivery), "deliveryId"), false); + for (const field of ["id", "workspaceId", "connectionId", "occurredAt", "paths", "payload"]) { + const incomplete: Record = serializedEvent(); + delete incomplete[field]; + assert.throws(() => parseAdapterEvent(incomplete), TypeError, field); + } + for (const value of ["", " ", " shifted ", null, 5]) { + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), id: value }), TypeError); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), connectionId: value }), TypeError); + assert.throws(() => parseEventSubscription({ ...subscription(), connectionId: value }), TypeError); + } + for (const occurredAt of ["2026-02-30T12:34:56.789Z", "2026-09-14", "2026-09-14T12:34:56.789+00:00"]) { + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), occurredAt }), /occurredAt/); + } +}); + +test("selectors and affected paths require concrete absolute paths", () => { + for (const path of ["relative", "//linear", "/linear/", "/linear//issues", "/linear/../slack", "/linear/./issues", "/linear/*", "/linear/{id}", "/linear/[id]", "/linear/?", "/linear\\issues", "/linear/\u0000"]) { + assert.throws(() => parseEventSubscription({ ...subscription(), pathPrefixes: [path] }), /concrete absolute Relayfile path/, path); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), paths: [path] }), /concrete absolute Relayfile path/, path); + } + for (const paths of [[], ["/linear", "/linear"], " /linear"]) { + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), paths }), /paths/); + assert.throws(() => parseEventSubscription({ ...subscription(), pathPrefixes: paths }), /pathPrefixes/); + } + for (const path of ["/", "/linear/issues", "/linear/issues/eng-123__id.json"]) { + assert.deepEqual(parseEventSubscription({ ...subscription(), pathPrefixes: [path] }).pathPrefixes, [path]); + assert.deepEqual(parseAdapterEvent({ ...serializedEvent(), paths: [path] }).paths, [path]); + } +}); + +test("payloads preserve JSON values including null and reject non-JSON or cyclic input", () => { + const shared = { value: "reused" }; + const payloads = [null, true, false, 0, 1.25, "", [null, true, 1], { shared, again: shared }]; + for (const payload of payloads) { + assert.deepEqual(parseAdapterEvent({ ...serializedEvent(), payload }).payload, payload); + } + const cyclic: Record = {}; + cyclic.self = cyclic; + const cyclicArray: unknown[] = []; + cyclicArray.push(cyclicArray); + const badPayloads = [undefined, NaN, Infinity, -Infinity, BigInt(1), () => 1, Symbol("value"), new Date(), new Map(), cyclic, cyclicArray, { value: undefined }, [NaN], new Array(1)]; + for (const payload of badPayloads) { + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), payload }), TypeError); + } + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), payload: { get secret() { throw new Error("getter executed"); } } }), /JSON data properties/); + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), payload: { [Symbol("hidden")]: true } }), /JSON data properties/); + const getterArray = Object.defineProperty([1], "0", { get() { throw new Error("getter executed"); } }); + const hiddenObject = Object.defineProperty({}, "secret", { value: 1 }); + for (const payload of [getterArray, hiddenObject, Object.assign([1], { extra: 2 })]) { + assert.throws(() => parseAdapterEvent({ ...serializedEvent(), payload }), /JSON data/); + } +}); + +// Compile-time examples are deliberately not executed: runtime callers use parsers. +function typeChecks() { + defineEventSubscription({ provider: "linear", eventTypes: ["issue.create"], connectionId: "connection" }); + // @ts-expect-error GitHub trigger names do not belong to Linear. + defineEventSubscription({ provider: "linear", eventTypes: ["issues.labeled"], connectionId: "connection" }); + // @ts-expect-error GitHub trigger names do not belong to Linear. + createAdapterEvent({ ...event(), eventType: "issues.labeled" }); +} +void typeChecks; diff --git a/packages/core/src/events/event.ts b/packages/core/src/events/event.ts new file mode 100644 index 00000000..55466094 --- /dev/null +++ b/packages/core/src/events/event.ts @@ -0,0 +1,29 @@ +import { ADAPTER_EVENT_SCHEMA, type AdapterEvent, type AdapterEventInput } from "./types.js"; +import type { KnownProviderName } from "../triggers/catalog.generated.js"; +import * as validate from "./validation.js"; + +/** Preserve an upstream identity; this function performs no matching, auth, or deduplication. */ +export function createAdapterEvent

(input: AdapterEventInput

): AdapterEvent

{ + const value = validate.record(input, "event"); + validate.knownKeys(value, ["id", "provider", "eventType", "workspaceId", "connectionId", "deliveryId", "occurredAt", "paths", "payload"], "event"); + return parseAdapterEvent({ ...value, schema: ADAPTER_EVENT_SCHEMA }) as AdapterEvent

; +} + +export function parseAdapterEvent(input: unknown): AdapterEvent { + const value = validate.record(input, "event"); + validate.knownKeys(value, ["schema", "id", "provider", "eventType", "workspaceId", "connectionId", "deliveryId", "occurredAt", "paths", "payload"], "event"); + if (value.schema !== ADAPTER_EVENT_SCHEMA) throw new TypeError("Unsupported adapter event schema"); + const provider = validate.provider(value.provider); + return Object.freeze({ + schema: ADAPTER_EVENT_SCHEMA, + id: validate.text(value.id, "id"), + provider, + eventType: validate.eventType(value.eventType, provider) as AdapterEvent["eventType"], + workspaceId: validate.text(value.workspaceId, "workspaceId"), + connectionId: validate.text(value.connectionId, "connectionId"), + ...(value.deliveryId === undefined ? {} : { deliveryId: validate.text(value.deliveryId, "deliveryId") }), + occurredAt: validate.timestamp(value.occurredAt), + paths: validate.list(value.paths, "paths", validate.path), + payload: validate.json(value.payload), + }); +} diff --git a/packages/core/src/events/index.ts b/packages/core/src/events/index.ts new file mode 100644 index 00000000..b0548526 --- /dev/null +++ b/packages/core/src/events/index.ts @@ -0,0 +1,4 @@ +// Keep this subpath browser-safe: no adapter imports, Node APIs, or runtime side effects. +export * from "./types.js"; +export * from "./subscription.js"; +export * from "./event.js"; diff --git a/packages/core/src/events/subscription.ts b/packages/core/src/events/subscription.ts new file mode 100644 index 00000000..1c40b0a3 --- /dev/null +++ b/packages/core/src/events/subscription.ts @@ -0,0 +1,27 @@ +import { EVENT_SUBSCRIPTION_SCHEMA, type EventSubscription, type EventSubscriptionInput } from "./types.js"; +import type { KnownProviderName } from "../triggers/catalog.generated.js"; +import * as validate from "./validation.js"; + +/** Snapshot a source declaration. The runtime resolves the connection before activation. */ +export function defineEventSubscription

(input: EventSubscriptionInput

): EventSubscription

{ + const value = validate.record(input, "subscription"); + validate.knownKeys(value, ["provider", "eventTypes", "connectionId", "pathPrefixes"], "subscription"); + return parseEventSubscription({ ...value, schema: EVENT_SUBSCRIPTION_SCHEMA }) as EventSubscription

; +} + +/** Parse untrusted serialized declarations; reject unsupported selectors rather than ignore them. */ +export function parseEventSubscription(input: unknown): EventSubscription { + const value = validate.record(input, "subscription"); + validate.knownKeys(value, ["schema", "provider", "eventTypes", "connectionId", "pathPrefixes"], "subscription"); + if (value.schema !== EVENT_SUBSCRIPTION_SCHEMA) throw new TypeError("Unsupported event subscription schema"); + const provider = validate.provider(value.provider); + return Object.freeze({ + schema: EVENT_SUBSCRIPTION_SCHEMA, + provider, + eventTypes: validate.list(value.eventTypes, "eventTypes", type => validate.eventType(type, provider)) as EventSubscription["eventTypes"], + connectionId: validate.text(value.connectionId, "connectionId"), + ...(value.pathPrefixes === undefined ? {} : { + pathPrefixes: validate.list(value.pathPrefixes, "pathPrefixes", validate.path), + }), + }); +} diff --git a/packages/core/src/events/types.ts b/packages/core/src/events/types.ts new file mode 100644 index 00000000..3ae74980 --- /dev/null +++ b/packages/core/src/events/types.ts @@ -0,0 +1,42 @@ +import type { KnownProviderName, KnownTriggerName } from "../triggers/catalog.generated.js"; + +export const EVENT_SUBSCRIPTION_SCHEMA = "relayfile.event-subscription/1" as const; +export const ADAPTER_EVENT_SCHEMA = "relayfile.adapter-event/1" as const; + +export type EventJson = null | boolean | number | string | readonly EventJson[] + | { readonly [key: string]: EventJson }; + +/** A declaration, not proof of connection authorization or successful matching. */ +export interface EventSubscription

{ + readonly schema: typeof EVENT_SUBSCRIPTION_SCHEMA; + readonly provider: P; + /** Exact adapter event names, without adding a provider prefix or wildcards. */ + readonly eventTypes: readonly KnownTriggerName

[]; + /** A reference resolved and authorized by the host. Never a credential. */ + readonly connectionId: string; + /** Optional concrete Relayfile subtrees. At least one affected path must match. */ + readonly pathPrefixes?: readonly string[]; +} + +export type EventSubscriptionInput

= + Omit, "schema">; + +/** Validated input to a handler; the host must authorize and match it first. */ +export interface AdapterEvent

{ + readonly schema: typeof ADAPTER_EVENT_SCHEMA; + /** Existing logical event identity, stable across retries. Never minted here. */ + readonly id: string; + readonly provider: P; + readonly eventType: KnownTriggerName

; + readonly workspaceId: string; + readonly connectionId: string; + /** Transport delivery identity, if present; it does not replace logical id. */ + readonly deliveryId?: string; + readonly occurredAt: string; + /** Concrete affected paths supplied by adapter-owned path mapping. */ + readonly paths: readonly string[]; + /** Adapter payload preserved as JSON. Provider-specific schemas remain separate. */ + readonly payload: EventJson; +} + +export type AdapterEventInput

= Omit, "schema">; diff --git a/packages/core/src/events/validation.ts b/packages/core/src/events/validation.ts new file mode 100644 index 00000000..6919e3a1 --- /dev/null +++ b/packages/core/src/events/validation.ts @@ -0,0 +1,100 @@ +import { KNOWN_TRIGGER_CATALOG, type KnownProviderName } from "../triggers/catalog.generated.js"; +import type { EventJson } from "./types.js"; + +export function record(value: unknown, at: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) { + throw new TypeError(`${at} must be a plain object`); + } + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError(`${at} must contain only JSON data properties`); + } + } + return value as Record; +} + +export function knownKeys(value: Record, keys: readonly string[], at: string): void { + for (const key of Object.keys(value)) { + if (!keys.includes(key)) throw new TypeError(`${at}.${key} is not supported`); + } +} + +export function text(value: unknown, at: string): string { + if (typeof value !== "string" || value.length === 0 || value.trim() !== value) { + throw new TypeError(`${at} must be a nonempty string without surrounding whitespace`); + } + return value; +} + +export function provider(value: unknown): KnownProviderName { + const name = text(value, "provider"); + if (!Object.hasOwn(KNOWN_TRIGGER_CATALOG, name)) throw new TypeError(`Unknown event provider: ${name}`); + return name as KnownProviderName; +} + +export function eventType(value: unknown, name: KnownProviderName): string { + const type = text(value, "eventType"); + if (!(KNOWN_TRIGGER_CATALOG[name] as readonly string[]).includes(type)) { + throw new TypeError(`Unknown ${name} event type: ${type}`); + } + return type; +} + +export function path(value: unknown, at: string): string { + const result = text(value, at); + if (!result.startsWith("/") || result.includes("\\") || /[\u0000-\u001f\u007f*?\[\]{}]/u.test(result) + || (result !== "/" && result.split("/").slice(1).some(part => !part || part === "." || part === ".."))) { + throw new TypeError(`${at} must be a concrete absolute Relayfile path without traversal or globs`); + } + return result; +} + +export function list(value: unknown, at: string, item: (value: unknown, at: string) => string): readonly string[] { + if (!Array.isArray(value) || value.length === 0) throw new TypeError(`${at} must be a nonempty array`); + arrayProperties(value); + const result: string[] = []; + for (let i = 0; i < value.length; i++) result.push(item(value[i], `${at}[${i}]`)); + if (new Set(result).size !== result.length) throw new TypeError(`${at} must not contain duplicates`); + return Object.freeze(result); +} + +function arrayProperties(value: unknown[]): void { + for (const key of Reflect.ownKeys(value)) { + if (key === "length") continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if (typeof key !== "string" || !/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length + || !descriptor.enumerable || !("value" in descriptor)) { + throw new TypeError("arrays must contain only JSON data elements"); + } + } +} + +export function timestamp(value: unknown): string { + const result = text(value, "occurredAt"); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(result) + || !Number.isFinite(Date.parse(result)) || new Date(result).toISOString() !== result) { + throw new TypeError("occurredAt must be a canonical UTC ISO timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)"); + } + return result; +} + +export function json(value: unknown, seen = new Set()): EventJson { + if (value === null || typeof value === "boolean" || typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "object" || seen.has(value)) throw new TypeError("payload must be finite, acyclic JSON data"); + seen.add(value); + try { + if (Array.isArray(value)) { + arrayProperties(value); + const items: EventJson[] = []; + for (let i = 0; i < value.length; i++) items.push(json(value[i], seen)); + return Object.freeze(items); + } + return Object.freeze(Object.fromEntries(Object.entries(record(value, "payload")) + .map(([key, item]) => [key, json(item, seen)]))); + } finally { + seen.delete(value); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 949dc5b6..8baa881a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,3 +35,4 @@ export * from "./triggers/catalog.generated.js"; export * from "./scope-keys/catalog.generated.js"; export * from "./writeback-paths/index.js"; export * from "./inbound/index.js"; +export * from "./events/index.js";