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: 12 additions & 10 deletions docs/content/docs/api/client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Authorization: Bearer YOUR_API_KEY

You can [create and manage API keys](/settings/access) in your project settings.

Every client endpoint is namespaced under `/api/client/projects/{projectID}/...`, where `{projectID}` is your project's UUID. The project is now an explicit part of the URL rather than being derived from the API key, and the API key you use must be authorized for that project.

<Callout type="info">
Client keys can ingest data but cannot read or modify existing resources. See
[Access](/settings/access) for a full breakdown of roles and permissions.
Expand All @@ -26,16 +28,16 @@ You can [create and manage API keys](/settings/access) in your project settings.

| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| `POST` | `/api/client/users` | Create or update a user |
| `POST` | `/api/client/users/events` | Track user events |
| `DELETE` | `/api/client/users` | Delete a user |
| `POST` | `/api/client/organizations` | Create or update an organization |
| `POST` | `/api/client/organizations/events` | Track organization events |
| `POST` | `/api/client/users/scheduled` | Create or update a user schedule |
| `DELETE` | `/api/client/users/scheduled` | Delete a user schedule |
| `POST` | `/api/client/organizations/scheduled` | Create or update an org schedule |
| `DELETE` | `/api/client/organizations/scheduled` | Delete an org schedule |
| `POST` | `/api/client/users/devices` | Register a device for push notifications |
| `POST` | `/api/client/projects/{projectID}/users` | Create or update a user |
| `POST` | `/api/client/projects/{projectID}/users/events` | Track user events |
| `DELETE` | `/api/client/projects/{projectID}/users` | Delete a user |
| `POST` | `/api/client/projects/{projectID}/organizations` | Create or update an organization |
| `POST` | `/api/client/projects/{projectID}/organizations/events` | Track organization events |
| `POST` | `/api/client/projects/{projectID}/users/scheduled` | Create or update a user schedule |
| `DELETE` | `/api/client/projects/{projectID}/users/scheduled` | Delete a user schedule |
| `POST` | `/api/client/projects/{projectID}/organizations/scheduled` | Create or update an org schedule |
| `DELETE` | `/api/client/projects/{projectID}/organizations/scheduled` | Delete an org schedule |
| `POST` | `/api/client/projects/{projectID}/users/devices` | Register a device for push notifications |

## SDKs

Expand Down
12 changes: 6 additions & 6 deletions docs/content/docs/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Send events through the [JavaScript SDK](/sdks/javascript) or the client API. Ev

### User Events

`POST /api/client/users/events`
`POST /api/client/projects/{projectID}/users/events`

<Tabs items={['JavaScript SDK', 'API']}>
<Tab value="JavaScript SDK">
Expand Down Expand Up @@ -70,7 +70,7 @@ Send events through the [JavaScript SDK](/sdks/javascript) or the client API. Ev
<Tab value="API">
```bash
# Target a specific user by identifier
curl -X POST https://your-instance.com/api/client/users/events \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/users/events \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '[
Expand All @@ -87,7 +87,7 @@ Send events through the [JavaScript SDK](/sdks/javascript) or the client API. Ev
]'

# Target all users whose data matches a filter
curl -X POST https://your-instance.com/api/client/users/events \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/users/events \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '[
Expand Down Expand Up @@ -144,7 +144,7 @@ The example above sends the `feature.announcement` event to every user whose dat

### Organization Events

`POST /api/client/organizations/events`
`POST /api/client/projects/{projectID}/organizations/events`

<Tabs items={['JavaScript SDK', 'API']}>
<Tab value="JavaScript SDK">
Expand Down Expand Up @@ -174,7 +174,7 @@ The example above sends the `feature.announcement` event to every user whose dat
<Tab value="API">
```bash
# Target a specific organization by identifier
curl -X POST https://your-instance.com/api/client/organizations/events \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations/events \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '[
Expand All @@ -190,7 +190,7 @@ The example above sends the `feature.announcement` event to every user whose dat
]'

# Target all organizations whose data matches a filter
curl -X POST https://your-instance.com/api/client/organizations/events \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations/events \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '[
Expand Down
34 changes: 17 additions & 17 deletions docs/content/docs/organizations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Create organizations through the client API. If an organization with a matching
</Tab>
<Tab value="API">
```bash
curl -X POST https://your-instance.com/api/client/organizations \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -111,7 +111,7 @@ Pass custom data as a JSON object when creating or updating an organization:
</Tab>
<Tab value="API">
```bash
curl -X POST https://your-instance.com/api/client/organizations \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -216,7 +216,7 @@ Delete an organization by its identifier. This also removes all memberships.
</Tab>
<Tab value="API">
```bash
curl -X DELETE https://your-instance.com/api/client/organizations \
curl -X DELETE https://your-instance.com/api/client/projects/{projectID}/organizations \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -256,7 +256,7 @@ Add a user to an organization using their identifiers:
</Tab>
<Tab value="API">
```bash
curl -X POST https://your-instance.com/api/client/organizations/users \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations/users \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -315,7 +315,7 @@ Remove a user from an organization:
</Tab>
<Tab value="API">
```bash
curl -X DELETE https://your-instance.com/api/client/organizations/users \
curl -X DELETE https://your-instance.com/api/client/projects/{projectID}/organizations/users \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down Expand Up @@ -358,7 +358,7 @@ Organizations have their own event timeline, separate from user events. Organiza
</Tab>
<Tab value="API">
```bash
curl -X POST https://your-instance.com/api/client/organizations/events \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/organizations/events \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '[
Expand Down Expand Up @@ -450,14 +450,14 @@ Organization member has role = "admin"

## API Reference

All organization endpoints use the client API and authenticate with your project API key:

| Method | Endpoint | Description |
| -------- | ------------------------------------- | ----------------------------- |
| `POST` | `/api/client/organizations` | Create or update organization |
| `DELETE` | `/api/client/organizations` | Delete organization |
| `POST` | `/api/client/organizations/users` | Add member |
| `DELETE` | `/api/client/organizations/users` | Remove member |
| `POST` | `/api/client/organizations/events` | Send organization events |
| `POST` | `/api/client/organizations/scheduled` | Create or update schedule |
| `DELETE` | `/api/client/organizations/scheduled` | Delete schedule |
All organization endpoints use the client API and authenticate with an API key authorized for your project. The project is now part of the URL path — every endpoint lives under `/api/client/projects/{projectID}/...`, where `{projectID}` is your project's UUID:

| Method | Endpoint | Description |
| -------- | -------------------------------------------------------- | ----------------------------- |
| `POST` | `/api/client/projects/{projectID}/organizations` | Create or update organization |
| `DELETE` | `/api/client/projects/{projectID}/organizations` | Delete organization |
| `POST` | `/api/client/projects/{projectID}/organizations/users` | Add member |
| `DELETE` | `/api/client/projects/{projectID}/organizations/users` | Remove member |
| `POST` | `/api/client/projects/{projectID}/organizations/events` | Send organization events |
| `POST` | `/api/client/projects/{projectID}/organizations/scheduled` | Create or update schedule |
| `DELETE` | `/api/client/projects/{projectID}/organizations/scheduled` | Delete schedule |
8 changes: 4 additions & 4 deletions docs/content/docs/schedules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Schedules are assigned through the client API. The endpoints use an upsert model
### User Schedules

```
POST /api/client/users/scheduled
POST /api/client/projects/{projectID}/users/scheduled
```

| Field | Type | Required | Description |
Expand All @@ -131,7 +131,7 @@ POST /api/client/users/scheduled
### Organization Schedules

```
POST /api/client/organizations/scheduled
POST /api/client/projects/{projectID}/organizations/scheduled
```

| Field | Type | Required | Description |
Expand All @@ -148,7 +148,7 @@ POST /api/client/organizations/scheduled
To remove a schedule assignment:

```
DELETE /api/client/users/scheduled
DELETE /api/client/projects/{projectID}/users/scheduled
```

```json
Expand All @@ -159,7 +159,7 @@ DELETE /api/client/users/scheduled
```

```
DELETE /api/client/organizations/scheduled
DELETE /api/client/projects/{projectID}/organizations/scheduled
```

```json
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/sdks/android.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ The Lunogram Android SDK will provide native support for user identification, ev

## In the meantime

You can integrate directly with the [Client API](/api/client) using any HTTP library. All you need is a public API key and standard JSON requests.
You can integrate directly with the [Client API](/api/client) using any HTTP library. All you need is a public API key and standard JSON requests. Client requests go to `/api/client/projects/{projectID}/...`, where `{projectID}` is your project's UUID.
2 changes: 1 addition & 1 deletion docs/content/docs/sdks/ios.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ The Lunogram iOS SDK will provide native support for user identification, event

## In the meantime

You can integrate directly with the [Client API](/api/client) using any HTTP library. All you need is a public API key and standard JSON requests.
You can integrate directly with the [Client API](/api/client) using any HTTP library. All you need is a public API key and standard JSON requests. Client requests go to `/api/client/projects/{projectID}/...`, where `{projectID}` is your project's UUID.
2 changes: 1 addition & 1 deletion docs/content/docs/users.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Store any data you need on user profiles. Custom properties let you personalize
</Tab>
<Tab value="API">
```bash
curl -X POST https://your-instance.com/api/client/users \
curl -X POST https://your-instance.com/api/client/projects/{projectID}/users \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{
Expand Down
65 changes: 65 additions & 0 deletions internal/http/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"time"

"github.com/getkin/kin-openapi/openapi3filter"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/lunogram/platform/internal/config"
"github.com/lunogram/platform/internal/rbac"
"github.com/lunogram/platform/internal/store/management"
Expand Down Expand Up @@ -136,6 +138,10 @@ func WithJWT(config config.Auth, mgmt *management.State) Handler {
// when the request is browser-originated (carries an Origin header); browser and
// mobile clients authenticate via a trusted issuer or a short-lived session
// instead. The management surface accepts any valid key.
//
// On the client surface the request URL names the project it acts on. A key is
// rejected (closed) when the URL project cannot be resolved or does not match the
// key's project, so a credential can never act on a project it is not scoped to.
func WithKey(mgmt *management.State, surface Surface) Handler {
return func(ctx context.Context, tokenString string) (context.Context, error) {
if tokenString == "" {
Expand All @@ -151,6 +157,10 @@ func WithKey(mgmt *management.State, surface Surface) Handler {
return ctx, err
}

if err := enforceURLProject(ctx, surface, key.ProjectID); err != nil {
return ctx, err
}

actor := rbac.NewActor(
rbac.ActorAPIKey,
key.ID,
Expand Down Expand Up @@ -187,6 +197,61 @@ func browserOriginated(ctx context.Context) bool {
return r != nil && r.Header.Get("Origin") != ""
}

// enforceURLProject binds a resolved credential to the project named in the
// request URL. On the client surface every route is mounted under
// /api/client/projects/{projectID}; the credential may act only on that project.
// It fails closed (ErrUnauthorized) when the URL project cannot be resolved or
// does not match the credential's project. The management surface carries no
// project in its URL, so the check is a no-op there.
func enforceURLProject(ctx context.Context, surface Surface, credentialProject uuid.UUID) error {
if surface != SurfaceClient {
return nil
}
urlProject, ok := projectFromRequest(ctx)
if !ok || urlProject != credentialProject {
return ErrUnauthorized
}
return nil
}

// projectFromRequest resolves the {projectID} path parameter of the in-flight
// request. Authentication runs inside the OpenAPI validator middleware, after chi
// has matched the route, so the chi route context carries the parameter; it falls
// back to scanning the URL path so the resolution is robust to middleware
// ordering. ok is false when no valid project UUID is present.
func projectFromRequest(ctx context.Context) (uuid.UUID, bool) {
r := RequestFromContext(ctx)
if r == nil {
return uuid.Nil, false
}
raw := ""
if rc := chi.RouteContext(r.Context()); rc != nil {
raw = rc.URLParam("projectID")
}
if raw == "" {
raw = projectIDFromPath(r.URL.Path)
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, false
}
return id, true
}

// projectIDFromPath extracts the path segment following /api/client/projects/.
// It is the fallback for when the chi route context is not populated.
func projectIDFromPath(path string) string {
const prefix = "/api/client/projects/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[:i]
}
return rest
}

// OAuthResponse represents the OAuth token response stored in cookies
type OAuthResponse struct {
AccessToken string `json:"access_token"`
Expand Down
Loading
Loading