@smthrs/triggers 1.0.0-rc.7 → 1.0.0-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,14 +15,8 @@ started run gets exactly the authority a person starting it would get.
15
15
 
16
16
  ## Install
17
17
 
18
- `@smthrs/triggers` is not on npm at 1.0.0-rc.0. It ships as a member of the
19
- [smithers repository](https://github.com/smithersai/smithers) workspace, so
20
- using it today means working from a checkout:
21
-
22
18
  ```bash
23
- git clone https://github.com/smithersai/smithers.git
24
- cd smithers
25
- pnpm install
19
+ npm install @smthrs/triggers@next effect
26
20
  ```
27
21
 
28
22
  It needs Node.js 26.4.0 or later and `effect` `^4.0.4`. The full
@@ -1 +1 @@
1
- {"version":3,"file":"Webhook.d.ts","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,eAAe,MAAM,0BAA0B,CAAA;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,KAAK,OAAO,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,UAAU,EAAE,UAAU,UAAU,KAAG,OAM9E,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,KACzC,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;CAC7C;AAcD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,eAAe,KAAG,OAAO,CAAC,MA2BvE,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,MAAM,CAAC,OAAO,EAAE,QAAQ,GAAG,KAAK,CAAE,SAAQ,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAA;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACvE,QAAQ,CAAC,MAAM,EAAE,CACf,GAAG,EAAE,OAAO,CAAC,UAAU,KACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;CACnF;AA6DD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,EAAE,QAAQ,GAAG,KAAK,EAC5C,QAAQ,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,KAChC,OAgCF,CAAA"}
1
+ {"version":3,"file":"Webhook.d.ts","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,eAAe,MAAM,0BAA0B,CAAA;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,KAAK,OAAO,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,UAAU,EAAE,UAAU,UAAU,KAAG,OAM9E,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,KACzC,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;CAC7C;AAmCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,eAAe,KAAG,OAAO,CAAC,MA2BvE,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,MAAM,CAAC,OAAO,EAAE,QAAQ,GAAG,KAAK,CAAE,SAAQ,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAA;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACvE,QAAQ,CAAC,MAAM,EAAE,CACf,GAAG,EAAE,OAAO,CAAC,UAAU,KACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;CACnF;AA6DD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,EAAE,QAAQ,GAAG,KAAK,EAC5C,QAAQ,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,KAChC,OAgCF,CAAA"}
@@ -47,6 +47,15 @@ const constantTimeEqual = (expected, supplied) => {
47
47
  return difference === 0;
48
48
  };
49
49
  const ownedCopy = (body) => Uint8Array.from(body);
50
+ const headerValue = (headers, name) => {
51
+ const lower = name.toLowerCase();
52
+ const direct = headers[lower] ?? headers[name];
53
+ if (direct !== void 0) return direct;
54
+ for (const [key, value] of Object.entries(headers)) {
55
+ if (value !== void 0 && key.toLowerCase() === lower) return value;
56
+ }
57
+ return void 0;
58
+ };
50
59
  const makeSignatureVerifier = (config) => (raw, credential) => {
51
60
  const refuse = (cause) => Effect.fail(
52
61
  new import_TriggerError.TriggerError({
@@ -56,7 +65,7 @@ const makeSignatureVerifier = (config) => (raw, credential) => {
56
65
  })
57
66
  );
58
67
  return Effect.suspend(() => {
59
- const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header];
68
+ const supplied = headerValue(raw.headers, config.header);
60
69
  if (supplied === void 0 || supplied.length === 0) {
61
70
  return refuse();
62
71
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/Webhook.ts"],
4
- "sourcesContent": ["/**\n * Verified webhook ingestion through the authoritative Control boundary.\n *\n * @see packages/smithers/agent/triggers/docs/api.md\n * @since 0.1.0\n */\n\nimport * as ControlChannels from \"@smthrs/control/Channels\"\nimport type { ControlError } from \"@smthrs/control/ControlError\"\nimport { InvalidInput, Unauthorized } from \"@smthrs/control/ControlError\"\nimport type { Receipt, RunSummary } from \"@smthrs/control/ControlSchema\"\nimport type { CredentialRef } from \"@smthrs/control/Credential\"\nimport * as ControlWebhook from \"@smthrs/control/WebhookChannel\"\nimport * as Effect from \"effect/Effect\"\nimport type * as Redacted from \"effect/Redacted\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Channel from \"./Channel.ts\"\nimport { TriggerError } from \"./TriggerError.ts\"\n\n/**\n * Compares a supplied byte string against the expected one without returning\n * early on a mismatch.\n *\n * The loop runs exactly `expected.length` times, so its iteration count is\n * fixed by the secret side of the comparison and never by the caller's. Reading\n * the longer of the two instead let a caller lengthen its input until the work\n * stopped growing, which reports the expected signature's length. The length\n * difference is folded into the result, so inputs of unequal length always\n * disagree.\n *\n * @category verification\n * @since 0.1.0\n */\nexport const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {\n let difference = expected.length ^ supplied.length\n for (const [index, byte] of expected.entries()) {\n difference |= byte ^ (supplied[index] ?? 0)\n }\n return difference === 0\n}\n\n/**\n * Configuration for a raw-byte signature verifier.\n *\n * `expected` receives a private copy of the request bytes and the redacted\n * credential reference the channel was declared with, and answers with the\n * signature bytes the request must carry in `header`. It returns an Effect so\n * the secret is resolved through the host's resolver per request rather than\n * captured in a closure at declaration time, and so a resolution or HMAC\n * failure arrives as a typed `verification_failed` instead of a defect that\n * kills the fiber.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface SignatureConfig {\n readonly header: string\n readonly expected: (\n body: Uint8Array,\n credential: Redacted.Redacted<CredentialRef>\n ) => Effect.Effect<Uint8Array, TriggerError>\n}\n\n/**\n * Copies request bytes into memory nobody else holds.\n *\n * `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose\n * `slice` is an alias of `subarray` and answers with a view over the same\n * memory, so a caller that reused its `Buffer` after handing it over changed\n * what got verified, and a verifier that edited the bytes it was handed edited\n * the caller's. `Uint8Array.from` always allocates, and it copies only the\n * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.\n */\nconst ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)\n\n/**\n * Builds a verifier that compares a signature header in constant time.\n *\n * An absent or empty header is refused before `expected` runs, and an\n * `expected` that answers with zero bytes is refused after it. Neither is a\n * comparison the constant-time equality can decide: it agrees on two empty\n * byte strings, so a request carrying no signature at all would have\n * authenticated against a secret that resolved to the empty string. A\n * zero-length expected signature is a misconfigured credential, never a valid\n * one, so the door stays closed instead of opening for everyone.\n *\n * Every refusal carries the same message, `webhook signature in <header> did\n * not verify`, whatever the reason. A failure raised by `expected` is kept in\n * `cause` only: its message names the credential reference or the resolver\n * that could not produce a secret, which is a host detail, and the refusal\n * that answers a bad signature answers a broken resolver the same way.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {\n const refuse = (cause?: TriggerError) =>\n Effect.fail(\n new TriggerError({\n code: \"verification_failed\",\n message: `webhook signature in ${config.header} did not verify`,\n ...(cause === undefined ? {} : { cause })\n })\n )\n return Effect.suspend(() => {\n const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header]\n if (supplied === undefined || supplied.length === 0) {\n return refuse()\n }\n const actual = new TextEncoder().encode(supplied)\n // The verifier gets its own copy: nothing it does to these bytes can reach\n // the buffer that is about to be fingerprinted and decoded.\n return config.expected(ownedCopy(raw.body), credential).pipe(\n Effect.matchEffect({\n onFailure: (error) => refuse(error),\n onSuccess: (expected) =>\n expected.length > 0 && constantTimeEqual(expected, actual)\n ? Effect.void\n : refuse()\n })\n )\n })\n}\n\n/**\n * Webhook declaration configuration.\n *\n * `credential` is required. It used to default to a reference named after the\n * channel, so a webhook declared without one verified against whatever\n * credential happened to share its name instead of being refused, and two\n * declarations differing only in credential verified identically.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {\n readonly credential: Redacted.Redacted<CredentialRef>\n}\n\n/**\n * A webhook door that can only ingest through `Channels` and `Control`.\n *\n * @category services\n * @since 0.1.0\n */\nexport interface Webhook {\n readonly name: string\n readonly register: Effect.Effect<void, never, ControlChannels.Channels>\n readonly ingest: (\n raw: Channel.RawInbound\n ) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>\n}\n\nconst invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })\n\nconst toControlChannel = <Payload, Outbound>(\n config: Config<Payload, Outbound>\n): ControlChannels.Channel<Payload> => {\n const channel = ControlWebhook.make({\n name: config.name,\n schema: config.schema,\n credential: config.credential,\n // The message is fixed here rather than forwarded from the verifier: a\n // custom `verify` may say why it refused, and that reason travels toward\n // the unauthenticated sender on the same error that answers a bad\n // signature. `Unauthorized` carries no cause, so nothing else survives\n // the crossing.\n verify: (raw, credential) =>\n config.verify(raw, credential).pipe(\n Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))\n ),\n map: (payload) => {\n const inbound = config.inbound(payload)\n if (\"start\" in inbound) {\n return Effect.succeed({\n _tag: \"Start\" as const,\n flowId: inbound.start.flowId,\n input: inbound.start.input\n })\n }\n return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(\n Effect.map(\n (value) => ({\n _tag: \"Signal\" as const,\n runId: inbound.signal.runId,\n signal: {\n name: inbound.signal.stepId,\n payload: value\n }\n })\n ),\n Effect.mapError((error) => invalidInput(String(error)))\n )\n },\n project: (run) => {\n if (config.outbound === undefined) {\n return {\n cursor: String(run.updatedAt),\n operation: \"noop\" as const,\n message: null\n }\n }\n return {\n cursor: String(run.updatedAt),\n operation: \"post\" as const,\n message: config.outbound(run)\n }\n }\n })\n return channel\n}\n\n/**\n * Builds a webhook door whose only dispatch path is the Control channel\n * coordinator.\n *\n * Verification occurs inside `Channels.ingest` before the adapter's JSON or\n * schema decoder and before any Control operation.\n *\n * `ingest` does not register: a channel is registered once, deliberately,\n * through {@link Webhook.register}, so traffic to an unregistered channel is\n * reported as unavailable rather than silently self-registering the door it\n * arrived at.\n *\n * `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything\n * reads them. Verification, delivery fingerprinting, and decoding then all see\n * one private snapshot, so a verifier that edits the bytes it was handed, or a\n * caller that mutates its own object between building this Effect and running\n * it, cannot authenticate one payload and have another decoded. A\n * `SharedArrayBuffer`-backed view is copied out of shared memory by the same\n * step.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const make = <Payload, Outbound = never>(\n config: Config<Payload, Outbound>\n): Webhook => {\n const channel = toControlChannel(config)\n const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))\n return {\n name: config.name,\n register,\n ingest: (raw) => {\n // Copied here rather than inside the Effect: the copy has to happen when\n // the caller hands the request over, not when the returned Effect\n // eventually runs, or a caller that reuses its own buffer in between\n // changes what gets authenticated.\n const snapshot: Channel.RawInbound = {\n body: ownedCopy(raw.body),\n headers: { ...raw.headers },\n idempotencyKey: raw.idempotencyKey\n }\n return Effect.gen(function*() {\n const channels = yield* ControlChannels.Channels\n return yield* channels.ingest({ channel: config.name, raw: snapshot })\n }).pipe(\n Effect.mapError((error) =>\n error instanceof Unauthorized\n ? new TriggerError({\n code: \"verification_failed\",\n message: `webhook ${config.name} did not verify the request`,\n cause: error\n })\n : error\n )\n )\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Verified webhook ingestion through the authoritative Control boundary.\n *\n * @see packages/smithers/agent/triggers/docs/api.md\n * @since 0.1.0\n */\n\nimport * as ControlChannels from \"@smthrs/control/Channels\"\nimport type { ControlError } from \"@smthrs/control/ControlError\"\nimport { InvalidInput, Unauthorized } from \"@smthrs/control/ControlError\"\nimport type { Receipt, RunSummary } from \"@smthrs/control/ControlSchema\"\nimport type { CredentialRef } from \"@smthrs/control/Credential\"\nimport * as ControlWebhook from \"@smthrs/control/WebhookChannel\"\nimport * as Effect from \"effect/Effect\"\nimport type * as Redacted from \"effect/Redacted\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Channel from \"./Channel.ts\"\nimport { TriggerError } from \"./TriggerError.ts\"\n\n/**\n * Compares a supplied byte string against the expected one without returning\n * early on a mismatch.\n *\n * The loop runs exactly `expected.length` times, so its iteration count is\n * fixed by the secret side of the comparison and never by the caller's. Reading\n * the longer of the two instead let a caller lengthen its input until the work\n * stopped growing, which reports the expected signature's length. The length\n * difference is folded into the result, so inputs of unequal length always\n * disagree.\n *\n * @category verification\n * @since 0.1.0\n */\nexport const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {\n let difference = expected.length ^ supplied.length\n for (const [index, byte] of expected.entries()) {\n difference |= byte ^ (supplied[index] ?? 0)\n }\n return difference === 0\n}\n\n/**\n * Configuration for a raw-byte signature verifier.\n *\n * `expected` receives a private copy of the request bytes and the redacted\n * credential reference the channel was declared with, and answers with the\n * signature bytes the request must carry in `header`. It returns an Effect so\n * the secret is resolved through the host's resolver per request rather than\n * captured in a closure at declaration time, and so a resolution or HMAC\n * failure arrives as a typed `verification_failed` instead of a defect that\n * kills the fiber.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface SignatureConfig {\n readonly header: string\n readonly expected: (\n body: Uint8Array,\n credential: Redacted.Redacted<CredentialRef>\n ) => Effect.Effect<Uint8Array, TriggerError>\n}\n\n/**\n * Copies request bytes into memory nobody else holds.\n *\n * `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose\n * `slice` is an alias of `subarray` and answers with a view over the same\n * memory, so a caller that reused its `Buffer` after handing it over changed\n * what got verified, and a verifier that edited the bytes it was handed edited\n * the caller's. `Uint8Array.from` always allocates, and it copies only the\n * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.\n */\nconst ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)\n\n/**\n * Reads a header whose name may be spelled in any case.\n *\n * The lowercase form and the declared spelling are tried first, then every\n * other key whose lowercase form matches. HTTP header names are\n * case-insensitive, and a host that keeps the sender's capitalisation used to\n * fail a lowercase declaration.\n */\nconst headerValue = (\n headers: Channel.RawInbound[\"headers\"],\n name: string\n): string | undefined => {\n const lower = name.toLowerCase()\n const direct = headers[lower] ?? headers[name]\n if (direct !== undefined) return direct\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined && key.toLowerCase() === lower) return value\n }\n return undefined\n}\n\n/**\n * Builds a verifier that compares a signature header in constant time.\n *\n * An absent or empty header is refused before `expected` runs, and an\n * `expected` that answers with zero bytes is refused after it. Neither is a\n * comparison the constant-time equality can decide: it agrees on two empty\n * byte strings, so a request carrying no signature at all would have\n * authenticated against a secret that resolved to the empty string. A\n * zero-length expected signature is a misconfigured credential, never a valid\n * one, so the door stays closed instead of opening for everyone.\n *\n * Every refusal carries the same message, `webhook signature in <header> did\n * not verify`, whatever the reason. A failure raised by `expected` is kept in\n * `cause` only: its message names the credential reference or the resolver\n * that could not produce a secret, which is a host detail, and the refusal\n * that answers a bad signature answers a broken resolver the same way.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {\n const refuse = (cause?: TriggerError) =>\n Effect.fail(\n new TriggerError({\n code: \"verification_failed\",\n message: `webhook signature in ${config.header} did not verify`,\n ...(cause === undefined ? {} : { cause })\n })\n )\n return Effect.suspend(() => {\n const supplied = headerValue(raw.headers, config.header)\n if (supplied === undefined || supplied.length === 0) {\n return refuse()\n }\n const actual = new TextEncoder().encode(supplied)\n // The verifier gets its own copy: nothing it does to these bytes can reach\n // the buffer that is about to be fingerprinted and decoded.\n return config.expected(ownedCopy(raw.body), credential).pipe(\n Effect.matchEffect({\n onFailure: (error) => refuse(error),\n onSuccess: (expected) =>\n expected.length > 0 && constantTimeEqual(expected, actual)\n ? Effect.void\n : refuse()\n })\n )\n })\n}\n\n/**\n * Webhook declaration configuration.\n *\n * `credential` is required. It used to default to a reference named after the\n * channel, so a webhook declared without one verified against whatever\n * credential happened to share its name instead of being refused, and two\n * declarations differing only in credential verified identically.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {\n readonly credential: Redacted.Redacted<CredentialRef>\n}\n\n/**\n * A webhook door that can only ingest through `Channels` and `Control`.\n *\n * @category services\n * @since 0.1.0\n */\nexport interface Webhook {\n readonly name: string\n readonly register: Effect.Effect<void, never, ControlChannels.Channels>\n readonly ingest: (\n raw: Channel.RawInbound\n ) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>\n}\n\nconst invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })\n\nconst toControlChannel = <Payload, Outbound>(\n config: Config<Payload, Outbound>\n): ControlChannels.Channel<Payload> => {\n const channel = ControlWebhook.make({\n name: config.name,\n schema: config.schema,\n credential: config.credential,\n // The message is fixed here rather than forwarded from the verifier: a\n // custom `verify` may say why it refused, and that reason travels toward\n // the unauthenticated sender on the same error that answers a bad\n // signature. `Unauthorized` carries no cause, so nothing else survives\n // the crossing.\n verify: (raw, credential) =>\n config.verify(raw, credential).pipe(\n Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))\n ),\n map: (payload) => {\n const inbound = config.inbound(payload)\n if (\"start\" in inbound) {\n return Effect.succeed({\n _tag: \"Start\" as const,\n flowId: inbound.start.flowId,\n input: inbound.start.input\n })\n }\n return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(\n Effect.map(\n (value) => ({\n _tag: \"Signal\" as const,\n runId: inbound.signal.runId,\n signal: {\n name: inbound.signal.stepId,\n payload: value\n }\n })\n ),\n Effect.mapError((error) => invalidInput(String(error)))\n )\n },\n project: (run) => {\n if (config.outbound === undefined) {\n return {\n cursor: String(run.updatedAt),\n operation: \"noop\" as const,\n message: null\n }\n }\n return {\n cursor: String(run.updatedAt),\n operation: \"post\" as const,\n message: config.outbound(run)\n }\n }\n })\n return channel\n}\n\n/**\n * Builds a webhook door whose only dispatch path is the Control channel\n * coordinator.\n *\n * Verification occurs inside `Channels.ingest` before the adapter's JSON or\n * schema decoder and before any Control operation.\n *\n * `ingest` does not register: a channel is registered once, deliberately,\n * through {@link Webhook.register}, so traffic to an unregistered channel is\n * reported as unavailable rather than silently self-registering the door it\n * arrived at.\n *\n * `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything\n * reads them. Verification, delivery fingerprinting, and decoding then all see\n * one private snapshot, so a verifier that edits the bytes it was handed, or a\n * caller that mutates its own object between building this Effect and running\n * it, cannot authenticate one payload and have another decoded. A\n * `SharedArrayBuffer`-backed view is copied out of shared memory by the same\n * step.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const make = <Payload, Outbound = never>(\n config: Config<Payload, Outbound>\n): Webhook => {\n const channel = toControlChannel(config)\n const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))\n return {\n name: config.name,\n register,\n ingest: (raw) => {\n // Copied here rather than inside the Effect: the copy has to happen when\n // the caller hands the request over, not when the returned Effect\n // eventually runs, or a caller that reuses its own buffer in between\n // changes what gets authenticated.\n const snapshot: Channel.RawInbound = {\n body: ownedCopy(raw.body),\n headers: { ...raw.headers },\n idempotencyKey: raw.idempotencyKey\n }\n return Effect.gen(function*() {\n const channels = yield* ControlChannels.Channels\n return yield* channels.ingest({ channel: config.name, raw: snapshot })\n }).pipe(\n Effect.mapError((error) =>\n error instanceof Unauthorized\n ? new TriggerError({\n code: \"verification_failed\",\n message: `webhook ${config.name} did not verify the request`,\n cause: error\n })\n : error\n )\n )\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DueOccurrences.d.ts","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAGvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,WAAW,GAAG;IAClB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAoBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,GAClB,SAAS,UAAU,EACnB,KAAK,MAAM,EACX,UAAU,MAAM,GAAG,SAAS,KAC3B,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,YAAY,CA2C9B,CAAA"}
1
+ {"version":3,"file":"DueOccurrences.d.ts","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAGvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,WAAW,GAAG;IAClB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAoBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,GAClB,SAAS,UAAU,EACnB,KAAK,MAAM,EACX,UAAU,MAAM,GAAG,SAAS,KAC3B,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,YAAY,CAqC9B,CAAA"}
@@ -44,37 +44,18 @@ const withinBound = (triggerId, owed) => Effect.catch(owed, (error) => error.cod
44
44
  const compute = (trigger, now, observed) => Effect.gen(function* () {
45
45
  const cron = yield* Cron.parse(trigger.cron, trigger.timezone);
46
46
  const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime();
47
- if (observed === void 0) {
48
- if (trigger.lastFiredAt === void 0) return { occurrences: [], watermark: current };
49
- if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current };
50
- const owed = yield* withinBound(
51
- trigger.id,
52
- CatchUp.occurrences(
53
- trigger.catchUp,
54
- trigger.maxCatchUp,
55
- new Date(trigger.lastFiredAt),
56
- new Date(now),
57
- cron
58
- )
59
- );
60
- const occurrences = owed.map((occurrence) => occurrence.getTime());
61
- if (occurrences.at(-1) !== current) occurrences.push(current);
62
- return { occurrences, watermark: current };
47
+ const since = observed ?? trigger.lastFiredAt;
48
+ if (since === void 0 || current <= since) {
49
+ return { occurrences: [], watermark: Math.max(current, observed ?? current) };
63
50
  }
64
- if (current <= observed) return { occurrences: [], watermark: observed };
65
- const backlog = yield* withinBound(
51
+ const lone = trigger.maxCatchUp === 0 && (yield* Cron.previousAtOrBefore(cron, new Date(current - 1))).getTime() <= since;
52
+ const bound = lone ? 1 : trigger.maxCatchUp;
53
+ const owed = yield* withinBound(
66
54
  trigger.id,
67
- CatchUp.occurrences(
68
- trigger.catchUp,
69
- trigger.maxCatchUp,
70
- new Date(observed),
71
- new Date(current - 1),
72
- cron
73
- )
55
+ CatchUp.occurrences(trigger.catchUp, bound, new Date(since), new Date(now), cron)
74
56
  );
75
- return {
76
- occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],
77
- watermark: current
78
- };
57
+ const occurrences = owed.map((occurrence) => occurrence.getTime());
58
+ if (occurrences.at(-1) !== current) occurrences.push(current);
59
+ return { occurrences, watermark: current };
79
60
  });
80
61
  //# sourceMappingURL=DueOccurrences.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/internal/DueOccurrences.ts"],
4
- "sourcesContent": ["/**\n * Which occurrences one scheduler tick owes a trigger.\n *\n * @since 1.0.0-rc.0\n */\n\nimport * as Effect from \"effect/Effect\"\nimport * as CatchUp from \"../CatchUp.ts\"\nimport * as Cron from \"../Cron.ts\"\nimport type { TriggerError } from \"../TriggerError.ts\"\nimport type { Registered } from \"../TriggerStore.ts\"\n\n/**\n * The occurrences to claim, oldest first, and the watermark that stands once\n * every one of them has been dispatched.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport interface Due {\n readonly occurrences: ReadonlyArray<number>\n readonly watermark: number\n}\n\n// A bound the declaration cannot honour is a statement about how much history\n// to replay, not a reason to stop scheduling: the backlog beyond the bound is\n// abandoned, loudly, and the current occurrence still fires.\nconst withinBound = (\n triggerId: string,\n owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>\n): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>\n Effect.catch(owed, (error) =>\n error.code === \"catch_up_bound_exceeded\"\n ? Effect.as(\n Effect.annotateLogs(\n Effect.logWarning(\"A trigger abandoned catch-up work beyond its bound\", error),\n { triggerId }\n ),\n [] as ReadonlyArray<Date>\n )\n : Effect.fail(error))\n\n/**\n * Computes what a trigger owes at `now`, given the watermark this process has\n * already dispatched through, or `undefined` on first sight of the trigger.\n *\n * The answer depends on nothing but its arguments. The watermark is the\n * caller's to keep: it only moves forward, and only past occurrences the\n * caller finished dispatching.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport const compute = (\n trigger: Registered,\n now: number,\n observed: number | undefined\n): Effect.Effect<Due, TriggerError> =>\n Effect.gen(function*() {\n const cron = yield* Cron.parse(trigger.cron, trigger.timezone)\n const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()\n if (observed === undefined) {\n // First sight of this trigger in this process. A trigger that has never\n // fired starts from here rather than from whatever occurrence last\n // passed: registering a weekly trigger on a Sunday evening owes nothing\n // for the Monday six days gone, which is what `catchUp` says.\n if (trigger.lastFiredAt === undefined) return { occurrences: [], watermark: current }\n if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current }\n const owed = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(\n trigger.catchUp,\n trigger.maxCatchUp,\n new Date(trigger.lastFiredAt),\n new Date(now),\n cron\n )\n )\n // The current boundary is owed whatever the policy says about the\n // backlog, exactly as on every later poll. Under `none`, or once the\n // bound abandoned the backlog, a restart used to skip it.\n const occurrences = owed.map((occurrence) => occurrence.getTime())\n if (occurrences.at(-1) !== current) occurrences.push(current)\n return { occurrences, watermark: current }\n }\n if (current <= observed) return { occurrences: [], watermark: observed }\n const backlog = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(\n trigger.catchUp,\n trigger.maxCatchUp,\n new Date(observed),\n new Date(current - 1),\n cron\n )\n )\n return {\n occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],\n watermark: current\n }\n })\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAMA,aAAwB;AACxB,cAAyB;AACzB,WAAsB;AAmBtB,MAAM,cAAc,CAClB,WACA,SAEA,OAAO,MAAM,MAAM,CAAC,UAClB,MAAM,SAAS,4BACX,OAAO;AAAA,EACP,OAAO;AAAA,IACL,OAAO,WAAW,sDAAsD,KAAK;AAAA,IAC7E,EAAE,UAAU;AAAA,EACd;AAAA,EACA,CAAC;AACH,IACE,OAAO,KAAK,KAAK,CAAC;AAanB,MAAM,UAAU,CACrB,SACA,KACA,aAEA,OAAO,IAAI,aAAY;AACrB,QAAM,OAAO,OAAO,KAAK,MAAM,QAAQ,MAAM,QAAQ,QAAQ;AAC7D,QAAM,WAAW,OAAO,KAAK,mBAAmB,MAAM,IAAI,KAAK,GAAG,CAAC,GAAG,QAAQ;AAC9E,MAAI,aAAa,QAAW;AAK1B,QAAI,QAAQ,gBAAgB,OAAW,QAAO,EAAE,aAAa,CAAC,GAAG,WAAW,QAAQ;AACpF,QAAI,WAAW,QAAQ,YAAa,QAAO,EAAE,aAAa,CAAC,GAAG,WAAW,QAAQ;AACjF,UAAM,OAAO,OAAO;AAAA,MAClB,QAAQ;AAAA,MACR,QAAQ;AAAA,QACN,QAAQ;AAAA,QACR,QAAQ;AAAA,QACR,IAAI,KAAK,QAAQ,WAAW;AAAA,QAC5B,IAAI,KAAK,GAAG;AAAA,QACZ;AAAA,MACF;AAAA,IACF;AAIA,UAAM,cAAc,KAAK,IAAI,CAAC,eAAe,WAAW,QAAQ,CAAC;AACjE,QAAI,YAAY,GAAG,EAAE,MAAM,QAAS,aAAY,KAAK,OAAO;AAC5D,WAAO,EAAE,aAAa,WAAW,QAAQ;AAAA,EAC3C;AACA,MAAI,WAAW,SAAU,QAAO,EAAE,aAAa,CAAC,GAAG,WAAW,SAAS;AACvE,QAAM,UAAU,OAAO;AAAA,IACrB,QAAQ;AAAA,IACR,QAAQ;AAAA,MACN,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,IAAI,KAAK,QAAQ;AAAA,MACjB,IAAI,KAAK,UAAU,CAAC;AAAA,MACpB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,aAAa,CAAC,GAAG,QAAQ,IAAI,CAAC,eAAe,WAAW,QAAQ,CAAC,GAAG,OAAO;AAAA,IAC3E,WAAW;AAAA,EACb;AACF,CAAC;",
4
+ "sourcesContent": ["/**\n * Which occurrences one scheduler tick owes a trigger.\n *\n * @since 1.0.0-rc.0\n */\n\nimport * as Effect from \"effect/Effect\"\nimport * as CatchUp from \"../CatchUp.ts\"\nimport * as Cron from \"../Cron.ts\"\nimport type { TriggerError } from \"../TriggerError.ts\"\nimport type { Registered } from \"../TriggerStore.ts\"\n\n/**\n * The occurrences to claim, oldest first, and the watermark that stands once\n * every one of them has been dispatched.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport interface Due {\n readonly occurrences: ReadonlyArray<number>\n readonly watermark: number\n}\n\n// A bound the declaration cannot honour is a statement about how much history\n// to replay, not a reason to stop scheduling: the backlog beyond the bound is\n// abandoned, loudly, and the current occurrence still fires.\nconst withinBound = (\n triggerId: string,\n owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>\n): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>\n Effect.catch(owed, (error) =>\n error.code === \"catch_up_bound_exceeded\"\n ? Effect.as(\n Effect.annotateLogs(\n Effect.logWarning(\"A trigger abandoned catch-up work beyond its bound\", error),\n { triggerId }\n ),\n [] as ReadonlyArray<Date>\n )\n : Effect.fail(error))\n\n/**\n * Computes what a trigger owes at `now`, given the watermark this process has\n * already dispatched through, or `undefined` on first sight of the trigger.\n *\n * The answer depends on nothing but its arguments. The watermark is the\n * caller's to keep: it only moves forward, and only past occurrences the\n * caller finished dispatching.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport const compute = (\n trigger: Registered,\n now: number,\n observed: number | undefined\n): Effect.Effect<Due, TriggerError> =>\n Effect.gen(function*() {\n const cron = yield* Cron.parse(trigger.cron, trigger.timezone)\n const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()\n // First sight of this trigger in this process starts from the last fire\n // the store recorded. A trigger that has never fired starts from here\n // rather than from whatever occurrence last passed: registering a weekly\n // trigger on a Sunday evening owes nothing for the Monday six days gone,\n // which is what `catchUp` says.\n const since = observed ?? trigger.lastFiredAt\n if (since === undefined || current <= since) {\n return { occurrences: [], watermark: Math.max(current, observed ?? current) }\n }\n // A restart and a stall in this process missed the same boundaries, so\n // both ask the policy about the same interval, the current boundary\n // included. The stalled poll used to leave the current boundary out, so\n // `one` fired two runs and a bound of 2 fired three where a restart fired\n // one.\n //\n // When only the boundary that just passed is due, nothing was missed: a\n // bound of zero has nothing to refuse, so it is read as one. Every other\n // bound is still validated and searched exactly as stated.\n // Only a zero bound needs the boundary before this one, so no other tick\n // pays for that search.\n const lone = trigger.maxCatchUp === 0 &&\n (yield* Cron.previousAtOrBefore(cron, new Date(current - 1))).getTime() <= since\n const bound = lone ? 1 : trigger.maxCatchUp\n const owed = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(trigger.catchUp, bound, new Date(since), new Date(now), cron)\n )\n // The current boundary is owed whatever the policy says about the\n // backlog. Under `none`, or once the bound abandoned the backlog, a\n // restart used to skip it.\n const occurrences = owed.map((occurrence) => occurrence.getTime())\n if (occurrences.at(-1) !== current) occurrences.push(current)\n return { occurrences, watermark: current }\n })\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAMA,aAAwB;AACxB,cAAyB;AACzB,WAAsB;AAmBtB,MAAM,cAAc,CAClB,WACA,SAEA,OAAO,MAAM,MAAM,CAAC,UAClB,MAAM,SAAS,4BACX,OAAO;AAAA,EACP,OAAO;AAAA,IACL,OAAO,WAAW,sDAAsD,KAAK;AAAA,IAC7E,EAAE,UAAU;AAAA,EACd;AAAA,EACA,CAAC;AACH,IACE,OAAO,KAAK,KAAK,CAAC;AAanB,MAAM,UAAU,CACrB,SACA,KACA,aAEA,OAAO,IAAI,aAAY;AACrB,QAAM,OAAO,OAAO,KAAK,MAAM,QAAQ,MAAM,QAAQ,QAAQ;AAC7D,QAAM,WAAW,OAAO,KAAK,mBAAmB,MAAM,IAAI,KAAK,GAAG,CAAC,GAAG,QAAQ;AAM9E,QAAM,QAAQ,YAAY,QAAQ;AAClC,MAAI,UAAU,UAAa,WAAW,OAAO;AAC3C,WAAO,EAAE,aAAa,CAAC,GAAG,WAAW,KAAK,IAAI,SAAS,YAAY,OAAO,EAAE;AAAA,EAC9E;AAYA,QAAM,OAAO,QAAQ,eAAe,MACjC,OAAO,KAAK,mBAAmB,MAAM,IAAI,KAAK,UAAU,CAAC,CAAC,GAAG,QAAQ,KAAK;AAC7E,QAAM,QAAQ,OAAO,IAAI,QAAQ;AACjC,QAAM,OAAO,OAAO;AAAA,IAClB,QAAQ;AAAA,IACR,QAAQ,YAAY,QAAQ,SAAS,OAAO,IAAI,KAAK,KAAK,GAAG,IAAI,KAAK,GAAG,GAAG,IAAI;AAAA,EAClF;AAIA,QAAM,cAAc,KAAK,IAAI,CAAC,eAAe,WAAW,QAAQ,CAAC;AACjE,MAAI,YAAY,GAAG,EAAE,MAAM,QAAS,aAAY,KAAK,OAAO;AAC5D,SAAO,EAAE,aAAa,WAAW,QAAQ;AAC3C,CAAC;",
6
6
  "names": []
7
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Webhook.d.ts","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,eAAe,MAAM,0BAA0B,CAAA;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,KAAK,OAAO,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,UAAU,EAAE,UAAU,UAAU,KAAG,OAM9E,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,KACzC,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;CAC7C;AAcD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,eAAe,KAAG,OAAO,CAAC,MA2BvE,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,MAAM,CAAC,OAAO,EAAE,QAAQ,GAAG,KAAK,CAAE,SAAQ,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAA;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACvE,QAAQ,CAAC,MAAM,EAAE,CACf,GAAG,EAAE,OAAO,CAAC,UAAU,KACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;CACnF;AA6DD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,EAAE,QAAQ,GAAG,KAAK,EAC5C,QAAQ,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,KAChC,OAgCF,CAAA"}
1
+ {"version":3,"file":"Webhook.d.ts","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,eAAe,MAAM,0BAA0B,CAAA;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAEhE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,+BAA+B,CAAA;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAE/D,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AACvC,OAAO,KAAK,KAAK,QAAQ,MAAM,iBAAiB,CAAA;AAEhD,OAAO,KAAK,KAAK,OAAO,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,UAAU,EAAE,UAAU,UAAU,KAAG,OAM9E,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,KACzC,MAAM,CAAC,MAAM,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;CAC7C;AAmCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,eAAe,KAAG,OAAO,CAAC,MA2BvE,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,MAAM,CAAC,OAAO,EAAE,QAAQ,GAAG,KAAK,CAAE,SAAQ,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,CAAC;IACtG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAA;CACtD;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;IACvE,QAAQ,CAAC,MAAM,EAAE,CACf,GAAG,EAAE,OAAO,CAAC,UAAU,KACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,EAAE,eAAe,CAAC,QAAQ,CAAC,CAAA;CACnF;AA6DD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,EAAE,QAAQ,GAAG,KAAK,EAC5C,QAAQ,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,KAChC,OAgCF,CAAA"}
@@ -42,6 +42,25 @@ export const constantTimeEqual = (expected, supplied) => {
42
42
  * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.
43
43
  */
44
44
  const ownedCopy = (body) => Uint8Array.from(body);
45
+ /**
46
+ * Reads a header whose name may be spelled in any case.
47
+ *
48
+ * The lowercase form and the declared spelling are tried first, then every
49
+ * other key whose lowercase form matches. HTTP header names are
50
+ * case-insensitive, and a host that keeps the sender's capitalisation used to
51
+ * fail a lowercase declaration.
52
+ */
53
+ const headerValue = (headers, name) => {
54
+ const lower = name.toLowerCase();
55
+ const direct = headers[lower] ?? headers[name];
56
+ if (direct !== undefined)
57
+ return direct;
58
+ for (const [key, value] of Object.entries(headers)) {
59
+ if (value !== undefined && key.toLowerCase() === lower)
60
+ return value;
61
+ }
62
+ return undefined;
63
+ };
45
64
  /**
46
65
  * Builds a verifier that compares a signature header in constant time.
47
66
  *
@@ -69,7 +88,7 @@ export const makeSignatureVerifier = (config) => (raw, credential) => {
69
88
  ...(cause === undefined ? {} : { cause })
70
89
  }));
71
90
  return Effect.suspend(() => {
72
- const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header];
91
+ const supplied = headerValue(raw.headers, config.header);
73
92
  if (supplied === undefined || supplied.length === 0) {
74
93
  return refuse();
75
94
  }
@@ -1 +1 @@
1
- {"version":3,"file":"Webhook.js","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Verified webhook ingestion through the authoritative Control boundary.\n *\n * @see packages/smithers/agent/triggers/docs/api.md\n * @since 0.1.0\n */\n\nimport * as ControlChannels from \"@smthrs/control/Channels\"\nimport type { ControlError } from \"@smthrs/control/ControlError\"\nimport { InvalidInput, Unauthorized } from \"@smthrs/control/ControlError\"\nimport type { Receipt, RunSummary } from \"@smthrs/control/ControlSchema\"\nimport type { CredentialRef } from \"@smthrs/control/Credential\"\nimport * as ControlWebhook from \"@smthrs/control/WebhookChannel\"\nimport * as Effect from \"effect/Effect\"\nimport type * as Redacted from \"effect/Redacted\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Channel from \"./Channel.ts\"\nimport { TriggerError } from \"./TriggerError.ts\"\n\n/**\n * Compares a supplied byte string against the expected one without returning\n * early on a mismatch.\n *\n * The loop runs exactly `expected.length` times, so its iteration count is\n * fixed by the secret side of the comparison and never by the caller's. Reading\n * the longer of the two instead let a caller lengthen its input until the work\n * stopped growing, which reports the expected signature's length. The length\n * difference is folded into the result, so inputs of unequal length always\n * disagree.\n *\n * @category verification\n * @since 0.1.0\n */\nexport const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {\n let difference = expected.length ^ supplied.length\n for (const [index, byte] of expected.entries()) {\n difference |= byte ^ (supplied[index] ?? 0)\n }\n return difference === 0\n}\n\n/**\n * Configuration for a raw-byte signature verifier.\n *\n * `expected` receives a private copy of the request bytes and the redacted\n * credential reference the channel was declared with, and answers with the\n * signature bytes the request must carry in `header`. It returns an Effect so\n * the secret is resolved through the host's resolver per request rather than\n * captured in a closure at declaration time, and so a resolution or HMAC\n * failure arrives as a typed `verification_failed` instead of a defect that\n * kills the fiber.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface SignatureConfig {\n readonly header: string\n readonly expected: (\n body: Uint8Array,\n credential: Redacted.Redacted<CredentialRef>\n ) => Effect.Effect<Uint8Array, TriggerError>\n}\n\n/**\n * Copies request bytes into memory nobody else holds.\n *\n * `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose\n * `slice` is an alias of `subarray` and answers with a view over the same\n * memory, so a caller that reused its `Buffer` after handing it over changed\n * what got verified, and a verifier that edited the bytes it was handed edited\n * the caller's. `Uint8Array.from` always allocates, and it copies only the\n * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.\n */\nconst ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)\n\n/**\n * Builds a verifier that compares a signature header in constant time.\n *\n * An absent or empty header is refused before `expected` runs, and an\n * `expected` that answers with zero bytes is refused after it. Neither is a\n * comparison the constant-time equality can decide: it agrees on two empty\n * byte strings, so a request carrying no signature at all would have\n * authenticated against a secret that resolved to the empty string. A\n * zero-length expected signature is a misconfigured credential, never a valid\n * one, so the door stays closed instead of opening for everyone.\n *\n * Every refusal carries the same message, `webhook signature in <header> did\n * not verify`, whatever the reason. A failure raised by `expected` is kept in\n * `cause` only: its message names the credential reference or the resolver\n * that could not produce a secret, which is a host detail, and the refusal\n * that answers a bad signature answers a broken resolver the same way.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {\n const refuse = (cause?: TriggerError) =>\n Effect.fail(\n new TriggerError({\n code: \"verification_failed\",\n message: `webhook signature in ${config.header} did not verify`,\n ...(cause === undefined ? {} : { cause })\n })\n )\n return Effect.suspend(() => {\n const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header]\n if (supplied === undefined || supplied.length === 0) {\n return refuse()\n }\n const actual = new TextEncoder().encode(supplied)\n // The verifier gets its own copy: nothing it does to these bytes can reach\n // the buffer that is about to be fingerprinted and decoded.\n return config.expected(ownedCopy(raw.body), credential).pipe(\n Effect.matchEffect({\n onFailure: (error) => refuse(error),\n onSuccess: (expected) =>\n expected.length > 0 && constantTimeEqual(expected, actual)\n ? Effect.void\n : refuse()\n })\n )\n })\n}\n\n/**\n * Webhook declaration configuration.\n *\n * `credential` is required. It used to default to a reference named after the\n * channel, so a webhook declared without one verified against whatever\n * credential happened to share its name instead of being refused, and two\n * declarations differing only in credential verified identically.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {\n readonly credential: Redacted.Redacted<CredentialRef>\n}\n\n/**\n * A webhook door that can only ingest through `Channels` and `Control`.\n *\n * @category services\n * @since 0.1.0\n */\nexport interface Webhook {\n readonly name: string\n readonly register: Effect.Effect<void, never, ControlChannels.Channels>\n readonly ingest: (\n raw: Channel.RawInbound\n ) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>\n}\n\nconst invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })\n\nconst toControlChannel = <Payload, Outbound>(\n config: Config<Payload, Outbound>\n): ControlChannels.Channel<Payload> => {\n const channel = ControlWebhook.make({\n name: config.name,\n schema: config.schema,\n credential: config.credential,\n // The message is fixed here rather than forwarded from the verifier: a\n // custom `verify` may say why it refused, and that reason travels toward\n // the unauthenticated sender on the same error that answers a bad\n // signature. `Unauthorized` carries no cause, so nothing else survives\n // the crossing.\n verify: (raw, credential) =>\n config.verify(raw, credential).pipe(\n Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))\n ),\n map: (payload) => {\n const inbound = config.inbound(payload)\n if (\"start\" in inbound) {\n return Effect.succeed({\n _tag: \"Start\" as const,\n flowId: inbound.start.flowId,\n input: inbound.start.input\n })\n }\n return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(\n Effect.map(\n (value) => ({\n _tag: \"Signal\" as const,\n runId: inbound.signal.runId,\n signal: {\n name: inbound.signal.stepId,\n payload: value\n }\n })\n ),\n Effect.mapError((error) => invalidInput(String(error)))\n )\n },\n project: (run) => {\n if (config.outbound === undefined) {\n return {\n cursor: String(run.updatedAt),\n operation: \"noop\" as const,\n message: null\n }\n }\n return {\n cursor: String(run.updatedAt),\n operation: \"post\" as const,\n message: config.outbound(run)\n }\n }\n })\n return channel\n}\n\n/**\n * Builds a webhook door whose only dispatch path is the Control channel\n * coordinator.\n *\n * Verification occurs inside `Channels.ingest` before the adapter's JSON or\n * schema decoder and before any Control operation.\n *\n * `ingest` does not register: a channel is registered once, deliberately,\n * through {@link Webhook.register}, so traffic to an unregistered channel is\n * reported as unavailable rather than silently self-registering the door it\n * arrived at.\n *\n * `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything\n * reads them. Verification, delivery fingerprinting, and decoding then all see\n * one private snapshot, so a verifier that edits the bytes it was handed, or a\n * caller that mutates its own object between building this Effect and running\n * it, cannot authenticate one payload and have another decoded. A\n * `SharedArrayBuffer`-backed view is copied out of shared memory by the same\n * step.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const make = <Payload, Outbound = never>(\n config: Config<Payload, Outbound>\n): Webhook => {\n const channel = toControlChannel(config)\n const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))\n return {\n name: config.name,\n register,\n ingest: (raw) => {\n // Copied here rather than inside the Effect: the copy has to happen when\n // the caller hands the request over, not when the returned Effect\n // eventually runs, or a caller that reuses its own buffer in between\n // changes what gets authenticated.\n const snapshot: Channel.RawInbound = {\n body: ownedCopy(raw.body),\n headers: { ...raw.headers },\n idempotencyKey: raw.idempotencyKey\n }\n return Effect.gen(function*() {\n const channels = yield* ControlChannels.Channels\n return yield* channels.ingest({ channel: config.name, raw: snapshot })\n }).pipe(\n Effect.mapError((error) =>\n error instanceof Unauthorized\n ? new TriggerError({\n code: \"verification_failed\",\n message: `webhook ${config.name} did not verify the request`,\n cause: error\n })\n : error\n )\n )\n }\n }\n}\n"]}
1
+ {"version":3,"file":"Webhook.js","sourceRoot":"","sources":["../../src/Webhook.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Verified webhook ingestion through the authoritative Control boundary.\n *\n * @see packages/smithers/agent/triggers/docs/api.md\n * @since 0.1.0\n */\n\nimport * as ControlChannels from \"@smthrs/control/Channels\"\nimport type { ControlError } from \"@smthrs/control/ControlError\"\nimport { InvalidInput, Unauthorized } from \"@smthrs/control/ControlError\"\nimport type { Receipt, RunSummary } from \"@smthrs/control/ControlSchema\"\nimport type { CredentialRef } from \"@smthrs/control/Credential\"\nimport * as ControlWebhook from \"@smthrs/control/WebhookChannel\"\nimport * as Effect from \"effect/Effect\"\nimport type * as Redacted from \"effect/Redacted\"\nimport * as Schema from \"effect/Schema\"\nimport type * as Channel from \"./Channel.ts\"\nimport { TriggerError } from \"./TriggerError.ts\"\n\n/**\n * Compares a supplied byte string against the expected one without returning\n * early on a mismatch.\n *\n * The loop runs exactly `expected.length` times, so its iteration count is\n * fixed by the secret side of the comparison and never by the caller's. Reading\n * the longer of the two instead let a caller lengthen its input until the work\n * stopped growing, which reports the expected signature's length. The length\n * difference is folded into the result, so inputs of unequal length always\n * disagree.\n *\n * @category verification\n * @since 0.1.0\n */\nexport const constantTimeEqual = (expected: Uint8Array, supplied: Uint8Array): boolean => {\n let difference = expected.length ^ supplied.length\n for (const [index, byte] of expected.entries()) {\n difference |= byte ^ (supplied[index] ?? 0)\n }\n return difference === 0\n}\n\n/**\n * Configuration for a raw-byte signature verifier.\n *\n * `expected` receives a private copy of the request bytes and the redacted\n * credential reference the channel was declared with, and answers with the\n * signature bytes the request must carry in `header`. It returns an Effect so\n * the secret is resolved through the host's resolver per request rather than\n * captured in a closure at declaration time, and so a resolution or HMAC\n * failure arrives as a typed `verification_failed` instead of a defect that\n * kills the fiber.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface SignatureConfig {\n readonly header: string\n readonly expected: (\n body: Uint8Array,\n credential: Redacted.Redacted<CredentialRef>\n ) => Effect.Effect<Uint8Array, TriggerError>\n}\n\n/**\n * Copies request bytes into memory nobody else holds.\n *\n * `body.slice()` is not enough: a Node `Buffer` is a `Uint8Array` whose\n * `slice` is an alias of `subarray` and answers with a view over the same\n * memory, so a caller that reused its `Buffer` after handing it over changed\n * what got verified, and a verifier that edited the bytes it was handed edited\n * the caller's. `Uint8Array.from` always allocates, and it copies only the\n * viewed range of an offset view and only a snapshot of a `SharedArrayBuffer`.\n */\nconst ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)\n\n/**\n * Reads a header whose name may be spelled in any case.\n *\n * The lowercase form and the declared spelling are tried first, then every\n * other key whose lowercase form matches. HTTP header names are\n * case-insensitive, and a host that keeps the sender's capitalisation used to\n * fail a lowercase declaration.\n */\nconst headerValue = (\n headers: Channel.RawInbound[\"headers\"],\n name: string\n): string | undefined => {\n const lower = name.toLowerCase()\n const direct = headers[lower] ?? headers[name]\n if (direct !== undefined) return direct\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined && key.toLowerCase() === lower) return value\n }\n return undefined\n}\n\n/**\n * Builds a verifier that compares a signature header in constant time.\n *\n * An absent or empty header is refused before `expected` runs, and an\n * `expected` that answers with zero bytes is refused after it. Neither is a\n * comparison the constant-time equality can decide: it agrees on two empty\n * byte strings, so a request carrying no signature at all would have\n * authenticated against a secret that resolved to the empty string. A\n * zero-length expected signature is a misconfigured credential, never a valid\n * one, so the door stays closed instead of opening for everyone.\n *\n * Every refusal carries the same message, `webhook signature in <header> did\n * not verify`, whatever the reason. A failure raised by `expected` is kept in\n * `cause` only: its message names the credential reference or the resolver\n * that could not produce a secret, which is a host detail, and the refusal\n * that answers a bad signature answers a broken resolver the same way.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify => (raw, credential) => {\n const refuse = (cause?: TriggerError) =>\n Effect.fail(\n new TriggerError({\n code: \"verification_failed\",\n message: `webhook signature in ${config.header} did not verify`,\n ...(cause === undefined ? {} : { cause })\n })\n )\n return Effect.suspend(() => {\n const supplied = headerValue(raw.headers, config.header)\n if (supplied === undefined || supplied.length === 0) {\n return refuse()\n }\n const actual = new TextEncoder().encode(supplied)\n // The verifier gets its own copy: nothing it does to these bytes can reach\n // the buffer that is about to be fingerprinted and decoded.\n return config.expected(ownedCopy(raw.body), credential).pipe(\n Effect.matchEffect({\n onFailure: (error) => refuse(error),\n onSuccess: (expected) =>\n expected.length > 0 && constantTimeEqual(expected, actual)\n ? Effect.void\n : refuse()\n })\n )\n })\n}\n\n/**\n * Webhook declaration configuration.\n *\n * `credential` is required. It used to default to a reference named after the\n * channel, so a webhook declared without one verified against whatever\n * credential happened to share its name instead of being refused, and two\n * declarations differing only in credential verified identically.\n *\n * @category models\n * @since 0.1.0\n */\nexport interface Config<Payload, Outbound = never> extends Channel.Config<Payload, RunSummary, Outbound> {\n readonly credential: Redacted.Redacted<CredentialRef>\n}\n\n/**\n * A webhook door that can only ingest through `Channels` and `Control`.\n *\n * @category services\n * @since 0.1.0\n */\nexport interface Webhook {\n readonly name: string\n readonly register: Effect.Effect<void, never, ControlChannels.Channels>\n readonly ingest: (\n raw: Channel.RawInbound\n ) => Effect.Effect<Receipt, ControlError | TriggerError, ControlChannels.Channels>\n}\n\nconst invalidInput = (issue: string): InvalidInput => new InvalidInput({ issue })\n\nconst toControlChannel = <Payload, Outbound>(\n config: Config<Payload, Outbound>\n): ControlChannels.Channel<Payload> => {\n const channel = ControlWebhook.make({\n name: config.name,\n schema: config.schema,\n credential: config.credential,\n // The message is fixed here rather than forwarded from the verifier: a\n // custom `verify` may say why it refused, and that reason travels toward\n // the unauthenticated sender on the same error that answers a bad\n // signature. `Unauthorized` carries no cause, so nothing else survives\n // the crossing.\n verify: (raw, credential) =>\n config.verify(raw, credential).pipe(\n Effect.mapError(() => new Unauthorized({ message: `webhook ${config.name} did not verify the request` }))\n ),\n map: (payload) => {\n const inbound = config.inbound(payload)\n if (\"start\" in inbound) {\n return Effect.succeed({\n _tag: \"Start\" as const,\n flowId: inbound.start.flowId,\n input: inbound.start.input\n })\n }\n return Schema.decodeUnknownEffect(Schema.Json)(inbound.signal.value).pipe(\n Effect.map(\n (value) => ({\n _tag: \"Signal\" as const,\n runId: inbound.signal.runId,\n signal: {\n name: inbound.signal.stepId,\n payload: value\n }\n })\n ),\n Effect.mapError((error) => invalidInput(String(error)))\n )\n },\n project: (run) => {\n if (config.outbound === undefined) {\n return {\n cursor: String(run.updatedAt),\n operation: \"noop\" as const,\n message: null\n }\n }\n return {\n cursor: String(run.updatedAt),\n operation: \"post\" as const,\n message: config.outbound(run)\n }\n }\n })\n return channel\n}\n\n/**\n * Builds a webhook door whose only dispatch path is the Control channel\n * coordinator.\n *\n * Verification occurs inside `Channels.ingest` before the adapter's JSON or\n * schema decoder and before any Control operation.\n *\n * `ingest` does not register: a channel is registered once, deliberately,\n * through {@link Webhook.register}, so traffic to an unregistered channel is\n * reported as unavailable rather than silently self-registering the door it\n * arrived at.\n *\n * `ingest` also copies `body`, `headers`, and `idempotencyKey` before anything\n * reads them. Verification, delivery fingerprinting, and decoding then all see\n * one private snapshot, so a verifier that edits the bytes it was handed, or a\n * caller that mutates its own object between building this Effect and running\n * it, cannot authenticate one payload and have another decoded. A\n * `SharedArrayBuffer`-backed view is copied out of shared memory by the same\n * step.\n *\n * @category constructors\n * @since 0.1.0\n */\nexport const make = <Payload, Outbound = never>(\n config: Config<Payload, Outbound>\n): Webhook => {\n const channel = toControlChannel(config)\n const register = Effect.flatMap(ControlChannels.Channels, (channels) => channels.register(channel))\n return {\n name: config.name,\n register,\n ingest: (raw) => {\n // Copied here rather than inside the Effect: the copy has to happen when\n // the caller hands the request over, not when the returned Effect\n // eventually runs, or a caller that reuses its own buffer in between\n // changes what gets authenticated.\n const snapshot: Channel.RawInbound = {\n body: ownedCopy(raw.body),\n headers: { ...raw.headers },\n idempotencyKey: raw.idempotencyKey\n }\n return Effect.gen(function*() {\n const channels = yield* ControlChannels.Channels\n return yield* channels.ingest({ channel: config.name, raw: snapshot })\n }).pipe(\n Effect.mapError((error) =>\n error instanceof Unauthorized\n ? new TriggerError({\n code: \"verification_failed\",\n message: `webhook ${config.name} did not verify the request`,\n cause: error\n })\n : error\n )\n )\n }\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"DueOccurrences.d.ts","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAGvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,WAAW,GAAG;IAClB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAoBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,GAClB,SAAS,UAAU,EACnB,KAAK,MAAM,EACX,UAAU,MAAM,GAAG,SAAS,KAC3B,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,YAAY,CA2C9B,CAAA"}
1
+ {"version":3,"file":"DueOccurrences.d.ts","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAA;AAGvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEpD;;;;;;GAMG;AACH,MAAM,WAAW,GAAG;IAClB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC3C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAoBD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,OAAO,GAClB,SAAS,UAAU,EACnB,KAAK,MAAM,EACX,UAAU,MAAM,GAAG,SAAS,KAC3B,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,YAAY,CAqC9B,CAAA"}
@@ -26,30 +26,36 @@ const withinBound = (triggerId, owed) => Effect.catch(owed, (error) => error.cod
26
26
  export const compute = (trigger, now, observed) => Effect.gen(function* () {
27
27
  const cron = yield* Cron.parse(trigger.cron, trigger.timezone);
28
28
  const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime();
29
- if (observed === undefined) {
30
- // First sight of this trigger in this process. A trigger that has never
31
- // fired starts from here rather than from whatever occurrence last
32
- // passed: registering a weekly trigger on a Sunday evening owes nothing
33
- // for the Monday six days gone, which is what `catchUp` says.
34
- if (trigger.lastFiredAt === undefined)
35
- return { occurrences: [], watermark: current };
36
- if (current <= trigger.lastFiredAt)
37
- return { occurrences: [], watermark: current };
38
- const owed = yield* withinBound(trigger.id, CatchUp.occurrences(trigger.catchUp, trigger.maxCatchUp, new Date(trigger.lastFiredAt), new Date(now), cron));
39
- // The current boundary is owed whatever the policy says about the
40
- // backlog, exactly as on every later poll. Under `none`, or once the
41
- // bound abandoned the backlog, a restart used to skip it.
42
- const occurrences = owed.map((occurrence) => occurrence.getTime());
43
- if (occurrences.at(-1) !== current)
44
- occurrences.push(current);
45
- return { occurrences, watermark: current };
29
+ // First sight of this trigger in this process starts from the last fire
30
+ // the store recorded. A trigger that has never fired starts from here
31
+ // rather than from whatever occurrence last passed: registering a weekly
32
+ // trigger on a Sunday evening owes nothing for the Monday six days gone,
33
+ // which is what `catchUp` says.
34
+ const since = observed ?? trigger.lastFiredAt;
35
+ if (since === undefined || current <= since) {
36
+ return { occurrences: [], watermark: Math.max(current, observed ?? current) };
46
37
  }
47
- if (current <= observed)
48
- return { occurrences: [], watermark: observed };
49
- const backlog = yield* withinBound(trigger.id, CatchUp.occurrences(trigger.catchUp, trigger.maxCatchUp, new Date(observed), new Date(current - 1), cron));
50
- return {
51
- occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],
52
- watermark: current
53
- };
38
+ // A restart and a stall in this process missed the same boundaries, so
39
+ // both ask the policy about the same interval, the current boundary
40
+ // included. The stalled poll used to leave the current boundary out, so
41
+ // `one` fired two runs and a bound of 2 fired three where a restart fired
42
+ // one.
43
+ //
44
+ // When only the boundary that just passed is due, nothing was missed: a
45
+ // bound of zero has nothing to refuse, so it is read as one. Every other
46
+ // bound is still validated and searched exactly as stated.
47
+ // Only a zero bound needs the boundary before this one, so no other tick
48
+ // pays for that search.
49
+ const lone = trigger.maxCatchUp === 0 &&
50
+ (yield* Cron.previousAtOrBefore(cron, new Date(current - 1))).getTime() <= since;
51
+ const bound = lone ? 1 : trigger.maxCatchUp;
52
+ const owed = yield* withinBound(trigger.id, CatchUp.occurrences(trigger.catchUp, bound, new Date(since), new Date(now), cron));
53
+ // The current boundary is owed whatever the policy says about the
54
+ // backlog. Under `none`, or once the bound abandoned the backlog, a
55
+ // restart used to skip it.
56
+ const occurrences = owed.map((occurrence) => occurrence.getTime());
57
+ if (occurrences.at(-1) !== current)
58
+ occurrences.push(current);
59
+ return { occurrences, watermark: current };
54
60
  });
55
61
  //# sourceMappingURL=DueOccurrences.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"DueOccurrences.js","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which occurrences one scheduler tick owes a trigger.\n *\n * @since 1.0.0-rc.0\n */\n\nimport * as Effect from \"effect/Effect\"\nimport * as CatchUp from \"../CatchUp.ts\"\nimport * as Cron from \"../Cron.ts\"\nimport type { TriggerError } from \"../TriggerError.ts\"\nimport type { Registered } from \"../TriggerStore.ts\"\n\n/**\n * The occurrences to claim, oldest first, and the watermark that stands once\n * every one of them has been dispatched.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport interface Due {\n readonly occurrences: ReadonlyArray<number>\n readonly watermark: number\n}\n\n// A bound the declaration cannot honour is a statement about how much history\n// to replay, not a reason to stop scheduling: the backlog beyond the bound is\n// abandoned, loudly, and the current occurrence still fires.\nconst withinBound = (\n triggerId: string,\n owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>\n): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>\n Effect.catch(owed, (error) =>\n error.code === \"catch_up_bound_exceeded\"\n ? Effect.as(\n Effect.annotateLogs(\n Effect.logWarning(\"A trigger abandoned catch-up work beyond its bound\", error),\n { triggerId }\n ),\n [] as ReadonlyArray<Date>\n )\n : Effect.fail(error))\n\n/**\n * Computes what a trigger owes at `now`, given the watermark this process has\n * already dispatched through, or `undefined` on first sight of the trigger.\n *\n * The answer depends on nothing but its arguments. The watermark is the\n * caller's to keep: it only moves forward, and only past occurrences the\n * caller finished dispatching.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport const compute = (\n trigger: Registered,\n now: number,\n observed: number | undefined\n): Effect.Effect<Due, TriggerError> =>\n Effect.gen(function*() {\n const cron = yield* Cron.parse(trigger.cron, trigger.timezone)\n const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()\n if (observed === undefined) {\n // First sight of this trigger in this process. A trigger that has never\n // fired starts from here rather than from whatever occurrence last\n // passed: registering a weekly trigger on a Sunday evening owes nothing\n // for the Monday six days gone, which is what `catchUp` says.\n if (trigger.lastFiredAt === undefined) return { occurrences: [], watermark: current }\n if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current }\n const owed = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(\n trigger.catchUp,\n trigger.maxCatchUp,\n new Date(trigger.lastFiredAt),\n new Date(now),\n cron\n )\n )\n // The current boundary is owed whatever the policy says about the\n // backlog, exactly as on every later poll. Under `none`, or once the\n // bound abandoned the backlog, a restart used to skip it.\n const occurrences = owed.map((occurrence) => occurrence.getTime())\n if (occurrences.at(-1) !== current) occurrences.push(current)\n return { occurrences, watermark: current }\n }\n if (current <= observed) return { occurrences: [], watermark: observed }\n const backlog = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(\n trigger.catchUp,\n trigger.maxCatchUp,\n new Date(observed),\n new Date(current - 1),\n cron\n )\n )\n return {\n occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],\n watermark: current\n }\n })\n"]}
1
+ {"version":3,"file":"DueOccurrences.js","sourceRoot":"","sources":["../../../src/internal/DueOccurrences.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Which occurrences one scheduler tick owes a trigger.\n *\n * @since 1.0.0-rc.0\n */\n\nimport * as Effect from \"effect/Effect\"\nimport * as CatchUp from \"../CatchUp.ts\"\nimport * as Cron from \"../Cron.ts\"\nimport type { TriggerError } from \"../TriggerError.ts\"\nimport type { Registered } from \"../TriggerStore.ts\"\n\n/**\n * The occurrences to claim, oldest first, and the watermark that stands once\n * every one of them has been dispatched.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport interface Due {\n readonly occurrences: ReadonlyArray<number>\n readonly watermark: number\n}\n\n// A bound the declaration cannot honour is a statement about how much history\n// to replay, not a reason to stop scheduling: the backlog beyond the bound is\n// abandoned, loudly, and the current occurrence still fires.\nconst withinBound = (\n triggerId: string,\n owed: Effect.Effect<ReadonlyArray<Date>, TriggerError>\n): Effect.Effect<ReadonlyArray<Date>, TriggerError> =>\n Effect.catch(owed, (error) =>\n error.code === \"catch_up_bound_exceeded\"\n ? Effect.as(\n Effect.annotateLogs(\n Effect.logWarning(\"A trigger abandoned catch-up work beyond its bound\", error),\n { triggerId }\n ),\n [] as ReadonlyArray<Date>\n )\n : Effect.fail(error))\n\n/**\n * Computes what a trigger owes at `now`, given the watermark this process has\n * already dispatched through, or `undefined` on first sight of the trigger.\n *\n * The answer depends on nothing but its arguments. The watermark is the\n * caller's to keep: it only moves forward, and only past occurrences the\n * caller finished dispatching.\n *\n * @private\n * @since 1.0.0-rc.0\n */\nexport const compute = (\n trigger: Registered,\n now: number,\n observed: number | undefined\n): Effect.Effect<Due, TriggerError> =>\n Effect.gen(function*() {\n const cron = yield* Cron.parse(trigger.cron, trigger.timezone)\n const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()\n // First sight of this trigger in this process starts from the last fire\n // the store recorded. A trigger that has never fired starts from here\n // rather than from whatever occurrence last passed: registering a weekly\n // trigger on a Sunday evening owes nothing for the Monday six days gone,\n // which is what `catchUp` says.\n const since = observed ?? trigger.lastFiredAt\n if (since === undefined || current <= since) {\n return { occurrences: [], watermark: Math.max(current, observed ?? current) }\n }\n // A restart and a stall in this process missed the same boundaries, so\n // both ask the policy about the same interval, the current boundary\n // included. The stalled poll used to leave the current boundary out, so\n // `one` fired two runs and a bound of 2 fired three where a restart fired\n // one.\n //\n // When only the boundary that just passed is due, nothing was missed: a\n // bound of zero has nothing to refuse, so it is read as one. Every other\n // bound is still validated and searched exactly as stated.\n // Only a zero bound needs the boundary before this one, so no other tick\n // pays for that search.\n const lone = trigger.maxCatchUp === 0 &&\n (yield* Cron.previousAtOrBefore(cron, new Date(current - 1))).getTime() <= since\n const bound = lone ? 1 : trigger.maxCatchUp\n const owed = yield* withinBound(\n trigger.id,\n CatchUp.occurrences(trigger.catchUp, bound, new Date(since), new Date(now), cron)\n )\n // The current boundary is owed whatever the policy says about the\n // backlog. Under `none`, or once the bound abandoned the backlog, a\n // restart used to skip it.\n const occurrences = owed.map((occurrence) => occurrence.getTime())\n if (occurrences.at(-1) !== current) occurrences.push(current)\n return { occurrences, watermark: current }\n })\n"]}
package/docs/README.md CHANGED
@@ -47,10 +47,12 @@ at a flow by id, and the flow itself never learns that a clock started it.
47
47
 
48
48
  ## Install
49
49
 
50
- `@smthrs/triggers` is not on npm at 1.0.0-rc.0. Its source lives in the
51
- [smithers repository](https://github.com/smithersai/smithers), and
52
- [Installation](./installation.md) covers how to depend on it from a checkout,
53
- what it requires at runtime, and which packages a running host adds.
50
+ ```bash
51
+ npm install @smthrs/triggers@next effect
52
+ ```
53
+
54
+ [Installation](./installation.md) covers what it requires at runtime and which
55
+ packages a running host adds.
54
56
 
55
57
  ## The smallest declaration
56
58
 
package/docs/api.md CHANGED
@@ -20,8 +20,8 @@ Effect reads it from context. Launching goes through
20
20
  [`@smthrs/control`](/api/control), and persistence through the SQL client and
21
21
  durable writer of [`@smthrs/database`](/api/database).
22
22
 
23
- The package is not on npm at 1.0.0-rc.0. See [Installation](./installation.md)
24
- for how to get it.
23
+ Install it with `npm install @smthrs/triggers@next effect`. See
24
+ [Installation](./installation.md) for its requirements.
25
25
 
26
26
  ## Example
27
27
 
@@ -316,6 +316,11 @@ backlog when catch-up exceeds its bound. It still dispatches the current
316
316
  occurrence subject to overlap, on the first poll after a restart and on every
317
317
  later poll.
318
318
 
319
+ The bound counts every boundary since the last dispatch, the one that just
320
+ passed included, and a restart and a stalled scheduler count the same
321
+ boundaries. A tick where only that one boundary passed fires it under any
322
+ bound.
323
+
319
324
  ## TriggerStore
320
325
 
321
326
  The durable state contract: registration, listing, the claim protocol, and
@@ -1144,9 +1149,10 @@ included. See [Overlap and catch-up](./concepts/policies.md).
1144
1149
  ## Webhook verification and input ownership
1145
1150
 
1146
1151
  The signature verifier looks up `SignatureConfig.header` in `RawInbound.headers`
1147
- first as lowercase and then exactly as written. It encodes the supplied header
1148
- value as UTF-8 with `TextEncoder` and compares those bytes with the bytes
1149
- returned by `SignatureConfig.expected`. An absent or empty header is refused
1152
+ first as lowercase, then exactly as written, then under any other casing, so a
1153
+ header name matches whatever capitalisation the request carries. It encodes the
1154
+ supplied header value as UTF-8 with `TextEncoder` and compares those bytes with
1155
+ the bytes returned by `SignatureConfig.expected`. An absent or empty header is refused
1150
1156
  before `expected` runs, and an `expected` that answers with zero bytes is
1151
1157
  refused after it. Constant-time equality agrees on two empty byte strings, so
1152
1158
  without those guards a request carrying no signature at all would authenticate
@@ -118,6 +118,11 @@ backlog when catch-up exceeds its bound. It still dispatches the current
118
118
  occurrence subject to overlap, on the first poll after a restart and on every
119
119
  later poll.
120
120
 
121
+ The bound counts every boundary since the last dispatch, the one that just
122
+ passed included, and a restart and a stalled scheduler count the same
123
+ boundaries. A tick where only that one boundary passed fires it under any
124
+ bound.
125
+
121
126
  ## Choosing
122
127
 
123
128
  The task-shaped version of this page, with the question to ask for each policy,
@@ -78,8 +78,9 @@ Three properties of that signature are the point of it.
78
78
  never by the caller's, so a caller cannot lengthen its input until the work
79
79
  stops growing and learn the expected signature's length.
80
80
 
81
- The verifier looks the header up first in lowercase and then exactly as written,
82
- so `x-hub-signature-256` and `X-Hub-Signature-256` both resolve. An absent or
81
+ The verifier looks the header up first in lowercase, then exactly as written,
82
+ then under any other casing, so `x-hub-signature-256` and `X-Hub-Signature-256`
83
+ both resolve whichever spelling the declaration uses. An absent or
83
84
  empty header is refused before `expected` runs, and an `expected` that answers
84
85
  with zero bytes is refused after it, so a secret that resolves to the empty
85
86
  string closes the door instead of opening it for everyone.
@@ -1,24 +1,18 @@
1
1
  ---
2
2
  title: "Installation"
3
- description: "How to get @smthrs/triggers, what it requires at runtime, the import forms it publishes, and the packages a running host adds on top."
3
+ description: "Install @smthrs/triggers from npm, what it requires at runtime, the import forms it publishes, and the packages a running host adds on top."
4
4
  sidebar:
5
5
  order: 1
6
6
  ---
7
7
 
8
8
  ## Get the package
9
9
 
10
- Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
11
-
12
- The installed manifest resolves to the synchronized RC:
13
-
14
- ```json
15
- {
16
- "dependencies": {
17
- "@smthrs/triggers": "1.0.0-rc.1"
18
- }
19
- }
10
+ ```bash
11
+ npm install @smthrs/triggers@next effect
20
12
  ```
21
13
 
14
+ The `next` tag carries the 1.0 release candidates.
15
+
22
16
  ## Requirements
23
17
 
24
18
  - Node.js 26.4.0 or later.
@@ -29,7 +23,8 @@ The installed manifest resolves to the synchronized RC:
29
23
  - [`@smthrs/database`](/api/database), for the SQL client and the durable
30
24
  writer that `SqlTriggerStore` writes through.
31
25
 
32
- All three are ordinary dependencies of the package and install with it.
26
+ `@smthrs/control` and `@smthrs/database` are ordinary dependencies and install
27
+ with the package. `effect` is a peer dependency, so the install line names it.
33
28
 
34
29
  ## Import forms
35
30
 
@@ -144,8 +144,8 @@ than expecting it in the message a sender receives.
144
144
  **What to change.** Check three things in order.
145
145
 
146
146
  1. The header name in `SignatureConfig.header`. The verifier looks it up first
147
- in lowercase and then exactly as written, so a casing mismatch is not the
148
- cause, but a wrong name is.
147
+ in lowercase, then exactly as written, then under any other casing, so a
148
+ casing mismatch is not the cause, but a wrong name is.
149
149
  2. The bytes `SignatureConfig.expected` returns. They are compared against the
150
150
  UTF-8 encoding of the header value, so an implementation that returns raw
151
151
  HMAC bytes where the provider sends hex will never match. Return the encoded
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/triggers",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.7",
4
+ "version": "1.0.0-rc.9",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "agent"
@@ -206,8 +206,8 @@
206
206
  "tag": "next"
207
207
  },
208
208
  "dependencies": {
209
- "@smthrs/control": "1.0.0-rc.7",
210
- "@smthrs/database": "1.0.0-rc.7"
209
+ "@smthrs/control": "1.0.0-rc.9",
210
+ "@smthrs/database": "1.0.0-rc.9"
211
211
  },
212
212
  "peerDependencies": {
213
213
  "effect": "^4.0.4"
@@ -232,7 +232,7 @@
232
232
  "vitest": "5.0.0"
233
233
  },
234
234
  "scripts": {
235
- "lint": "eslint src --max-warnings=0 && dprint check",
235
+ "lint": "eslint src examples --max-warnings=0 && dprint check",
236
236
  "format": "dprint fmt",
237
237
  "build": "node scripts/build.mjs",
238
238
  "check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
package/src/Webhook.ts CHANGED
@@ -73,6 +73,27 @@ export interface SignatureConfig {
73
73
  */
74
74
  const ownedCopy = (body: Uint8Array): Uint8Array => Uint8Array.from(body)
75
75
 
76
+ /**
77
+ * Reads a header whose name may be spelled in any case.
78
+ *
79
+ * The lowercase form and the declared spelling are tried first, then every
80
+ * other key whose lowercase form matches. HTTP header names are
81
+ * case-insensitive, and a host that keeps the sender's capitalisation used to
82
+ * fail a lowercase declaration.
83
+ */
84
+ const headerValue = (
85
+ headers: Channel.RawInbound["headers"],
86
+ name: string
87
+ ): string | undefined => {
88
+ const lower = name.toLowerCase()
89
+ const direct = headers[lower] ?? headers[name]
90
+ if (direct !== undefined) return direct
91
+ for (const [key, value] of Object.entries(headers)) {
92
+ if (value !== undefined && key.toLowerCase() === lower) return value
93
+ }
94
+ return undefined
95
+ }
96
+
76
97
  /**
77
98
  * Builds a verifier that compares a signature header in constant time.
78
99
  *
@@ -103,7 +124,7 @@ export const makeSignatureVerifier = (config: SignatureConfig): Channel.Verify =
103
124
  })
104
125
  )
105
126
  return Effect.suspend(() => {
106
- const supplied = raw.headers[config.header.toLowerCase()] ?? raw.headers[config.header]
127
+ const supplied = headerValue(raw.headers, config.header)
107
128
  if (supplied === undefined || supplied.length === 0) {
108
129
  return refuse()
109
130
  }
@@ -59,43 +59,37 @@ export const compute = (
59
59
  Effect.gen(function*() {
60
60
  const cron = yield* Cron.parse(trigger.cron, trigger.timezone)
61
61
  const current = (yield* Cron.previousAtOrBefore(cron, new Date(now))).getTime()
62
- if (observed === undefined) {
63
- // First sight of this trigger in this process. A trigger that has never
64
- // fired starts from here rather than from whatever occurrence last
65
- // passed: registering a weekly trigger on a Sunday evening owes nothing
66
- // for the Monday six days gone, which is what `catchUp` says.
67
- if (trigger.lastFiredAt === undefined) return { occurrences: [], watermark: current }
68
- if (current <= trigger.lastFiredAt) return { occurrences: [], watermark: current }
69
- const owed = yield* withinBound(
70
- trigger.id,
71
- CatchUp.occurrences(
72
- trigger.catchUp,
73
- trigger.maxCatchUp,
74
- new Date(trigger.lastFiredAt),
75
- new Date(now),
76
- cron
77
- )
78
- )
79
- // The current boundary is owed whatever the policy says about the
80
- // backlog, exactly as on every later poll. Under `none`, or once the
81
- // bound abandoned the backlog, a restart used to skip it.
82
- const occurrences = owed.map((occurrence) => occurrence.getTime())
83
- if (occurrences.at(-1) !== current) occurrences.push(current)
84
- return { occurrences, watermark: current }
62
+ // First sight of this trigger in this process starts from the last fire
63
+ // the store recorded. A trigger that has never fired starts from here
64
+ // rather than from whatever occurrence last passed: registering a weekly
65
+ // trigger on a Sunday evening owes nothing for the Monday six days gone,
66
+ // which is what `catchUp` says.
67
+ const since = observed ?? trigger.lastFiredAt
68
+ if (since === undefined || current <= since) {
69
+ return { occurrences: [], watermark: Math.max(current, observed ?? current) }
85
70
  }
86
- if (current <= observed) return { occurrences: [], watermark: observed }
87
- const backlog = yield* withinBound(
71
+ // A restart and a stall in this process missed the same boundaries, so
72
+ // both ask the policy about the same interval, the current boundary
73
+ // included. The stalled poll used to leave the current boundary out, so
74
+ // `one` fired two runs and a bound of 2 fired three where a restart fired
75
+ // one.
76
+ //
77
+ // When only the boundary that just passed is due, nothing was missed: a
78
+ // bound of zero has nothing to refuse, so it is read as one. Every other
79
+ // bound is still validated and searched exactly as stated.
80
+ // Only a zero bound needs the boundary before this one, so no other tick
81
+ // pays for that search.
82
+ const lone = trigger.maxCatchUp === 0 &&
83
+ (yield* Cron.previousAtOrBefore(cron, new Date(current - 1))).getTime() <= since
84
+ const bound = lone ? 1 : trigger.maxCatchUp
85
+ const owed = yield* withinBound(
88
86
  trigger.id,
89
- CatchUp.occurrences(
90
- trigger.catchUp,
91
- trigger.maxCatchUp,
92
- new Date(observed),
93
- new Date(current - 1),
94
- cron
95
- )
87
+ CatchUp.occurrences(trigger.catchUp, bound, new Date(since), new Date(now), cron)
96
88
  )
97
- return {
98
- occurrences: [...backlog.map((occurrence) => occurrence.getTime()), current],
99
- watermark: current
100
- }
89
+ // The current boundary is owed whatever the policy says about the
90
+ // backlog. Under `none`, or once the bound abandoned the backlog, a
91
+ // restart used to skip it.
92
+ const occurrences = owed.map((occurrence) => occurrence.getTime())
93
+ if (occurrences.at(-1) !== current) occurrences.push(current)
94
+ return { occurrences, watermark: current }
101
95
  })