Skip to content

Commit dda5ddc

Browse files
committed
fix: tracking script
1 parent 6b10424 commit dda5ddc

File tree

6 files changed

+197
-19
lines changed

6 files changed

+197
-19
lines changed

apps/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
66
import { autumnHandler } from 'autumn-js/elysia';
77
import { Elysia } from 'elysia';
88
import { logger } from './lib/logger';
9+
import { createAuthMiddleware } from './middleware/auth';
910
import { assistant } from './routes/assistant';
1011
import { health } from './routes/health';
1112
import { query } from './routes/query';
@@ -22,6 +23,7 @@ const app = new Elysia()
2223
],
2324
})
2425
)
26+
.use(createAuthMiddleware())
2527
.use(
2628
autumnHandler({
2729
identify: async ({ request }) => {

apps/api/src/lib/logger.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { Logtail } from '@logtail/edge';
22

33
const token = process.env.LOGTAIL_SOURCE_TOKEN as string;
44
const endpoint = process.env.LOGTAIL_ENDPOINT as string;
5-
export const logger = new Logtail(token || '', {
6-
endpoint: endpoint || '',
5+
6+
if (!(token && endpoint)) {
7+
console.log('LOGTAIL_SOURCE_TOKEN and LOGTAIL_ENDPOINT must be set');
8+
}
9+
10+
export const logger = new Logtail(token, {
11+
endpoint,
712
batchSize: 10,
813
batchInterval: 1000,
914
});

apps/api/src/middleware/auth.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { auth, type User } from '@databuddy/auth';
2+
import { apikey, apikeyAccess, db } from '@databuddy/db';
3+
import { eq, type InferSelectModel } from 'drizzle-orm';
4+
import { Elysia } from 'elysia';
5+
6+
type ApiScope = InferSelectModel<typeof apikey>['scopes'][number];
7+
8+
export interface ApiKeyAuthContext {
9+
apikey: InferSelectModel<typeof apikey>;
10+
scopes: ApiScope[];
11+
principal: { userId?: string | null; organizationId?: string | null };
12+
}
13+
14+
export interface AuthContext {
15+
mode: 'session' | 'apiKey' | 'public';
16+
session?: { user: User } | null;
17+
apiKey?: ApiKeyAuthContext;
18+
identifier: string; // for rate limiting and logging
19+
}
20+
21+
export function extractApiKey(headers: Headers): string | null {
22+
const authHeader = headers.get('authorization');
23+
const xApiKey = headers.get('x-api-key');
24+
25+
if (xApiKey && xApiKey.trim().length > 0) {
26+
return xApiKey.trim();
27+
}
28+
29+
if (!authHeader) {
30+
return null;
31+
}
32+
33+
const parts = authHeader.split(' ');
34+
if (parts.length === 2) {
35+
const scheme = parts[0] ?? '';
36+
const token = parts[1] ?? '';
37+
const normalized = scheme.toLowerCase();
38+
if (normalized === 'bearer' || normalized === 'apikey') {
39+
return token.trim() || null;
40+
}
41+
}
42+
return null;
43+
}
44+
45+
export async function resolveApiKeyContext(
46+
key: string
47+
): Promise<ApiKeyAuthContext | null> {
48+
const found = await db.query.apikey.findFirst({ where: eq(apikey.key, key) });
49+
if (!found) {
50+
return null;
51+
}
52+
53+
if (!found.enabled) {
54+
return null;
55+
}
56+
if (found.revokedAt != null) {
57+
return null;
58+
}
59+
if (found.expiresAt && new Date(found.expiresAt).getTime() < Date.now()) {
60+
return null;
61+
}
62+
63+
const accessRows = await db
64+
.select()
65+
.from(apikeyAccess)
66+
.where(eq(apikeyAccess.apikeyId, found.id));
67+
68+
const effectiveScopes = new Set<ApiScope>();
69+
for (const s of found.scopes) {
70+
effectiveScopes.add(s as ApiScope);
71+
}
72+
for (const row of accessRows) {
73+
for (const s of row.scopes) {
74+
effectiveScopes.add(s as ApiScope);
75+
}
76+
}
77+
78+
return {
79+
apikey: found,
80+
scopes: Array.from(effectiveScopes),
81+
principal: {
82+
userId: found.userId ?? null,
83+
organizationId: found.organizationId ?? null,
84+
},
85+
};
86+
}
87+
88+
export function createAuthMiddleware() {
89+
return new Elysia().derive(async ({ request }) => {
90+
const session = await auth.api.getSession({ headers: request.headers });
91+
if (session?.user) {
92+
const identifier = session.user.id;
93+
return {
94+
auth: {
95+
mode: 'session',
96+
session: { user: session.user as User },
97+
identifier,
98+
} as AuthContext,
99+
} as const;
100+
}
101+
102+
const key = extractApiKey(request.headers);
103+
if (key) {
104+
const apiKeyCtx = await resolveApiKeyContext(key);
105+
if (apiKeyCtx) {
106+
const identifier = `apikey:${apiKeyCtx.apikey.id}`;
107+
return {
108+
auth: {
109+
mode: 'apiKey',
110+
apiKey: apiKeyCtx,
111+
session: null,
112+
identifier,
113+
} as AuthContext,
114+
} as const;
115+
}
116+
}
117+
118+
return {
119+
auth: {
120+
mode: 'public',
121+
session: null,
122+
identifier: 'anonymous',
123+
} as AuthContext,
124+
} as const;
125+
});
126+
}
127+
128+
export function hasRequiredScopes(
129+
scopes: ApiScope[],
130+
required: ApiScope[]
131+
): boolean {
132+
if (!required.length) {
133+
return true;
134+
}
135+
const set = new Set(scopes);
136+
for (const req of required) {
137+
if (!set.has(req)) {
138+
return false;
139+
}
140+
}
141+
return true;
142+
}
143+
144+
export async function resolveResourceScopes(
145+
apiKeyId: string,
146+
resourceType: 'global' | 'website' | 'ab_experiment' | 'feature_flag',
147+
resourceId?: string | null
148+
): Promise<ApiScope[]> {
149+
const key = await db.query.apikey.findFirst({
150+
where: eq(apikey.id, apiKeyId),
151+
});
152+
if (!key) {
153+
return [];
154+
}
155+
const entries = await db
156+
.select()
157+
.from(apikeyAccess)
158+
.where(eq(apikeyAccess.apikeyId, apiKeyId));
159+
const effective = new Set<ApiScope>();
160+
for (const s of key.scopes) {
161+
effective.add(s as ApiScope);
162+
}
163+
for (const e of entries) {
164+
const match =
165+
e.resourceType === 'global' ||
166+
(e.resourceType === resourceType &&
167+
(resourceId ?? null) === (e.resourceId ?? null));
168+
if (match) {
169+
for (const s of e.scopes) {
170+
effective.add(s as ApiScope);
171+
}
172+
}
173+
}
174+
return Array.from(effective);
175+
}

apps/api/src/middleware/logging.ts

Whitespace-only changes.

apps/api/src/routes/assistant.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { auth } from '@databuddy/auth';
1+
import { auth, type User } from '@databuddy/auth';
22
import { Elysia } from 'elysia';
33
import type { StreamingUpdate } from '../agent';
44
import {
@@ -21,15 +21,11 @@ async function* createErrorResponse(
2121
export const assistant = new Elysia({ prefix: '/v1/assistant' })
2222
.use(createRateLimitMiddleware({ type: 'expensive' }))
2323
.derive(async ({ request }) => {
24-
const session = await auth.api.getSession({
25-
headers: request.headers,
26-
});
27-
24+
const session = await auth.api.getSession({ headers: request.headers });
2825
if (!session?.user) {
2926
throw new Error('Unauthorized');
3027
}
31-
32-
return { user: session.user, session };
28+
return { user: session.user as User, session };
3329
})
3430
.post(
3531
'/stream',

apps/docs/app/layout.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,19 +98,19 @@ export default function Layout({ children }: { children: ReactNode }) {
9898
lang="en"
9999
suppressHydrationWarning
100100
>
101+
<Script
102+
async
103+
data-client-id="OXmNQsViBT-FOS_wZCTHc"
104+
data-track-attributes={true}
105+
data-track-errors={true}
106+
data-track-outgoing-links={true}
107+
data-track-web-vitals={true}
108+
src="https://cdn.databuddy.cc/databuddy.js"
109+
strategy="afterInteractive"
110+
/>
101111
<Head>
102112
<link href="https://icons.duckduckgo.com" rel="preconnect" />
103113
<link href="https://icons.duckduckgo.com" rel="dns-prefetch" />
104-
<Script
105-
async
106-
data-client-id="OXmNQsViBT-FOS_wZCTHc"
107-
data-track-attributes={true}
108-
data-track-errors={true}
109-
data-track-outgoing-links={true}
110-
data-track-web-vitals={true}
111-
src="https://cdn.databuddy.cc/databuddy.js"
112-
strategy="afterInteractive"
113-
/>
114114
</Head>
115115
<body>
116116
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>

0 commit comments

Comments
 (0)