@solumflow-app/crm-client 0.1.0 → 0.3.0

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/dist/webhooks.cjs CHANGED
@@ -44,6 +44,12 @@ function productsTag() {
44
44
  function productTag(slugOrId) {
45
45
  return `${PREFIX}:product:${slugOrId}`;
46
46
  }
47
+ function categoriesTag() {
48
+ return `${PREFIX}:categories`;
49
+ }
50
+ function categoryTag(slugOrId) {
51
+ return `${PREFIX}:category:${slugOrId}`;
52
+ }
47
53
  function eventsTag() {
48
54
  return `${PREFIX}:events`;
49
55
  }
@@ -51,15 +57,21 @@ function eventTag(idOrSlug) {
51
57
  return `${PREFIX}:event:${idOrSlug}`;
52
58
  }
53
59
  function tagsForDelivery(input) {
54
- const collection = input.type.startsWith("product.") ? productsTag() : input.type.startsWith("event.") ? eventsTag() : null;
55
- if (collection === null) {
60
+ const collections = input.type.startsWith("product.") ? [productsTag()] : input.type.startsWith("category.") ? (
61
+ // Both, and the second one is the point: a category page is a list of
62
+ // products, and `getProducts({ category })` is filed under the products
63
+ // collection. Clearing only the category tag would leave every shop
64
+ // showing yesterday's shelf under today's heading.
65
+ [categoriesTag(), productsTag()]
66
+ ) : input.type.startsWith("event.") ? [eventsTag()] : [];
67
+ if (collections.length === 0) {
56
68
  return [];
57
69
  }
58
70
  if (input.truncated) {
59
- return [collection];
71
+ return collections;
60
72
  }
61
- const item = input.type.startsWith("product.") ? productTag : eventTag;
62
- return [collection, ...input.ids.map((id) => item(id))];
73
+ const item = input.type.startsWith("product.") ? productTag : input.type.startsWith("category.") ? categoryTag : eventTag;
74
+ return [...collections, ...input.ids.map((id) => item(id))];
63
75
  }
64
76
 
65
77
  // src/webhooks.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/webhooks.ts","../src/tags.ts"],"sourcesContent":["import type { ApiWebhookPayload } from './generated/api-types';\nimport { tagsForDelivery } from './tags';\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nconst SIGNING_KEY_PREFIX = 'whsec_';\n\n/**\n * How far out a delivery's clock may be before it is refused.\n *\n * The same five minutes the sender enforces. Shortening it here buys nothing\n * and starts refusing deliveries that queued behind a slow one.\n */\nexport const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;\n\nexport type WebhookVerdict =\n | 'ok'\n | 'malformed_key'\n | 'missing_headers'\n | 'stale_timestamp'\n | 'bad_signature';\n\nexport interface VerifyWebhookInput {\n /** The endpoint's signing key, whole, including its `whsec_` prefix. */\n signingKey: string;\n /** `webhook-id`, `webhook-timestamp` and `webhook-signature`, lowercased. */\n headers: Record<string, string | undefined>;\n /**\n * The request body as it arrived, byte for byte.\n *\n * NOT a parsed object turned back into a string. `JSON.parse` followed by\n * `JSON.stringify` changes the bytes — whitespace, key order, how numbers are\n * rendered — and the signature is over the bytes. A receiver that parses\n * first gets `bad_signature` every single time, and goes looking at its key.\n */\n payload: string;\n now?: Date;\n}\n\n/**\n * Whether this delivery really came from the account that claims to send it.\n *\n * This is a second implementation of an algorithm that already exists on the\n * sending side, and that is unavoidable rather than careless: the sender lives\n * in a private package that is never published, and a receiver has to be able\n * to check a signature without it. What keeps the two honest is not a rule\n * against copies but a test in the sending repository that signs with the real\n * signer and verifies with this function, plus a fixed known-answer vector that\n * neither side computed.\n *\n * Three properties are load-bearing and each has a way of being got wrong:\n *\n * - The key is the **base64-decoded** bytes after `whsec_`, not the string\n * itself. Using the string produces well-formed signatures that match\n * nothing, anywhere, with no error to read.\n * - The timestamp is checked **before** the signature, so a replayed message\n * costs no HMAC.\n * - The comparison is constant-time, and the lengths are compared first,\n * because `timingSafeEqual` throws on a length mismatch rather than\n * returning false.\n *\n * A `webhook-signature` header may carry several space-separated `v1,…` values\n * while a key is being rotated. Any one of them matching is a match.\n */\nexport function verifyWebhook(input: VerifyWebhookInput): WebhookVerdict {\n const key = keyFrom(input.signingKey);\n\n if (key === null) {\n return 'malformed_key';\n }\n\n const messageId = input.headers['webhook-id'];\n const timestamp = input.headers['webhook-timestamp'];\n const offered = input.headers['webhook-signature'];\n\n if (!messageId || !timestamp || !offered) {\n return 'missing_headers';\n }\n\n const seconds = Number(timestamp);\n const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);\n\n if (\n !Number.isFinite(seconds) ||\n Math.abs(nowSeconds - seconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS\n ) {\n return 'stale_timestamp';\n }\n\n const expected = createHmac('sha256', key)\n .update(`${messageId}.${timestamp}.${input.payload}`)\n .digest();\n\n for (const versioned of offered.split(' ')) {\n const [version, value] = versioned.split(',');\n\n if (version !== 'v1' || !value) {\n continue;\n }\n\n const candidate = Buffer.from(value, 'base64');\n\n if (\n candidate.length === expected.length &&\n timingSafeEqual(new Uint8Array(candidate), new Uint8Array(expected))\n ) {\n return 'ok';\n }\n }\n\n return 'bad_signature';\n}\n\nfunction keyFrom(value: string) {\n if (!value?.startsWith(SIGNING_KEY_PREFIX)) {\n return null;\n }\n\n const key = Buffer.from(value.slice(SIGNING_KEY_PREFIX.length), 'base64');\n\n return key.length === 0 ? null : key;\n}\n\nexport interface RevalidateRouteOptions {\n /** The endpoint's signing key, as shown once when it was created. */\n signingKey: string;\n /**\n * Anything else this delivery should set off, after the tags are cleared.\n *\n * Called for every verified delivery including the ones that clear no tags —\n * `order.status_changed` is the reason it exists. It is handed the message id\n * as well, because a delivery can arrive twice: the sender reuses that id on\n * every retry, which makes it exactly the right thing to remember if what you\n * do here is not safe to do twice.\n *\n * Throwing from here answers 500, which the sender retries.\n */\n onEvent?: (delivery: {\n messageId: string;\n payload: ApiWebhookPayload;\n tags: string[];\n }) => void | Promise<void>;\n /**\n * How a tag is cleared. Next.js' own `revalidateTag` when you leave it out.\n *\n * The seam exists so this route can be tested, and so a site that is not on\n * Next.js can still be handed a verified delivery rather than writing the\n * signature check itself — which is the half that goes wrong.\n */\n revalidate?: (tag: string) => void | Promise<void>;\n /**\n * Which of Next.js' two invalidation behaviours to ask for.\n *\n * `{ expire: 0 }` — the default here — expires the entry outright, so the\n * next visitor waits for fresh data and never sees the old answer. That is\n * the right default for a catalogue, because the old answer is usually a\n * price, and a shop that shows a price it will not honour has a problem that\n * a fast page does not make up for.\n *\n * `'max'` is Next's own recommendation and trades that away: the entry is\n * marked stale, the next visitor is served the old answer immediately and the\n * fresh one is fetched behind them. Worth switching to for a large catalogue\n * where a bulk change would otherwise make the next visitor to each of five\n * hundred pages wait — as long as somebody has decided that showing one\n * stale price per page is acceptable.\n *\n * Ignored when `revalidate` is supplied, since then you are doing the\n * clearing yourself.\n */\n profile?: string | { expire?: number };\n}\n\n/**\n * The route that keeps a cached shop from showing yesterday's prices.\n *\n * ```ts\n * // app/api/crm/revalidate/route.ts\n * export const POST = createRevalidateRoute({\n * signingKey: process.env.CRM_WEBHOOK_KEY!,\n * });\n * ```\n *\n * It is shipped rather than described because every site that writes it itself\n * also writes the signature check itself, and that is where it goes wrong — a\n * verifier that returns true too easily looks exactly like one that works.\n *\n * On the status codes: a refused signature answers 401, and the sender never\n * retries a 401. That is right, because a rotated key will be just as wrong in\n * thirty seconds. A stale timestamp answers 400 and *is* retried, which is also\n * right: every attempt is signed afresh, so a delivery that merely queued\n * behind a slow one gets through on the next try.\n */\nexport function createRevalidateRoute(options: RevalidateRouteOptions) {\n return async function POST(request: Request) {\n try {\n return await handle(request);\n } catch (error) {\n /*\n * Nothing may escape this handler, for the same reason the API's own\n * guard closes over its whole body: a throw that leaves here becomes\n * whatever the host framework makes of an unhandled rejection, which is\n * usually an HTML error page — and the sender then records that page as\n * the reason a shop stopped updating.\n *\n * 500 is the right status: the sender retries it, which is what a\n * transient fault in a shop's own handler deserves. The message travels\n * in the body on purpose. It is read by this system's dispatcher, which\n * stores it and shows it on the account's webhooks screen, so the person\n * whose site is failing can see why without reading their own logs.\n */\n return json(\n { error: 'handler_failed', reason: `${error}`.slice(0, 500) },\n 500,\n );\n }\n };\n\n async function handle(request: Request) {\n const body = await request.text();\n\n const verdict = verifyWebhook({\n signingKey: options.signingKey,\n headers: {\n 'webhook-id': request.headers.get('webhook-id') ?? undefined,\n 'webhook-timestamp':\n request.headers.get('webhook-timestamp') ?? undefined,\n 'webhook-signature':\n request.headers.get('webhook-signature') ?? undefined,\n },\n payload: body,\n });\n\n if (verdict !== 'ok') {\n return json(\n { error: verdict },\n verdict === 'stale_timestamp' ? 400 : 401,\n );\n }\n\n let payload: ApiWebhookPayload;\n\n try {\n payload = JSON.parse(body) as ApiWebhookPayload;\n } catch {\n return json({ error: 'malformed_body' }, 400);\n }\n\n if (!payload?.type || !Array.isArray(payload.ids)) {\n return json({ error: 'malformed_body' }, 400);\n }\n\n const tags = tagsForDelivery({\n type: payload.type,\n ids: payload.ids,\n truncated: payload.truncated === true,\n });\n\n const clear =\n options.revalidate ?? (await nextRevalidateTag(options.profile));\n\n for (const tag of tags) {\n await clear(tag);\n }\n\n await options.onEvent?.({\n messageId: request.headers.get('webhook-id') ?? '',\n payload,\n tags,\n });\n\n return json({ revalidated: tags }, 200);\n }\n}\n\n/**\n * Next.js' tag invalidation, reached for only when it is actually needed.\n *\n * A static import would make `next` a hard requirement of this entry point, and\n * `verifyWebhook` above is useful to a receiver that has never heard of Next.\n *\n * The second argument is not optional from Next 16 on. Calling it with one\n * argument still works today and is documented as deprecated, so passing the\n * profile explicitly is what keeps this route from breaking on an upgrade\n * somebody else performs.\n */\nasync function nextRevalidateTag(\n profile: string | { expire?: number } = {\n expire: 0,\n },\n) {\n const cache = (await import('next/cache')) as unknown as {\n revalidateTag: (tag: string, profile: string | { expire?: number }) => void;\n };\n\n return (tag: string) => cache.revalidateTag(tag, profile);\n}\n\nfunction json(body: unknown, status: number) {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n });\n}\n","/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collection = input.type.startsWith('product.')\n ? productsTag()\n : input.type.startsWith('event.')\n ? eventsTag()\n : null;\n\n if (collection === null) {\n return [];\n }\n\n if (input.truncated) {\n return [collection];\n }\n\n const item = input.type.startsWith('product.') ? productTag : eventTag;\n\n return [collection, ...input.ids.map((id) => item(id))];\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/webhooks.ts","../src/tags.ts"],"sourcesContent":["import type { ApiWebhookPayload } from './generated/api-types';\nimport { tagsForDelivery } from './tags';\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nconst SIGNING_KEY_PREFIX = 'whsec_';\n\n/**\n * How far out a delivery's clock may be before it is refused.\n *\n * The same five minutes the sender enforces. Shortening it here buys nothing\n * and starts refusing deliveries that queued behind a slow one.\n */\nexport const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;\n\nexport type WebhookVerdict =\n | 'ok'\n | 'malformed_key'\n | 'missing_headers'\n | 'stale_timestamp'\n | 'bad_signature';\n\nexport interface VerifyWebhookInput {\n /** The endpoint's signing key, whole, including its `whsec_` prefix. */\n signingKey: string;\n /** `webhook-id`, `webhook-timestamp` and `webhook-signature`, lowercased. */\n headers: Record<string, string | undefined>;\n /**\n * The request body as it arrived, byte for byte.\n *\n * NOT a parsed object turned back into a string. `JSON.parse` followed by\n * `JSON.stringify` changes the bytes — whitespace, key order, how numbers are\n * rendered — and the signature is over the bytes. A receiver that parses\n * first gets `bad_signature` every single time, and goes looking at its key.\n */\n payload: string;\n now?: Date;\n}\n\n/**\n * Whether this delivery really came from the account that claims to send it.\n *\n * This is a second implementation of an algorithm that already exists on the\n * sending side, and that is unavoidable rather than careless: the sender lives\n * in a private package that is never published, and a receiver has to be able\n * to check a signature without it. What keeps the two honest is not a rule\n * against copies but a test in the sending repository that signs with the real\n * signer and verifies with this function, plus a fixed known-answer vector that\n * neither side computed.\n *\n * Three properties are load-bearing and each has a way of being got wrong:\n *\n * - The key is the **base64-decoded** bytes after `whsec_`, not the string\n * itself. Using the string produces well-formed signatures that match\n * nothing, anywhere, with no error to read.\n * - The timestamp is checked **before** the signature, so a replayed message\n * costs no HMAC.\n * - The comparison is constant-time, and the lengths are compared first,\n * because `timingSafeEqual` throws on a length mismatch rather than\n * returning false.\n *\n * A `webhook-signature` header may carry several space-separated `v1,…` values\n * while a key is being rotated. Any one of them matching is a match.\n */\nexport function verifyWebhook(input: VerifyWebhookInput): WebhookVerdict {\n const key = keyFrom(input.signingKey);\n\n if (key === null) {\n return 'malformed_key';\n }\n\n const messageId = input.headers['webhook-id'];\n const timestamp = input.headers['webhook-timestamp'];\n const offered = input.headers['webhook-signature'];\n\n if (!messageId || !timestamp || !offered) {\n return 'missing_headers';\n }\n\n const seconds = Number(timestamp);\n const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);\n\n if (\n !Number.isFinite(seconds) ||\n Math.abs(nowSeconds - seconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS\n ) {\n return 'stale_timestamp';\n }\n\n const expected = createHmac('sha256', key)\n .update(`${messageId}.${timestamp}.${input.payload}`)\n .digest();\n\n for (const versioned of offered.split(' ')) {\n const [version, value] = versioned.split(',');\n\n if (version !== 'v1' || !value) {\n continue;\n }\n\n const candidate = Buffer.from(value, 'base64');\n\n if (\n candidate.length === expected.length &&\n timingSafeEqual(new Uint8Array(candidate), new Uint8Array(expected))\n ) {\n return 'ok';\n }\n }\n\n return 'bad_signature';\n}\n\nfunction keyFrom(value: string) {\n if (!value?.startsWith(SIGNING_KEY_PREFIX)) {\n return null;\n }\n\n const key = Buffer.from(value.slice(SIGNING_KEY_PREFIX.length), 'base64');\n\n return key.length === 0 ? null : key;\n}\n\nexport interface RevalidateRouteOptions {\n /** The endpoint's signing key, as shown once when it was created. */\n signingKey: string;\n /**\n * Anything else this delivery should set off, after the tags are cleared.\n *\n * Called for every verified delivery including the ones that clear no tags —\n * `order.status_changed` is the reason it exists. It is handed the message id\n * as well, because a delivery can arrive twice: the sender reuses that id on\n * every retry, which makes it exactly the right thing to remember if what you\n * do here is not safe to do twice.\n *\n * Throwing from here answers 500, which the sender retries.\n */\n onEvent?: (delivery: {\n messageId: string;\n payload: ApiWebhookPayload;\n tags: string[];\n }) => void | Promise<void>;\n /**\n * How a tag is cleared. Next.js' own `revalidateTag` when you leave it out.\n *\n * The seam exists so this route can be tested, and so a site that is not on\n * Next.js can still be handed a verified delivery rather than writing the\n * signature check itself — which is the half that goes wrong.\n */\n revalidate?: (tag: string) => void | Promise<void>;\n /**\n * Which of Next.js' two invalidation behaviours to ask for.\n *\n * `{ expire: 0 }` — the default here — expires the entry outright, so the\n * next visitor waits for fresh data and never sees the old answer. That is\n * the right default for a catalogue, because the old answer is usually a\n * price, and a shop that shows a price it will not honour has a problem that\n * a fast page does not make up for.\n *\n * `'max'` is Next's own recommendation and trades that away: the entry is\n * marked stale, the next visitor is served the old answer immediately and the\n * fresh one is fetched behind them. Worth switching to for a large catalogue\n * where a bulk change would otherwise make the next visitor to each of five\n * hundred pages wait — as long as somebody has decided that showing one\n * stale price per page is acceptable.\n *\n * Ignored when `revalidate` is supplied, since then you are doing the\n * clearing yourself.\n */\n profile?: string | { expire?: number };\n}\n\n/**\n * The route that keeps a cached shop from showing yesterday's prices.\n *\n * ```ts\n * // app/api/crm/revalidate/route.ts\n * export const POST = createRevalidateRoute({\n * signingKey: process.env.CRM_WEBHOOK_KEY!,\n * });\n * ```\n *\n * It is shipped rather than described because every site that writes it itself\n * also writes the signature check itself, and that is where it goes wrong — a\n * verifier that returns true too easily looks exactly like one that works.\n *\n * On the status codes: a refused signature answers 401, and the sender never\n * retries a 401. That is right, because a rotated key will be just as wrong in\n * thirty seconds. A stale timestamp answers 400 and *is* retried, which is also\n * right: every attempt is signed afresh, so a delivery that merely queued\n * behind a slow one gets through on the next try.\n */\nexport function createRevalidateRoute(options: RevalidateRouteOptions) {\n return async function POST(request: Request) {\n try {\n return await handle(request);\n } catch (error) {\n /*\n * Nothing may escape this handler, for the same reason the API's own\n * guard closes over its whole body: a throw that leaves here becomes\n * whatever the host framework makes of an unhandled rejection, which is\n * usually an HTML error page — and the sender then records that page as\n * the reason a shop stopped updating.\n *\n * 500 is the right status: the sender retries it, which is what a\n * transient fault in a shop's own handler deserves. The message travels\n * in the body on purpose. It is read by this system's dispatcher, which\n * stores it and shows it on the account's webhooks screen, so the person\n * whose site is failing can see why without reading their own logs.\n */\n return json(\n { error: 'handler_failed', reason: `${error}`.slice(0, 500) },\n 500,\n );\n }\n };\n\n async function handle(request: Request) {\n const body = await request.text();\n\n const verdict = verifyWebhook({\n signingKey: options.signingKey,\n headers: {\n 'webhook-id': request.headers.get('webhook-id') ?? undefined,\n 'webhook-timestamp':\n request.headers.get('webhook-timestamp') ?? undefined,\n 'webhook-signature':\n request.headers.get('webhook-signature') ?? undefined,\n },\n payload: body,\n });\n\n if (verdict !== 'ok') {\n return json(\n { error: verdict },\n verdict === 'stale_timestamp' ? 400 : 401,\n );\n }\n\n let payload: ApiWebhookPayload;\n\n try {\n payload = JSON.parse(body) as ApiWebhookPayload;\n } catch {\n return json({ error: 'malformed_body' }, 400);\n }\n\n if (!payload?.type || !Array.isArray(payload.ids)) {\n return json({ error: 'malformed_body' }, 400);\n }\n\n const tags = tagsForDelivery({\n type: payload.type,\n ids: payload.ids,\n truncated: payload.truncated === true,\n });\n\n const clear =\n options.revalidate ?? (await nextRevalidateTag(options.profile));\n\n for (const tag of tags) {\n await clear(tag);\n }\n\n await options.onEvent?.({\n messageId: request.headers.get('webhook-id') ?? '',\n payload,\n tags,\n });\n\n return json({ revalidated: tags }, 200);\n }\n}\n\n/**\n * Next.js' tag invalidation, reached for only when it is actually needed.\n *\n * A static import would make `next` a hard requirement of this entry point, and\n * `verifyWebhook` above is useful to a receiver that has never heard of Next.\n *\n * The second argument is not optional from Next 16 on. Calling it with one\n * argument still works today and is documented as deprecated, so passing the\n * profile explicitly is what keeps this route from breaking on an upgrade\n * somebody else performs.\n */\nasync function nextRevalidateTag(\n profile: string | { expire?: number } = {\n expire: 0,\n },\n) {\n const cache = (await import('next/cache')) as unknown as {\n revalidateTag: (tag: string, profile: string | { expire?: number }) => void;\n };\n\n return (tag: string) => cache.revalidateTag(tag, profile);\n}\n\nfunction json(body: unknown, status: number) {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n });\n}\n","/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\n/**\n * Everything that lists categories, and every category page.\n *\n * A category delivery clears both this and the products collection, because a\n * category page *is* a list of products: renaming a category, publishing one,\n * or moving a product into one all change what a product listing filtered by\n * that category answers.\n */\nexport function categoriesTag() {\n return `${PREFIX}:categories`;\n}\n\n/** One category, by whichever key it was fetched with. */\nexport function categoryTag(slugOrId: string) {\n return `${PREFIX}:category:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collections = input.type.startsWith('product.')\n ? [productsTag()]\n : input.type.startsWith('category.')\n ? // Both, and the second one is the point: a category page is a list of\n // products, and `getProducts({ category })` is filed under the products\n // collection. Clearing only the category tag would leave every shop\n // showing yesterday's shelf under today's heading.\n [categoriesTag(), productsTag()]\n : input.type.startsWith('event.')\n ? [eventsTag()]\n : [];\n\n if (collections.length === 0) {\n return [];\n }\n\n if (input.truncated) {\n return collections;\n }\n\n const item = input.type.startsWith('product.')\n ? productTag\n : input.type.startsWith('category.')\n ? categoryTag\n : eventTag;\n\n return [...collections, ...input.ids.map((id) => item(id))];\n}\n"],"mappings":"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","names":[]}
package/dist/webhooks.js CHANGED
@@ -6,6 +6,12 @@ function productsTag() {
6
6
  function productTag(slugOrId) {
7
7
  return `${PREFIX}:product:${slugOrId}`;
8
8
  }
9
+ function categoriesTag() {
10
+ return `${PREFIX}:categories`;
11
+ }
12
+ function categoryTag(slugOrId) {
13
+ return `${PREFIX}:category:${slugOrId}`;
14
+ }
9
15
  function eventsTag() {
10
16
  return `${PREFIX}:events`;
11
17
  }
@@ -13,15 +19,21 @@ function eventTag(idOrSlug) {
13
19
  return `${PREFIX}:event:${idOrSlug}`;
14
20
  }
15
21
  function tagsForDelivery(input) {
16
- const collection = input.type.startsWith("product.") ? productsTag() : input.type.startsWith("event.") ? eventsTag() : null;
17
- if (collection === null) {
22
+ const collections = input.type.startsWith("product.") ? [productsTag()] : input.type.startsWith("category.") ? (
23
+ // Both, and the second one is the point: a category page is a list of
24
+ // products, and `getProducts({ category })` is filed under the products
25
+ // collection. Clearing only the category tag would leave every shop
26
+ // showing yesterday's shelf under today's heading.
27
+ [categoriesTag(), productsTag()]
28
+ ) : input.type.startsWith("event.") ? [eventsTag()] : [];
29
+ if (collections.length === 0) {
18
30
  return [];
19
31
  }
20
32
  if (input.truncated) {
21
- return [collection];
33
+ return collections;
22
34
  }
23
- const item = input.type.startsWith("product.") ? productTag : eventTag;
24
- return [collection, ...input.ids.map((id) => item(id))];
35
+ const item = input.type.startsWith("product.") ? productTag : input.type.startsWith("category.") ? categoryTag : eventTag;
36
+ return [...collections, ...input.ids.map((id) => item(id))];
25
37
  }
26
38
 
27
39
  // src/webhooks.ts
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tags.ts","../src/webhooks.ts"],"sourcesContent":["/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collection = input.type.startsWith('product.')\n ? productsTag()\n : input.type.startsWith('event.')\n ? eventsTag()\n : null;\n\n if (collection === null) {\n return [];\n }\n\n if (input.truncated) {\n return [collection];\n }\n\n const item = input.type.startsWith('product.') ? productTag : eventTag;\n\n return [collection, ...input.ids.map((id) => item(id))];\n}\n","import type { ApiWebhookPayload } from './generated/api-types';\nimport { tagsForDelivery } from './tags';\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nconst SIGNING_KEY_PREFIX = 'whsec_';\n\n/**\n * How far out a delivery's clock may be before it is refused.\n *\n * The same five minutes the sender enforces. Shortening it here buys nothing\n * and starts refusing deliveries that queued behind a slow one.\n */\nexport const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;\n\nexport type WebhookVerdict =\n | 'ok'\n | 'malformed_key'\n | 'missing_headers'\n | 'stale_timestamp'\n | 'bad_signature';\n\nexport interface VerifyWebhookInput {\n /** The endpoint's signing key, whole, including its `whsec_` prefix. */\n signingKey: string;\n /** `webhook-id`, `webhook-timestamp` and `webhook-signature`, lowercased. */\n headers: Record<string, string | undefined>;\n /**\n * The request body as it arrived, byte for byte.\n *\n * NOT a parsed object turned back into a string. `JSON.parse` followed by\n * `JSON.stringify` changes the bytes — whitespace, key order, how numbers are\n * rendered — and the signature is over the bytes. A receiver that parses\n * first gets `bad_signature` every single time, and goes looking at its key.\n */\n payload: string;\n now?: Date;\n}\n\n/**\n * Whether this delivery really came from the account that claims to send it.\n *\n * This is a second implementation of an algorithm that already exists on the\n * sending side, and that is unavoidable rather than careless: the sender lives\n * in a private package that is never published, and a receiver has to be able\n * to check a signature without it. What keeps the two honest is not a rule\n * against copies but a test in the sending repository that signs with the real\n * signer and verifies with this function, plus a fixed known-answer vector that\n * neither side computed.\n *\n * Three properties are load-bearing and each has a way of being got wrong:\n *\n * - The key is the **base64-decoded** bytes after `whsec_`, not the string\n * itself. Using the string produces well-formed signatures that match\n * nothing, anywhere, with no error to read.\n * - The timestamp is checked **before** the signature, so a replayed message\n * costs no HMAC.\n * - The comparison is constant-time, and the lengths are compared first,\n * because `timingSafeEqual` throws on a length mismatch rather than\n * returning false.\n *\n * A `webhook-signature` header may carry several space-separated `v1,…` values\n * while a key is being rotated. Any one of them matching is a match.\n */\nexport function verifyWebhook(input: VerifyWebhookInput): WebhookVerdict {\n const key = keyFrom(input.signingKey);\n\n if (key === null) {\n return 'malformed_key';\n }\n\n const messageId = input.headers['webhook-id'];\n const timestamp = input.headers['webhook-timestamp'];\n const offered = input.headers['webhook-signature'];\n\n if (!messageId || !timestamp || !offered) {\n return 'missing_headers';\n }\n\n const seconds = Number(timestamp);\n const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);\n\n if (\n !Number.isFinite(seconds) ||\n Math.abs(nowSeconds - seconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS\n ) {\n return 'stale_timestamp';\n }\n\n const expected = createHmac('sha256', key)\n .update(`${messageId}.${timestamp}.${input.payload}`)\n .digest();\n\n for (const versioned of offered.split(' ')) {\n const [version, value] = versioned.split(',');\n\n if (version !== 'v1' || !value) {\n continue;\n }\n\n const candidate = Buffer.from(value, 'base64');\n\n if (\n candidate.length === expected.length &&\n timingSafeEqual(new Uint8Array(candidate), new Uint8Array(expected))\n ) {\n return 'ok';\n }\n }\n\n return 'bad_signature';\n}\n\nfunction keyFrom(value: string) {\n if (!value?.startsWith(SIGNING_KEY_PREFIX)) {\n return null;\n }\n\n const key = Buffer.from(value.slice(SIGNING_KEY_PREFIX.length), 'base64');\n\n return key.length === 0 ? null : key;\n}\n\nexport interface RevalidateRouteOptions {\n /** The endpoint's signing key, as shown once when it was created. */\n signingKey: string;\n /**\n * Anything else this delivery should set off, after the tags are cleared.\n *\n * Called for every verified delivery including the ones that clear no tags —\n * `order.status_changed` is the reason it exists. It is handed the message id\n * as well, because a delivery can arrive twice: the sender reuses that id on\n * every retry, which makes it exactly the right thing to remember if what you\n * do here is not safe to do twice.\n *\n * Throwing from here answers 500, which the sender retries.\n */\n onEvent?: (delivery: {\n messageId: string;\n payload: ApiWebhookPayload;\n tags: string[];\n }) => void | Promise<void>;\n /**\n * How a tag is cleared. Next.js' own `revalidateTag` when you leave it out.\n *\n * The seam exists so this route can be tested, and so a site that is not on\n * Next.js can still be handed a verified delivery rather than writing the\n * signature check itself — which is the half that goes wrong.\n */\n revalidate?: (tag: string) => void | Promise<void>;\n /**\n * Which of Next.js' two invalidation behaviours to ask for.\n *\n * `{ expire: 0 }` — the default here — expires the entry outright, so the\n * next visitor waits for fresh data and never sees the old answer. That is\n * the right default for a catalogue, because the old answer is usually a\n * price, and a shop that shows a price it will not honour has a problem that\n * a fast page does not make up for.\n *\n * `'max'` is Next's own recommendation and trades that away: the entry is\n * marked stale, the next visitor is served the old answer immediately and the\n * fresh one is fetched behind them. Worth switching to for a large catalogue\n * where a bulk change would otherwise make the next visitor to each of five\n * hundred pages wait — as long as somebody has decided that showing one\n * stale price per page is acceptable.\n *\n * Ignored when `revalidate` is supplied, since then you are doing the\n * clearing yourself.\n */\n profile?: string | { expire?: number };\n}\n\n/**\n * The route that keeps a cached shop from showing yesterday's prices.\n *\n * ```ts\n * // app/api/crm/revalidate/route.ts\n * export const POST = createRevalidateRoute({\n * signingKey: process.env.CRM_WEBHOOK_KEY!,\n * });\n * ```\n *\n * It is shipped rather than described because every site that writes it itself\n * also writes the signature check itself, and that is where it goes wrong — a\n * verifier that returns true too easily looks exactly like one that works.\n *\n * On the status codes: a refused signature answers 401, and the sender never\n * retries a 401. That is right, because a rotated key will be just as wrong in\n * thirty seconds. A stale timestamp answers 400 and *is* retried, which is also\n * right: every attempt is signed afresh, so a delivery that merely queued\n * behind a slow one gets through on the next try.\n */\nexport function createRevalidateRoute(options: RevalidateRouteOptions) {\n return async function POST(request: Request) {\n try {\n return await handle(request);\n } catch (error) {\n /*\n * Nothing may escape this handler, for the same reason the API's own\n * guard closes over its whole body: a throw that leaves here becomes\n * whatever the host framework makes of an unhandled rejection, which is\n * usually an HTML error page — and the sender then records that page as\n * the reason a shop stopped updating.\n *\n * 500 is the right status: the sender retries it, which is what a\n * transient fault in a shop's own handler deserves. The message travels\n * in the body on purpose. It is read by this system's dispatcher, which\n * stores it and shows it on the account's webhooks screen, so the person\n * whose site is failing can see why without reading their own logs.\n */\n return json(\n { error: 'handler_failed', reason: `${error}`.slice(0, 500) },\n 500,\n );\n }\n };\n\n async function handle(request: Request) {\n const body = await request.text();\n\n const verdict = verifyWebhook({\n signingKey: options.signingKey,\n headers: {\n 'webhook-id': request.headers.get('webhook-id') ?? undefined,\n 'webhook-timestamp':\n request.headers.get('webhook-timestamp') ?? undefined,\n 'webhook-signature':\n request.headers.get('webhook-signature') ?? undefined,\n },\n payload: body,\n });\n\n if (verdict !== 'ok') {\n return json(\n { error: verdict },\n verdict === 'stale_timestamp' ? 400 : 401,\n );\n }\n\n let payload: ApiWebhookPayload;\n\n try {\n payload = JSON.parse(body) as ApiWebhookPayload;\n } catch {\n return json({ error: 'malformed_body' }, 400);\n }\n\n if (!payload?.type || !Array.isArray(payload.ids)) {\n return json({ error: 'malformed_body' }, 400);\n }\n\n const tags = tagsForDelivery({\n type: payload.type,\n ids: payload.ids,\n truncated: payload.truncated === true,\n });\n\n const clear =\n options.revalidate ?? (await nextRevalidateTag(options.profile));\n\n for (const tag of tags) {\n await clear(tag);\n }\n\n await options.onEvent?.({\n messageId: request.headers.get('webhook-id') ?? '',\n payload,\n tags,\n });\n\n return json({ revalidated: tags }, 200);\n }\n}\n\n/**\n * Next.js' tag invalidation, reached for only when it is actually needed.\n *\n * A static import would make `next` a hard requirement of this entry point, and\n * `verifyWebhook` above is useful to a receiver that has never heard of Next.\n *\n * The second argument is not optional from Next 16 on. Calling it with one\n * argument still works today and is documented as deprecated, so passing the\n * profile explicitly is what keeps this route from breaking on an upgrade\n * somebody else performs.\n */\nasync function nextRevalidateTag(\n profile: string | { expire?: number } = {\n expire: 0,\n },\n) {\n const cache = (await import('next/cache')) as unknown as {\n revalidateTag: (tag: string, profile: string | { expire?: number }) => void;\n };\n\n return (tag: string) => cache.revalidateTag(tag, profile);\n}\n\nfunction json(body: unknown, status: number) {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/tags.ts","../src/webhooks.ts"],"sourcesContent":["/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\n/**\n * Everything that lists categories, and every category page.\n *\n * A category delivery clears both this and the products collection, because a\n * category page *is* a list of products: renaming a category, publishing one,\n * or moving a product into one all change what a product listing filtered by\n * that category answers.\n */\nexport function categoriesTag() {\n return `${PREFIX}:categories`;\n}\n\n/** One category, by whichever key it was fetched with. */\nexport function categoryTag(slugOrId: string) {\n return `${PREFIX}:category:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collections = input.type.startsWith('product.')\n ? [productsTag()]\n : input.type.startsWith('category.')\n ? // Both, and the second one is the point: a category page is a list of\n // products, and `getProducts({ category })` is filed under the products\n // collection. Clearing only the category tag would leave every shop\n // showing yesterday's shelf under today's heading.\n [categoriesTag(), productsTag()]\n : input.type.startsWith('event.')\n ? [eventsTag()]\n : [];\n\n if (collections.length === 0) {\n return [];\n }\n\n if (input.truncated) {\n return collections;\n }\n\n const item = input.type.startsWith('product.')\n ? productTag\n : input.type.startsWith('category.')\n ? categoryTag\n : eventTag;\n\n return [...collections, ...input.ids.map((id) => item(id))];\n}\n","import type { ApiWebhookPayload } from './generated/api-types';\nimport { tagsForDelivery } from './tags';\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nconst SIGNING_KEY_PREFIX = 'whsec_';\n\n/**\n * How far out a delivery's clock may be before it is refused.\n *\n * The same five minutes the sender enforces. Shortening it here buys nothing\n * and starts refusing deliveries that queued behind a slow one.\n */\nexport const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;\n\nexport type WebhookVerdict =\n | 'ok'\n | 'malformed_key'\n | 'missing_headers'\n | 'stale_timestamp'\n | 'bad_signature';\n\nexport interface VerifyWebhookInput {\n /** The endpoint's signing key, whole, including its `whsec_` prefix. */\n signingKey: string;\n /** `webhook-id`, `webhook-timestamp` and `webhook-signature`, lowercased. */\n headers: Record<string, string | undefined>;\n /**\n * The request body as it arrived, byte for byte.\n *\n * NOT a parsed object turned back into a string. `JSON.parse` followed by\n * `JSON.stringify` changes the bytes — whitespace, key order, how numbers are\n * rendered — and the signature is over the bytes. A receiver that parses\n * first gets `bad_signature` every single time, and goes looking at its key.\n */\n payload: string;\n now?: Date;\n}\n\n/**\n * Whether this delivery really came from the account that claims to send it.\n *\n * This is a second implementation of an algorithm that already exists on the\n * sending side, and that is unavoidable rather than careless: the sender lives\n * in a private package that is never published, and a receiver has to be able\n * to check a signature without it. What keeps the two honest is not a rule\n * against copies but a test in the sending repository that signs with the real\n * signer and verifies with this function, plus a fixed known-answer vector that\n * neither side computed.\n *\n * Three properties are load-bearing and each has a way of being got wrong:\n *\n * - The key is the **base64-decoded** bytes after `whsec_`, not the string\n * itself. Using the string produces well-formed signatures that match\n * nothing, anywhere, with no error to read.\n * - The timestamp is checked **before** the signature, so a replayed message\n * costs no HMAC.\n * - The comparison is constant-time, and the lengths are compared first,\n * because `timingSafeEqual` throws on a length mismatch rather than\n * returning false.\n *\n * A `webhook-signature` header may carry several space-separated `v1,…` values\n * while a key is being rotated. Any one of them matching is a match.\n */\nexport function verifyWebhook(input: VerifyWebhookInput): WebhookVerdict {\n const key = keyFrom(input.signingKey);\n\n if (key === null) {\n return 'malformed_key';\n }\n\n const messageId = input.headers['webhook-id'];\n const timestamp = input.headers['webhook-timestamp'];\n const offered = input.headers['webhook-signature'];\n\n if (!messageId || !timestamp || !offered) {\n return 'missing_headers';\n }\n\n const seconds = Number(timestamp);\n const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000);\n\n if (\n !Number.isFinite(seconds) ||\n Math.abs(nowSeconds - seconds) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS\n ) {\n return 'stale_timestamp';\n }\n\n const expected = createHmac('sha256', key)\n .update(`${messageId}.${timestamp}.${input.payload}`)\n .digest();\n\n for (const versioned of offered.split(' ')) {\n const [version, value] = versioned.split(',');\n\n if (version !== 'v1' || !value) {\n continue;\n }\n\n const candidate = Buffer.from(value, 'base64');\n\n if (\n candidate.length === expected.length &&\n timingSafeEqual(new Uint8Array(candidate), new Uint8Array(expected))\n ) {\n return 'ok';\n }\n }\n\n return 'bad_signature';\n}\n\nfunction keyFrom(value: string) {\n if (!value?.startsWith(SIGNING_KEY_PREFIX)) {\n return null;\n }\n\n const key = Buffer.from(value.slice(SIGNING_KEY_PREFIX.length), 'base64');\n\n return key.length === 0 ? null : key;\n}\n\nexport interface RevalidateRouteOptions {\n /** The endpoint's signing key, as shown once when it was created. */\n signingKey: string;\n /**\n * Anything else this delivery should set off, after the tags are cleared.\n *\n * Called for every verified delivery including the ones that clear no tags —\n * `order.status_changed` is the reason it exists. It is handed the message id\n * as well, because a delivery can arrive twice: the sender reuses that id on\n * every retry, which makes it exactly the right thing to remember if what you\n * do here is not safe to do twice.\n *\n * Throwing from here answers 500, which the sender retries.\n */\n onEvent?: (delivery: {\n messageId: string;\n payload: ApiWebhookPayload;\n tags: string[];\n }) => void | Promise<void>;\n /**\n * How a tag is cleared. Next.js' own `revalidateTag` when you leave it out.\n *\n * The seam exists so this route can be tested, and so a site that is not on\n * Next.js can still be handed a verified delivery rather than writing the\n * signature check itself — which is the half that goes wrong.\n */\n revalidate?: (tag: string) => void | Promise<void>;\n /**\n * Which of Next.js' two invalidation behaviours to ask for.\n *\n * `{ expire: 0 }` — the default here — expires the entry outright, so the\n * next visitor waits for fresh data and never sees the old answer. That is\n * the right default for a catalogue, because the old answer is usually a\n * price, and a shop that shows a price it will not honour has a problem that\n * a fast page does not make up for.\n *\n * `'max'` is Next's own recommendation and trades that away: the entry is\n * marked stale, the next visitor is served the old answer immediately and the\n * fresh one is fetched behind them. Worth switching to for a large catalogue\n * where a bulk change would otherwise make the next visitor to each of five\n * hundred pages wait — as long as somebody has decided that showing one\n * stale price per page is acceptable.\n *\n * Ignored when `revalidate` is supplied, since then you are doing the\n * clearing yourself.\n */\n profile?: string | { expire?: number };\n}\n\n/**\n * The route that keeps a cached shop from showing yesterday's prices.\n *\n * ```ts\n * // app/api/crm/revalidate/route.ts\n * export const POST = createRevalidateRoute({\n * signingKey: process.env.CRM_WEBHOOK_KEY!,\n * });\n * ```\n *\n * It is shipped rather than described because every site that writes it itself\n * also writes the signature check itself, and that is where it goes wrong — a\n * verifier that returns true too easily looks exactly like one that works.\n *\n * On the status codes: a refused signature answers 401, and the sender never\n * retries a 401. That is right, because a rotated key will be just as wrong in\n * thirty seconds. A stale timestamp answers 400 and *is* retried, which is also\n * right: every attempt is signed afresh, so a delivery that merely queued\n * behind a slow one gets through on the next try.\n */\nexport function createRevalidateRoute(options: RevalidateRouteOptions) {\n return async function POST(request: Request) {\n try {\n return await handle(request);\n } catch (error) {\n /*\n * Nothing may escape this handler, for the same reason the API's own\n * guard closes over its whole body: a throw that leaves here becomes\n * whatever the host framework makes of an unhandled rejection, which is\n * usually an HTML error page — and the sender then records that page as\n * the reason a shop stopped updating.\n *\n * 500 is the right status: the sender retries it, which is what a\n * transient fault in a shop's own handler deserves. The message travels\n * in the body on purpose. It is read by this system's dispatcher, which\n * stores it and shows it on the account's webhooks screen, so the person\n * whose site is failing can see why without reading their own logs.\n */\n return json(\n { error: 'handler_failed', reason: `${error}`.slice(0, 500) },\n 500,\n );\n }\n };\n\n async function handle(request: Request) {\n const body = await request.text();\n\n const verdict = verifyWebhook({\n signingKey: options.signingKey,\n headers: {\n 'webhook-id': request.headers.get('webhook-id') ?? undefined,\n 'webhook-timestamp':\n request.headers.get('webhook-timestamp') ?? undefined,\n 'webhook-signature':\n request.headers.get('webhook-signature') ?? undefined,\n },\n payload: body,\n });\n\n if (verdict !== 'ok') {\n return json(\n { error: verdict },\n verdict === 'stale_timestamp' ? 400 : 401,\n );\n }\n\n let payload: ApiWebhookPayload;\n\n try {\n payload = JSON.parse(body) as ApiWebhookPayload;\n } catch {\n return json({ error: 'malformed_body' }, 400);\n }\n\n if (!payload?.type || !Array.isArray(payload.ids)) {\n return json({ error: 'malformed_body' }, 400);\n }\n\n const tags = tagsForDelivery({\n type: payload.type,\n ids: payload.ids,\n truncated: payload.truncated === true,\n });\n\n const clear =\n options.revalidate ?? (await nextRevalidateTag(options.profile));\n\n for (const tag of tags) {\n await clear(tag);\n }\n\n await options.onEvent?.({\n messageId: request.headers.get('webhook-id') ?? '',\n payload,\n tags,\n });\n\n return json({ revalidated: tags }, 200);\n }\n}\n\n/**\n * Next.js' tag invalidation, reached for only when it is actually needed.\n *\n * A static import would make `next` a hard requirement of this entry point, and\n * `verifyWebhook` above is useful to a receiver that has never heard of Next.\n *\n * The second argument is not optional from Next 16 on. Calling it with one\n * argument still works today and is documented as deprecated, so passing the\n * profile explicitly is what keeps this route from breaking on an upgrade\n * somebody else performs.\n */\nasync function nextRevalidateTag(\n profile: string | { expire?: number } = {\n expire: 0,\n },\n) {\n const cache = (await import('next/cache')) as unknown as {\n revalidateTag: (tag: string, profile: string | { expire?: number }) => void;\n };\n\n return (tag: string) => cache.revalidateTag(tag, profile);\n}\n\nfunction json(body: unknown, status: number) {\n return new Response(JSON.stringify(body), {\n status,\n headers: {\n 'content-type': 'application/json',\n 'cache-control': 'no-store',\n },\n });\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solumflow-app/crm-client",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Read a CRM catalogue and send orders back to it, from your own website.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -25,26 +25,42 @@
25
25
  "types": "./dist/mirror.d.ts",
26
26
  "import": "./dist/mirror.js",
27
27
  "require": "./dist/mirror.cjs"
28
+ },
29
+ "./react": {
30
+ "types": "./dist/react/index.d.ts",
31
+ "import": "./dist/react.js",
32
+ "require": "./dist/react.cjs"
28
33
  }
29
34
  },
30
35
  "publishConfig": {
31
36
  "access": "public"
32
37
  },
33
38
  "devDependencies": {
34
- "next": "16.3.0",
35
- "tsup": "8.5.1",
36
- "typescript": "^7.0.2",
37
- "vitest": "^4.1.10",
39
+ "@kit/api-keys": "0.1.0",
38
40
  "@kit/crm-automations": "0.1.0",
41
+ "@kit/crm-chat": "0.1.0",
42
+ "@kit/crm-forms": "0.1.0",
43
+ "@kit/crm-objects": "0.1.0",
39
44
  "@kit/tsconfig": "0.1.0",
40
- "@kit/api-keys": "0.1.0"
45
+ "@types/react": "19.2.18",
46
+ "@types/react-dom": "19.2.5",
47
+ "next": "16.3.8",
48
+ "react": "19.2.8",
49
+ "react-dom": "19.2.8",
50
+ "tsup": "8.5.1",
51
+ "typescript": "^7.0.2",
52
+ "vitest": "^4.1.11"
41
53
  },
42
54
  "peerDependencies": {
43
- "next": ">=15"
55
+ "next": ">=15",
56
+ "react": ">=19"
44
57
  },
45
58
  "peerDependenciesMeta": {
46
59
  "next": {
47
60
  "optional": true
61
+ },
62
+ "react": {
63
+ "optional": true
48
64
  }
49
65
  },
50
66
  "engines": {