Skip to content

Commit ddffc27

Browse files
authored
feat: add scoped endpoints for tenant isolation (#8)
1 parent 53f972c commit ddffc27

File tree

9 files changed

+853
-305
lines changed

9 files changed

+853
-305
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,28 @@ Format as markdown: [Item Name](viewUrl)
10071007
10081008
---
10091009

1010+
## Security & Multi-tenant Access
1011+
1012+
This component is auth-agnostic. You must supply `externalId` from your own
1013+
auth system and never accept it directly from untrusted clients.
1014+
1015+
Use the scoped endpoints below to enforce ownership checks server-side. These
1016+
throw `Not found` if the conversation or stream is missing or not owned by the
1017+
provided `externalId` to avoid leaking existence across tenants.
1018+
1019+
- `conversations.getForExternalId`
1020+
- `messages.listForExternalId`
1021+
- `messages.getLatestForExternalId`
1022+
- `stream.getStreamForExternalId`
1023+
- `stream.listDeltasForExternalId`
1024+
- `stream.abortForExternalId`
1025+
- `chat.sendForExternalId`
1026+
1027+
Unscoped endpoints are low-level helpers and should only be used if you already
1028+
enforce access control in your app.
1029+
1030+
---
1031+
10101032
## API Reference
10111033

10121034
### Component Functions
@@ -1015,16 +1037,24 @@ Format as markdown: [Item Name](viewUrl)
10151037
|----------|------|-------------|
10161038
| `conversations.create` | Mutation | Create a new conversation |
10171039
| `conversations.get` | Query | Get conversation by ID |
1040+
| `conversations.getForExternalId` | Query | Get conversation by ID scoped to externalId |
10181041
| `conversations.list` | Query | List conversations by externalId |
10191042
| `messages.add` | Mutation | Add a message |
10201043
| `messages.list` | Query | List messages in conversation |
1044+
| `messages.listForExternalId` | Query | List messages scoped to externalId |
1045+
| `messages.getLatestForExternalId` | Query | Get latest message scoped to externalId |
10211046
| `stream.create` | Mutation | Create a new stream for delta-based streaming |
10221047
| `stream.addDelta` | Mutation | Add a delta (batch of parts) to a stream |
10231048
| `stream.finish` | Mutation | Mark stream as finished, clean up deltas |
10241049
| `stream.abort` | Mutation | Abort a stream by stream ID |
10251050
| `stream.abortByConversation` | Mutation | Abort a stream by conversation ID |
1051+
| `stream.abortForExternalId` | Mutation | Abort a stream by conversation ID scoped to externalId |
10261052
| `stream.getStream` | Query | Get current stream state for a conversation |
10271053
| `stream.listDeltas` | Query | Get deltas from a cursor position |
1054+
| `stream.getStreamForExternalId` | Query | Get current stream state scoped to externalId |
1055+
| `stream.listDeltasForExternalId` | Query | Get deltas scoped to externalId |
1056+
| `chat.send` | Action | Send a message via OpenRouter |
1057+
| `chat.sendForExternalId` | Action | Send a message scoped to externalId |
10281058

10291059
### React Hooks & Components
10301060

convex/component/access.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Id } from "./_generated/dataModel";
2+
3+
type DbCtx = {
4+
db: {
5+
get: (id: Id<"conversations"> | Id<"streamingMessages">) => Promise<any>;
6+
};
7+
};
8+
9+
function notFound(): never {
10+
throw new Error("Not found");
11+
}
12+
13+
export async function requireConversationExternalId(
14+
ctx: DbCtx,
15+
conversationId: Id<"conversations">,
16+
externalId: string
17+
) {
18+
const conversation = await ctx.db.get(conversationId);
19+
if (!conversation || conversation.externalId !== externalId) {
20+
notFound();
21+
}
22+
return conversation;
23+
}
24+
25+
export async function requireStreamExternalId(
26+
ctx: DbCtx,
27+
streamId: Id<"streamingMessages">,
28+
externalId: string
29+
) {
30+
const stream = await ctx.db.get(streamId);
31+
if (!stream) {
32+
notFound();
33+
}
34+
await requireConversationExternalId(ctx, stream.conversationId, externalId);
35+
return stream;
36+
}

0 commit comments

Comments
 (0)