Host-remote refresh token implementation (#18044)
This commit is contained in:
+529
@@ -0,0 +1,529 @@
|
||||
import { transform } from 'esbuild';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
vi.mock('twenty-shared/application', () => ({
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME: 'TWENTY_APP_ACCESS_TOKEN',
|
||||
DEFAULT_API_KEY_NAME: 'TWENTY_API_KEY',
|
||||
DEFAULT_API_URL_NAME: 'TWENTY_API_URL',
|
||||
GENERATED_DIR: 'generated',
|
||||
}));
|
||||
|
||||
import { ClientService } from '@/cli/utilities/client/client-service';
|
||||
|
||||
type TwentyClassType = new (options?: {
|
||||
url?: string;
|
||||
metadataUrl?: string;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}) => {
|
||||
query: (request: Record<string, unknown>) => Promise<unknown>;
|
||||
uploadFile: (
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
fieldMetadataUniversalIdentifier: string,
|
||||
) => Promise<{
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const stubGeneratedIndexSource = `
|
||||
export type QueryGenqlSelection = Record<string, unknown>
|
||||
export type MutationGenqlSelection = Record<string, unknown>
|
||||
export type GraphqlOperation = Record<string, unknown>
|
||||
|
||||
export type ClientOptions = {
|
||||
url?: string
|
||||
metadataUrl?: string
|
||||
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
|
||||
fetcher?: (operation: GraphqlOperation | GraphqlOperation[]) => Promise<unknown>
|
||||
fetch?: typeof globalThis.fetch
|
||||
batch?: unknown
|
||||
}
|
||||
|
||||
export type Client = {
|
||||
query: (request: QueryGenqlSelection & { __name?: string }) => Promise<unknown>
|
||||
mutation: (
|
||||
request: MutationGenqlSelection & { __name?: string },
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
export class GenqlError extends Error {
|
||||
constructor(
|
||||
public readonly errors: unknown,
|
||||
public readonly data: unknown,
|
||||
) {
|
||||
super('GenqlError')
|
||||
}
|
||||
}
|
||||
|
||||
export const createClient = (options: ClientOptions): Client => {
|
||||
return {
|
||||
query: (request) => {
|
||||
return options.fetcher?.({
|
||||
query: 'query',
|
||||
variables: request,
|
||||
})
|
||||
},
|
||||
mutation: (request) => {
|
||||
return options.fetcher?.({
|
||||
query: 'mutation',
|
||||
variables: request,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createJsonResponse = ({
|
||||
body,
|
||||
status = 200,
|
||||
statusText = 'OK',
|
||||
}: {
|
||||
body: unknown;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
statusText,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const getAuthorizationHeaderValue = (requestInit: RequestInit | undefined) => {
|
||||
return new Headers(requestInit?.headers).get('Authorization');
|
||||
};
|
||||
|
||||
describe('ClientService generated Twenty auth behavior', () => {
|
||||
let temporaryGeneratedClientDirectory: string;
|
||||
let TwentyClass: TwentyClassType;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporaryGeneratedClientDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'twenty-generated-client-'),
|
||||
);
|
||||
|
||||
const temporaryGeneratedIndexTsPath = join(
|
||||
temporaryGeneratedClientDirectory,
|
||||
'index.ts',
|
||||
);
|
||||
|
||||
await writeFile(temporaryGeneratedIndexTsPath, stubGeneratedIndexSource);
|
||||
|
||||
const clientService = new ClientService();
|
||||
await (
|
||||
clientService as unknown as {
|
||||
injectTwentyClient: (output: string) => Promise<void>;
|
||||
}
|
||||
).injectTwentyClient(temporaryGeneratedClientDirectory);
|
||||
|
||||
const generatedIndexContent = await readFile(
|
||||
temporaryGeneratedIndexTsPath,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const transpiledModule = await transform(generatedIndexContent, {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'es2022',
|
||||
});
|
||||
|
||||
const temporaryGeneratedIndexMjsPath = join(
|
||||
temporaryGeneratedClientDirectory,
|
||||
'index.mjs',
|
||||
);
|
||||
await writeFile(temporaryGeneratedIndexMjsPath, transpiledModule.code);
|
||||
|
||||
const generatedModule = await import(
|
||||
`${pathToFileURL(temporaryGeneratedIndexMjsPath).href}?t=${Date.now()}`
|
||||
);
|
||||
|
||||
TwentyClass = generatedModule.default as TwentyClassType;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TWENTY_APP_ACCESS_TOKEN;
|
||||
delete process.env.TWENTY_API_KEY;
|
||||
delete (globalThis as Record<string, unknown>)
|
||||
.frontComponentHostCommunicationApi;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (temporaryGeneratedClientDirectory) {
|
||||
await rm(temporaryGeneratedClientDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses TWENTY_APP_ACCESS_TOKEN before TWENTY_API_KEY', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token';
|
||||
process.env.TWENTY_API_KEY = 'api-key-token';
|
||||
|
||||
const capturedAuthorizationHeaders: string[] = [];
|
||||
const fetchMock = vi.fn(
|
||||
async (_url: string | URL | Request, requestInit?: RequestInit) => {
|
||||
const authorizationHeaderValue =
|
||||
getAuthorizationHeaderValue(requestInit);
|
||||
|
||||
if (authorizationHeaderValue) {
|
||||
capturedAuthorizationHeaders.push(authorizationHeaderValue);
|
||||
}
|
||||
|
||||
return createJsonResponse({
|
||||
body: { data: { record: { id: 'record-id' } } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await twentyClient.query({ record: { id: true } });
|
||||
|
||||
expect(capturedAuthorizationHeaders).toEqual(['Bearer application-token']);
|
||||
});
|
||||
|
||||
it('falls back to TWENTY_API_KEY when TWENTY_APP_ACCESS_TOKEN is absent', async () => {
|
||||
process.env.TWENTY_API_KEY = 'legacy-api-key-token';
|
||||
|
||||
const capturedAuthorizationHeaders: string[] = [];
|
||||
const fetchMock = vi.fn(
|
||||
async (_url: string | URL | Request, requestInit?: RequestInit) => {
|
||||
const authorizationHeaderValue =
|
||||
getAuthorizationHeaderValue(requestInit);
|
||||
|
||||
if (authorizationHeaderValue) {
|
||||
capturedAuthorizationHeaders.push(authorizationHeaderValue);
|
||||
}
|
||||
|
||||
return createJsonResponse({
|
||||
body: { data: { record: { id: 'record-id' } } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await twentyClient.query({ record: { id: true } });
|
||||
|
||||
expect(capturedAuthorizationHeaders).toEqual([
|
||||
'Bearer legacy-api-key-token',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes and retries once after auth error when refresh callback is available', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const requestAccessTokenRefresh = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValue('fresh-token');
|
||||
|
||||
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
|
||||
{
|
||||
requestAccessTokenRefresh,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(
|
||||
async (_url: string | URL | Request, requestInit?: RequestInit) => {
|
||||
const authorizationHeaderValue =
|
||||
getAuthorizationHeaderValue(requestInit);
|
||||
|
||||
if (authorizationHeaderValue === 'Bearer stale-token') {
|
||||
return createJsonResponse({
|
||||
body: {
|
||||
errors: [
|
||||
{
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createJsonResponse({
|
||||
body: { data: { record: { id: 'record-id' } } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
const queryResult = await twentyClient.query({ record: { id: true } });
|
||||
|
||||
expect(queryResult).toEqual({ data: { record: { id: 'record-id' } } });
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(process.env.TWENTY_APP_ACCESS_TOKEN).toBe('fresh-token');
|
||||
expect(process.env.TWENTY_API_KEY).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('deduplicates concurrent token refresh requests', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const requestAccessTokenRefresh = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return 'fresh-token';
|
||||
});
|
||||
|
||||
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
|
||||
{
|
||||
requestAccessTokenRefresh,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(
|
||||
async (_url: string | URL | Request, requestInit?: RequestInit) => {
|
||||
const authorizationHeaderValue =
|
||||
getAuthorizationHeaderValue(requestInit);
|
||||
|
||||
if (authorizationHeaderValue === 'Bearer stale-token') {
|
||||
return createJsonResponse({
|
||||
body: {
|
||||
errors: [
|
||||
{
|
||||
extensions: { code: 'UNAUTHENTICATED' },
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createJsonResponse({
|
||||
body: { data: { record: { id: 'record-id' } } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
twentyClient.query({ record: { id: true } }),
|
||||
twentyClient.query({ record: { id: true } }),
|
||||
]);
|
||||
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('retries uploadFile once after 401 when refresh callback is available', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const requestAccessTokenRefresh = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValue('fresh-token');
|
||||
|
||||
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
|
||||
{
|
||||
requestAccessTokenRefresh,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(
|
||||
async (_url: string | URL | Request, requestInit?: RequestInit) => {
|
||||
const authorizationHeaderValue =
|
||||
getAuthorizationHeaderValue(requestInit);
|
||||
|
||||
if (authorizationHeaderValue === 'Bearer stale-token') {
|
||||
return createJsonResponse({
|
||||
body: { message: 'Unauthorized' },
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
return createJsonResponse({
|
||||
body: {
|
||||
data: {
|
||||
uploadFilesFieldFileByUniversalIdentifier: {
|
||||
id: 'uploaded-file-id',
|
||||
path: 'test/path.txt',
|
||||
size: 10,
|
||||
createdAt: '2026-02-24T00:00:00.000Z',
|
||||
url: 'https://example.com/test/path.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
metadataUrl: 'https://example.com/metadata',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
const uploadResult = await twentyClient.uploadFile(
|
||||
Buffer.from('content'),
|
||||
'test.txt',
|
||||
'text/plain',
|
||||
'field-uuid',
|
||||
);
|
||||
|
||||
expect(uploadResult.id).toBe('uploaded-file-id');
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bubbles auth error when refresh callback throws', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const requestAccessTokenRefresh = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValue(new Error('refresh failed'));
|
||||
|
||||
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
|
||||
{
|
||||
requestAccessTokenRefresh,
|
||||
};
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return createJsonResponse({
|
||||
body: { message: 'Unauthorized' },
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
|
||||
'Unauthorized',
|
||||
);
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Twenty client: token refresh failed',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('bubbles auth error when refresh callback returns an empty token', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const requestAccessTokenRefresh = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValue('');
|
||||
|
||||
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
|
||||
{
|
||||
requestAccessTokenRefresh,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return createJsonResponse({
|
||||
body: { message: 'Unauthorized' },
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
|
||||
'Unauthorized',
|
||||
);
|
||||
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bubbles auth error without retry when refresh callback is unavailable', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'stale-token';
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return createJsonResponse({
|
||||
body: { message: 'Unauthorized' },
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
fetch: fetchMock as unknown as typeof globalThis.fetch,
|
||||
});
|
||||
|
||||
await expect(twentyClient.query({ record: { id: true } })).rejects.toThrow(
|
||||
'Unauthorized',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls global fetch with globalThis binding when no custom fetch is provided', async () => {
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'application-token';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const capturedFetchThisValues: unknown[] = [];
|
||||
|
||||
const fetchMock = vi.fn(function (
|
||||
this: unknown,
|
||||
_url: string | URL | Request,
|
||||
_requestInit?: RequestInit,
|
||||
) {
|
||||
capturedFetchThisValues.push(this);
|
||||
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
body: { data: { record: { id: 'record-id' } } },
|
||||
}),
|
||||
);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
try {
|
||||
const twentyClient = new TwentyClass({
|
||||
url: 'https://example.com/graphql',
|
||||
});
|
||||
|
||||
await twentyClient.query({ record: { id: true } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(capturedFetchThisValues).toEqual([globalThis]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { generate } from '@genql/cli';
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
DEFAULT_APP_ACCESS_TOKEN_NAME,
|
||||
DEFAULT_API_KEY_NAME,
|
||||
DEFAULT_API_URL_NAME,
|
||||
GENERATED_DIR,
|
||||
@@ -65,12 +66,108 @@ export class ClientService {
|
||||
// Custom Twenty client (auto-injected)
|
||||
// ----------------------------------------------------
|
||||
|
||||
const defaultOptions: ClientOptions = {
|
||||
const APP_ACCESS_TOKEN_ENV_KEY = '${DEFAULT_APP_ACCESS_TOKEN_NAME}';
|
||||
const API_KEY_ENV_KEY = '${DEFAULT_API_KEY_NAME}';
|
||||
|
||||
type TwentyClientOptions = ClientOptions & {
|
||||
metadataUrl?: string;
|
||||
}
|
||||
|
||||
type ProcessEnvironment = Record<string, string | undefined>
|
||||
|
||||
type GraphqlError = {
|
||||
message?: string;
|
||||
extensions?: { code?: string };
|
||||
}
|
||||
|
||||
type GraphqlResponsePayload = {
|
||||
data?: Record<string, unknown>
|
||||
errors?: GraphqlError[];
|
||||
}
|
||||
|
||||
type GraphqlResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
payload: GraphqlResponsePayload | null;
|
||||
rawBody: string;
|
||||
}
|
||||
|
||||
const getProcessEnvironment = (): ProcessEnvironment => {
|
||||
const processObject = (globalThis as { process?: { env?: ProcessEnvironment } })
|
||||
.process;
|
||||
|
||||
return processObject?.env ?? {};
|
||||
}
|
||||
|
||||
const getTokenFromAuthorizationHeader = (
|
||||
authorizationHeader: string | undefined,
|
||||
): string | null => {
|
||||
if (typeof authorizationHeader !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmedAuthorizationHeader = authorizationHeader.trim();
|
||||
|
||||
if (trimmedAuthorizationHeader.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmedAuthorizationHeader === 'Bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmedAuthorizationHeader.startsWith('Bearer ')) {
|
||||
return trimmedAuthorizationHeader.slice('Bearer '.length).trim();
|
||||
}
|
||||
|
||||
return trimmedAuthorizationHeader;
|
||||
}
|
||||
|
||||
const getTokenFromHeaders = (headers: HeadersInit | undefined): string | null => {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return getTokenFromAuthorizationHeader(headers.get('Authorization') ?? undefined);
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
const matchedAuthorizationHeader = headers.find(
|
||||
([headerName]) => headerName.toLowerCase() === 'authorization',
|
||||
);
|
||||
|
||||
return getTokenFromAuthorizationHeader(matchedAuthorizationHeader?.[1]);
|
||||
}
|
||||
|
||||
const headersRecord = headers as Record<string, string | undefined>;
|
||||
|
||||
return getTokenFromAuthorizationHeader(
|
||||
headersRecord.Authorization ?? headersRecord.authorization,
|
||||
);
|
||||
}
|
||||
|
||||
const hasAuthenticationErrorInGraphqlPayload = (
|
||||
payload: GraphqlResponsePayload | null,
|
||||
): boolean => {
|
||||
if (!payload?.errors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return payload.errors.some((error) => {
|
||||
return (
|
||||
error.extensions?.code === 'UNAUTHENTICATED' ||
|
||||
// Fallback for payloads that don't provide structured error codes.
|
||||
error.message?.toLowerCase() === 'unauthorized'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const defaultOptions: TwentyClientOptions = {
|
||||
url: \`\${process.env.${DEFAULT_API_URL_NAME}}/graphql\`,
|
||||
metadataUrl: \`\${process.env.${DEFAULT_API_URL_NAME}}/metadata\`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: \`Bearer \${process.env.${DEFAULT_API_KEY_NAME}}\`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -78,21 +175,54 @@ export default class Twenty {
|
||||
private client: Client;
|
||||
private url: string;
|
||||
private metadataUrl: string;
|
||||
private authorizationToken: string;
|
||||
private requestOptions: RequestInit;
|
||||
private headers: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
||||
private fetchImplementation: typeof globalThis.fetch | null;
|
||||
private authorizationToken: string | null;
|
||||
private refreshAccessTokenPromise: Promise<string | null> | null = null;
|
||||
|
||||
constructor(options?: ClientOptions) {
|
||||
const merged: ClientOptions = {
|
||||
constructor(options?: TwentyClientOptions) {
|
||||
const merged: TwentyClientOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
};
|
||||
this.client = createClient(merged);
|
||||
this.url = merged.url;
|
||||
this.metadataUrl = merged.metadataUrl;
|
||||
this.authorizationToken = merged.headers.Authorization;
|
||||
}
|
||||
|
||||
const {
|
||||
url,
|
||||
metadataUrl,
|
||||
headers,
|
||||
fetch: customFetchImplementation,
|
||||
fetcher: _fetcher,
|
||||
batch: _batch,
|
||||
...requestOptions
|
||||
} = merged;
|
||||
|
||||
this.url = url ?? '';
|
||||
this.metadataUrl = metadataUrl ?? this.url.replace(/\\/graphql$/, '/metadata');
|
||||
this.requestOptions = requestOptions;
|
||||
this.headers = headers ?? {};
|
||||
this.fetchImplementation = customFetchImplementation ?? globalThis.fetch ?? null;
|
||||
|
||||
const processEnvironment = getProcessEnvironment();
|
||||
const tokenFromHeaders = getTokenFromHeaders(
|
||||
typeof headers === 'function' ? undefined : headers,
|
||||
);
|
||||
|
||||
// Priority: explicit header > TWENTY_APP_ACCESS_TOKEN > TWENTY_API_KEY (legacy fallback).
|
||||
this.authorizationToken =
|
||||
tokenFromHeaders ??
|
||||
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] ??
|
||||
processEnvironment[API_KEY_ENV_KEY] ??
|
||||
null;
|
||||
|
||||
this.client = createClient({
|
||||
...merged,
|
||||
headers: undefined,
|
||||
fetcher: async (operation) =>
|
||||
this.executeGraphqlRequestWithOptionalRefresh({
|
||||
operation,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
query<R extends QueryGenqlSelection>(request: R & { __name?: string }) {
|
||||
@@ -129,21 +259,208 @@ export default class Twenty {
|
||||
form.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
||||
form.append('0', new Blob([fileBuffer], { type: contentType }), filename);
|
||||
|
||||
const response = await fetch(this.metadataUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: this.authorizationToken,
|
||||
const result = await this.executeGraphqlRequestWithOptionalRefresh({
|
||||
operation: form,
|
||||
url: this.metadataUrl,
|
||||
headers: {},
|
||||
requestInit: {
|
||||
method: 'POST',
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.errors) {
|
||||
throw new GenqlError(result.errors, result.data);
|
||||
}
|
||||
|
||||
return result.data.uploadFilesFieldFileByUniversalIdentifier;
|
||||
const data = result.data as Record<string, unknown>;
|
||||
|
||||
return data.uploadFilesFieldFileByUniversalIdentifier as {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeGraphqlRequestWithOptionalRefresh({
|
||||
operation,
|
||||
url = this.url,
|
||||
headers,
|
||||
requestInit,
|
||||
}: {
|
||||
operation: GraphqlOperation | GraphqlOperation[] | FormData;
|
||||
url?: string;
|
||||
headers?: HeadersInit;
|
||||
requestInit?: RequestInit;
|
||||
}) {
|
||||
const firstResponse = await this.executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token: this.authorizationToken,
|
||||
});
|
||||
|
||||
if (this.shouldRefreshToken(firstResponse)) {
|
||||
const refreshedAccessToken = await this.requestRefreshedAccessToken();
|
||||
|
||||
if (refreshedAccessToken) {
|
||||
const retryResponse = await this.executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token: refreshedAccessToken,
|
||||
});
|
||||
|
||||
return this.assertResponseIsSuccessful(retryResponse);
|
||||
}
|
||||
}
|
||||
|
||||
return this.assertResponseIsSuccessful(firstResponse);
|
||||
}
|
||||
|
||||
private async executeGraphqlRequest({
|
||||
operation,
|
||||
url,
|
||||
headers,
|
||||
requestInit,
|
||||
token,
|
||||
}: {
|
||||
operation: GraphqlOperation | GraphqlOperation[] | FormData;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
requestInit?: RequestInit;
|
||||
token: string | null;
|
||||
}): Promise<GraphqlResponse> {
|
||||
if (!this.fetchImplementation) {
|
||||
throw new Error(
|
||||
'Global \`fetch\` function is not available, pass a fetch implementation to the Twenty client',
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedHeaders = await this.resolveHeaders();
|
||||
const requestHeaders = new Headers(resolvedHeaders);
|
||||
|
||||
if (headers) {
|
||||
new Headers(headers).forEach((value, key) => requestHeaders.set(key, value));
|
||||
}
|
||||
|
||||
if (operation instanceof FormData) {
|
||||
requestHeaders.delete('Content-Type');
|
||||
} else {
|
||||
requestHeaders.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
if (token) {
|
||||
requestHeaders.set('Authorization', \`Bearer \${token}\`);
|
||||
} else {
|
||||
requestHeaders.delete('Authorization');
|
||||
}
|
||||
|
||||
const response = await this.fetchImplementation.call(globalThis, url, {
|
||||
...this.requestOptions,
|
||||
...requestInit,
|
||||
method: requestInit?.method ?? 'POST',
|
||||
headers: requestHeaders,
|
||||
body: operation instanceof FormData ? operation : JSON.stringify(operation),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: GraphqlResponsePayload | null = null;
|
||||
|
||||
if (rawBody.trim().length > 0) {
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as GraphqlResponsePayload;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
payload,
|
||||
rawBody,
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveHeaders(): Promise<HeadersInit> {
|
||||
if (typeof this.headers === 'function') {
|
||||
return (await this.headers()) ?? {};
|
||||
}
|
||||
|
||||
return this.headers ?? {};
|
||||
}
|
||||
|
||||
private shouldRefreshToken(response: GraphqlResponse): boolean {
|
||||
if (response.status === 401) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasAuthenticationErrorInGraphqlPayload(response.payload);
|
||||
}
|
||||
|
||||
private assertResponseIsSuccessful(response: GraphqlResponse) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(\`\${response.statusText}: \${response.rawBody}\`);
|
||||
}
|
||||
|
||||
if (response.payload === null) {
|
||||
throw new Error('Invalid JSON response');
|
||||
}
|
||||
|
||||
return response.payload;
|
||||
}
|
||||
|
||||
private async requestRefreshedAccessToken(): Promise<string | null> {
|
||||
const refreshAccessTokenFunction = (
|
||||
globalThis as {
|
||||
frontComponentHostCommunicationApi?: {
|
||||
requestAccessTokenRefresh?: () => Promise<string>
|
||||
}
|
||||
}
|
||||
).frontComponentHostCommunicationApi?.requestAccessTokenRefresh;
|
||||
|
||||
if (typeof refreshAccessTokenFunction !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.refreshAccessTokenPromise) {
|
||||
this.refreshAccessTokenPromise = refreshAccessTokenFunction()
|
||||
.then((refreshedAccessToken) => {
|
||||
if (
|
||||
typeof refreshedAccessToken !== 'string' ||
|
||||
refreshedAccessToken.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.setAuthorizationToken(refreshedAccessToken);
|
||||
|
||||
return refreshedAccessToken;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Twenty client: token refresh failed', error);
|
||||
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshAccessTokenPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return this.refreshAccessTokenPromise;
|
||||
}
|
||||
|
||||
private setAuthorizationToken(token: string) {
|
||||
this.authorizationToken = token;
|
||||
|
||||
const processEnvironment = getProcessEnvironment();
|
||||
|
||||
processEnvironment[APP_ACCESS_TOKEN_ENV_KEY] = token;
|
||||
processEnvironment[API_KEY_ENV_KEY] = token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user