Skip to content

Commit 20a29b8

Browse files
committed
Introduce shopId in browserSession to set shop tenant
1 parent 0bfc4f6 commit 20a29b8

4 files changed

Lines changed: 155 additions & 36 deletions

File tree

packages/api-client-core/spec/GadgetConnection-suite.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,45 @@ export const GadgetConnectionSharedSuite = (queryExtra = "") => {
756756
expect(customResult.error).toBeUndefined();
757757
expect(customResult.data).toEqual({ meta: { appName: "some app" } });
758758
});
759+
760+
it("should support browser session with shop tenant", async () => {
761+
nock("https://someapp.gadget.app")
762+
.post("/api/graphql?operation=meta", { query: `{\n meta {\n appName\n${queryExtra} }\n}`, variables: {} })
763+
.reply(200, function () {
764+
expect(this.req.headers["x-gadget-for-shop-id"]).toEqual(["1234"]);
765+
766+
return {
767+
data: {
768+
meta: {
769+
appName: "some app",
770+
},
771+
},
772+
};
773+
});
774+
775+
const connection = new GadgetConnection({
776+
endpoint: "https://someapp.gadget.app/api/graphql",
777+
browserSession: {
778+
shopId: "1234",
779+
},
780+
});
781+
782+
const result = await connection.currentClient
783+
.query(
784+
gql`
785+
{
786+
meta {
787+
appName
788+
}
789+
}
790+
`,
791+
{}
792+
)
793+
.toPromise();
794+
795+
expect(result.error).toBeUndefined();
796+
expect(result.data).toEqual({ meta: { appName: "some app" } });
797+
});
759798
});
760799

761800
describe("raw fetching", () => {

packages/api-client-core/src/ClientOptions.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ import type { Exchange } from "@urql/core";
22
import type { GadgetSubscriptionClientOptions } from "./GadgetConnection.js";
33

44
/** All the options for a Gadget client */
5-
export interface ClientOptions {
5+
export type ClientOptions = {
66
/**
77
* The HTTP GraphQL endpoint this connection should connect to
88
**/
99
endpoint?: string;
10-
/**
11-
* The authentication strategy for connecting to the upstream API
12-
**/
13-
authenticationMode?: AuthenticationModeOptions;
1410
/**
1511
* The Websockets GraphQL endpoint this connection should connect to for transactional processing
1612
**/
@@ -45,7 +41,15 @@ export interface ClientOptions {
4541
* A list of exchanges to merge into the default exchanges used by the client.
4642
*/
4743
exchanges?: Exchanges;
48-
}
44+
} & (
45+
| {
46+
/**
47+
* The authentication strategy for connecting to the upstream API
48+
**/
49+
authenticationMode?: AuthenticationModeOptions;
50+
}
51+
| AuthenticationModeOptions
52+
);
4953

5054
/** Options to configure a specific browser-based authentication mode */
5155
export interface BrowserSessionAuthenticationModeOptions {
@@ -58,7 +62,11 @@ export interface BrowserSessionAuthenticationModeOptions {
5862
/**
5963
* Configures how the authentication token is persisted. See `BrowserSessionStorageType`.
6064
*/
61-
storageType: BrowserSessionStorageType;
65+
storageType?: BrowserSessionStorageType;
66+
/**
67+
* The shop ID to set shop tenant. Useful for fetching shop-specific data.
68+
*/
69+
shopId?: string;
6270
}
6371

6472
/**
@@ -81,29 +89,44 @@ export enum BrowserSessionStorageType {
8189

8290
/** Describes how to authenticate an instance of the client with the Gadget platform */
8391
export interface AuthenticationModeOptions {
84-
// Use an API key to authenticate with Gadget.
85-
// Not strictly required, but without this the client might be useless depending on the app's permissions.
92+
/**
93+
* Use an API key to authenticate with Gadget.
94+
* It's not strictly required, but without this the client might be useless depending on the app's permissions.
95+
*/
8696
apiKey?: string;
8797

88-
// Use a web browser's `localStorage` or `sessionStorage` to persist authentication information.
89-
// This allows the browser to have a persistent identity as the user navigates around and logs in and out.
98+
/**
99+
* Use a web browser's `localStorage` or `sessionStorage` to persist authentication information.
100+
* This allows the browser to have a persistent identity as the user navigates around and logs in and out.
101+
*/
90102
browserSession?: boolean | BrowserSessionAuthenticationModeOptions;
91103

92-
// Use no authentication at all, and get access only to the data that the Unauthenticated backend role has access to.
104+
/**
105+
* Use no authentication at all, and get access only to the data that the Unauthenticated backend role has access to.
106+
*/
93107
anonymous?: true;
94108

95-
// @deprecated Use internal instead
109+
/**
110+
* @deprecated Use internal instead.
111+
*/
96112
internalAuthToken?: string;
97113

98-
// @private Use an internal platform auth token for authentication
99-
// This is used to communicate within Gadget itself and shouldn't be used to connect to Gadget from other systems
114+
/**
115+
* Use an internal platform auth token for authentication
116+
* This is used to communicate within Gadget itself and shouldn't be used to connect to Gadget from other systems.
117+
* @private
118+
*/
100119
internal?: {
101120
authToken: string;
102121
actAsSession?: boolean;
103122
getSessionId?: () => Promise<string | undefined>;
104123
};
105124

106-
// @private Use a passed custom function for managing authentication. For some fancy integrations that the API client supports, like embedded Shopify apps, we use platform native features to authenticate with the Gadget backend.
125+
/**
126+
* Use a passed custom function for managing authentication.
127+
* For some fancy integrations that the API client supports, like embedded Shopify apps, we use platform native features to authenticate with the Gadget backend.
128+
* @private
129+
*/
107130
custom?: {
108131
processFetch(input: RequestInfo | URL, init: RequestInit): Promise<void>;
109132
processTransactionConnectionParams(params: Record<string, any>): Promise<void>;

packages/api-client-core/src/GadgetConnection.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
GadgetTooManyRequestsError,
2020
GadgetUnexpectedCloseError,
2121
GadgetWebsocketConnectionTimeoutError,
22+
availableAuthenticationModes,
2223
isCloseEvent,
24+
maybeGetAuthenticationModeOptionsFromConnectionOptions,
2325
storageAvailable,
2426
} from "./support.js";
2527

@@ -46,9 +48,8 @@ export const $gadgetConnection = Symbol.for("gadget/connection");
4648
const sessionStorageKey = "token";
4749
const base64 = typeof btoa !== "undefined" ? btoa : (str: string) => Buffer.from(str).toString("base64");
4850

49-
export interface GadgetConnectionOptions {
51+
export type GadgetConnectionOptions = {
5052
endpoint: string;
51-
authenticationMode?: AuthenticationModeOptions;
5253
websocketsEndpoint?: string;
5354
subscriptionClientOptions?: GadgetSubscriptionClientOptions;
5455
websocketImplementation?: typeof globalThis.WebSocket;
@@ -59,7 +60,12 @@ export interface GadgetConnectionOptions {
5960
baseRouteURL?: string;
6061
exchanges?: Exchanges;
6162
createSubscriptionClient?: typeof createSubscriptionClient;
62-
}
63+
} & (
64+
| {
65+
authenticationMode?: AuthenticationModeOptions;
66+
}
67+
| AuthenticationModeOptions
68+
);
6369

6470
/**
6571
* Represents the current strategy for authenticating with the Gadget platform.
@@ -84,6 +90,7 @@ const objectForGlobals = typeof globalThis != "undefined" ? globalThis : typeof
8490
*/
8591
export class GadgetConnection {
8692
static version = "<prerelease>" as const;
93+
static availableAuthenticationModes = availableAuthenticationModes;
8794

8895
// Options used when generating new GraphQL clients for the base connection and for for transactions
8996
readonly endpoint: string;
@@ -109,7 +116,15 @@ export class GadgetConnection {
109116
private requestPolicy: RequestPolicy;
110117
createSubscriptionClient: typeof createSubscriptionClient;
111118

119+
/**
120+
* The authentication mode that came from the connection options in the constructor.
121+
* We have two methods of setting the authentication mode, so we're storing it separately to avoid having to manually determine which method was used.
122+
*/
123+
private authenticationModeOptions?: AuthenticationModeOptions;
124+
112125
constructor(readonly options: GadgetConnectionOptions) {
126+
this.authenticationModeOptions = maybeGetAuthenticationModeOptionsFromConnectionOptions(options);
127+
113128
if (!options.endpoint) throw new Error("Must provide an `endpoint` option for a GadgetConnection to connect to");
114129
this.endpoint = options.endpoint;
115130
if (options.fetchImplementation) {
@@ -141,7 +156,7 @@ export class GadgetConnection {
141156
};
142157
this.createSubscriptionClient = options.createSubscriptionClient ?? createSubscriptionClient;
143158

144-
this.setAuthenticationMode(options.authenticationMode);
159+
this.setAuthenticationMode(this.authenticationModeOptions);
145160

146161
this.baseClient = this.newBaseClient();
147162
}
@@ -176,7 +191,7 @@ export class GadgetConnection {
176191
} else if (options.custom) {
177192
this.authenticationMode = AuthenticationMode.Custom;
178193
}
179-
this.options.authenticationMode = options;
194+
this.authenticationModeOptions = options;
180195
}
181196

182197
this.authenticationMode ??= AuthenticationMode.Anonymous;
@@ -185,7 +200,8 @@ export class GadgetConnection {
185200
enableSessionMode(options?: true | BrowserSessionAuthenticationModeOptions) {
186201
this.authenticationMode = AuthenticationMode.BrowserSession;
187202

188-
const desiredMode = !options || typeof options == "boolean" ? BrowserSessionStorageType.Durable : options.storageType;
203+
const desiredMode =
204+
!options || typeof options == "boolean" || !("storageType" in options) ? BrowserSessionStorageType.Durable : options.storageType;
189205
let sessionTokenStore;
190206
if (desiredMode == BrowserSessionStorageType.Durable && storageAvailable("localStorage")) {
191207
sessionTokenStore = window.localStorage;
@@ -316,7 +332,7 @@ export class GadgetConnection {
316332
init.headers = { ...requestHeaders, ...init.headers };
317333

318334
if (this.authenticationMode == AuthenticationMode.Custom) {
319-
await this.options.authenticationMode!.custom!.processFetch(input, init);
335+
await this.authenticationModeOptions!.custom!.processFetch(input, init);
320336
}
321337
}
322338

@@ -470,24 +486,24 @@ export class GadgetConnection {
470486
// In the browser, we can't set arbitrary headers on the websocket request, so we don't use the same auth mechanism that we use for normal HTTP requests. Instead we use graphql-ws' connectionParams to send the auth information in the connection setup message to the server.
471487
const connectionParams: Record<string, any> = { environment: this.environment, auth: { type: this.authenticationMode } };
472488
if (this.authenticationMode == AuthenticationMode.APIKey) {
473-
connectionParams.auth.key = this.options.authenticationMode!.apiKey!;
489+
connectionParams.auth.key = this.authenticationModeOptions!.apiKey!;
474490
} else if (
475491
this.authenticationMode == AuthenticationMode.Internal ||
476492
this.authenticationMode == AuthenticationMode.InternalAuthToken
477493
) {
478494
const authToken =
479495
this.authenticationMode == AuthenticationMode.Internal
480-
? this.options.authenticationMode!.internal!.authToken
481-
: this.options.authenticationMode!.internalAuthToken!;
496+
? this.authenticationModeOptions!.internal!.authToken
497+
: this.authenticationModeOptions!.internalAuthToken!;
482498
connectionParams.auth.token = authToken;
483-
if (this.authenticationMode == AuthenticationMode.Internal && this.options.authenticationMode!.internal!.actAsSession) {
499+
if (this.authenticationMode == AuthenticationMode.Internal && this.authenticationModeOptions!.internal!.actAsSession) {
484500
connectionParams.auth.actAsInternalSession = true;
485-
connectionParams.auth.internalSessionId = await this.options.authenticationMode!.internal!.getSessionId?.();
501+
connectionParams.auth.internalSessionId = await this.authenticationModeOptions!.internal!.getSessionId?.();
486502
}
487503
} else if (this.authenticationMode == AuthenticationMode.BrowserSession) {
488504
connectionParams.auth.sessionToken = this.sessionTokenStore!.getItem(this.sessionStorageKey);
489505
} else if (this.authenticationMode == AuthenticationMode.Custom) {
490-
await this.options.authenticationMode?.custom?.processTransactionConnectionParams(connectionParams);
506+
await this.authenticationModeOptions?.custom?.processTransactionConnectionParams(connectionParams);
491507
}
492508
return connectionParams;
493509
},
@@ -498,8 +514,11 @@ export class GadgetConnection {
498514
connected: (socket, payload) => {
499515
// If we're using session token authorization, we don't use request headers to exchange the session token, we use graphql-ws' ConnectionAck payload to persist the token. When the subscription client first starts, the server will send us session token identifying this client, and we persist it to the session token store
500516
if (this.authenticationMode == AuthenticationMode.BrowserSession && payload?.sessionToken) {
501-
const browserSession = this.options.authenticationMode?.browserSession;
502-
const initialToken = browserSession !== null && typeof browserSession === "object" ? browserSession.initialToken : null;
517+
const browserSession = this.authenticationModeOptions?.browserSession;
518+
const initialToken =
519+
browserSession !== null && typeof browserSession === "object" && "initialToken" in browserSession
520+
? browserSession.initialToken
521+
: null;
503522
if (!initialToken) {
504523
this.sessionTokenStore!.setItem(this.sessionStorageKey, payload.sessionToken as string);
505524
}
@@ -532,26 +551,32 @@ export class GadgetConnection {
532551
if (this.authenticationMode == AuthenticationMode.Internal || this.authenticationMode == AuthenticationMode.InternalAuthToken) {
533552
const authToken =
534553
this.authenticationMode == AuthenticationMode.Internal
535-
? this.options.authenticationMode!.internal!.authToken
536-
: this.options.authenticationMode!.internalAuthToken!;
554+
? this.authenticationModeOptions!.internal!.authToken
555+
: this.authenticationModeOptions!.internalAuthToken!;
537556

538557
headers.authorization = "Basic " + base64("gadget-internal" + ":" + authToken);
539558

540-
if (this.authenticationMode == AuthenticationMode.Internal && this.options.authenticationMode!.internal!.actAsSession) {
559+
if (this.authenticationMode == AuthenticationMode.Internal && this.authenticationModeOptions!.internal!.actAsSession) {
541560
headers["x-gadget-act-as-internal-session"] = "true";
542561

543-
const sessionId = await this.options.authenticationMode!.internal!.getSessionId?.();
562+
const sessionId = await this.authenticationModeOptions!.internal!.getSessionId?.();
544563
if (sessionId) {
545564
headers["x-gadget-internal-session-id"] = sessionId;
546565
}
547566
}
548567
} else if (this.authenticationMode == AuthenticationMode.APIKey) {
549-
headers.authorization = `Bearer ${this.options.authenticationMode?.apiKey}`;
568+
headers.authorization = `Bearer ${this.authenticationModeOptions?.apiKey}`;
550569
} else if (this.authenticationMode == AuthenticationMode.BrowserSession) {
551570
const val = this.sessionTokenStore!.getItem(this.sessionStorageKey);
552571
if (val) {
553572
headers.authorization = `Session ${val}`;
554573
}
574+
575+
const browserSessionOptions = this.authenticationModeOptions!.browserSession!;
576+
const shopId = typeof browserSessionOptions === "boolean" ? undefined : browserSessionOptions.shopId;
577+
if (shopId) {
578+
headers["x-gadget-for-shop-id"] = shopId;
579+
}
555580
}
556581

557582
headers["x-gadget-environment"] = this.environment;

packages/api-client-core/src/support.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { OperationResult } from "@urql/core";
22
import { CombinedError } from "@urql/core";
33
import { Call, type FieldSelection as BuilderFieldSelection } from "tiny-graphql-query-compiler";
4+
import type { AuthenticationModeOptions, ClientOptions } from "./ClientOptions.js";
45
import { DataHydrator } from "./DataHydrator.js";
6+
import type { GadgetConnectionOptions } from "./GadgetConnection.js";
57
import type { ActionFunctionMetadata, AnyActionFunction } from "./GadgetFunctions.js";
68
import type { RecordShape } from "./GadgetRecord.js";
79
import { GadgetRecord } from "./GadgetRecord.js";
@@ -752,3 +754,33 @@ export const formatErrorMessages = (error: Error) => {
752754

753755
return result;
754756
};
757+
758+
export const availableAuthenticationModes = [
759+
"apiKey",
760+
"browserSession",
761+
"anonymous",
762+
"internalAuthToken",
763+
"internal",
764+
"custom",
765+
] as const satisfies readonly (keyof AuthenticationModeOptions)[];
766+
767+
// Type assertion to ensure all `AuthenticationModeOptions` keys are included in the `availableAuthenticationModes` array
768+
type MissingKeys = Exclude<keyof AuthenticationModeOptions, (typeof availableAuthenticationModes)[number]>;
769+
type _AssertAllKeysIncluded = [MissingKeys] extends [never] ? true : `Missing keys: ${MissingKeys}`;
770+
const _assertAllAuthKeysIncluded: _AssertAllKeysIncluded = true as _AssertAllKeysIncluded;
771+
772+
export const maybeGetAuthenticationModeOptionsFromConnectionOptions = (
773+
options: GadgetConnectionOptions | ClientOptions
774+
): AuthenticationModeOptions | undefined => {
775+
if ("authenticationMode" in options) {
776+
return options.authenticationMode;
777+
}
778+
779+
const result: AuthenticationModeOptions = {};
780+
for (const key of availableAuthenticationModes) {
781+
if (key in options) {
782+
result[key] = (options as any)[key];
783+
}
784+
}
785+
return result;
786+
};

0 commit comments

Comments
 (0)