Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "[[ \"$TOOL_INPUT\" == *\"apps/resolver\"* ]] && npm test --workspace=@experiences/resolver 2>&1 | tail -10 || true",
"async": true,
"timeout": 60
}
]
}
]
}
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# Claude
.claude/projects
.claude/settings.local.json

# Dependencies
node_modules
.pnp
Expand Down
2 changes: 2 additions & 0 deletions apps/resolver/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
.wrangler
27 changes: 27 additions & 0 deletions apps/resolver/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Experiences Resolver

Cloudflare Worker that resolves experience bundle URLs based on configured versions.

## Endpoints

- `GET /{experienceId}` - Returns the bundle URL for an experience
- `POST /{experienceId}` - Updates the bundle version for an experience

Both endpoints require `X-Algolia-Application-Id` and `X-Algolia-API-Key` headers.

## Running dev

```bash
npm ci
npm run dev
```

## Deploying

> [!NOTE]
> You need to create a KV namespace and update the `id` in `wrangler.toml` before deploying.

```bash
npx wrangler kv namespace create EXPERIENCES_BUNDLE_VERSIONS
npm run deploy
```
11 changes: 11 additions & 0 deletions apps/resolver/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { KVNamespace } from '@cloudflare/workers-types';

declare global {
interface Env {
EXPERIENCES_BUNDLE_VERSIONS: KVNamespace;
}
}

declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
21 changes: 12 additions & 9 deletions apps/resolver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
"name": "@experiences/resolver",
"version": "0.0.0",
"private": true,
"description": "Algolia Experiences resolver API",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"check-types": "tsc --noEmit"
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"algoliasearch": "^5"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.12.0",
"@cloudflare/workers-types": "^4",
"@experiences/typescript-config": "*",
"typescript": "5.9.2"
"typescript": "5.9.2",
"vitest": "3.0.5",
"wrangler": "^4"
}
}
248 changes: 248 additions & 0 deletions apps/resolver/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { env, SELF } from 'cloudflare:test';

type ResponseBody = {
message?: string;
experienceId?: string;
bundleUrl?: string;
};

const mockGetApiKey = vi.fn();
vi.mock('algoliasearch', () => ({
algoliasearch: vi.fn(() => ({
getApiKey: mockGetApiKey,
})),
}));

describe('/{experienceId}', () => {
const CREDENTIALS_HEADERS = {
'X-Algolia-Application-Id': 'TEST_APP_ID',
'X-Algolia-API-Key': 'test-api-key',
};

beforeEach(() => {
vi.clearAllMocks();
});

// Common tests
it('returns 204 with CORS headers for OPTIONS preflight', async () => {
const response = await createTestRequest('/exp123', {
method: 'OPTIONS',
});

expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toContain(
'GET'
);
expect(response.headers.get('Access-Control-Allow-Methods')).toContain(
'POST'
);
});

it('returns 404 for invalid path', async () => {
const response = await createTestRequest('/foo/bar', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(404);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Not found.');
});

it('returns 405 for unsupported method', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['search', 'editSettings'] });

const response = await createTestRequest('/exp123', {
method: 'PUT',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(405);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Method not allowed.');
});

it('returns 400 when credentials are missing', async () => {
const response = await createTestRequest('/exp123', {
method: 'GET',
});

expect(response.status).toBe(400);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Missing credentials.');
});

describe('GET', () => {
it('returns 403 when getApiKey throws (invalid credentials)', async () => {
mockGetApiKey.mockRejectedValue(new Error('Invalid API key'));

const response = await createTestRequest('/exp123', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(403);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Forbidden');
});

it('returns 403 when API key lacks search ACL', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['browse'] });

const response = await createTestRequest('/exp123', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(403);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Forbidden');
});

it('returns 404 when experience is not found in KV', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['search'] });

const response = await createTestRequest('/nonexistent', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(404);
const body = await response.json<ResponseBody>();
expect(body.message).toContain('Experience not found');
});

it('returns 200 with bundle URL when experience exists', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['search'] });
await env.EXPERIENCES_BUNDLE_VERSIONS.put('TEST_APP_ID:exp123', '2.0.0');

const response = await createTestRequest('/exp123', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.status).toBe(200);
const body = await response.json<ResponseBody>();
expect(body.experienceId).toBe('exp123');
expect(body.bundleUrl).toBe(
'https://cdn.jsdelivr.net/npm/@algolia/runtime@2.0.0/dist/experiences.umd.js'
);
});

it('includes CORS headers in response', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['search'] });
await env.EXPERIENCES_BUNDLE_VERSIONS.put('TEST_APP_ID:exp123', '1.0.0');

const response = await createTestRequest('/exp123', {
method: 'GET',
headers: CREDENTIALS_HEADERS,
});

expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Methods')).toContain(
'GET'
);
});
});

describe('POST', () => {
it('returns 403 when getApiKey throws (invalid credentials)', async () => {
mockGetApiKey.mockRejectedValue(new Error('Invalid API key'));

const response = await createTestRequest('/exp123', {
method: 'POST',
headers: {
...CREDENTIALS_HEADERS,
'Content-Type': 'application/json',
},
body: JSON.stringify({ bundleVersion: '1.0.0' }),
});

expect(response.status).toBe(403);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Forbidden');
});

it('returns 403 when API key lacks editSettings ACL', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['search'] });

const response = await createTestRequest('/exp123', {
method: 'POST',
headers: {
...CREDENTIALS_HEADERS,
'Content-Type': 'application/json',
},
body: JSON.stringify({ bundleVersion: '1.0.0' }),
});

expect(response.status).toBe(403);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Forbidden');
});

it('returns 400 when bundleVersion is missing', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['editSettings'] });

const response = await createTestRequest('/exp123', {
method: 'POST',
headers: {
...CREDENTIALS_HEADERS,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});

expect(response.status).toBe(400);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Missing bundleVersion.');
});

it('returns 400 when body is invalid JSON', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['editSettings'] });

const response = await createTestRequest('/exp123', {
method: 'POST',
headers: {
...CREDENTIALS_HEADERS,
'Content-Type': 'application/json',
},
body: 'not valid json',
});

expect(response.status).toBe(400);
const body = await response.json<ResponseBody>();
expect(body.message).toBe('Missing bundleVersion.');
});

it('returns 200 and updates KV on valid request', async () => {
mockGetApiKey.mockResolvedValue({ acl: ['editSettings'] });

const response = await createTestRequest('/exp123', {
method: 'POST',
headers: {
...CREDENTIALS_HEADERS,
'Content-Type': 'application/json',
},
body: JSON.stringify({ bundleVersion: '3.0.0' }),
});

expect(response.status).toBe(200);
const body = await response.json<ResponseBody>();
expect(body.experienceId).toBe('exp123');

// Verify KV was updated
const storedVersion =
await env.EXPERIENCES_BUNDLE_VERSIONS.get('TEST_APP_ID:exp123');
expect(storedVersion).toBe('3.0.0');
});
});
});

function createTestRequest(
path: string,
init?: RequestInit
): Promise<Response> {
return SELF.fetch(`http://localhost${path}`, init);
}
Loading