@hostwebhook/platform-node 0.2.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.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Shopify OAuth 2.0 configuration for HostWebhook.
3
+ *
4
+ * The app is registered in the **Dev Dashboard** (<https://dev.shopify.com/dashboard/>),
5
+ * not in the Partner Dashboard and not in a store's admin — both of those
6
+ * paths are dead: admin-created custom apps stopped being creatable on
7
+ * 2026-01-01. The redirect URL is
8
+ * `{API_URL}/api/credentials/shopify/oauth/callback`.
9
+ *
10
+ * See `docs/ADR-0007-shopify.md` for why this app uses **custom
11
+ * distribution**, which is the decision most of the notes below hang off.
12
+ *
13
+ * ## Three things that make Shopify unlike every other provider here
14
+ *
15
+ * **The store is part of the host.** Calls go to
16
+ * `https://{shop}.myshopify.com/admin/api/{version}/graphql.json`, and so does
17
+ * the authorization URL. That is worse than Mailchimp's `dc`, which at least
18
+ * arrives from Mailchimp: here the shop is **typed by the user before the flow
19
+ * starts**, so it is attacker-influenced input that we then put in a URL. It
20
+ * is validated twice — in the DTO and again here — against Shopify's own
21
+ * anchored pattern, and every call goes through `fetchSeguro`.
22
+ *
23
+ * **The callback is signed, and checking it is not optional.** Shopify appends
24
+ * an `hmac` over the rest of the query string. Skipping that check would let
25
+ * anyone hit our public callback with a `shop` of their choosing.
26
+ *
27
+ * **Tokens may or may not expire, and which one you get is a property of the
28
+ * app, not of the code.** Apps with *public* distribution created on or after
29
+ * 2026-04-01 must use expiring offline tokens (1 h access, 90-day refresh);
30
+ * apps with custom distribution are exempt and get a token that lives until
31
+ * the app is uninstalled. Ours is custom, so today no expiry arrives.
32
+ *
33
+ * We deliberately do **not** send `expiring: 1` in the token request — that is
34
+ * the flag that opts into the expiring kind, and asking for a token that dies
35
+ * every hour when we do not have to would buy the rotation problem for
36
+ * nothing. But the reader below stores `expires_in` / `refresh_token`
37
+ * **whenever Shopify sends them**, so the day this app goes public (which
38
+ * means a NEW app — distribution cannot be changed) the credential shape does
39
+ * not have to change with it.
40
+ */
41
+ /**
42
+ * The Admin API version, in ONE place.
43
+ *
44
+ * Shopify ships quarterly and supports each version for at least 12 months, so
45
+ * this is a scheduled edit, not a fire. What is *not* optional is sending it:
46
+ * omit the version and Shopify "falls forward" to the oldest stable version it
47
+ * still serves, which means the API changes under us on a date nobody chose.
48
+ */
49
+ export declare const SHOPIFY_API_VERSION = "2026-07";
50
+ export declare const SHOPIFY_OAUTH_CONFIG: {
51
+ /** Env var names holding HW's registered client credentials. */
52
+ readonly clientIdEnv: "SHOPIFY_OAUTH_CLIENT_ID";
53
+ readonly clientSecretEnv: "SHOPIFY_OAUTH_CLIENT_SECRET";
54
+ };
55
+ /** The credential `type` string. Must also exist in `@hostwebhook/node-types`
56
+ * `CREDENTIAL_TYPES` — the Mongoose enum and the DTO's `@IsIn` both come from
57
+ * there, and a type missing from that list fails only at runtime. */
58
+ export declare const SHOPIFY_CREDENTIAL_TYPE = "shopify_oauth2";
59
+ /**
60
+ * What the node needs, and nothing else.
61
+ *
62
+ * These must ALSO be declared on the app's released version in the Dev
63
+ * Dashboard — same two-places rule as Slack, not the one-place rule of
64
+ * Google. A scope requested here but not declared there is refused by
65
+ * Shopify at the authorize step.
66
+ *
67
+ * `read_all_orders` is deliberately absent: it needs a separate approval from
68
+ * Shopify and only widens the window past 60 days, which nothing in v1 uses.
69
+ */
70
+ export declare const SHOPIFY_DEFAULT_SCOPES: readonly ["read_orders", "write_orders", "read_customers", "write_customers", "read_products", "write_products", "read_inventory", "write_inventory"];
71
+ /**
72
+ * Shopify's own pattern for a store domain, anchored at both ends.
73
+ *
74
+ * Taken from their authorization guide rather than written from scratch, and
75
+ * the anchors are the whole point: without them
76
+ * `evil.com/?x=shop.myshopify.com` passes.
77
+ */
78
+ export declare const SHOP_DOMAIN_RE: RegExp;
79
+ /**
80
+ * Normalize and validate a store domain, or throw.
81
+ *
82
+ * Accepts what a user actually types — trailing slash, protocol, capitals, the
83
+ * bare handle — and returns the canonical `{shop}.myshopify.com`. Throwing
84
+ * rather than returning null is on purpose: every caller builds a URL with the
85
+ * result, so a soft failure would produce a request to a host we did not mean
86
+ * to call.
87
+ */
88
+ export declare function normalizeShopDomain(input: string): string;
89
+ /**
90
+ * Base URL for every Admin API call made with this credential.
91
+ *
92
+ * Lives here rather than being interpolated at each call site so there is one
93
+ * place that knows the shape — and so a credential missing its shop fails
94
+ * loudly here instead of producing a request to `https://undefined…`. Same
95
+ * reasoning as `mailchimpApiBase(dc)`.
96
+ */
97
+ export declare function shopifyGraphqlUrl(shop: string): string;
98
+ export declare function buildShopifyAuthorizeUrl(params: {
99
+ shop: string;
100
+ clientId: string;
101
+ redirectUri: string;
102
+ state: string;
103
+ scopes?: readonly string[];
104
+ }): string;
105
+ /**
106
+ * The body of the authorization-code exchange, including the one flag that
107
+ * decides whether the resulting token works at all.
108
+ *
109
+ * ## `expiring: 1` is not optional, and this file used to say the opposite
110
+ *
111
+ * Custom-distribution apps are exempt from expiring offline tokens, so the
112
+ * original reasoning was that asking for a token which dies every hour would
113
+ * buy the rotation problem for nothing. That is right — for a custom app. This
114
+ * app went **public**, and Shopify then refuses the non-expiring kind outright:
115
+ *
116
+ * ```
117
+ * [API] Non-expiring access tokens are no longer accepted for the Admin API.
118
+ * Start using expiring offline tokens.
119
+ * ```
120
+ *
121
+ * HTTP 403 on every call, including ones touching nothing protected. Seen on
122
+ * 2026-08-26 the first time this was pointed at a real store: the credential
123
+ * connected, the shop name came back, and then not one webhook subscription
124
+ * could be registered — because the token TYPE is decided here and refused over
125
+ * there, which leaves the symptom nowhere near the cause.
126
+ *
127
+ * Sent unconditionally rather than only when public, because the failure is
128
+ * asymmetric: a custom app handed an expiring token still works, since
129
+ * `refreshAccessToken` keeps it alive, while a public app handed a
130
+ * non-expiring one is simply dead. And the distribution method lives in
131
+ * Shopify's dashboard, where this code cannot read it.
132
+ *
133
+ * Pure and exported for the same reason as `verifyShopifyOAuthHmac` below: a
134
+ * flag buried in a private method is a flag no spec can pin, and this one is
135
+ * the difference between a working integration and 403 on everything.
136
+ *
137
+ * ⚠️ No `redirect_uri`. Unlike every other provider here, Shopify's token
138
+ * endpoint does not take one.
139
+ */
140
+ export declare function buildShopifyTokenExchangeBody(params: {
141
+ clientId: string;
142
+ clientSecret: string;
143
+ code: string;
144
+ }): URLSearchParams;
145
+ /**
146
+ * Verify the `hmac` Shopify appends to the callback.
147
+ *
148
+ * The recipe, from Shopify's authorization guide: drop `hmac`, sort what is
149
+ * left by key, join as `k=v&k=v` with the values **decoded**, HMAC-SHA256 with
150
+ * the client secret, compare the **hex** digest in constant time.
151
+ *
152
+ * Pure and exported so a spec can exercise it without a Nest module — the same
153
+ * reason `matchesGmailQuery` and friends live outside their services. A
154
+ * signature check that is awkward to test is a signature check nobody tests.
155
+ *
156
+ * ⚠️ Only `hmac` is removed. Older write-ups also strip `signature`; the
157
+ * current guide does not, and removing a parameter that Shopify *did* include
158
+ * in the message would make every valid callback fail to verify.
159
+ */
160
+ export declare function verifyShopifyOAuthHmac(params: {
161
+ query: Record<string, string | undefined>;
162
+ clientSecret: string;
163
+ }): boolean;
164
+ /** What `POST /admin/oauth/access_token` gives back. */
165
+ export interface ShopifyTokenResponse {
166
+ access_token?: string;
167
+ scope?: string;
168
+ /** Only present for expiring tokens — i.e. never for a custom-distribution
169
+ * app today. Its absence is normal, not an error. */
170
+ expires_in?: number;
171
+ refresh_token?: string;
172
+ refresh_token_expires_in?: number;
173
+ error?: string;
174
+ error_description?: string;
175
+ }
176
+ export interface ShopifyOAuthStoredAuth {
177
+ accessToken: string;
178
+ /** Canonical `{shop}.myshopify.com`. Without this there is no host to call
179
+ * — see the note at the top. */
180
+ shop: string;
181
+ /** Scopes Shopify actually granted, which can be narrower than requested. */
182
+ scope: string;
183
+ /** Absolute ms timestamp, or null when the token does not expire. */
184
+ expiresAt: number | null;
185
+ refreshToken: string | null;
186
+ /** Absolute ms timestamp the refresh token itself dies, or null. */
187
+ refreshTokenExpiresAt: number | null;
188
+ /** Shop display name, for the credential list. */
189
+ shopName: string;
190
+ /** Whether a test query answered when the credential was connected. */
191
+ probeOk: boolean;
192
+ }
193
+ export interface PendingShopifyOAuthState {
194
+ orgId: string;
195
+ userId: string;
196
+ credentialName: string;
197
+ /** Set on reconnect to update an existing credential instead of creating one. */
198
+ credentialId?: string;
199
+ tags?: string[];
200
+ folderId?: string;
201
+ /** The store this flow was started for. The callback's `shop` must match it:
202
+ * the callback is a public route, and without this a valid-looking request
203
+ * could bind a different store to the credential. */
204
+ shop: string;
205
+ clientId: string;
206
+ clientSecret: string;
207
+ /** Absolute ms timestamp when this pending state expires. */
208
+ expiresAt: number;
209
+ }
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SHOP_DOMAIN_RE = exports.SHOPIFY_DEFAULT_SCOPES = exports.SHOPIFY_CREDENTIAL_TYPE = exports.SHOPIFY_OAUTH_CONFIG = exports.SHOPIFY_API_VERSION = void 0;
4
+ exports.normalizeShopDomain = normalizeShopDomain;
5
+ exports.shopifyGraphqlUrl = shopifyGraphqlUrl;
6
+ exports.buildShopifyAuthorizeUrl = buildShopifyAuthorizeUrl;
7
+ exports.buildShopifyTokenExchangeBody = buildShopifyTokenExchangeBody;
8
+ exports.verifyShopifyOAuthHmac = verifyShopifyOAuthHmac;
9
+ const crypto_1 = require("crypto");
10
+ /**
11
+ * Shopify OAuth 2.0 configuration for HostWebhook.
12
+ *
13
+ * The app is registered in the **Dev Dashboard** (<https://dev.shopify.com/dashboard/>),
14
+ * not in the Partner Dashboard and not in a store's admin — both of those
15
+ * paths are dead: admin-created custom apps stopped being creatable on
16
+ * 2026-01-01. The redirect URL is
17
+ * `{API_URL}/api/credentials/shopify/oauth/callback`.
18
+ *
19
+ * See `docs/ADR-0007-shopify.md` for why this app uses **custom
20
+ * distribution**, which is the decision most of the notes below hang off.
21
+ *
22
+ * ## Three things that make Shopify unlike every other provider here
23
+ *
24
+ * **The store is part of the host.** Calls go to
25
+ * `https://{shop}.myshopify.com/admin/api/{version}/graphql.json`, and so does
26
+ * the authorization URL. That is worse than Mailchimp's `dc`, which at least
27
+ * arrives from Mailchimp: here the shop is **typed by the user before the flow
28
+ * starts**, so it is attacker-influenced input that we then put in a URL. It
29
+ * is validated twice — in the DTO and again here — against Shopify's own
30
+ * anchored pattern, and every call goes through `fetchSeguro`.
31
+ *
32
+ * **The callback is signed, and checking it is not optional.** Shopify appends
33
+ * an `hmac` over the rest of the query string. Skipping that check would let
34
+ * anyone hit our public callback with a `shop` of their choosing.
35
+ *
36
+ * **Tokens may or may not expire, and which one you get is a property of the
37
+ * app, not of the code.** Apps with *public* distribution created on or after
38
+ * 2026-04-01 must use expiring offline tokens (1 h access, 90-day refresh);
39
+ * apps with custom distribution are exempt and get a token that lives until
40
+ * the app is uninstalled. Ours is custom, so today no expiry arrives.
41
+ *
42
+ * We deliberately do **not** send `expiring: 1` in the token request — that is
43
+ * the flag that opts into the expiring kind, and asking for a token that dies
44
+ * every hour when we do not have to would buy the rotation problem for
45
+ * nothing. But the reader below stores `expires_in` / `refresh_token`
46
+ * **whenever Shopify sends them**, so the day this app goes public (which
47
+ * means a NEW app — distribution cannot be changed) the credential shape does
48
+ * not have to change with it.
49
+ */
50
+ /**
51
+ * The Admin API version, in ONE place.
52
+ *
53
+ * Shopify ships quarterly and supports each version for at least 12 months, so
54
+ * this is a scheduled edit, not a fire. What is *not* optional is sending it:
55
+ * omit the version and Shopify "falls forward" to the oldest stable version it
56
+ * still serves, which means the API changes under us on a date nobody chose.
57
+ */
58
+ exports.SHOPIFY_API_VERSION = '2026-07';
59
+ exports.SHOPIFY_OAUTH_CONFIG = {
60
+ /** Env var names holding HW's registered client credentials. */
61
+ clientIdEnv: 'SHOPIFY_OAUTH_CLIENT_ID',
62
+ clientSecretEnv: 'SHOPIFY_OAUTH_CLIENT_SECRET',
63
+ };
64
+ /** The credential `type` string. Must also exist in `@hostwebhook/node-types`
65
+ * `CREDENTIAL_TYPES` — the Mongoose enum and the DTO's `@IsIn` both come from
66
+ * there, and a type missing from that list fails only at runtime. */
67
+ exports.SHOPIFY_CREDENTIAL_TYPE = 'shopify_oauth2';
68
+ /**
69
+ * What the node needs, and nothing else.
70
+ *
71
+ * These must ALSO be declared on the app's released version in the Dev
72
+ * Dashboard — same two-places rule as Slack, not the one-place rule of
73
+ * Google. A scope requested here but not declared there is refused by
74
+ * Shopify at the authorize step.
75
+ *
76
+ * `read_all_orders` is deliberately absent: it needs a separate approval from
77
+ * Shopify and only widens the window past 60 days, which nothing in v1 uses.
78
+ */
79
+ exports.SHOPIFY_DEFAULT_SCOPES = [
80
+ 'read_orders',
81
+ 'write_orders',
82
+ 'read_customers',
83
+ 'write_customers',
84
+ 'read_products',
85
+ 'write_products',
86
+ 'read_inventory',
87
+ 'write_inventory',
88
+ ];
89
+ /**
90
+ * Shopify's own pattern for a store domain, anchored at both ends.
91
+ *
92
+ * Taken from their authorization guide rather than written from scratch, and
93
+ * the anchors are the whole point: without them
94
+ * `evil.com/?x=shop.myshopify.com` passes.
95
+ */
96
+ exports.SHOP_DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
97
+ /**
98
+ * Normalize and validate a store domain, or throw.
99
+ *
100
+ * Accepts what a user actually types — trailing slash, protocol, capitals, the
101
+ * bare handle — and returns the canonical `{shop}.myshopify.com`. Throwing
102
+ * rather than returning null is on purpose: every caller builds a URL with the
103
+ * result, so a soft failure would produce a request to a host we did not mean
104
+ * to call.
105
+ */
106
+ function normalizeShopDomain(input) {
107
+ let shop = String(input ?? '')
108
+ .trim()
109
+ .toLowerCase();
110
+ // `https://tienda.myshopify.com/admin` → `tienda.myshopify.com`
111
+ shop = shop.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
112
+ // A bare handle is what people paste most often.
113
+ if (shop && !shop.includes('.'))
114
+ shop = `${shop}.myshopify.com`;
115
+ if (!exports.SHOP_DOMAIN_RE.test(shop)) {
116
+ throw new Error(`"${input}" is not a Shopify store domain. It should look like ` +
117
+ `your-store.myshopify.com — that is the permanent domain in Settings → ` +
118
+ `Domains, not a custom domain you may have pointed at the store.`);
119
+ }
120
+ return shop;
121
+ }
122
+ /**
123
+ * Base URL for every Admin API call made with this credential.
124
+ *
125
+ * Lives here rather than being interpolated at each call site so there is one
126
+ * place that knows the shape — and so a credential missing its shop fails
127
+ * loudly here instead of producing a request to `https://undefined…`. Same
128
+ * reasoning as `mailchimpApiBase(dc)`.
129
+ */
130
+ function shopifyGraphqlUrl(shop) {
131
+ const domain = normalizeShopDomain(shop);
132
+ return `https://${domain}/admin/api/${exports.SHOPIFY_API_VERSION}/graphql.json`;
133
+ }
134
+ function buildShopifyAuthorizeUrl(params) {
135
+ const domain = normalizeShopDomain(params.shop);
136
+ const qs = new URLSearchParams({
137
+ client_id: params.clientId,
138
+ // Comma-separated, not space-separated. Shopify is the odd one out here.
139
+ scope: (params.scopes ?? exports.SHOPIFY_DEFAULT_SCOPES).join(','),
140
+ redirect_uri: params.redirectUri,
141
+ state: params.state,
142
+ });
143
+ return `https://${domain}/admin/oauth/authorize?${qs.toString()}`;
144
+ }
145
+ /**
146
+ * The body of the authorization-code exchange, including the one flag that
147
+ * decides whether the resulting token works at all.
148
+ *
149
+ * ## `expiring: 1` is not optional, and this file used to say the opposite
150
+ *
151
+ * Custom-distribution apps are exempt from expiring offline tokens, so the
152
+ * original reasoning was that asking for a token which dies every hour would
153
+ * buy the rotation problem for nothing. That is right — for a custom app. This
154
+ * app went **public**, and Shopify then refuses the non-expiring kind outright:
155
+ *
156
+ * ```
157
+ * [API] Non-expiring access tokens are no longer accepted for the Admin API.
158
+ * Start using expiring offline tokens.
159
+ * ```
160
+ *
161
+ * HTTP 403 on every call, including ones touching nothing protected. Seen on
162
+ * 2026-08-26 the first time this was pointed at a real store: the credential
163
+ * connected, the shop name came back, and then not one webhook subscription
164
+ * could be registered — because the token TYPE is decided here and refused over
165
+ * there, which leaves the symptom nowhere near the cause.
166
+ *
167
+ * Sent unconditionally rather than only when public, because the failure is
168
+ * asymmetric: a custom app handed an expiring token still works, since
169
+ * `refreshAccessToken` keeps it alive, while a public app handed a
170
+ * non-expiring one is simply dead. And the distribution method lives in
171
+ * Shopify's dashboard, where this code cannot read it.
172
+ *
173
+ * Pure and exported for the same reason as `verifyShopifyOAuthHmac` below: a
174
+ * flag buried in a private method is a flag no spec can pin, and this one is
175
+ * the difference between a working integration and 403 on everything.
176
+ *
177
+ * ⚠️ No `redirect_uri`. Unlike every other provider here, Shopify's token
178
+ * endpoint does not take one.
179
+ */
180
+ function buildShopifyTokenExchangeBody(params) {
181
+ return new URLSearchParams({
182
+ client_id: params.clientId,
183
+ client_secret: params.clientSecret,
184
+ code: params.code,
185
+ expiring: '1',
186
+ });
187
+ }
188
+ /**
189
+ * Verify the `hmac` Shopify appends to the callback.
190
+ *
191
+ * The recipe, from Shopify's authorization guide: drop `hmac`, sort what is
192
+ * left by key, join as `k=v&k=v` with the values **decoded**, HMAC-SHA256 with
193
+ * the client secret, compare the **hex** digest in constant time.
194
+ *
195
+ * Pure and exported so a spec can exercise it without a Nest module — the same
196
+ * reason `matchesGmailQuery` and friends live outside their services. A
197
+ * signature check that is awkward to test is a signature check nobody tests.
198
+ *
199
+ * ⚠️ Only `hmac` is removed. Older write-ups also strip `signature`; the
200
+ * current guide does not, and removing a parameter that Shopify *did* include
201
+ * in the message would make every valid callback fail to verify.
202
+ */
203
+ function verifyShopifyOAuthHmac(params) {
204
+ const provided = params.query.hmac;
205
+ if (!provided)
206
+ return false;
207
+ const message = Object.keys(params.query)
208
+ .filter((k) => k !== 'hmac')
209
+ .filter((k) => params.query[k] !== undefined)
210
+ .sort()
211
+ .map((k) => `${k}=${params.query[k]}`)
212
+ .join('&');
213
+ const expected = (0, crypto_1.createHmac)('sha256', params.clientSecret)
214
+ .update(message, 'utf8')
215
+ .digest('hex');
216
+ const a = Buffer.from(expected, 'utf8');
217
+ const b = Buffer.from(provided, 'utf8');
218
+ // `timingSafeEqual` throws on a length mismatch, which is itself a leak of
219
+ // the comparison — check the length first and return the same `false`.
220
+ if (a.length !== b.length)
221
+ return false;
222
+ return (0, crypto_1.timingSafeEqual)(a, b);
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostwebhook/platform-node",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",