@hostwebhook/platform-node 0.3.0 → 0.4.1

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/index.d.ts CHANGED
@@ -8,3 +8,6 @@ export * from './presets-de-oauth/github-oauth-presets';
8
8
  export * from './presets-de-oauth/linkedin-oauth-presets';
9
9
  export * from './presets-de-oauth/mailchimp-oauth-presets';
10
10
  export * from './presets-de-oauth/shopify-oauth-presets';
11
+ export * from './reference-id';
12
+ export * from './rate-limiter';
13
+ export * from './un-hueco-se-toma-de-una-vez';
package/dist/index.js CHANGED
@@ -30,3 +30,12 @@ __exportStar(require("./presets-de-oauth/github-oauth-presets"), exports);
30
30
  __exportStar(require("./presets-de-oauth/linkedin-oauth-presets"), exports);
31
31
  __exportStar(require("./presets-de-oauth/mailchimp-oauth-presets"), exports);
32
32
  __exportStar(require("./presets-de-oauth/shopify-oauth-presets"), exports);
33
+ /* `reference-id` necesita `mongoose` de verdad (`Types.ObjectId` en ejecución):
34
+ es la ÚNICA dependencia real del paquete, y va como `peerDependency` porque
35
+ todo consumidor ya la tiene.
36
+
37
+ `rate-limiter` y su ayudante sólo usan `ioredis` como TIPO, así que no pesan
38
+ nada en ejecución — el `Redis` se lo pasa quien llama. */
39
+ __exportStar(require("./reference-id"), exports);
40
+ __exportStar(require("./rate-limiter"), exports);
41
+ __exportStar(require("./un-hueco-se-toma-de-una-vez"), exports);
@@ -66,8 +66,39 @@ export declare const SHOPIFY_CREDENTIAL_TYPE = "shopify_oauth2";
66
66
  *
67
67
  * `read_all_orders` is deliberately absent: it needs a separate approval from
68
68
  * Shopify and only widens the window past 60 days, which nothing in v1 uses.
69
+ *
70
+ * ## 🔑 [2026-09-08] Three added, and they cost a reconnect
71
+ *
72
+ * The action node grew six operations, and three of them need permissions the
73
+ * app never asked for. Two things follow, and both are the kind that are
74
+ * discovered at the worst moment if they are not written down:
75
+ *
76
+ * 1. **Declaring them here is half the edit.** The other half is releasing a
77
+ * new version of the app in the Dev Dashboard with the same list. Until
78
+ * that is done Shopify refuses the AUTHORIZE step — so the symptom is not a
79
+ * failing node, it is a connection flow that will not start.
80
+ *
81
+ * 2. **Scopes are granted at install time.** Every credential connected before
82
+ * this list changed keeps the eight it was granted. It goes on working for
83
+ * the operations it already covered, and answers HTTP 403 on the three new
84
+ * ones until somebody presses Reconnect on it. That is why
85
+ * `SHOPIFY_OPERATION_SCOPES` exists in node-types: so the node can say
86
+ * which scope is missing instead of relaying a 403.
87
+ *
88
+ * `write_assigned_fulfillment_orders` is deliberately absent, and it is the
89
+ * one an over-eager list would include. It is for apps that ARE a fulfilment
90
+ * service — Shopify assigns them the fulfilment orders. HostWebhook is an
91
+ * order-management app: it fulfils what the merchant's own locations
92
+ * (`merchant_managed`) or their 3PL (`third_party`) hold. Asking for a
93
+ * permission the app has no use for is what makes a merchant read the install
94
+ * screen twice.
95
+ *
96
+ * Read scopes for the same resources are not listed either: on Shopify a write
97
+ * scope includes read, so `write_draft_orders` already grants
98
+ * `read_draft_orders`. (The eight above predate that being written down here
99
+ * and keep both spellings; they are harmless, just redundant.)
69
100
  */
70
- export declare const SHOPIFY_DEFAULT_SCOPES: readonly ["read_orders", "write_orders", "read_customers", "write_customers", "read_products", "write_products", "read_inventory", "write_inventory"];
101
+ export declare const SHOPIFY_DEFAULT_SCOPES: readonly ["read_orders", "write_orders", "read_customers", "write_customers", "read_products", "write_products", "read_inventory", "write_inventory", "write_draft_orders", "write_merchant_managed_fulfillment_orders", "write_third_party_fulfillment_orders"];
71
102
  /**
72
103
  * Shopify's own pattern for a store domain, anchored at both ends.
73
104
  *
@@ -75,6 +75,37 @@ exports.SHOPIFY_CREDENTIAL_TYPE = 'shopify_oauth2';
75
75
  *
76
76
  * `read_all_orders` is deliberately absent: it needs a separate approval from
77
77
  * Shopify and only widens the window past 60 days, which nothing in v1 uses.
78
+ *
79
+ * ## 🔑 [2026-09-08] Three added, and they cost a reconnect
80
+ *
81
+ * The action node grew six operations, and three of them need permissions the
82
+ * app never asked for. Two things follow, and both are the kind that are
83
+ * discovered at the worst moment if they are not written down:
84
+ *
85
+ * 1. **Declaring them here is half the edit.** The other half is releasing a
86
+ * new version of the app in the Dev Dashboard with the same list. Until
87
+ * that is done Shopify refuses the AUTHORIZE step — so the symptom is not a
88
+ * failing node, it is a connection flow that will not start.
89
+ *
90
+ * 2. **Scopes are granted at install time.** Every credential connected before
91
+ * this list changed keeps the eight it was granted. It goes on working for
92
+ * the operations it already covered, and answers HTTP 403 on the three new
93
+ * ones until somebody presses Reconnect on it. That is why
94
+ * `SHOPIFY_OPERATION_SCOPES` exists in node-types: so the node can say
95
+ * which scope is missing instead of relaying a 403.
96
+ *
97
+ * `write_assigned_fulfillment_orders` is deliberately absent, and it is the
98
+ * one an over-eager list would include. It is for apps that ARE a fulfilment
99
+ * service — Shopify assigns them the fulfilment orders. HostWebhook is an
100
+ * order-management app: it fulfils what the merchant's own locations
101
+ * (`merchant_managed`) or their 3PL (`third_party`) hold. Asking for a
102
+ * permission the app has no use for is what makes a merchant read the install
103
+ * screen twice.
104
+ *
105
+ * Read scopes for the same resources are not listed either: on Shopify a write
106
+ * scope includes read, so `write_draft_orders` already grants
107
+ * `read_draft_orders`. (The eight above predate that being written down here
108
+ * and keep both spellings; they are harmless, just redundant.)
78
109
  */
79
110
  exports.SHOPIFY_DEFAULT_SCOPES = [
80
111
  'read_orders',
@@ -85,6 +116,9 @@ exports.SHOPIFY_DEFAULT_SCOPES = [
85
116
  'write_products',
86
117
  'read_inventory',
87
118
  'write_inventory',
119
+ 'write_draft_orders',
120
+ 'write_merchant_managed_fulfillment_orders',
121
+ 'write_third_party_fulfillment_orders',
88
122
  ];
89
123
  /**
90
124
  * Shopify's own pattern for a store domain, anchored at both ends.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Limitadores de ventana deslizante sobre conjuntos ordenados de Redis.
3
+ *
4
+ * Cada uno aporta su prefijo de clave, su ventana y su TTL; la mecánica —
5
+ * contar, decidir y ocupar el hueco sin que nadie se meta en medio — vive en
6
+ * `un-hueco-se-toma-de-una-vez.ts`, en una sola copia y dentro de un script
7
+ * Lua. Estuvo escrita cinco veces y con una carrera entre contar y ocupar;
8
+ * arreglarla en un sitio la habría dejado en cuatro.
9
+ *
10
+ * Claves:
11
+ * entrega: ratelimit:ep:<webhookId>
12
+ * ingress: ratelimit:ingress:<webhookId>
13
+ * chat: ratelimit:chat:<chatTriggerId>:<scopeKey>
14
+ * oauth: ratelimit:oauth:<provider>:<userId>
15
+ * flow-links: ratelimit:flowlink:<organizationId>
16
+ */
17
+ import type { Redis } from 'ioredis';
18
+ import { type RateLimitResult } from './un-hueco-se-toma-de-una-vez';
19
+ export type { RateLimitResult };
20
+ export declare function checkRateLimit(redis: Redis, webhookId: string, limitPerMinute: number): Promise<RateLimitResult>;
21
+ export declare function checkChatRateLimit(redis: Redis, chatTriggerId: string, scopeKey: string, limitPerMinute: number): Promise<RateLimitResult>;
22
+ export declare function checkOAuthRateLimit(redis: Redis, userId: string, provider: 'google' | 'mcp' | 'atlassian' | 'slack' | 'twitter' | 'mastodon' | 'linkedin' | 'mailchimp' | 'shopify' | 'notion' | 'github' | 'threads' | 'instagram' | 'facebook' | 'discord', limitPerHour: number): Promise<RateLimitResult>;
23
+ export declare function checkFlowLinkRateLimit(redis: Redis, organizationId: string, limitPerMinute: number): Promise<RateLimitResult>;
24
+ /** Rate-limit incoming webhook requests at the ingress level. */
25
+ export declare function checkIngressRateLimit(redis: Redis, webhookId: string, limitPerMinute: number): Promise<RateLimitResult>;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * Limitadores de ventana deslizante sobre conjuntos ordenados de Redis.
4
+ *
5
+ * Cada uno aporta su prefijo de clave, su ventana y su TTL; la mecánica —
6
+ * contar, decidir y ocupar el hueco sin que nadie se meta en medio — vive en
7
+ * `un-hueco-se-toma-de-una-vez.ts`, en una sola copia y dentro de un script
8
+ * Lua. Estuvo escrita cinco veces y con una carrera entre contar y ocupar;
9
+ * arreglarla en un sitio la habría dejado en cuatro.
10
+ *
11
+ * Claves:
12
+ * entrega: ratelimit:ep:<webhookId>
13
+ * ingress: ratelimit:ingress:<webhookId>
14
+ * chat: ratelimit:chat:<chatTriggerId>:<scopeKey>
15
+ * oauth: ratelimit:oauth:<provider>:<userId>
16
+ * flow-links: ratelimit:flowlink:<organizationId>
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.checkRateLimit = checkRateLimit;
20
+ exports.checkChatRateLimit = checkChatRateLimit;
21
+ exports.checkOAuthRateLimit = checkOAuthRateLimit;
22
+ exports.checkFlowLinkRateLimit = checkFlowLinkRateLimit;
23
+ exports.checkIngressRateLimit = checkIngressRateLimit;
24
+ const un_hueco_se_toma_de_una_vez_1 = require("./un-hueco-se-toma-de-una-vez");
25
+ const KEY_PREFIX = 'ratelimit:ep:';
26
+ const INGRESS_KEY_PREFIX = 'ratelimit:ingress:';
27
+ const WINDOW_MS = 60000; // 1 minute
28
+ const KEY_TTL_S = 120; // expire key after 2 minutes of inactivity
29
+ async function checkRateLimit(redis, webhookId, limitPerMinute) {
30
+ return (0, un_hueco_se_toma_de_una_vez_1.pedirHueco)(redis, `${KEY_PREFIX}${webhookId}`, limitPerMinute, WINDOW_MS, KEY_TTL_S);
31
+ }
32
+ /**
33
+ * Sliding-window rate limit for chat trigger messages. Keyed by
34
+ * (chatTriggerId, scopeKey) where scopeKey is sessionId or IP depending
35
+ * on the trigger's rateLimitScope. Lets each trigger owner pick their
36
+ * own ceiling (default 60/min) and key strategy without sharing a
37
+ * global IP bucket with every other tenant.
38
+ */
39
+ const CHAT_KEY_PREFIX = 'ratelimit:chat:';
40
+ async function checkChatRateLimit(redis, chatTriggerId, scopeKey, limitPerMinute) {
41
+ return (0, un_hueco_se_toma_de_una_vez_1.pedirHueco)(redis, `${CHAT_KEY_PREFIX}${chatTriggerId}:${scopeKey}`, limitPerMinute, WINDOW_MS, KEY_TTL_S);
42
+ }
43
+ /**
44
+ * Rate-limit OAuth flow initiations (Google auth-url + MCP OAuth start).
45
+ * Bucketed per (userId, provider) on a 1-hour sliding window so automated
46
+ * abuse — fuzzing auth URLs, mass DCR registrations against a server —
47
+ * is capped without blocking legitimate re-auths during the day.
48
+ *
49
+ * Separate from the ingress / delivery limiters because the window is
50
+ * longer (60 min vs 1 min) and the blast radius is different: overflow
51
+ * here means the upstream provider sees thousands of HW flow starts,
52
+ * which can get HW's OAuth app flagged or throttled by that provider.
53
+ */
54
+ const OAUTH_KEY_PREFIX = 'ratelimit:oauth:';
55
+ const OAUTH_WINDOW_MS = 60 * 60000; // 1 hour
56
+ const OAUTH_KEY_TTL_S = 2 * 60 * 60; // 2h — stale keys self-evict
57
+ async function checkOAuthRateLimit(redis, userId, provider, limitPerHour) {
58
+ return (0, un_hueco_se_toma_de_una_vez_1.pedirHueco)(redis, `${OAUTH_KEY_PREFIX}${provider}:${userId}`, limitPerHour, OAUTH_WINDOW_MS, OAUTH_KEY_TTL_S);
59
+ }
60
+ /**
61
+ * Rate-limit cross-workspace FlowLink invocations, per organisation.
62
+ *
63
+ * `maxFlowCallsPerMinute` has existed in the plan table since FlowLinks
64
+ * shipped (free 60 / pro 600 / enterprise unlimited) and nothing ever read
65
+ * it. The two sibling caps — call depth and sync timeout — are enforced, so
66
+ * the gap read as covered.
67
+ *
68
+ * It is the one that actually bounds throughput. Depth stops a chain from
69
+ * recursing, but a workspace whose flows fan out to several others, each
70
+ * firing on every event, multiplies work per event without ever exceeding
71
+ * the depth cap. The counter is per-org rather than per-link for that
72
+ * reason: what needs bounding is total work the org can push through the
73
+ * workers, not any single edge.
74
+ *
75
+ * -1 means unlimited (enterprise) and skips Redis entirely.
76
+ */
77
+ const FLOW_LINK_KEY_PREFIX = 'ratelimit:flowlink:';
78
+ async function checkFlowLinkRateLimit(redis, organizationId, limitPerMinute) {
79
+ return (0, un_hueco_se_toma_de_una_vez_1.pedirHueco)(redis, `${FLOW_LINK_KEY_PREFIX}${organizationId}`, limitPerMinute, WINDOW_MS, KEY_TTL_S);
80
+ }
81
+ /** Rate-limit incoming webhook requests at the ingress level. */
82
+ async function checkIngressRateLimit(redis, webhookId, limitPerMinute) {
83
+ return (0, un_hueco_se_toma_de_una_vez_1.pedirHueco)(redis, `${INGRESS_KEY_PREFIX}${webhookId}`, limitPerMinute, WINDOW_MS, KEY_TTL_S);
84
+ }
@@ -0,0 +1,89 @@
1
+ import { Types } from 'mongoose';
2
+ /**
3
+ * Every reference field in this codebase — `webhookId`, `organizationId`,
4
+ * `credentialId`, `workspaceId`, … — is declared as
5
+ * `@Prop({ type: Types.ObjectId })`. `Types.ObjectId` is the BSON value
6
+ * constructor, not the SchemaType, and `SchemaFactory` does not recognise it:
7
+ * all 298 of those declarations compile to `Mixed`.
8
+ *
9
+ * A `Mixed` path neither casts nor validates, so it stores whatever it is
10
+ * handed. `TokenStrategy` caches the validated user in Redis as JSON, which
11
+ * means `_id` and `organizationId` come back as strings for five minutes at a
12
+ * time — and get written that way. Collections therefore hold a mix of strings
13
+ * and ObjectIds, and MongoDB compares the two as different values. A filter
14
+ * matches whichever half shares its type and silently misses the rest.
15
+ *
16
+ * Until the schemas are corrected and the data migrated, a filter on a
17
+ * reference field has to match both representations, and an id compared in
18
+ * JavaScript has to be normalised first.
19
+ *
20
+ * These helpers stay correct after the migration: once the path is typed,
21
+ * Mongoose casts a valid hex string to ObjectId, so a dual-valued `$in` still
22
+ * resolves to the right documents. They can be simplified away later — there
23
+ * is no window in which they have to be removed urgently.
24
+ */
25
+ /** The canonical, comparable form of an id. */
26
+ export declare function refIdKey(id: unknown): string;
27
+ /**
28
+ * Both representations of a single id, deduplicated. A value that is not a
29
+ * valid ObjectId (a legacy key, an empty string) is passed through as-is
30
+ * rather than dropped, so a malformed filter still matches nothing instead of
31
+ * matching everything.
32
+ */
33
+ export declare function refIdVariants(id: string | Types.ObjectId): Array<string | Types.ObjectId>;
34
+ /**
35
+ * The `$in` arrays below hold both representations of every id, which no
36
+ * generated entity type can express: each entity declares the field as one
37
+ * type or the other — `webhookId: string`, `credentialId: Types.ObjectId` —
38
+ * while the collection holds both. That disagreement between the declared type
39
+ * and the stored value is the bug these helpers exist to paper over, so the
40
+ * element type is widened here, in the one place where it is deliberate,
41
+ * rather than casting at each of the ~25 call sites.
42
+ */
43
+ type RefIdFilter = {
44
+ $in: any[];
45
+ };
46
+ /**
47
+ * Filter clause for a list of ids: `{ webhookId: refIdIn(ids) }`.
48
+ * Replaces `{ webhookId: { $in: ids } }`.
49
+ */
50
+ export declare function refIdIn(ids: ReadonlyArray<string | Types.ObjectId>): RefIdFilter;
51
+ /**
52
+ * Filter clause for a single id: `{ webhookId: refIdEq(id) }`.
53
+ * Replaces `{ webhookId: id }`.
54
+ */
55
+ export declare function refIdEq(id: string | Types.ObjectId): RefIdFilter;
56
+ /**
57
+ * Restore ObjectId fields on an object that has been through JSON.
58
+ *
59
+ * JSON has no ObjectId, so anything cached as a string comes back as one. That
60
+ * is where the mixed data came from in the first place: `TokenStrategy` caches
61
+ * the validated user in Redis, and for the five minutes that entry lives, every
62
+ * request downstream sees `_id` and `organizationId` as strings and writes them
63
+ * on as strings.
64
+ *
65
+ * Fixing it at the source means the cache-hit path and the cache-miss path hand
66
+ * downstream code the same shapes, instead of every consumer having to defend
67
+ * itself against a difference it cannot see.
68
+ *
69
+ * Schema-driven rather than a list of field names — and it only became possible
70
+ * once the declarations were corrected, because a path that compiles to Mixed
71
+ * cannot tell anyone it holds a reference.
72
+ */
73
+ export declare function rehydrateObjectIds<T extends Record<string, any>>(obj: T, schema: {
74
+ paths: Record<string, {
75
+ instance?: string;
76
+ caster?: {
77
+ instance?: string;
78
+ };
79
+ }>;
80
+ }): T;
81
+ /** True when both ids denote the same document, whatever their runtime type. */
82
+ export declare function sameRefId(a: unknown, b: unknown): boolean;
83
+ /**
84
+ * Membership test for ownership and authorisation checks. Replaces
85
+ * `ids.includes(candidate)`, which compares an ObjectId to a string by
86
+ * identity and therefore returns false for a document the caller does own.
87
+ */
88
+ export declare function includesRefId(ids: ReadonlyArray<string | Types.ObjectId>, candidate: unknown): boolean;
89
+ export {};
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refIdKey = refIdKey;
4
+ exports.refIdVariants = refIdVariants;
5
+ exports.refIdIn = refIdIn;
6
+ exports.refIdEq = refIdEq;
7
+ exports.rehydrateObjectIds = rehydrateObjectIds;
8
+ exports.sameRefId = sameRefId;
9
+ exports.includesRefId = includesRefId;
10
+ const mongoose_1 = require("mongoose");
11
+ /**
12
+ * Every reference field in this codebase — `webhookId`, `organizationId`,
13
+ * `credentialId`, `workspaceId`, … — is declared as
14
+ * `@Prop({ type: Types.ObjectId })`. `Types.ObjectId` is the BSON value
15
+ * constructor, not the SchemaType, and `SchemaFactory` does not recognise it:
16
+ * all 298 of those declarations compile to `Mixed`.
17
+ *
18
+ * A `Mixed` path neither casts nor validates, so it stores whatever it is
19
+ * handed. `TokenStrategy` caches the validated user in Redis as JSON, which
20
+ * means `_id` and `organizationId` come back as strings for five minutes at a
21
+ * time — and get written that way. Collections therefore hold a mix of strings
22
+ * and ObjectIds, and MongoDB compares the two as different values. A filter
23
+ * matches whichever half shares its type and silently misses the rest.
24
+ *
25
+ * Until the schemas are corrected and the data migrated, a filter on a
26
+ * reference field has to match both representations, and an id compared in
27
+ * JavaScript has to be normalised first.
28
+ *
29
+ * These helpers stay correct after the migration: once the path is typed,
30
+ * Mongoose casts a valid hex string to ObjectId, so a dual-valued `$in` still
31
+ * resolves to the right documents. They can be simplified away later — there
32
+ * is no window in which they have to be removed urgently.
33
+ */
34
+ /** The canonical, comparable form of an id. */
35
+ function refIdKey(id) {
36
+ return id == null ? '' : String(id);
37
+ }
38
+ /**
39
+ * Both representations of a single id, deduplicated. A value that is not a
40
+ * valid ObjectId (a legacy key, an empty string) is passed through as-is
41
+ * rather than dropped, so a malformed filter still matches nothing instead of
42
+ * matching everything.
43
+ */
44
+ function refIdVariants(id) {
45
+ const key = refIdKey(id);
46
+ if (!mongoose_1.Types.ObjectId.isValid(key))
47
+ return [key];
48
+ return [key, new mongoose_1.Types.ObjectId(key)];
49
+ }
50
+ /**
51
+ * Filter clause for a list of ids: `{ webhookId: refIdIn(ids) }`.
52
+ * Replaces `{ webhookId: { $in: ids } }`.
53
+ */
54
+ function refIdIn(ids) {
55
+ const values = [];
56
+ const seen = new Set();
57
+ for (const id of ids) {
58
+ const key = refIdKey(id);
59
+ if (seen.has(key))
60
+ continue;
61
+ seen.add(key);
62
+ values.push(...refIdVariants(key));
63
+ }
64
+ return { $in: values };
65
+ }
66
+ /**
67
+ * Filter clause for a single id: `{ webhookId: refIdEq(id) }`.
68
+ * Replaces `{ webhookId: id }`.
69
+ */
70
+ function refIdEq(id) {
71
+ return { $in: refIdVariants(id) };
72
+ }
73
+ /**
74
+ * Restore ObjectId fields on an object that has been through JSON.
75
+ *
76
+ * JSON has no ObjectId, so anything cached as a string comes back as one. That
77
+ * is where the mixed data came from in the first place: `TokenStrategy` caches
78
+ * the validated user in Redis, and for the five minutes that entry lives, every
79
+ * request downstream sees `_id` and `organizationId` as strings and writes them
80
+ * on as strings.
81
+ *
82
+ * Fixing it at the source means the cache-hit path and the cache-miss path hand
83
+ * downstream code the same shapes, instead of every consumer having to defend
84
+ * itself against a difference it cannot see.
85
+ *
86
+ * Schema-driven rather than a list of field names — and it only became possible
87
+ * once the declarations were corrected, because a path that compiles to Mixed
88
+ * cannot tell anyone it holds a reference.
89
+ */
90
+ function rehydrateObjectIds(obj, schema) {
91
+ if (!obj || !schema?.paths)
92
+ return obj;
93
+ for (const [path, schemaType] of Object.entries(schema.paths)) {
94
+ // Only top-level fields: the cached blob is a lean user document, and a
95
+ // dotted path would need walking that this does not need to do.
96
+ if (path.includes('.'))
97
+ continue;
98
+ const value = obj[path];
99
+ if (value == null)
100
+ continue;
101
+ if (schemaType.instance === 'ObjectId') {
102
+ if (typeof value === 'string' && mongoose_1.Types.ObjectId.isValid(value)) {
103
+ obj[path] = new mongoose_1.Types.ObjectId(value);
104
+ }
105
+ continue;
106
+ }
107
+ if (schemaType.instance === 'Array' &&
108
+ schemaType.caster?.instance === 'ObjectId' &&
109
+ Array.isArray(value)) {
110
+ obj[path] = value.map((v) => typeof v === 'string' && mongoose_1.Types.ObjectId.isValid(v)
111
+ ? new mongoose_1.Types.ObjectId(v)
112
+ : v);
113
+ }
114
+ }
115
+ return obj;
116
+ }
117
+ /** True when both ids denote the same document, whatever their runtime type. */
118
+ function sameRefId(a, b) {
119
+ const left = refIdKey(a);
120
+ return left !== '' && left === refIdKey(b);
121
+ }
122
+ /**
123
+ * Membership test for ownership and authorisation checks. Replaces
124
+ * `ids.includes(candidate)`, which compares an ObjectId to a string by
125
+ * identity and therefore returns false for a document the caller does own.
126
+ */
127
+ function includesRefId(ids, candidate) {
128
+ const key = refIdKey(candidate);
129
+ if (key === '')
130
+ return false;
131
+ return ids.some((id) => refIdKey(id) === key);
132
+ }
@@ -0,0 +1,16 @@
1
+ import type { Redis } from 'ioredis';
2
+ export interface RateLimitResult {
3
+ allowed: boolean;
4
+ retryAfterMs?: number;
5
+ }
6
+ /**
7
+ * Pide un hueco en la ventana deslizante. `true` si lo hay, y en ese mismo
8
+ * momento queda ocupado.
9
+ *
10
+ * Un fallo de Redis se propaga, igual que antes de este cambio. Aquí no se
11
+ * decide qué hacer sin limitador —si se deja pasar o se corta— porque la
12
+ * respuesta no es la misma en los cinco sitios: quedarse sin Redis no debería
13
+ * significar lo mismo para una entrega que para el arranque de un flujo OAuth.
14
+ * Esa decisión es de quien llama, y hoy ninguno la toma; queda apuntado.
15
+ */
16
+ export declare function pedirHueco(redis: Redis, clave: string, tope: number, ventanaMs: number, ttlSegundos: number): Promise<RateLimitResult>;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pedirHueco = pedirHueco;
4
+ /**
5
+ * Contar y ocupar el hueco, en una sola operación.
6
+ *
7
+ * ## Lo que estaba mal
8
+ *
9
+ * Los cinco limitadores —entrega, ingress, chat, OAuth y flow-links— tenían
10
+ * calcado el mismo cuerpo:
11
+ *
12
+ * pipeline.zremrangebyscore(key, 0, windowStart);
13
+ * pipeline.zcard(key); // ← cuenta
14
+ * await pipeline.exec();
15
+ * if (count >= limite) rechazar;
16
+ * await redis.zadd(key, now, member); // ← ocupa, OTRO viaje de red
17
+ *
18
+ * Entre contar y ocupar hay una ida y vuelta entera a Redis. Todo lo que
19
+ * llegue en ese hueco lee el MISMO recuento y pasa. Con C peticiones a la vez
20
+ * entran hasta `límite + C - 1`.
21
+ *
22
+ * No es teórico en `ingress`: está de cara a internet, mandar cien peticiones
23
+ * simultáneas no cuesta nada, y con un tope de 60/min se cuelan unas 160 en la
24
+ * ráfaga. Y como el estado vive en Redis y compartido, escalar la API a más de
25
+ * una instancia sube la concurrencia y por tanto el exceso.
26
+ *
27
+ * ## Por qué Lua y no MULTI
28
+ *
29
+ * Porque la decisión depende del recuento: hay que contar, decidir y ocupar
30
+ * sin que nadie se meta en medio. `MULTI` agrupa escrituras pero no deja
31
+ * ramificar sobre un valor leído dentro de la misma transacción. Un script Lua
32
+ * corre entero y sin interrupciones en el servidor, que es exactamente la
33
+ * garantía que faltaba.
34
+ *
35
+ * ## Una sola copia
36
+ *
37
+ * Los cinco limitadores se diferenciaban en el prefijo de la clave, la ventana
38
+ * y el TTL — nada más. Estaba escrito cinco veces, así que el fallo estaba
39
+ * cinco veces y arreglarlo en uno lo habría dejado en cuatro. Ahora la
40
+ * mecánica vive aquí y cada limitador aporta sus tres parámetros.
41
+ */
42
+ /**
43
+ * KEYS[1] la clave del cubo. ARGV: ahora, inicio de ventana, límite, miembro,
44
+ * ttl. Devuelve `{permitido, tsDelMasViejo}`.
45
+ *
46
+ * El más viejo sólo se lee cuando se rechaza — es para estimar cuándo se
47
+ * libera un hueco, y pedirlo siempre sería un comando de más en el camino
48
+ * bueno, que es el que corre a todas horas.
49
+ */
50
+ const GUION = `
51
+ local clave = KEYS[1]
52
+ local ahora = tonumber(ARGV[1])
53
+ local desde = tonumber(ARGV[2])
54
+ local tope = tonumber(ARGV[3])
55
+ local quien = ARGV[4]
56
+ local ttl = tonumber(ARGV[5])
57
+
58
+ redis.call('ZREMRANGEBYSCORE', clave, 0, desde)
59
+ local cuantos = redis.call('ZCARD', clave)
60
+
61
+ if cuantos >= tope then
62
+ local viejo = redis.call('ZRANGE', clave, 0, 0, 'WITHSCORES')
63
+ local ts = ahora
64
+ if viejo[2] then ts = tonumber(viejo[2]) end
65
+ return {0, ts}
66
+ end
67
+
68
+ redis.call('ZADD', clave, ahora, quien)
69
+ redis.call('EXPIRE', clave, ttl)
70
+ return {1, 0}
71
+ `;
72
+ /**
73
+ * Pide un hueco en la ventana deslizante. `true` si lo hay, y en ese mismo
74
+ * momento queda ocupado.
75
+ *
76
+ * Un fallo de Redis se propaga, igual que antes de este cambio. Aquí no se
77
+ * decide qué hacer sin limitador —si se deja pasar o se corta— porque la
78
+ * respuesta no es la misma en los cinco sitios: quedarse sin Redis no debería
79
+ * significar lo mismo para una entrega que para el arranque de un flujo OAuth.
80
+ * Esa decisión es de quien llama, y hoy ninguno la toma; queda apuntado.
81
+ */
82
+ async function pedirHueco(redis, clave, tope, ventanaMs, ttlSegundos) {
83
+ /* `-1` es sin límite en toda la tabla de planes. Ni se pregunta a Redis. */
84
+ if (tope < 0)
85
+ return { allowed: true };
86
+ /* Un tope de 0 es «nada pasa». El guión lo resolvería igual, pero decirlo
87
+ aquí ahorra el viaje y deja explícito que 0 no significa «sin límite». */
88
+ if (tope === 0)
89
+ return { allowed: false, retryAfterMs: ventanaMs };
90
+ const ahora = Date.now();
91
+ const quien = `${ahora}:${Math.random().toString(36).slice(2, 8)}`;
92
+ const salida = (await redis.eval(GUION, 1, clave, String(ahora), String(ahora - ventanaMs), String(tope), quien, String(ttlSegundos)));
93
+ if (salida[0] === 1)
94
+ return { allowed: true };
95
+ const masViejo = Number(salida[1]) || ahora;
96
+ return {
97
+ allowed: false,
98
+ retryAfterMs: Math.max(1000, masViejo + ventanaMs - ahora),
99
+ };
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/platform-node",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Lo que los servicios de HostWebhook comparten del lado de NODE: cifrado y utilidades que no pueden estar escritas dos veces",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,6 +21,16 @@
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.0.0",
23
23
  "typescript": "^5.0.0",
24
- "vitest": "^3.0.0"
24
+ "vitest": "^3.0.0",
25
+ "mongoose": "^9.2.1",
26
+ "ioredis": "^5.4.1"
27
+ },
28
+ "peerDependencies": {
29
+ "mongoose": ">=8"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "mongoose": {
33
+ "optional": false
34
+ }
25
35
  }
26
36
  }