Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workers/api-gateway/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
ORIGIN_URL=http://localhost:4000
WORKER_SECRET=local-worker-secret
PAT_HASH_SALT=local-pat-hash-salt
LFX_API_URL=
M2M_ISSUER_URL=
M2M_AUDIENCE=
M2M_CLIENT_ID=
M2M_CLIENT_SECRET=
2 changes: 1 addition & 1 deletion workers/api-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Cloudflare Worker in front of the Insights public API ([ADR-0006](../../api/docs
3. On a miss, exchanges the PAT for an Auth0 JWT and resolves the user's org and tier from the member-tiers endpoint.
4. Forwards to the API with `Bearer <JWT>`, `x-tier`, `x-org-id`, `x-worker-secret` and `x-client-ip`, replacing any client-supplied copies.

The Auth0 exchange (`src/exchange.ts`) and the member-tiers call (`src/tiers.ts`) are stubs that return the real response shapes. The origin is reached over `ORIGIN_URL` until the Workers VPC binding exists.
The Auth0 exchange (`src/exchange.ts`) is a stub that returns the real response shape. The origin is reached over `ORIGIN_URL` until the Workers VPC binding exists.

## Local development

Expand Down
5 changes: 5 additions & 0 deletions workers/api-gateway/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ export interface Env {
ORIGIN_URL: string;
WORKER_SECRET: string;
PAT_HASH_SALT: string;
LFX_API_URL: string;
M2M_ISSUER_URL: string;
M2M_AUDIENCE: string;
M2M_CLIENT_ID: string;
M2M_CLIENT_SECRET: string;
}
30 changes: 30 additions & 0 deletions workers/api-gateway/src/m2m.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Env } from './env';

const EXPIRY_MARGIN_MS = 60_000;

let cached: { token: string; expiresAt: number } | undefined;

export async function m2mToken(env: Env): Promise<string> {
if (cached && cached.expiresAt > Date.now()) return cached.token;

const response = await fetch(new URL('oauth/token', env.M2M_ISSUER_URL), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: env.M2M_CLIENT_ID,
client_secret: env.M2M_CLIENT_SECRET,
audience: env.M2M_AUDIENCE,
}),
});
if (!response.ok) throw new Error(`M2M token request failed: ${response.status}`);

const body = (await response.json()) as { access_token: string; expires_in: number };
cached = {
token: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000 - EXPIRY_MARGIN_MS,
};
return cached.token;
}
Comment thread
epipav marked this conversation as resolved.
20 changes: 8 additions & 12 deletions workers/api-gateway/src/tiers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Env } from './env';
import { m2mToken } from './m2m';

export interface MemberOrgTier {
b2b_org_uid: string;
Expand All @@ -17,18 +18,13 @@ export interface OrgTier {
tier: string;
}

export async function fetchMemberTiers(_username: string, _env: Env): Promise<MemberOrgTier[]> {
return [
{
b2b_org_uid: '001B000000IqhSLIAZ',
membership_uid: '02i2M000009ABCdIAM',
tier: 'gold',
company_name: 'Example Corp',
project_slug: 'lf-main',
tier_name: 'Gold Corporate Membership',
status: 'Active',
},
];
export async function fetchMemberTiers(username: string, env: Env): Promise<MemberOrgTier[]> {
const url = new URL(`b2b_orgs/member-tiers/${encodeURIComponent(username)}?v=1`, env.LFX_API_URL);
const response = await fetch(url, {
headers: { authorization: `Bearer ${await m2mToken(env)}` },
});
if (!response.ok) throw new Error(`member-tiers request failed: ${response.status}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does member-tiers return for a user with no membership - 200 [] or a 404? if it's a 404, this throws and the worker answers 500 instead of the forbidden() 403 in handle, or am I missing something?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

member-tiers answers 200 [] for users without a membership (unknown users too), so this lands on forbidden()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does member-tiers return for a user with no membership, a 200 with an empty array or a 404? If it can 404, this throw has nothing catching it in handle(), so the worker would answer with a raw 500 instead of the 403 that forbidden() already builds for exactly that case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

member-tiers answers 200 [] for users without a membership (unknown users too), so this lands on forbidden()

return (await response.json()) as MemberOrgTier[];
Comment thread
epipav marked this conversation as resolved.
}

export function pickOrgTier(tiers: MemberOrgTier[]): OrgTier | null {
Expand Down
5 changes: 5 additions & 0 deletions workers/api-gateway/tests/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const env: Env = {
ORIGIN_URL: 'http://origin.test',
WORKER_SECRET: 'worker-secret',
PAT_HASH_SALT: 'salt',
LFX_API_URL: 'https://lfx-api.test/',
M2M_ISSUER_URL: 'https://auth.test/',
M2M_AUDIENCE: 'https://lfx-api.test/',
M2M_CLIENT_ID: 'client',
M2M_CLIENT_SECRET: 'secret',
};

const goldTier: MemberOrgTier = {
Expand Down
80 changes: 80 additions & 0 deletions workers/api-gateway/tests/tiers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import { afterEach, describe, expect, it, vi } from 'vitest';

import type { Env } from '../src/env';
import { fetchMemberTiers } from '../src/tiers';

const env = {
LFX_API_URL: 'https://lfx-api.test/',
M2M_ISSUER_URL: 'https://auth.test/',
M2M_AUDIENCE: 'https://lfx-api.test/',
M2M_CLIENT_ID: 'client',
M2M_CLIENT_SECRET: 'secret',
} as Env;

afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

describe('fetchMemberTiers', () => {
it('calls member-tiers with an M2M token and reuses the token', async () => {
const tiers = [{ b2b_org_uid: 'org', membership_uid: 'm', tier: 'gold' }];
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) =>
String(input).startsWith('https://auth.test/')
? Response.json({ access_token: 'm2m-token', expires_in: 86400 })
: Response.json(tiers),
);
vi.stubGlobal('fetch', fetchMock);

expect(await fetchMemberTiers('jane doe', env)).toEqual(tiers);
await fetchMemberTiers('jane doe', env);

const urls = fetchMock.mock.calls.map(([input]) => String(input));
expect(urls).toEqual([
'https://auth.test/oauth/token',
'https://lfx-api.test/b2b_orgs/member-tiers/jane%20doe?v=1',
'https://lfx-api.test/b2b_orgs/member-tiers/jane%20doe?v=1',
]);
const tokenInit = fetchMock.mock.calls[0]![1]!;
expect(tokenInit.method).toBe('POST');
expect(JSON.parse(String(tokenInit.body))).toEqual({
grant_type: 'client_credentials',
client_id: 'client',
client_secret: 'secret',
audience: 'https://lfx-api.test/',
});
const init = fetchMock.mock.calls[1]![1]!;
expect(new Headers(init.headers).get('authorization')).toBe('Bearer m2m-token');
});
Comment thread
epipav marked this conversation as resolved.

it('fetches a new token once the cached one is inside the expiry margin', async () => {
vi.useFakeTimers({ now: Date.now() + 2 * 86_400_000 });
const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
String(input).startsWith('https://auth.test/')
? Response.json({ access_token: 'short-token', expires_in: 120 })
: Response.json([]),
);
vi.stubGlobal('fetch', fetchMock);

await fetchMemberTiers('jane', env);
await fetchMemberTiers('jane', env);
vi.advanceTimersByTime(61_000);
await fetchMemberTiers('jane', env);

const tokenCalls = fetchMock.mock.calls.filter(([input]) =>
String(input).endsWith('/oauth/token'),
);
expect(tokenCalls).toHaveLength(2);
});

it('throws when member-tiers rejects the call', async () => {
vi.stubGlobal('fetch', async (input: RequestInfo | URL) =>
String(input).startsWith('https://auth.test/')
? Response.json({ access_token: 'm2m-token', expires_in: 86400 })
: new Response('forbidden', { status: 403 }),
);
await expect(fetchMemberTiers('jane', env)).rejects.toThrow('member-tiers request failed: 403');
});
});
Loading