Skip to content

Commit 42b7fae

Browse files
committed
woo, it works
1 parent 5055624 commit 42b7fae

33 files changed

+27158
-393
lines changed

epicshop/epic-me/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@
1515
!.dev.vars.example
1616
.env*
1717
!.env.example
18+
19+
dist/

epicshop/epic-me/app/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export default [
55
route('/authorize', 'routes/authorize.tsx'),
66
route('/whoami', 'routes/whoami.tsx'),
77
route('/db-api', 'routes/db-api.tsx'),
8+
route('/introspect', 'routes/introspect.tsx'),
89
] satisfies RouteConfig

epicshop/epic-me/app/routes/authorize.tsx

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Form } from 'react-router'
22
import { type EpicExecutionContext } from 'workers/app.ts'
3+
import { z } from 'zod'
34
import { type Route } from './+types/authorize'
45

56
export function meta({}: Route.MetaArgs) {
@@ -14,6 +15,35 @@ export async function loader({ context }: Route.LoaderArgs) {
1415
return { users }
1516
}
1617

18+
const requestParamsSchema = z
19+
.object({
20+
response_type: z.string().default('code'),
21+
client_id: z.string(),
22+
code_challenge: z.string(),
23+
code_challenge_method: z.string(),
24+
redirect_uri: z.string(),
25+
scope: z.string().array().optional().default([]),
26+
state: z.string().optional().default(''),
27+
})
28+
.passthrough()
29+
.transform(
30+
({
31+
response_type: responseType,
32+
client_id: clientId,
33+
code_challenge: codeChallenge,
34+
code_challenge_method: codeChallengeMethod,
35+
redirect_uri: redirectUri,
36+
...val
37+
}) => ({
38+
responseType,
39+
clientId,
40+
codeChallenge,
41+
codeChallengeMethod,
42+
redirectUri,
43+
...val,
44+
}),
45+
)
46+
1747
export async function action({ request, context }: Route.ActionArgs) {
1848
const formData = await request.formData()
1949
const selectedUserId = formData.get('userId')
@@ -28,30 +58,12 @@ export async function action({ request, context }: Route.ActionArgs) {
2858
return { status: 'error', message: 'User not found' } as const
2959
}
3060

31-
// Get the OAuth request info from the URL parameters
3261
const url = new URL(request.url)
33-
const oauthReqInfo = url.searchParams.get('oauth_req_info')
3462

35-
if (!oauthReqInfo) {
36-
return {
37-
status: 'error',
38-
message: 'Missing OAuth request information',
39-
} as const
40-
}
41-
42-
// Parse the OAuth request info
43-
let requestParams
44-
try {
45-
requestParams = JSON.parse(oauthReqInfo)
46-
} catch (error) {
47-
console.error('Invalid OAuth request information', error)
48-
return {
49-
status: 'error',
50-
message: 'Invalid OAuth request information',
51-
} as const
52-
}
63+
const requestParams = requestParamsSchema.parse(
64+
Object.fromEntries(url.searchParams),
65+
)
5366

54-
// Complete the authorization
5567
const { redirectTo } =
5668
await context.cloudflare.env.OAUTH_PROVIDER.completeAuthorization({
5769
request: requestParams,
@@ -182,20 +194,7 @@ export default function Authorize({
182194
href={actionData.redirectTo}
183195
className="inline-flex items-center rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:outline-none dark:bg-green-500 dark:hover:bg-green-600 dark:focus:ring-green-400"
184196
>
185-
<svg
186-
className="mr-2 h-4 w-4"
187-
fill="none"
188-
stroke="currentColor"
189-
viewBox="0 0 24 24"
190-
>
191-
<path
192-
strokeLinecap="round"
193-
strokeLinejoin="round"
194-
strokeWidth={2}
195-
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
196-
/>
197-
</svg>
198-
Continue to Application
197+
<small className="text-xs">{actionData.redirectTo}</small>
199198
</a>
200199
</div>
201200
</div>

epicshop/epic-me/app/routes/db-api.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ const methodSchemas = {
5959
id: z.number(),
6060
}),
6161

62+
// User methods
63+
getUserById: z.object({
64+
id: z.number(),
65+
}),
66+
6267
// Entry tag methods
6368
addTagToEntry: z.object({
6469
entryId: z.number(),
@@ -161,6 +166,12 @@ export async function action({ request, context }: Route.ActionArgs) {
161166
break
162167
}
163168

169+
case 'getUserById': {
170+
const userParams = methodSchemas.getUserById.parse(params)
171+
result = await context.db.getUserById(userParams.id)
172+
break
173+
}
174+
164175
case 'addTagToEntry': {
165176
const entryTagParams = methodSchemas.addTagToEntry.parse(params)
166177
result = await context.db.addTagToEntry(Number(userId), entryTagParams)
@@ -178,7 +189,7 @@ export async function action({ request, context }: Route.ActionArgs) {
178189

179190
default:
180191
return Response.json(
181-
{ error: `Method not implemented: ${method}` },
192+
{ error: `epicme:db-api:Method not implemented: ${method}` },
182193
{ status: 501 },
183194
)
184195
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { type Token } from '@cloudflare/workers-oauth-provider'
2+
import { type Route } from './+types/introspect'
3+
4+
export async function loader({ request, context }: Route.LoaderArgs) {
5+
const tokenInfo = await getTokenInfo(request, context.cloudflare.env)
6+
if (!tokenInfo) return new Response('Unauthorized', { status: 401 })
7+
8+
return Response.json({
9+
userId: tokenInfo.userId,
10+
clientId: tokenInfo.grant.clientId,
11+
scopes: tokenInfo.grant.scope,
12+
expiresAt: tokenInfo.expiresAt,
13+
})
14+
}
15+
16+
async function getTokenInfo(
17+
request: Request,
18+
env: Env,
19+
): Promise<Token | undefined> {
20+
const token = request.headers.get('authorization')?.slice('Bearer '.length)
21+
if (!token) return undefined
22+
return resolveTokenInfo(token, env)
23+
}
24+
25+
async function resolveTokenInfo(
26+
token: string,
27+
env: Env,
28+
): Promise<Token | undefined> {
29+
const parts = token.split(':')
30+
if (parts.length !== 3) throw new Error('Invalid token format')
31+
32+
const [userId, grantId] = parts
33+
const tokenId = await generateTokenId(token)
34+
const tokenKey = `token:${userId}:${grantId}:${tokenId}`
35+
36+
const tokenData = await env.OAUTH_KV.get(tokenKey, { type: 'json' })
37+
if (!tokenData) throw new Error('Token not found')
38+
39+
return tokenData as Token
40+
}
41+
42+
// copied from @cloudflare/workers-oauth-provider
43+
async function generateTokenId(token: string) {
44+
const encoder = new TextEncoder()
45+
const data = encoder.encode(token)
46+
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
47+
const hashArray = Array.from(new Uint8Array(hashBuffer))
48+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
49+
return hashHex
50+
}

epicshop/epic-me/package-lock.json

Lines changed: 15 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

epicshop/epic-me/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@cloudflare/workers-oauth-provider": "^0.0.5",
18+
"zod": "^3.25.67",
1819
"isbot": "^5.1.29",
1920
"react": "^19.1.1",
2021
"react-dom": "^19.1.1",

epicshop/epic-me/vite.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import devtoolsJson from 'vite-plugin-devtools-json'
66
import tsconfigPaths from 'vite-tsconfig-paths'
77

88
export default defineConfig({
9+
server: {
10+
port: 7788,
11+
},
12+
build: {
13+
rollupOptions: {
14+
external: ['cloudflare:workers'],
15+
},
16+
},
917
plugins: [
1018
{
1119
name: 'strip-typegen-imports',

epicshop/epic-me/workers/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const defaultHandler = {
4141
} satisfies ExportedHandler<Env>
4242

4343
export default new OAuthProvider({
44-
apiRoute: ['/whoami', '/db-api'],
44+
apiRoute: ['/whoami', '/db-api', '/introspect'],
4545
// @ts-expect-error these types are wrong...
4646
apiHandler: defaultHandler,
4747
// @ts-expect-error these types are wrong...
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Epic Me DB Client
2+
3+
A typesafe client for making requests to the Epic Me database API.
4+
5+
## Installation
6+
7+
```bash
8+
npm install
9+
```
10+
11+
## Usage
12+
13+
```typescript
14+
import { DBClient } from './index.js'
15+
16+
// Create a client instance with your base URL
17+
const client = new DBClient('https://your-app.com')
18+
19+
// Entry operations
20+
const entry = await client.createEntry({
21+
title: 'My Journal Entry',
22+
content: 'Today was a great day!',
23+
mood: 'happy',
24+
location: 'home',
25+
weather: 'sunny',
26+
isPrivate: 1,
27+
isFavorite: 0,
28+
})
29+
30+
const entries = await client.getEntries({
31+
tagIds: [1, 2],
32+
from: '2024-01-01',
33+
to: '2024-12-31',
34+
})
35+
36+
const entry = await client.getEntry(1)
37+
await client.updateEntry(1, { title: 'Updated Title' })
38+
await client.deleteEntry(1)
39+
40+
// Tag operations
41+
const tag = await client.createTag({
42+
name: 'Work',
43+
description: 'Work-related entries',
44+
})
45+
46+
const tags = await client.getTags()
47+
const tag = await client.getTag(1)
48+
await client.updateTag(1, { name: 'Updated Tag' })
49+
await client.deleteTag(1)
50+
51+
// Entry-Tag operations
52+
await client.addTagToEntry({ entryId: 1, tagId: 1 })
53+
const entryTags = await client.getEntryTags(1)
54+
```
55+
56+
## API Reference
57+
58+
### Constructor
59+
60+
```typescript
61+
new DBClient(baseUrl: string)
62+
```
63+
64+
Creates a new client instance with the specified base URL.
65+
66+
### Entry Methods
67+
68+
- `createEntry(entry: NewEntry): Promise<EntryWithTags>`
69+
- `getEntry(id: number): Promise<EntryWithTags | null>`
70+
- `getEntries(options?: { tagIds?: number[], from?: string, to?: string }): Promise<Array<{ id: number, title: string, tagCount: number }>>`
71+
- `updateEntry(id: number, entry: Partial<NewEntry>): Promise<EntryWithTags>`
72+
- `deleteEntry(id: number): Promise<boolean>`
73+
74+
### Tag Methods
75+
76+
- `createTag(tag: NewTag): Promise<Tag>`
77+
- `getTag(id: number): Promise<Tag | null>`
78+
- `getTags(): Promise<Array<{ id: number, name: string }>>`
79+
- `updateTag(id: number, tag: Partial<NewTag>): Promise<Tag>`
80+
- `deleteTag(id: number): Promise<boolean>`
81+
82+
### Entry-Tag Methods
83+
84+
- `addTagToEntry(entryTag: NewEntryTag): Promise<EntryTag>`
85+
- `getEntryTags(entryId: number): Promise<Tag[]>`
86+
87+
## Error Handling
88+
89+
The client will throw `Error` instances with descriptive messages for:
90+
91+
- Network errors
92+
- HTTP errors (4xx, 5xx)
93+
- API errors returned by the server
94+
- Validation errors
95+
96+
## Types
97+
98+
All types are exported from the client for use in your application:
99+
100+
```typescript
101+
import type {
102+
Entry,
103+
NewEntry,
104+
Tag,
105+
NewTag,
106+
EntryTag,
107+
NewEntryTag,
108+
EntryWithTags,
109+
} from './index.js'
110+
```

0 commit comments

Comments
 (0)