Skip to content

Commit 1256deb

Browse files
committed
looking good
1 parent 92b8dc5 commit 1256deb

File tree

8 files changed

+234
-392
lines changed

8 files changed

+234
-392
lines changed

exercises/99.final/01.solution/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"dependencies": {
2424
"@cloudflare/workers-oauth-provider": "^0.0.3",
2525
"@epic-web/invariant": "^1.0.0",
26+
"@epic-web/totp": "^4.0.1",
2627
"@modelcontextprotocol/sdk": "^1.10.0",
2728
"agents": "^0.0.60",
2829
"zod": "^3.24.3"

exercises/99.final/01.solution/src/db/index.ts

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
entryTagSchema,
1818
newEntryTagSchema,
1919
userSchema,
20+
grantSchema,
2021
} from './schema.ts'
2122
import { sql, snakeToCamel } from './utils.ts'
2223

@@ -34,18 +35,31 @@ export class DB {
3435
return db
3536
}
3637

37-
async getUserByToken(token: string) {
38+
async createUnclaimedGrant(grantUserId: string) {
39+
const insertResult = await this.db
40+
.prepare(sql`INSERT INTO grants (grant_user_id) VALUES (?1)`)
41+
.bind(grantUserId)
42+
.run()
43+
44+
if (!insertResult.success || !insertResult.meta.last_row_id) {
45+
throw new Error('Failed to create grant: ' + insertResult.error)
46+
}
47+
48+
return insertResult.meta.last_row_id
49+
}
50+
51+
async getUserByGrantId(grantId: string) {
3852
// TODO: I don't have internet, so turn this into a single query when I do...
3953
// also add TAKE 1 or whatever
40-
const tokensResult = await this.db
41-
.prepare(sql`SELECT user_id FROM access_tokens WHERE token_value = ?1`)
42-
.bind(token)
54+
const grantsResult = await this.db
55+
.prepare(sql`SELECT user_id FROM grants WHERE id = ?1`)
56+
.bind(grantId)
4357
.first()
44-
if (!tokensResult) return null
58+
if (!grantsResult) return null
4559

4660
const userResult = await this.db
4761
.prepare(sql`SELECT * FROM users WHERE id = ?1`)
48-
.bind(tokensResult.user_id)
62+
.bind(grantsResult.user_id)
4963
.first()
5064
if (!userResult) return null
5165

@@ -62,35 +76,14 @@ export class DB {
6276
return userSchema.parse(snakeToCamel(userResult))
6377
}
6478

65-
async getAccessTokenIdByValue(tokenValue: string) {
66-
const tokenResult = await this.db
67-
.prepare(sql`SELECT id FROM access_tokens WHERE token_value = ?1`)
68-
.bind(tokenValue)
79+
async getGrant(grantId: string) {
80+
const grantResult = await this.db
81+
.prepare(sql`SELECT * FROM grants WHERE id = ?1`)
82+
.bind(grantId)
6983
.first()
70-
if (!tokenResult) return null
84+
if (!grantResult) return null
7185

72-
return tokenResult.id
73-
}
74-
75-
async createAccessTokenIfNecessary(tokenValue: string) {
76-
const existingAccessTokenId = await this.getAccessTokenIdByValue(tokenValue)
77-
if (existingAccessTokenId) return existingAccessTokenId
78-
79-
const insertResult = await this.db
80-
.prepare(
81-
sql`
82-
INSERT INTO access_tokens (token_value)
83-
VALUES (?1)
84-
`,
85-
)
86-
.bind(tokenValue)
87-
.run()
88-
89-
if (!insertResult.success || !insertResult.meta.last_row_id) {
90-
throw new Error('Failed to create access token: ' + insertResult.error)
91-
}
92-
93-
return insertResult.meta.last_row_id
86+
return grantSchema.parse(snakeToCamel(grantResult))
9487
}
9588

9689
async createValidationToken(
@@ -117,15 +110,15 @@ export class DB {
117110
return insertResult.meta.last_row_id
118111
}
119112

120-
async validateAccessToken(accessTokenId: number, validationToken: string) {
113+
async validateTokenToGrant(grantId: number, validationToken: string) {
121114
const validationResult = await this.db
122115
.prepare(
123116
sql`
124-
SELECT id, email, access_token_id FROM validation_tokens
125-
WHERE access_token_id = ?1 AND token_value = ?2
117+
SELECT id, email, grant_id FROM validation_tokens
118+
WHERE grant_id = ?1 AND token_value = ?2
126119
`,
127120
)
128-
.bind(accessTokenId, validationToken)
121+
.bind(grantId, validationToken)
129122
.first()
130123

131124
if (!validationResult) {
@@ -150,22 +143,19 @@ export class DB {
150143
}
151144

152145
// set access token to user id
153-
const claimAccessTokenResult = await this.db
146+
const claimGrantResult = await this.db
154147
.prepare(
155148
sql`
156-
UPDATE access_tokens
157-
SET user_id = ?2, updated_at = CURRENT_TIMESTAMP
158-
WHERE id = ?1
149+
UPDATE grants
150+
SET user_id = ?1, updated_at = CURRENT_TIMESTAMP
151+
WHERE id = ?2
159152
`,
160153
)
161-
.bind(validationResult.access_token_id, userId)
154+
.bind(userId, validationResult.grant_id)
162155
.run()
163156

164-
if (
165-
!claimAccessTokenResult.success ||
166-
!claimAccessTokenResult.meta.last_row_id
167-
) {
168-
throw new Error('Failed to create user: ' + claimAccessTokenResult.error)
157+
if (!claimGrantResult.success) {
158+
throw new Error('Failed to claim grant: ' + claimGrantResult.error)
169159
}
170160

171161
// delete validation token (fire and forget)
@@ -181,16 +171,21 @@ export class DB {
181171
}
182172
}
183173

184-
async deleteAccessToken(userId: number, tokenValue: string) {
185-
await this.db
174+
async deleteGrant(userId: number, grantId: string) {
175+
const deleteResult = await this.db
186176
.prepare(
187177
sql`
188-
DELETE FROM access_tokens
189-
WHERE user_id = ?1 AND token_value = ?2
178+
DELETE FROM grants
179+
WHERE user_id = ?1 AND grant_user_id = ?2
190180
`,
191181
)
192-
.bind(userId, tokenValue)
182+
.bind(userId, grantId)
193183
.run()
184+
185+
if (!deleteResult.success) {
186+
throw new Error('Failed to delete grant: ' + deleteResult.error)
187+
}
188+
// TODO: delete the grant from OAUTH_PROVIDER as well
194189
}
195190

196191
async createUserByEmail(email: string) {
@@ -403,9 +398,10 @@ export class DB {
403398
return tagSchema.parse(snakeToCamel(result))
404399
}
405400

406-
async listTags() {
401+
async listTags(userId: number) {
407402
const results = await this.db
408-
.prepare(sql`SELECT * FROM tags ORDER BY name`)
403+
.prepare(sql`SELECT * FROM tags WHERE user_id = ?1 ORDER BY name`)
404+
.bind(userId)
409405
.all()
410406

411407
return z
@@ -521,7 +517,7 @@ export class DB {
521517
async getEntryTag(userId: number, id: number) {
522518
const result = await this.db
523519
.prepare(sql`SELECT * FROM entry_tags WHERE id = ?1 AND user_id = ?2`)
524-
.bind(id)
520+
.bind(id, userId)
525521
.first()
526522

527523
if (!result) return null

exercises/99.final/01.solution/src/db/migrations.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,29 @@ const migrations = [
2828
updated_at integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL
2929
);
3030
`),
31-
// OAuth Access tokens. If user_id is null then it's not yet been claimed
31+
// This is a mapping of a grant_user_id (accessible via props.grantId)
32+
// to the user_id that can be used to claim the grant. If user_id is
33+
// null then it's not yet been claimed. When a user validates their
34+
// email, we can update the user_id for the grant and find the
35+
// appropriate user in future requests. and then the client
36+
// can use that user_id to list and revoke grants (find all grants for
37+
// a user then list/revoke them).
3238
db.prepare(sql`
33-
CREATE TABLE IF NOT EXISTS access_tokens (
39+
CREATE TABLE IF NOT EXISTS grants (
3440
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
35-
token_value text NOT NULL UNIQUE,
41+
grant_user_id text NOT NULL,
3642
user_id integer,
3743
created_at integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
3844
updated_at integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL
3945
);
4046
`),
41-
// A OTP emailed to the user to allow them to claim an access_token
47+
// An OTP emailed to the user to allow them to claim an access_token
4248
db.prepare(sql`
4349
CREATE TABLE IF NOT EXISTS validation_tokens (
4450
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
4551
token_value text NOT NULL,
4652
email text NOT NULL,
47-
access_token_id integer NOT NULL,
53+
grant_id text NOT NULL,
4854
created_at integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
4955
updated_at integer DEFAULT (CURRENT_TIMESTAMP) NOT NULL
5056
);

exercises/99.final/01.solution/src/db/schema.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export const userSchema = z.object({
1818
updatedAt: timestampSchema,
1919
})
2020

21+
export const grantSchema = z.object({
22+
id: z.coerce.number(),
23+
grantUserId: z.string(),
24+
userId: z.coerce.number().optional(),
25+
createdAt: timestampSchema,
26+
updatedAt: timestampSchema,
27+
})
28+
29+
export const validationTokenSchema = z.object({
30+
id: z.coerce.number(),
31+
tokenValue: z.string(),
32+
email: z.string().email(),
33+
accessTokenId: z.coerce.number(),
34+
createdAt: timestampSchema,
35+
updatedAt: timestampSchema,
36+
})
37+
2138
// Schema Validation
2239
export const entrySchema = z.object({
2340
id: z.coerce.number(),

exercises/99.final/01.solution/src/index.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { initializeTools } from './tools.ts'
88
import { type Env } from './types'
99

1010
type State = {}
11-
type Props = { accessToken: string }
11+
type Props = { grantId: string; grantUserId: string }
1212

1313
export class EpicMeMCP extends McpAgent<Env, State, Props> {
1414
db!: DB
@@ -60,27 +60,23 @@ const defaultHandler = {
6060
return new Response('Invalid client', { status: 400 })
6161
}
6262

63-
const userId = 'EpicMeMCP'
64-
// TODO: make this like crypto nice or whatever
65-
const accessToken = Math.random().toString(16).slice(2)
63+
const db = await DB.getInstance(env)
64+
const grantUserId = crypto.randomUUID()
65+
const grantId = await db.createUnclaimedGrant(grantUserId)
6666

6767
const result = await env.OAUTH_PROVIDER.completeAuthorization({
6868
request: oauthReqInfo,
69-
userId,
70-
props: { accessToken },
69+
// Here's one of the hacks. We don't know who the user is yet since the token at
70+
// this point is unclaimed. But completeAuthorization expects a userId.
71+
// So we'll generate a random UUID as a temporary userId
72+
userId: grantUserId,
73+
props: { grantId, grantUserId },
7174
scope: ['full'],
72-
metadata: {
73-
grantDate: new Date().toISOString(),
74-
},
75+
metadata: { grantDate: new Date().toISOString() },
7576
})
7677

7778
// Redirect to the client with the authorization code
78-
return new Response(null, {
79-
status: 302,
80-
headers: {
81-
Location: result.redirectTo,
82-
},
83-
})
79+
return Response.redirect(result.redirectTo)
8480
} catch (error) {
8581
console.error('Authorization error:', error)
8682
return new Response(
@@ -105,7 +101,6 @@ const oauthProvider = new OAuthProvider({
105101
authorizeEndpoint: '/authorize',
106102
tokenEndpoint: '/oauth/token',
107103
clientRegistrationEndpoint: '/oauth/register',
108-
scopesSupported: ['full'],
109104
})
110105

111106
export default {

0 commit comments

Comments
 (0)