@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 +3 -0
- package/dist/index.js +9 -0
- package/dist/presets-de-oauth/shopify-oauth-presets.d.ts +32 -1
- package/dist/presets-de-oauth/shopify-oauth-presets.js +34 -0
- package/dist/rate-limiter.d.ts +25 -0
- package/dist/rate-limiter.js +84 -0
- package/dist/reference-id.d.ts +89 -0
- package/dist/reference-id.js +132 -0
- package/dist/un-hueco-se-toma-de-una-vez.d.ts +16 -0
- package/dist/un-hueco-se-toma-de-una-vez.js +100 -0
- package/package.json +12 -2
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
|
+
"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
|
}
|