Skip to content
Closed
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
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ npm install @lunogram/js-sdk
```typescript
import { Lunogram } from '@lunogram/js-sdk'

const lunogram = new Lunogram('your-api-key')
// The project UUID is required and scopes every request to that project.
const lunogram = new Lunogram('your-api-key', 'your-project-uuid')

// Identify a user
await lunogram.user.upsert({
Expand All @@ -46,6 +47,27 @@ await lunogram.organization.upsert({
})
```

## Project Scoping

Every Lunogram Client API request is scoped to a single **project**. You supply
the project UUID once, when constructing the client, and the SDK injects it into
every request path automatically — you never pass it per call.

All Client API endpoints live under `/api/client/projects/{projectId}/...`:

| Operation | Path |
| ------------------------ | ----------------------------------------------------------------- |
| Users | `/api/client/projects/{projectId}/users` |
| User events | `/api/client/projects/{projectId}/users/events` |
| User scheduled | `/api/client/projects/{projectId}/users/scheduled` |
| Organizations | `/api/client/projects/{projectId}/organizations` |
| Organization members | `/api/client/projects/{projectId}/organizations/users` |
| Organization events | `/api/client/projects/{projectId}/organizations/events` |
| Organization scheduled | `/api/client/projects/{projectId}/organizations/scheduled` |

The `projectId` must be a valid UUID; the client throws a `ValidationError` at
construction time if it is missing, empty, or malformed.

## Identity Model

Users and organizations are identified by an array of `ExternalID` objects:
Expand All @@ -69,7 +91,10 @@ The SDK automatically generates an anonymous identifier for each session. When y
```typescript
import { BrowserClient } from '@lunogram/js-sdk'

const client = new BrowserClient({ apiKey: 'your-api-key' })
const client = new BrowserClient({
apiKey: 'your-api-key',
projectId: 'your-project-uuid',
})

// anonymousId is auto-generated
client.user.anonymousId
Expand Down Expand Up @@ -100,7 +125,7 @@ The SDK is also exposed on `window.Lunogram`:

```html
<script>
const lunogram = new Lunogram('your-api-key')
const lunogram = new Lunogram('your-api-key', 'your-project-uuid')
lunogram.user.upsert({
identifier: [{ externalId: 'user-123' }],
email: 'user@example.com',
Expand All @@ -115,6 +140,7 @@ import { Client } from '@lunogram/js-sdk'

const client = new Client({
apiKey: 'your-api-key',
projectId: 'your-project-uuid', // required — scopes all requests to this project
urlEndpoint: 'https://your-api.com/api', // optional
})

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build:esm": "tsc --project ./tsconfig.module.json",
"build:cjs": "tsc --project ./tsconfig.json",
"lint": "eslint . --ext .ts",
"test": "vitest run test/url.test.ts",
"test:e2e": "vitest run test/e2e.test.ts",
"release:github": "pnpm publish --no-git-checks --registry=https://npm.pkg.github.com",
"release:npm": "npm publish --access public --registry=https://registry.npmjs.org"
Expand Down
14 changes: 12 additions & 2 deletions src/core/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ClientProps } from '../types'
import { mapKeys } from '../utils'
import { mapKeys, isUuid } from '../utils'
import { DefaultEndpoint } from './constants'
import {
LunogramError,
Expand All @@ -23,10 +23,20 @@ const statusErrorFactories: Partial<Record<number, () => LunogramError>> = {
export class HttpHandler {
readonly #apiKey: string
readonly #baseUrl: string
readonly #projectId: string

constructor(props: ClientProps) {
const projectId = props.projectId?.trim()
if (!projectId) {
throw new ValidationError('A non-empty `projectId` is required to create a Lunogram client')
}
if (!isUuid(projectId)) {
throw new ValidationError('`projectId` must be a valid UUID')
}

this.#apiKey = props.apiKey
this.#baseUrl = props.urlEndpoint ?? DefaultEndpoint
this.#projectId = projectId
}

async get<T = unknown>(path: string, data?: unknown): Promise<T> {
Expand All @@ -42,7 +52,7 @@ export class HttpHandler {
}

async #request<T>(method: HttpMethod, path: string, data?: unknown): Promise<T> {
const url = `${this.#baseUrl}/client/${path}`
const url = `${this.#baseUrl}/client/projects/${this.#projectId}/${path}`

try {
const response = await fetch(url, {
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ export class Lunogram {
/**
* Creates a new Lunogram instance.
* @param apiKey - Your Lunogram API key
* @param projectId - The UUID of your Lunogram project (required). All
* Client API requests are scoped to this project.
* @param urlEndpoint - Optional custom API endpoint URL
*/
constructor(apiKey: string, urlEndpoint?: string) {
this.client = new BrowserClient({ apiKey, urlEndpoint })
constructor(apiKey: string, projectId: string, urlEndpoint?: string) {
this.client = new BrowserClient({ apiKey, projectId, urlEndpoint })
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@ export type JSONValue = string | number | boolean | null | { [key: string]: JSON

export interface ClientProps {
apiKey: string
/**
* The UUID of the Lunogram project. Required. Every Client API request is
* scoped to this project and issued under `/client/projects/{projectId}/...`.
*/
projectId: string
urlEndpoint?: string
}
6 changes: 5 additions & 1 deletion src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { v4 as uuidv4 } from 'uuid'
import { v4 as uuidv4, validate as uuidValidate } from 'uuid'

export function generateUuid(): string {
return uuidv4()
}

export function isUuid(value: string): boolean {
return uuidValidate(value)
}
6 changes: 5 additions & 1 deletion test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Client } from '../src'

const apiKey = process.env.LUNOGRAM_API_KEY
const projectId = process.env.LUNOGRAM_PROJECT_ID
const urlEndpoint = process.env.LUNOGRAM_API_URL

const userId = `test-user-${Date.now()}`
Expand All @@ -13,7 +14,10 @@
if (!apiKey) {
throw new Error('LUNOGRAM_API_KEY environment variable is required')
}
client = new Client({ apiKey, urlEndpoint })
if (!projectId) {
throw new Error('LUNOGRAM_PROJECT_ID environment variable is required')

Check failure on line 18 in test/e2e.test.ts

View workflow job for this annotation

GitHub Actions / E2E Tests

test/e2e.test.ts

Error: LUNOGRAM_PROJECT_ID environment variable is required ❯ test/e2e.test.ts:18:15
}
client = new Client({ apiKey, projectId, urlEndpoint })
})

describe('Users', () => {
Expand Down
101 changes: 101 additions & 0 deletions test/url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Client, ValidationError } from '../src'

const apiKey = 'test-api-key'
const projectId = '11111111-1111-4111-8111-111111111111'
const urlEndpoint = 'https://api.example.com/api'

function mockFetch() {
return vi.fn(async () =>
new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
}

describe('Client API URL construction', () => {
let fetchMock: ReturnType<typeof mockFetch>

beforeEach(() => {
fetchMock = mockFetch()
vi.stubGlobal('fetch', fetchMock)
})

afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

function lastUrl(): string {
const call = fetchMock.mock.calls.at(-1)
return String(call?.[0])
}

it('prefixes user requests with /client/projects/{projectId}', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.user.upsert({ identifier: [{ externalId: 'user-1' }] })
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/users`)
})

it('prefixes user events', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.user.events.post([{ name: 'evt', identifier: [{ externalId: 'u' }], data: {} }])
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/users/events`)
})

it('prefixes user scheduled', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.user.schedule.upsert({
name: 'r',
identifier: [{ externalId: 'u' }],
scheduledAt: new Date().toISOString(),
})
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/users/scheduled`)
})

it('prefixes organizations', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.organization.upsert({ identifier: [{ externalId: 'org-1' }] })
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/organizations`)
})

it('prefixes organization member operations (path override)', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.organization.addUser({
organization: { identifier: [{ externalId: 'org-1' }] },
user: { identifier: [{ externalId: 'user-1' }] },
})
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/organizations/users`)
})

it('prefixes organization events', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.organization.events.post([{ name: 'evt', identifier: [{ externalId: 'o' }], data: {} }])
expect(lastUrl()).toBe(`${urlEndpoint}/client/projects/${projectId}/organizations/events`)
})

it('does not double-prefix on repeated calls', async () => {
const client = new Client({ apiKey, projectId, urlEndpoint })
await client.user.upsert({ identifier: [{ externalId: 'a' }] })
await client.user.upsert({ identifier: [{ externalId: 'b' }] })
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).toBe(`${urlEndpoint}/client/projects/${projectId}/users`)
}
})
})

describe('projectId validation', () => {
it('throws when projectId is missing', () => {
// @ts-expect-error projectId is required
expect(() => new Client({ apiKey, urlEndpoint })).toThrow(ValidationError)
})

it('throws when projectId is empty', () => {
expect(() => new Client({ apiKey, projectId: ' ', urlEndpoint })).toThrow(ValidationError)
})

it('throws when projectId is not a UUID', () => {
expect(() => new Client({ apiKey, projectId: 'not-a-uuid', urlEndpoint })).toThrow(ValidationError)
})
})
Loading