@tanqory/plugin-sdk 0.2.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/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # @tanqory/plugin-sdk
2
+
3
+ Build an app for the Tanqory marketplace. You host it; Tanqory frames it, routes
4
+ its API calls, and tells it who the merchant is.
5
+
6
+ ```bash
7
+ npm install @tanqory/plugin-sdk
8
+ ```
9
+
10
+ ## The two halves
11
+
12
+ ```ts
13
+ import { createTanqoryApp, verifyWebhookSignature } from '@tanqory/plugin-sdk' // your server
14
+ import { useTanqoryApp } from '@tanqory/plugin-sdk/client' // your UI
15
+ ```
16
+
17
+ Separate entry points on purpose: importing the server half must not drag React
18
+ into a serverless function.
19
+
20
+ ## Embedded UI
21
+
22
+ ```tsx
23
+ 'use client'
24
+ import { useTanqoryApp } from '@tanqory/plugin-sdk/client'
25
+
26
+ export default function Page() {
27
+ const { ready, store, api } = useTanqoryApp()
28
+ if (!ready) return <p>Loading…</p>
29
+
30
+ return (
31
+ <button onClick={() => api!.get('/orders', { limit: 5 }).then(console.log)}>
32
+ Recent orders for {store!.storeName}
33
+ </button>
34
+ )
35
+ }
36
+ ```
37
+
38
+ `useTanqoryApp` performs the whole handshake: verifying who framed you,
39
+ requesting store context, retrying an unanswered handshake, holding a token in
40
+ memory and renewing it before it expires, and reporting a dead session to the
41
+ host. Getting any of that wrong produces a blank frame with a clean console,
42
+ which is unpleasant to debug from the outside.
43
+
44
+ ## Server side
45
+
46
+ ```ts
47
+ const tanqory = createTanqoryApp({
48
+ clientId: process.env.TANQORY_CLIENT_ID!,
49
+ clientSecret: process.env.TANQORY_CLIENT_SECRET!,
50
+ })
51
+
52
+ const orders = await tanqory.store(storeId).get('/orders', { query: { limit: 5 } })
53
+ ```
54
+
55
+ Tokens are cached per store and renewals are single-flighted, so parallel work
56
+ triggers one exchange rather than one each.
57
+
58
+ ## Webhooks
59
+
60
+ ```ts
61
+ export async function POST(request: Request) {
62
+ const raw = await request.text()
63
+ if (!verifyWebhookSignature(raw, request.headers.get('x-tanqory-signature'), SECRET)) {
64
+ return new Response('bad signature', { status: 401 })
65
+ }
66
+ …
67
+ }
68
+ ```
69
+
70
+ Pass the **raw** body. Re-serialising a parsed object changes key order and
71
+ whitespace, and the signature will not match.
72
+
73
+ ## Three things that will save you a day
74
+
75
+ **Never name an API host.** Every request is relative and Tanqory's edge resolves
76
+ which regional cell holds the store from the id in the path. An app that
77
+ hardcodes a hostname is pinned to one cell and breaks for merchants elsewhere.
78
+
79
+ **Never send `X-Frame-Options`.** It has no allow-list form, so `SAMEORIGIN`
80
+ overrides your `Content-Security-Policy: frame-ancestors` in browsers that still
81
+ read it, and the dashboard renders a blank frame. Set `frame-ancestors` instead.
82
+
83
+ **Never navigate to a login page.** You are inside an iframe: a login screen
84
+ renders in a box, and if it escaped it would throw the merchant out of their
85
+ dashboard. On auth failure the SDK tells the host and the host decides.
86
+
87
+ ## The manifest
88
+
89
+ `tanqory.app.json` is what review approves, and the only thing a version pins.
90
+ Your code is yours — deploy whenever you like. Changing the _manifest_ (a new
91
+ scope, origin, or webhook topic) needs approval, because it changes what
92
+ merchants agreed to.
93
+
94
+ ```json
95
+ {
96
+ "handle": "order-notifier",
97
+ "name": "Order Notifier",
98
+ "appUrl": "https://order-notifier.example.com",
99
+ "devUrl": "http://localhost:3000",
100
+ "embedded": { "enabled": true, "path": "/embed" },
101
+ "scopes": ["orders.view"],
102
+ "webhooks": [{ "topic": "orders/create", "path": "/hooks/orders" }]
103
+ }
104
+ ```
105
+
106
+ `devUrl` is used **instead of** `appUrl`, and only on a DEVELOPMENT store — so
107
+ you can point at localhost while building without any localhost origin ever
108
+ being trusted for a real merchant.
109
+
110
+ Validate it in your own build:
111
+
112
+ ```ts
113
+ import { parseAppManifest } from '@tanqory/plugin-sdk'
114
+ parseAppManifest(JSON.parse(readFileSync('tanqory.app.json', 'utf8')))
115
+ ```
116
+
117
+ It reports every problem at once rather than stopping at the first.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export interface TanqoryAppCredentials {
2
+ clientId: string;
3
+ clientSecret: string;
4
+ /** Defaults to `TANQORY_API_BASE`, then production. */
5
+ apiBase?: string;
6
+ }
7
+ /**
8
+ * Exchanges your app credentials for a token scoped to one store.
9
+ *
10
+ * One instance per app; it caches per store and single-flights renewals, so N
11
+ * concurrent calls trigger at most one exchange rather than N. That matters
12
+ * because the token endpoint is rate limited, and a burst of parallel work is
13
+ * exactly when an app would otherwise hammer it.
14
+ *
15
+ * There is no refresh token by design. Renewal re-runs the exchange, which
16
+ * re-checks that the merchant still has your app installed — so uninstalling
17
+ * stops your access within one token lifetime rather than whenever a refresh
18
+ * token happened to expire.
19
+ */
20
+ export declare class TanqoryAuth {
21
+ private readonly credentials;
22
+ private readonly apiBase;
23
+ private readonly cache;
24
+ private readonly inFlight;
25
+ constructor(credentials: TanqoryAppCredentials);
26
+ /** A token good right now for this store, renewing only when it has to. */
27
+ tokenFor(storeId: string): Promise<string>;
28
+ /** What the merchant actually granted. Useful for hiding UI you cannot serve. */
29
+ scopesFor(storeId: string): Promise<string[]>;
30
+ /**
31
+ * Drop a cached token — call after a 401 so the next request re-exchanges.
32
+ * Cheaper than pre-emptively shortening every lifetime.
33
+ */
34
+ invalidate(storeId: string): void;
35
+ private entryFor;
36
+ private exchange;
37
+ }
38
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAkBD;;;;;;;;;;;;GAYG;AACH,qBAAa,WAAW;IAKV,OAAO,CAAC,QAAQ,CAAC,WAAW;IAJxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IACvD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA0C;gBAEtC,WAAW,EAAE,qBAAqB;IAQ/D,2EAA2E;IACrE,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAIhD,iFAAiF;IAC3E,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAInD;;;OAGG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;YAInB,QAAQ;YAYR,QAAQ;CAsCvB"}
package/dist/auth.js ADDED
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TanqoryAuth = void 0;
4
+ const errors_1 = require("./errors");
5
+ /** Where the platform lives. Overridable only for Tanqory's own dev tier. */
6
+ const DEFAULT_API_BASE = 'https://api.tanqory.com';
7
+ /**
8
+ * Renew this long before the token actually expires.
9
+ *
10
+ * A token that expires mid-flight produces a 401 the app cannot distinguish
11
+ * from a revoked installation, so it is worth spending a little lifetime to
12
+ * never see one.
13
+ */
14
+ const EXPIRY_SKEW_MS = 30_000;
15
+ /**
16
+ * Exchanges your app credentials for a token scoped to one store.
17
+ *
18
+ * One instance per app; it caches per store and single-flights renewals, so N
19
+ * concurrent calls trigger at most one exchange rather than N. That matters
20
+ * because the token endpoint is rate limited, and a burst of parallel work is
21
+ * exactly when an app would otherwise hammer it.
22
+ *
23
+ * There is no refresh token by design. Renewal re-runs the exchange, which
24
+ * re-checks that the merchant still has your app installed — so uninstalling
25
+ * stops your access within one token lifetime rather than whenever a refresh
26
+ * token happened to expire.
27
+ */
28
+ class TanqoryAuth {
29
+ credentials;
30
+ apiBase;
31
+ cache = new Map();
32
+ inFlight = new Map();
33
+ constructor(credentials) {
34
+ this.credentials = credentials;
35
+ this.apiBase = (credentials.apiBase ??
36
+ process.env.TANQORY_API_BASE ??
37
+ DEFAULT_API_BASE).replace(/\/+$/, '');
38
+ }
39
+ /** A token good right now for this store, renewing only when it has to. */
40
+ async tokenFor(storeId) {
41
+ return (await this.entryFor(storeId)).token;
42
+ }
43
+ /** What the merchant actually granted. Useful for hiding UI you cannot serve. */
44
+ async scopesFor(storeId) {
45
+ return (await this.entryFor(storeId)).scopes;
46
+ }
47
+ /**
48
+ * Drop a cached token — call after a 401 so the next request re-exchanges.
49
+ * Cheaper than pre-emptively shortening every lifetime.
50
+ */
51
+ invalidate(storeId) {
52
+ this.cache.delete(storeId);
53
+ }
54
+ async entryFor(storeId) {
55
+ const cached = this.cache.get(storeId);
56
+ if (cached && Date.now() < cached.usableUntil)
57
+ return cached;
58
+ const pending = this.inFlight.get(storeId);
59
+ if (pending)
60
+ return pending;
61
+ const exchange = this.exchange(storeId).finally(() => this.inFlight.delete(storeId));
62
+ this.inFlight.set(storeId, exchange);
63
+ return exchange;
64
+ }
65
+ async exchange(storeId) {
66
+ const response = await fetch(`${this.apiBase}/api/v1/apps/token`, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
69
+ body: JSON.stringify({
70
+ grant_type: 'client_credentials',
71
+ client_id: this.credentials.clientId,
72
+ client_secret: this.credentials.clientSecret,
73
+ store_id: storeId,
74
+ }),
75
+ });
76
+ if (response.status === 401)
77
+ throw new errors_1.TanqoryAuthError();
78
+ if (!response.ok) {
79
+ throw new errors_1.TanqoryError(`Token exchange failed with ${response.status}.`, response.status, 'TOKEN_EXCHANGE');
80
+ }
81
+ const body = (await response.json());
82
+ if (!body.access_token) {
83
+ throw new errors_1.TanqoryError('Token exchange returned no access_token.', 500, 'TOKEN_EXCHANGE');
84
+ }
85
+ const entry = {
86
+ token: body.access_token,
87
+ usableUntil: Date.now() + Math.max((body.expires_in ?? 600) * 1000 - EXPIRY_SKEW_MS, 0),
88
+ scopes: body.scope ? body.scope.split(' ').filter(Boolean) : [],
89
+ };
90
+ this.cache.set(storeId, entry);
91
+ return entry;
92
+ }
93
+ }
94
+ exports.TanqoryAuth = TanqoryAuth;
95
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import { type BridgeContext } from '@tanqory/app-bridge';
2
+ /**
3
+ * The embedded half of the SDK.
4
+ *
5
+ * Everything here runs in the browser, inside the iframe the Tanqory dashboard
6
+ * renders your app in. Kept in its own entry point so importing the server half
7
+ * cannot drag React into a serverless function.
8
+ */
9
+ export type TanqoryAppStatus = 'loading' | 'ready' | 'error';
10
+ export interface TanqoryAppState {
11
+ status: TanqoryAppStatus;
12
+ ready: boolean;
13
+ error: string | null;
14
+ /** Non-null once `ready`. */
15
+ store: BridgeContext | null;
16
+ /** Call the API as the merchant currently looking at your app. */
17
+ api: EmbeddedApi | null;
18
+ /** True when the merchant granted this scope. Fails closed while loading. */
19
+ can: (scope: string) => boolean;
20
+ /** Show the dashboard's own toast — one rendered inside a sized iframe is clipped. */
21
+ toast: (variant: 'success' | 'error' | 'info', title: string, description?: string) => void;
22
+ }
23
+ export interface EmbeddedApi {
24
+ get<T = unknown>(path: string, query?: Record<string, string | number | boolean>): Promise<T>;
25
+ post<T = unknown>(path: string, body?: unknown): Promise<T>;
26
+ patch<T = unknown>(path: string, body?: unknown): Promise<T>;
27
+ delete<T = unknown>(path: string): Promise<T>;
28
+ }
29
+ export interface UseTanqoryAppOptions {
30
+ /**
31
+ * Origins permitted to embed you. Defaults to Tanqory's dashboard.
32
+ *
33
+ * The bridge refuses to post to, or accept from, anything not on this list,
34
+ * and never uses `'*'` — so a page that frames your app without permission
35
+ * receives no context, no token, and no store data.
36
+ */
37
+ allowedHostOrigins?: string[];
38
+ }
39
+ /**
40
+ * Boot an embedded Tanqory app.
41
+ *
42
+ * ```tsx
43
+ * const { ready, store, api } = useTanqoryApp()
44
+ * if (!ready) return <p>Loading…</p>
45
+ * return <p>Hello {store.storeName}</p>
46
+ * ```
47
+ *
48
+ * This replaces the handshake every embedded app would otherwise reimplement:
49
+ * creating the bridge guest, verifying who framed you, requesting context,
50
+ * retrying an unanswered handshake, holding a token in memory and renewing it
51
+ * before it expires, and telling the host when the session dies. Getting any of
52
+ * those wrong produces a blank frame with a clean console, which is a genuinely
53
+ * hard thing to debug from the outside.
54
+ *
55
+ * ## Two rules the platform enforces, which this respects for you
56
+ *
57
+ * **Never navigate to a login page.** Inside an iframe that renders a login
58
+ * screen in a box, and if it escaped it would throw the merchant out of their
59
+ * dashboard. On auth failure the host is told and decides.
60
+ *
61
+ * **Never name an API host.** Requests are relative; Tanqory's edge resolves
62
+ * which regional cell holds this store from the store id in the path. An app
63
+ * that hardcodes a host is pinned to one cell and breaks for merchants
64
+ * elsewhere.
65
+ */
66
+ export declare function useTanqoryApp(options?: UseTanqoryAppOptions): TanqoryAppState;
67
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,aAAa,EAAoB,MAAM,qBAAqB,CAAA;AAG7F;;;;;;GAMG;AAEH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AAE5D,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,gBAAgB,CAAA;IACxB,KAAK,EAAE,OAAO,CAAA;IACd,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,6BAA6B;IAC7B,KAAK,EAAE,aAAa,GAAG,IAAI,CAAA;IAC3B,kEAAkE;IAClE,GAAG,EAAE,WAAW,GAAG,IAAI,CAAA;IACvB,6EAA6E;IAC7E,GAAG,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAA;IAC/B,sFAAsF;IACtF,KAAK,EAAE,CAAC,OAAO,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5F;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IAC7F,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IAC3D,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IAC5D,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;CAC9C;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;CAC9B;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,oBAAyB,GAAG,eAAe,CA4EjF"}
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useTanqoryApp = useTanqoryApp;
4
+ const app_bridge_1 = require("@tanqory/app-bridge");
5
+ const react_1 = require("react");
6
+ const DEFAULT_HOST_ORIGINS = ['https://app.tanqory.com'];
7
+ /**
8
+ * Boot an embedded Tanqory app.
9
+ *
10
+ * ```tsx
11
+ * const { ready, store, api } = useTanqoryApp()
12
+ * if (!ready) return <p>Loading…</p>
13
+ * return <p>Hello {store.storeName}</p>
14
+ * ```
15
+ *
16
+ * This replaces the handshake every embedded app would otherwise reimplement:
17
+ * creating the bridge guest, verifying who framed you, requesting context,
18
+ * retrying an unanswered handshake, holding a token in memory and renewing it
19
+ * before it expires, and telling the host when the session dies. Getting any of
20
+ * those wrong produces a blank frame with a clean console, which is a genuinely
21
+ * hard thing to debug from the outside.
22
+ *
23
+ * ## Two rules the platform enforces, which this respects for you
24
+ *
25
+ * **Never navigate to a login page.** Inside an iframe that renders a login
26
+ * screen in a box, and if it escaped it would throw the merchant out of their
27
+ * dashboard. On auth failure the host is told and decides.
28
+ *
29
+ * **Never name an API host.** Requests are relative; Tanqory's edge resolves
30
+ * which regional cell holds this store from the store id in the path. An app
31
+ * that hardcodes a host is pinned to one cell and breaks for merchants
32
+ * elsewhere.
33
+ */
34
+ function useTanqoryApp(options = {}) {
35
+ const [status, setStatus] = (0, react_1.useState)('loading');
36
+ const [error, setError] = (0, react_1.useState)(null);
37
+ const [store, setStore] = (0, react_1.useState)(null);
38
+ const bridgeRef = (0, react_1.useRef)(null);
39
+ const tokenRef = (0, react_1.useRef)(null);
40
+ const booted = (0, react_1.useRef)(false);
41
+ (0, react_1.useEffect)(() => {
42
+ // React Strict Mode double-invokes effects in development. The handshake is
43
+ // not idempotent from the host's side, so guard it.
44
+ if (booted.current)
45
+ return;
46
+ booted.current = true;
47
+ const bridge = (0, app_bridge_1.createBridgeGuest)({
48
+ allowedOrigins: options.allowedHostOrigins ?? DEFAULT_HOST_ORIGINS,
49
+ });
50
+ bridgeRef.current = bridge;
51
+ if (!bridge.isEmbedded || !bridge.hostOrigin) {
52
+ setError('This app must be opened from the Tanqory dashboard.');
53
+ setStatus('error');
54
+ return;
55
+ }
56
+ const stop = bridge.start();
57
+ let cancelled = false;
58
+ void (async () => {
59
+ try {
60
+ // Retried, because a single unanswered handshake is unrecoverable and
61
+ // looks exactly like a hung app: the frame stays blank, the console is
62
+ // clean, and every network request is green.
63
+ let context = null;
64
+ for (let attempt = 1; attempt <= 3 && !context; attempt += 1) {
65
+ try {
66
+ context = await bridge.init();
67
+ }
68
+ catch (initError) {
69
+ if (cancelled)
70
+ return;
71
+ if (attempt === 3)
72
+ throw initError;
73
+ }
74
+ }
75
+ if (cancelled || !context)
76
+ return;
77
+ setStore(context);
78
+ setStatus('ready');
79
+ bridge.ready();
80
+ }
81
+ catch (bootError) {
82
+ if (cancelled)
83
+ return;
84
+ setError(bootError instanceof Error ? bootError.message : 'Failed to start.');
85
+ setStatus('error');
86
+ }
87
+ })();
88
+ return () => {
89
+ cancelled = true;
90
+ stop();
91
+ };
92
+ }, [options.allowedHostOrigins]);
93
+ const api = store ? buildApi(bridgeRef, tokenRef, store.storeId) : null;
94
+ return {
95
+ status,
96
+ ready: status === 'ready',
97
+ error,
98
+ store,
99
+ api,
100
+ can: (scope) =>
101
+ // `'*'` is the store-owner wildcard. Without this branch the one merchant
102
+ // guaranteed to be allowed everything would see no actions at all.
103
+ store?.permissions.includes('*') || store?.permissions.includes(scope) || false,
104
+ toast: (variant, title, description) => bridgeRef.current?.post('TOAST', { variant, title, description }),
105
+ };
106
+ }
107
+ /** Renew this long before expiry — a token that dies mid-flight becomes a 401. */
108
+ const EXPIRY_SKEW_MS = 30_000;
109
+ function buildApi(bridgeRef, tokenRef, storeId) {
110
+ async function token() {
111
+ const cached = tokenRef.current;
112
+ if (cached && Date.now() < cached.expiresAt - EXPIRY_SKEW_MS)
113
+ return cached.value;
114
+ const bridge = bridgeRef.current;
115
+ if (!bridge)
116
+ throw new Error('Bridge is not ready.');
117
+ // The host holds a live token and answers instantly. It is also the only
118
+ // party that can recover a dead session, which is why failure is reported
119
+ // to it rather than handled here.
120
+ const { token: value, expiresAt } = await bridge.requestToken();
121
+ tokenRef.current = { value, expiresAt };
122
+ return value;
123
+ }
124
+ async function request(method, path, body, query) {
125
+ // Relative on purpose — see the note on `useTanqoryApp`.
126
+ const url = new URL(`/api/v1/stores/${storeId}${path.startsWith('/') ? path : `/${path}`}`, window.location.origin);
127
+ for (const [key, value] of Object.entries(query ?? {})) {
128
+ url.searchParams.set(key, String(value));
129
+ }
130
+ const response = await fetch(url, {
131
+ method,
132
+ headers: {
133
+ authorization: `Bearer ${await token()}`,
134
+ accept: 'application/json',
135
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
136
+ },
137
+ body: body === undefined ? undefined : JSON.stringify(body),
138
+ });
139
+ if (response.status === 401) {
140
+ tokenRef.current = null;
141
+ bridgeRef.current?.post('AUTH_EXPIRED', undefined);
142
+ throw new Error('The session expired.');
143
+ }
144
+ if (!response.ok)
145
+ throw new Error(`Request failed with ${response.status}.`);
146
+ if (response.status === 204)
147
+ return undefined;
148
+ return (await response.json());
149
+ }
150
+ return {
151
+ get: (path, query) => request('GET', path, undefined, query),
152
+ post: (path, body) => request('POST', path, body),
153
+ patch: (path, body) => request('PATCH', path, body),
154
+ delete: (path) => request('DELETE', path),
155
+ };
156
+ }
157
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import { TanqoryAuth, type TanqoryAppCredentials } from './auth';
2
+ export interface RequestOptions {
3
+ query?: Record<string, string | number | boolean | undefined>;
4
+ body?: unknown;
5
+ headers?: Record<string, string>;
6
+ signal?: AbortSignal;
7
+ }
8
+ /**
9
+ * A REST client for one store.
10
+ *
11
+ * Paths are relative to that store: `get('/orders')` reaches
12
+ * `/api/v1/stores/<storeId>/orders`. You never assemble a store path yourself,
13
+ * which is the point — a hand-built path is how an app ends up reading a store
14
+ * it was not installed on and getting a confusing 403 instead of a clear one.
15
+ *
16
+ * There is no `baseURL` option, and no way to name a host. Tanqory runs one
17
+ * store's data in one of several regional cells, and which cell is not
18
+ * something an app should know, cache, or get wrong: the platform edge resolves
19
+ * it per request from the store id already in the path.
20
+ */
21
+ export declare class TanqoryStoreClient {
22
+ private readonly auth;
23
+ private readonly storeId;
24
+ private readonly apiBase;
25
+ constructor(auth: TanqoryAuth, storeId: string, apiBase: string);
26
+ get<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
27
+ post<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
28
+ put<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
29
+ patch<T = unknown>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
30
+ delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
31
+ /** What the merchant granted this app on this store. */
32
+ scopes(): Promise<string[]>;
33
+ private request;
34
+ private messageFrom;
35
+ }
36
+ /**
37
+ * Entry point for server-side work.
38
+ *
39
+ * ```ts
40
+ * const tanqory = createTanqoryApp({
41
+ * clientId: process.env.TANQORY_CLIENT_ID!,
42
+ * clientSecret: process.env.TANQORY_CLIENT_SECRET!,
43
+ * })
44
+ *
45
+ * const orders = await tanqory.store(storeId).get('/orders', { query: { limit: 5 } })
46
+ * ```
47
+ */
48
+ export declare function createTanqoryApp(credentials: TanqoryAppCredentials): {
49
+ /** A client bound to one store. Memoised, so calling it in a loop is free. */
50
+ store(storeId: string): TanqoryStoreClient;
51
+ auth: TanqoryAuth;
52
+ };
53
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,qBAAqB,EAAE,MAAM,QAAQ,CAAA;AAGhE,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC,CAAA;IAC7D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAkB;IAE3B,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAFP,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM;IAGlC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAIpE,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAIrF,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAIpF,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAItF,MAAM,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC;IAIvE,wDAAwD;IACxD,MAAM,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;YAIb,OAAO;YAmDP,WAAW;CAQ1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,qBAAqB;IAW/D,8EAA8E;mBAC/D,MAAM,GAAG,kBAAkB;;EAU7C"}