@dupecom/botcha-cloudflare 0.3.3 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics.d.ts +60 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +130 -0
- package/dist/apps.d.ts +159 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +307 -0
- package/dist/auth.d.ts +93 -6
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +251 -9
- package/dist/challenges.d.ts +31 -7
- package/dist/challenges.d.ts.map +1 -1
- package/dist/challenges.js +551 -144
- package/dist/dashboard/api.d.ts +70 -0
- package/dist/dashboard/api.d.ts.map +1 -0
- package/dist/dashboard/api.js +546 -0
- package/dist/dashboard/auth.d.ts +183 -0
- package/dist/dashboard/auth.d.ts.map +1 -0
- package/dist/dashboard/auth.js +401 -0
- package/dist/dashboard/device-code.d.ts +43 -0
- package/dist/dashboard/device-code.d.ts.map +1 -0
- package/dist/dashboard/device-code.js +77 -0
- package/dist/dashboard/index.d.ts +31 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +64 -0
- package/dist/dashboard/layout.d.ts +47 -0
- package/dist/dashboard/layout.d.ts.map +1 -0
- package/dist/dashboard/layout.js +38 -0
- package/dist/dashboard/pages.d.ts +11 -0
- package/dist/dashboard/pages.d.ts.map +1 -0
- package/dist/dashboard/pages.js +18 -0
- package/dist/dashboard/styles.d.ts +11 -0
- package/dist/dashboard/styles.d.ts.map +1 -0
- package/dist/dashboard/styles.js +633 -0
- package/dist/email.d.ts +44 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +644 -50
- package/dist/rate-limit.d.ts +11 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +13 -2
- package/dist/routes/stream.js +1 -1
- package/dist/static.d.ts +728 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +818 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOTCHA Dashboard Authentication
|
|
3
|
+
*
|
|
4
|
+
* Two auth flows, both require an agent:
|
|
5
|
+
*
|
|
6
|
+
* Flow 1 — Challenge-Based Login (agent direct):
|
|
7
|
+
* POST /v1/auth/dashboard → get challenge
|
|
8
|
+
* POST /v1/auth/dashboard/verify → solve challenge → session token
|
|
9
|
+
*
|
|
10
|
+
* Flow 2 — Device Code (agent → human handoff):
|
|
11
|
+
* POST /v1/auth/device-code → get challenge
|
|
12
|
+
* POST /v1/auth/device-code/verify → solve challenge → device code
|
|
13
|
+
* Human visits /dashboard/code, enters code → dashboard session
|
|
14
|
+
*
|
|
15
|
+
* Legacy — App ID + Secret login (still valid, agent created the app):
|
|
16
|
+
* POST /dashboard/login → app_id + app_secret → session
|
|
17
|
+
*
|
|
18
|
+
* All paths require an agent to be involved. No agent, no access.
|
|
19
|
+
*/
|
|
20
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
21
|
+
import type { KVNamespace } from '../challenges';
|
|
22
|
+
type Bindings = {
|
|
23
|
+
CHALLENGES: KVNamespace;
|
|
24
|
+
RATE_LIMITS: KVNamespace;
|
|
25
|
+
APPS: KVNamespace;
|
|
26
|
+
ANALYTICS?: AnalyticsEngineDataset;
|
|
27
|
+
JWT_SECRET: string;
|
|
28
|
+
BOTCHA_VERSION: string;
|
|
29
|
+
};
|
|
30
|
+
type Variables = {
|
|
31
|
+
dashboardAppId?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Middleware: Require dashboard authentication.
|
|
35
|
+
*
|
|
36
|
+
* Checks for session in two places:
|
|
37
|
+
* 1. Cookie `botcha_session` (browser sessions)
|
|
38
|
+
* 2. Authorization: Bearer header (agent API access)
|
|
39
|
+
*
|
|
40
|
+
* On success: sets c.get('dashboardAppId') for downstream handlers
|
|
41
|
+
* On failure: redirects to /dashboard/login (browser) or returns 401 (API)
|
|
42
|
+
*/
|
|
43
|
+
export declare const requireDashboardAuth: MiddlewareHandler<{
|
|
44
|
+
Bindings: Bindings;
|
|
45
|
+
Variables: Variables;
|
|
46
|
+
}>;
|
|
47
|
+
/**
|
|
48
|
+
* POST /v1/auth/dashboard
|
|
49
|
+
*
|
|
50
|
+
* Agent requests a speed challenge to prove it's an agent.
|
|
51
|
+
* Requires app_id in the request body.
|
|
52
|
+
*/
|
|
53
|
+
export declare function handleDashboardAuthChallenge(c: Context<{
|
|
54
|
+
Bindings: Bindings;
|
|
55
|
+
}>): Promise<(Response & import("hono").TypedResponse<{
|
|
56
|
+
error: string;
|
|
57
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
58
|
+
error: string;
|
|
59
|
+
}, 404, "json">) | (Response & import("hono").TypedResponse<{
|
|
60
|
+
challenge_id: string;
|
|
61
|
+
type: string;
|
|
62
|
+
problems: number[];
|
|
63
|
+
time_limit_ms: number;
|
|
64
|
+
instructions: string;
|
|
65
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
66
|
+
/**
|
|
67
|
+
* POST /v1/auth/dashboard/verify
|
|
68
|
+
*
|
|
69
|
+
* Agent submits challenge solution. On success, returns a session token
|
|
70
|
+
* usable as Bearer header or cookie.
|
|
71
|
+
*/
|
|
72
|
+
export declare function handleDashboardAuthVerify(c: Context<{
|
|
73
|
+
Bindings: Bindings;
|
|
74
|
+
}>): Promise<(Response & import("hono").TypedResponse<{
|
|
75
|
+
error: string;
|
|
76
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
77
|
+
error: string;
|
|
78
|
+
}, 403, "json">) | (Response & import("hono").TypedResponse<{
|
|
79
|
+
success: true;
|
|
80
|
+
session_token: string;
|
|
81
|
+
expires_in: number;
|
|
82
|
+
app_id: string;
|
|
83
|
+
dashboard_url: string;
|
|
84
|
+
usage: string;
|
|
85
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
86
|
+
/**
|
|
87
|
+
* POST /v1/auth/device-code
|
|
88
|
+
*
|
|
89
|
+
* Same challenge as dashboard auth. Agent must solve it to get a device code.
|
|
90
|
+
*/
|
|
91
|
+
export declare function handleDeviceCodeChallenge(c: Context<{
|
|
92
|
+
Bindings: Bindings;
|
|
93
|
+
}>): Promise<(Response & import("hono").TypedResponse<{
|
|
94
|
+
error: string;
|
|
95
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
96
|
+
error: string;
|
|
97
|
+
}, 404, "json">) | (Response & import("hono").TypedResponse<{
|
|
98
|
+
challenge_id: string;
|
|
99
|
+
type: string;
|
|
100
|
+
problems: number[];
|
|
101
|
+
time_limit_ms: number;
|
|
102
|
+
instructions: string;
|
|
103
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
104
|
+
/**
|
|
105
|
+
* POST /v1/auth/device-code/verify
|
|
106
|
+
*
|
|
107
|
+
* Agent submits challenge solution. On success, returns a short-lived
|
|
108
|
+
* device code (BOTCHA-XXXX) that a human can enter at /dashboard/code.
|
|
109
|
+
*/
|
|
110
|
+
export declare function handleDeviceCodeVerify(c: Context<{
|
|
111
|
+
Bindings: Bindings;
|
|
112
|
+
}>): Promise<(Response & import("hono").TypedResponse<{
|
|
113
|
+
error: string;
|
|
114
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
115
|
+
error: string;
|
|
116
|
+
}, 403, "json">) | (Response & import("hono").TypedResponse<{
|
|
117
|
+
success: true;
|
|
118
|
+
code: string;
|
|
119
|
+
login_url: string;
|
|
120
|
+
expires_in: number;
|
|
121
|
+
instructions: string;
|
|
122
|
+
}, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
|
|
123
|
+
/**
|
|
124
|
+
* GET /dashboard/code
|
|
125
|
+
*
|
|
126
|
+
* Renders the device code redemption page for humans.
|
|
127
|
+
*/
|
|
128
|
+
export declare function renderDeviceCodePage(c: Context<{
|
|
129
|
+
Bindings: Bindings;
|
|
130
|
+
}>): Promise<Response>;
|
|
131
|
+
/**
|
|
132
|
+
* POST /dashboard/code
|
|
133
|
+
*
|
|
134
|
+
* Human submits device code. If valid, creates session and redirects to dashboard.
|
|
135
|
+
*/
|
|
136
|
+
export declare function handleDeviceCodeRedeem(c: Context<{
|
|
137
|
+
Bindings: Bindings;
|
|
138
|
+
}>): Promise<Response & import("hono").TypedResponse<undefined, 302, "redirect">>;
|
|
139
|
+
/**
|
|
140
|
+
* POST /dashboard/login
|
|
141
|
+
*
|
|
142
|
+
* Login with app_id + app_secret. The agent created the app (so an agent
|
|
143
|
+
* was involved at creation time). Still supported as a convenience.
|
|
144
|
+
*/
|
|
145
|
+
export declare function handleLogin(c: Context<{
|
|
146
|
+
Bindings: Bindings;
|
|
147
|
+
}>): Promise<Response & import("hono").TypedResponse<undefined, 302, "redirect">>;
|
|
148
|
+
/**
|
|
149
|
+
* GET /dashboard/logout
|
|
150
|
+
*/
|
|
151
|
+
export declare function handleLogout(c: Context<{
|
|
152
|
+
Bindings: Bindings;
|
|
153
|
+
}>): Promise<Response & import("hono").TypedResponse<undefined, 302, "redirect">>;
|
|
154
|
+
/**
|
|
155
|
+
* POST /dashboard/email-login
|
|
156
|
+
*
|
|
157
|
+
* Human enters their email. If a verified app exists for that email,
|
|
158
|
+
* a device code is generated and emailed. The human then enters the
|
|
159
|
+
* code at /dashboard/code to get a session.
|
|
160
|
+
*
|
|
161
|
+
* This doesn't violate agent-first: the agent was involved at account
|
|
162
|
+
* creation and email verification. This is just session resumption.
|
|
163
|
+
*
|
|
164
|
+
* Anti-enumeration: always shows the same "check your email" message
|
|
165
|
+
* regardless of whether the email exists.
|
|
166
|
+
*/
|
|
167
|
+
export declare function handleEmailLogin(c: Context<{
|
|
168
|
+
Bindings: Bindings;
|
|
169
|
+
}>): Promise<Response & import("hono").TypedResponse<undefined, 302, "redirect">>;
|
|
170
|
+
/**
|
|
171
|
+
* GET /dashboard/login
|
|
172
|
+
*
|
|
173
|
+
* Four ways in:
|
|
174
|
+
* 1. Device code (agent generated the code) — primary
|
|
175
|
+
* 2. Email login (returning users — code emailed to verified address)
|
|
176
|
+
* 3. App ID + Secret (agent created the app)
|
|
177
|
+
* 4. Create new app (triggers POST /v1/apps)
|
|
178
|
+
*/
|
|
179
|
+
export declare function renderLoginPage(c: Context<{
|
|
180
|
+
Bindings: Bindings;
|
|
181
|
+
}>): Promise<Response>;
|
|
182
|
+
export {};
|
|
183
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/dashboard/auth.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAMvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAKjD,KAAK,QAAQ,GAAG;IACd,UAAU,EAAE,WAAW,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAGF,KAAK,SAAS,GAAG;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAsCF;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,EAAE,iBAAiB,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAmChG,CAAC;AAIF;;;;;GAKG;AACH,wBAAsB,4BAA4B,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;;;;;;;;;;oEA2CpF;AAgDD;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;;;;;;;;;;;oEAwBjF;AAID;;;;GAIG;AACH,wBAAsB,yBAAyB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;;;;;;;;;;oEAEjF;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC;;;;;;;;;;oEA2B9E;AAID;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,qBAkE5E;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,gFAqB9E;AAID;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,gFAyBnE;AAED;;GAEG;AACH,wBAAsB,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,gFAGpE;AAID;;;;;;;;;;;;GAYG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,gFA2BxE;AAID;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAA;CAAE,CAAC,qBA0HvE"}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "hono/jsx/jsx-runtime";
|
|
2
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
|
3
|
+
import { SignJWT } from 'jose';
|
|
4
|
+
import { verifyToken } from '../auth';
|
|
5
|
+
import { validateAppSecret, getAppByEmail } from '../apps';
|
|
6
|
+
import { sendEmail, recoveryEmail } from '../email';
|
|
7
|
+
import { generateDeviceCode, storeDeviceCode, redeemDeviceCode } from './device-code';
|
|
8
|
+
import { LoginLayout, Card, Divider } from './layout';
|
|
9
|
+
// ============ SESSION HELPERS ============
|
|
10
|
+
/**
|
|
11
|
+
* Generate a 1-hour dashboard session JWT for the given app_id.
|
|
12
|
+
*/
|
|
13
|
+
async function generateSessionToken(appId, jwtSecret) {
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const secretKey = encoder.encode(jwtSecret);
|
|
16
|
+
return new SignJWT({
|
|
17
|
+
type: 'botcha-verified',
|
|
18
|
+
solveTime: 0,
|
|
19
|
+
jti: crypto.randomUUID(),
|
|
20
|
+
app_id: appId,
|
|
21
|
+
})
|
|
22
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
23
|
+
.setSubject('dashboard-session')
|
|
24
|
+
.setIssuedAt()
|
|
25
|
+
.setExpirationTime('1h')
|
|
26
|
+
.sign(secretKey);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Set the dashboard session cookie on a response context.
|
|
30
|
+
*/
|
|
31
|
+
function setSessionCookie(c, token) {
|
|
32
|
+
setCookie(c, 'botcha_session', token, {
|
|
33
|
+
path: '/dashboard',
|
|
34
|
+
httpOnly: true,
|
|
35
|
+
secure: true,
|
|
36
|
+
sameSite: 'Lax',
|
|
37
|
+
maxAge: 3600,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// ============ MIDDLEWARE ============
|
|
41
|
+
/**
|
|
42
|
+
* Middleware: Require dashboard authentication.
|
|
43
|
+
*
|
|
44
|
+
* Checks for session in two places:
|
|
45
|
+
* 1. Cookie `botcha_session` (browser sessions)
|
|
46
|
+
* 2. Authorization: Bearer header (agent API access)
|
|
47
|
+
*
|
|
48
|
+
* On success: sets c.get('dashboardAppId') for downstream handlers
|
|
49
|
+
* On failure: redirects to /dashboard/login (browser) or returns 401 (API)
|
|
50
|
+
*/
|
|
51
|
+
export const requireDashboardAuth = async (c, next) => {
|
|
52
|
+
// Try cookie first (browser sessions)
|
|
53
|
+
let sessionToken = getCookie(c, 'botcha_session');
|
|
54
|
+
// Fall back to Bearer header (agent API access)
|
|
55
|
+
if (!sessionToken) {
|
|
56
|
+
const authHeader = c.req.header('Authorization');
|
|
57
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
58
|
+
sessionToken = authHeader.slice(7);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!sessionToken) {
|
|
62
|
+
const isApi = c.req.header('Accept')?.includes('application/json') ||
|
|
63
|
+
c.req.header('HX-Request');
|
|
64
|
+
if (isApi) {
|
|
65
|
+
return c.json({ error: 'Authentication required', login: '/dashboard/login' }, 401);
|
|
66
|
+
}
|
|
67
|
+
return c.redirect('/dashboard/login');
|
|
68
|
+
}
|
|
69
|
+
const result = await verifyToken(sessionToken, c.env.JWT_SECRET, c.env);
|
|
70
|
+
if (!result.valid || !result.payload?.app_id) {
|
|
71
|
+
deleteCookie(c, 'botcha_session', { path: '/dashboard' });
|
|
72
|
+
const isApi = c.req.header('Accept')?.includes('application/json') ||
|
|
73
|
+
c.req.header('HX-Request');
|
|
74
|
+
if (isApi) {
|
|
75
|
+
return c.json({ error: 'Session expired', login: '/dashboard/login' }, 401);
|
|
76
|
+
}
|
|
77
|
+
return c.redirect('/dashboard/login');
|
|
78
|
+
}
|
|
79
|
+
c.set('dashboardAppId', result.payload.app_id);
|
|
80
|
+
await next();
|
|
81
|
+
};
|
|
82
|
+
// ============ CHALLENGE-BASED LOGIN (Flow 1: agent direct) ============
|
|
83
|
+
/**
|
|
84
|
+
* POST /v1/auth/dashboard
|
|
85
|
+
*
|
|
86
|
+
* Agent requests a speed challenge to prove it's an agent.
|
|
87
|
+
* Requires app_id in the request body.
|
|
88
|
+
*/
|
|
89
|
+
export async function handleDashboardAuthChallenge(c) {
|
|
90
|
+
const body = await c.req.json().catch(() => ({}));
|
|
91
|
+
const appId = body.app_id;
|
|
92
|
+
if (!appId) {
|
|
93
|
+
return c.json({ error: 'app_id is required' }, 400);
|
|
94
|
+
}
|
|
95
|
+
// Verify the app exists
|
|
96
|
+
const appData = await c.env.APPS.get(`app:${appId}`, 'text');
|
|
97
|
+
if (!appData) {
|
|
98
|
+
return c.json({ error: 'App not found' }, 404);
|
|
99
|
+
}
|
|
100
|
+
// Generate a speed challenge (5 SHA256 hashes)
|
|
101
|
+
const challengeId = crypto.randomUUID();
|
|
102
|
+
const problems = [];
|
|
103
|
+
for (let i = 0; i < 5; i++) {
|
|
104
|
+
const buf = new Uint8Array(4);
|
|
105
|
+
crypto.getRandomValues(buf);
|
|
106
|
+
const num = ((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]) >>> 0;
|
|
107
|
+
problems.push(num % 1000000);
|
|
108
|
+
}
|
|
109
|
+
// Store challenge in KV with 60s TTL (KV minimum)
|
|
110
|
+
await c.env.CHALLENGES.put(`dashboard-auth:${challengeId}`, JSON.stringify({
|
|
111
|
+
problems,
|
|
112
|
+
app_id: appId,
|
|
113
|
+
created_at: Date.now(),
|
|
114
|
+
type: 'dashboard-login',
|
|
115
|
+
}), { expirationTtl: 60 });
|
|
116
|
+
return c.json({
|
|
117
|
+
challenge_id: challengeId,
|
|
118
|
+
type: 'speed',
|
|
119
|
+
problems,
|
|
120
|
+
time_limit_ms: 500,
|
|
121
|
+
instructions: 'Compute SHA-256 hex digest of each number (as string). Return first 8 chars of each hash.',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Verify challenge answers. Shared between dashboard login and device code flows.
|
|
126
|
+
* Returns the challenge data on success, or null with an error response sent.
|
|
127
|
+
*/
|
|
128
|
+
async function verifyChallengeAnswers(c, challengeId, answers) {
|
|
129
|
+
// Retrieve and delete challenge (one attempt only)
|
|
130
|
+
const raw = await c.env.CHALLENGES.get(`dashboard-auth:${challengeId}`, 'text');
|
|
131
|
+
await c.env.CHALLENGES.delete(`dashboard-auth:${challengeId}`);
|
|
132
|
+
if (!raw) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const challenge = JSON.parse(raw);
|
|
136
|
+
// Check timing (2s generous limit including network)
|
|
137
|
+
const elapsed = Date.now() - challenge.created_at;
|
|
138
|
+
if (elapsed > 2000) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// Verify answers: SHA-256 of each number as string, first 8 hex chars
|
|
142
|
+
const problems = challenge.problems;
|
|
143
|
+
if (answers.length !== problems.length) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
for (let i = 0; i < problems.length; i++) {
|
|
147
|
+
const data = new TextEncoder().encode(String(problems[i]));
|
|
148
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
149
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
150
|
+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
151
|
+
const expected = hashHex.substring(0, 8);
|
|
152
|
+
if (answers[i]?.toLowerCase() !== expected) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { app_id: challenge.app_id, problems };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* POST /v1/auth/dashboard/verify
|
|
160
|
+
*
|
|
161
|
+
* Agent submits challenge solution. On success, returns a session token
|
|
162
|
+
* usable as Bearer header or cookie.
|
|
163
|
+
*/
|
|
164
|
+
export async function handleDashboardAuthVerify(c) {
|
|
165
|
+
const body = await c.req.json().catch(() => ({}));
|
|
166
|
+
const challengeId = body.challenge_id;
|
|
167
|
+
const answers = body.answers;
|
|
168
|
+
if (!challengeId || !answers || !Array.isArray(answers)) {
|
|
169
|
+
return c.json({ error: 'challenge_id and answers[] are required' }, 400);
|
|
170
|
+
}
|
|
171
|
+
const result = await verifyChallengeAnswers(c, challengeId, answers);
|
|
172
|
+
if (!result) {
|
|
173
|
+
return c.json({ error: 'Challenge failed: not found, expired, or wrong answers' }, 403);
|
|
174
|
+
}
|
|
175
|
+
const sessionToken = await generateSessionToken(result.app_id, c.env.JWT_SECRET);
|
|
176
|
+
return c.json({
|
|
177
|
+
success: true,
|
|
178
|
+
session_token: sessionToken,
|
|
179
|
+
expires_in: 3600,
|
|
180
|
+
app_id: result.app_id,
|
|
181
|
+
dashboard_url: '/dashboard',
|
|
182
|
+
usage: 'Use as cookie "botcha_session" or Authorization: Bearer header',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// ============ DEVICE CODE (Flow 2: agent → human handoff) ============
|
|
186
|
+
/**
|
|
187
|
+
* POST /v1/auth/device-code
|
|
188
|
+
*
|
|
189
|
+
* Same challenge as dashboard auth. Agent must solve it to get a device code.
|
|
190
|
+
*/
|
|
191
|
+
export async function handleDeviceCodeChallenge(c) {
|
|
192
|
+
return handleDashboardAuthChallenge(c);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* POST /v1/auth/device-code/verify
|
|
196
|
+
*
|
|
197
|
+
* Agent submits challenge solution. On success, returns a short-lived
|
|
198
|
+
* device code (BOTCHA-XXXX) that a human can enter at /dashboard/code.
|
|
199
|
+
*/
|
|
200
|
+
export async function handleDeviceCodeVerify(c) {
|
|
201
|
+
const body = await c.req.json().catch(() => ({}));
|
|
202
|
+
const challengeId = body.challenge_id;
|
|
203
|
+
const answers = body.answers;
|
|
204
|
+
if (!challengeId || !answers || !Array.isArray(answers)) {
|
|
205
|
+
return c.json({ error: 'challenge_id and answers[] are required' }, 400);
|
|
206
|
+
}
|
|
207
|
+
const result = await verifyChallengeAnswers(c, challengeId, answers);
|
|
208
|
+
if (!result) {
|
|
209
|
+
return c.json({ error: 'Challenge failed: not found, expired, or wrong answers' }, 403);
|
|
210
|
+
}
|
|
211
|
+
// Generate device code
|
|
212
|
+
const code = generateDeviceCode();
|
|
213
|
+
await storeDeviceCode(c.env.CHALLENGES, code, result.app_id);
|
|
214
|
+
const baseUrl = new URL(c.req.url).origin;
|
|
215
|
+
return c.json({
|
|
216
|
+
success: true,
|
|
217
|
+
code,
|
|
218
|
+
login_url: `${baseUrl}/dashboard/code`,
|
|
219
|
+
expires_in: 600,
|
|
220
|
+
instructions: `Tell your human: Visit ${baseUrl}/dashboard/code and enter code: ${code}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// ============ DEVICE CODE REDEMPTION (human-facing) ============
|
|
224
|
+
/**
|
|
225
|
+
* GET /dashboard/code
|
|
226
|
+
*
|
|
227
|
+
* Renders the device code redemption page for humans.
|
|
228
|
+
*/
|
|
229
|
+
export async function renderDeviceCodePage(c) {
|
|
230
|
+
const url = new URL(c.req.url);
|
|
231
|
+
const error = url.searchParams.get('error');
|
|
232
|
+
const prefill = url.searchParams.get('code') || '';
|
|
233
|
+
const emailSent = url.searchParams.get('email_sent') === '1';
|
|
234
|
+
const errorMap = {
|
|
235
|
+
invalid: 'Invalid or expired code. Ask your agent for a new one.',
|
|
236
|
+
missing: 'Please enter a device code.',
|
|
237
|
+
};
|
|
238
|
+
return c.html(_jsxs(LoginLayout, { title: "Enter Device Code - BOTCHA", children: [_jsxs("div", { style: "font-size: 0.875rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; text-align: center; margin-bottom: 2rem;", children: ['>', "_\u00A0BOTCHA"] }), _jsx("form", { method: "post", action: "/dashboard/code", children: _jsxs(Card, { title: "Device Code", children: [emailSent && (_jsx("div", { class: "success-message", style: "background: #f0faf0; border: 1px solid #1a8a2a; color: #1a6a1a; padding: 0.75rem; font-size: 0.75rem; margin-bottom: 1rem;", children: "If an account with that email exists, a login code has been sent. Check your inbox." })), error && errorMap[error] && (_jsx("div", { class: "error-message", children: errorMap[error] })), _jsx("p", { class: "text-muted mb-2", style: "font-size: 0.75rem;", children: emailSent
|
|
239
|
+
? 'Enter the code from your email below.'
|
|
240
|
+
: 'Your AI agent generated a login code for you. Enter it below to access the dashboard.' }), _jsxs("div", { class: "form-group", children: [_jsx("label", { for: "code", children: "Code" }), _jsxs("div", { style: "display: flex; align-items: stretch; gap: 0;", children: [_jsx("span", { style: "display: flex; align-items: center; font-size: 1.5rem; font-weight: 700; letter-spacing: 0.15em; padding: 0 0.25rem 0 1rem; border: 1px solid var(--border); border-right: none; background: var(--bg-raised); color: var(--text-muted);", children: "BOTCHA-" }), _jsx("input", { type: "text", id: "code", name: "code", placeholder: "XXXX", value: prefill, required: true, autocomplete: "off", maxlength: 11, style: "font-size: 1.5rem; font-weight: 700; text-align: center; letter-spacing: 0.15em; padding: 1rem; flex: 1; border-left: none;" })] })] }), _jsx("script", { dangerouslySetInnerHTML: { __html: `
|
|
241
|
+
document.getElementById('code').addEventListener('input', function(e) {
|
|
242
|
+
var v = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
|
|
243
|
+
if (v.startsWith('BOTCHA-')) v = v.slice(7);
|
|
244
|
+
else if (v.startsWith('BOTCHA')) v = v.slice(6);
|
|
245
|
+
e.target.value = v.slice(0, 4);
|
|
246
|
+
});
|
|
247
|
+
` } }), _jsxs("button", { type: "submit", children: ["Verify Code ", '>'] })] }) }), _jsxs("div", { class: "hint", style: "text-align: center; line-height: 1.8; margin-top: 1.5rem;", children: ["Don't have a code? Ask your AI agent to run:", _jsx("br", {}), _jsx("code", { children: "POST /v1/auth/device-code" }), _jsx("br", {}), _jsx("br", {}), _jsx("a", { href: "/dashboard/login", children: "Back to login" })] })] }));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* POST /dashboard/code
|
|
251
|
+
*
|
|
252
|
+
* Human submits device code. If valid, creates session and redirects to dashboard.
|
|
253
|
+
*/
|
|
254
|
+
export async function handleDeviceCodeRedeem(c) {
|
|
255
|
+
const body = await c.req.parseBody();
|
|
256
|
+
let code = (body.code || '').trim().toUpperCase();
|
|
257
|
+
if (!code) {
|
|
258
|
+
return c.redirect('/dashboard/code?error=missing');
|
|
259
|
+
}
|
|
260
|
+
// Normalize: accept both "AR8C" and "BOTCHA-AR8C", always look up with prefix
|
|
261
|
+
if (!code.startsWith('BOTCHA-')) {
|
|
262
|
+
code = `BOTCHA-${code}`;
|
|
263
|
+
}
|
|
264
|
+
const data = await redeemDeviceCode(c.env.CHALLENGES, code);
|
|
265
|
+
if (!data) {
|
|
266
|
+
return c.redirect('/dashboard/code?error=invalid');
|
|
267
|
+
}
|
|
268
|
+
const sessionToken = await generateSessionToken(data.app_id, c.env.JWT_SECRET);
|
|
269
|
+
setSessionCookie(c, sessionToken);
|
|
270
|
+
return c.redirect('/dashboard');
|
|
271
|
+
}
|
|
272
|
+
// ============ LEGACY LOGIN (app_id + app_secret) ============
|
|
273
|
+
/**
|
|
274
|
+
* POST /dashboard/login
|
|
275
|
+
*
|
|
276
|
+
* Login with app_id + app_secret. The agent created the app (so an agent
|
|
277
|
+
* was involved at creation time). Still supported as a convenience.
|
|
278
|
+
*/
|
|
279
|
+
export async function handleLogin(c) {
|
|
280
|
+
try {
|
|
281
|
+
const body = await c.req.parseBody();
|
|
282
|
+
const app_id = body.app_id;
|
|
283
|
+
const app_secret = body.app_secret;
|
|
284
|
+
if (!app_id || !app_secret) {
|
|
285
|
+
return c.redirect('/dashboard/login?error=missing');
|
|
286
|
+
}
|
|
287
|
+
const trimmedAppId = app_id.trim();
|
|
288
|
+
const trimmedSecret = app_secret.trim();
|
|
289
|
+
const isValid = await validateAppSecret(c.env.APPS, trimmedAppId, trimmedSecret);
|
|
290
|
+
if (!isValid) {
|
|
291
|
+
return c.redirect('/dashboard/login?error=invalid');
|
|
292
|
+
}
|
|
293
|
+
const sessionToken = await generateSessionToken(trimmedAppId, c.env.JWT_SECRET);
|
|
294
|
+
setSessionCookie(c, sessionToken);
|
|
295
|
+
return c.redirect('/dashboard');
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
console.error('Login error:', error);
|
|
299
|
+
return c.redirect('/dashboard/login?error=server');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* GET /dashboard/logout
|
|
304
|
+
*/
|
|
305
|
+
export async function handleLogout(c) {
|
|
306
|
+
deleteCookie(c, 'botcha_session', { path: '/dashboard' });
|
|
307
|
+
return c.redirect('/dashboard/login');
|
|
308
|
+
}
|
|
309
|
+
// ============ EMAIL LOGIN (session re-entry) ============
|
|
310
|
+
/**
|
|
311
|
+
* POST /dashboard/email-login
|
|
312
|
+
*
|
|
313
|
+
* Human enters their email. If a verified app exists for that email,
|
|
314
|
+
* a device code is generated and emailed. The human then enters the
|
|
315
|
+
* code at /dashboard/code to get a session.
|
|
316
|
+
*
|
|
317
|
+
* This doesn't violate agent-first: the agent was involved at account
|
|
318
|
+
* creation and email verification. This is just session resumption.
|
|
319
|
+
*
|
|
320
|
+
* Anti-enumeration: always shows the same "check your email" message
|
|
321
|
+
* regardless of whether the email exists.
|
|
322
|
+
*/
|
|
323
|
+
export async function handleEmailLogin(c) {
|
|
324
|
+
const body = await c.req.parseBody();
|
|
325
|
+
const email = (body.email || '').trim().toLowerCase();
|
|
326
|
+
if (!email) {
|
|
327
|
+
return c.redirect('/dashboard/login?error=email_missing');
|
|
328
|
+
}
|
|
329
|
+
// Look up app by email — but always show same response (anti-enumeration)
|
|
330
|
+
const lookup = await getAppByEmail(c.env.APPS, email);
|
|
331
|
+
if (lookup && lookup.email_verified) {
|
|
332
|
+
// Generate a device code and email it
|
|
333
|
+
const code = generateDeviceCode();
|
|
334
|
+
await storeDeviceCode(c.env.CHALLENGES, code, lookup.app_id);
|
|
335
|
+
const baseUrl = new URL(c.req.url).origin;
|
|
336
|
+
const loginUrl = `${baseUrl}/dashboard/code`;
|
|
337
|
+
const template = recoveryEmail(code, loginUrl);
|
|
338
|
+
await sendEmail(c.env.RESEND_API_KEY, {
|
|
339
|
+
...template,
|
|
340
|
+
to: email,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
// Always redirect to code page with success message (anti-enumeration)
|
|
344
|
+
return c.redirect('/dashboard/code?email_sent=1');
|
|
345
|
+
}
|
|
346
|
+
// ============ LOGIN PAGE ============
|
|
347
|
+
/**
|
|
348
|
+
* GET /dashboard/login
|
|
349
|
+
*
|
|
350
|
+
* Four ways in:
|
|
351
|
+
* 1. Device code (agent generated the code) — primary
|
|
352
|
+
* 2. Email login (returning users — code emailed to verified address)
|
|
353
|
+
* 3. App ID + Secret (agent created the app)
|
|
354
|
+
* 4. Create new app (triggers POST /v1/apps)
|
|
355
|
+
*/
|
|
356
|
+
export async function renderLoginPage(c) {
|
|
357
|
+
const url = new URL(c.req.url);
|
|
358
|
+
const error = url.searchParams.get('error');
|
|
359
|
+
const errorMap = {
|
|
360
|
+
invalid: 'Invalid app ID or secret',
|
|
361
|
+
missing: 'Please provide both app ID and secret',
|
|
362
|
+
server: 'Server error. Please try again.',
|
|
363
|
+
email_missing: 'Please enter your email address.',
|
|
364
|
+
};
|
|
365
|
+
const CREATE_APP_SCRIPT = `
|
|
366
|
+
async function createApp() {
|
|
367
|
+
var btn = document.getElementById('create-btn');
|
|
368
|
+
btn.classList.add('loading');
|
|
369
|
+
btn.textContent = 'Creating...';
|
|
370
|
+
try {
|
|
371
|
+
var resp = await fetch('/v1/apps', { method: 'POST' });
|
|
372
|
+
var data = await resp.json();
|
|
373
|
+
if (data.app_id && data.app_secret) {
|
|
374
|
+
document.getElementById('new-app-id').textContent = data.app_id;
|
|
375
|
+
document.getElementById('new-app-secret').textContent = data.app_secret;
|
|
376
|
+
document.getElementById('create-result').classList.add('show');
|
|
377
|
+
btn.style.display = 'none';
|
|
378
|
+
} else {
|
|
379
|
+
btn.textContent = '[ERR] try again >';
|
|
380
|
+
btn.classList.remove('loading');
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
btn.textContent = '[ERR] try again >';
|
|
384
|
+
btn.classList.remove('loading');
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function fillAndLogin() {
|
|
388
|
+
var appId = document.getElementById('new-app-id').textContent;
|
|
389
|
+
var secret = document.getElementById('new-app-secret').textContent;
|
|
390
|
+
document.getElementById('app_id').value = appId;
|
|
391
|
+
document.getElementById('app_secret').value = secret;
|
|
392
|
+
document.querySelector('form').submit();
|
|
393
|
+
}
|
|
394
|
+
`;
|
|
395
|
+
return c.html(_jsxs(LoginLayout, { title: "Dashboard Login - BOTCHA", children: [_jsx("div", { class: "ascii-logo", children: ` ____ ___ _____ ____ _ _ _
|
|
396
|
+
| __ ) / _ \\_ _/ ___| | | | / \\
|
|
397
|
+
| _ \\| | | || || | | |_| |/ _ \\
|
|
398
|
+
| |_) | |_| || || |___| _ / ___ \\
|
|
399
|
+
|____/ \\___/ |_| \\____|_| |_/_/ \\_\\
|
|
400
|
+
>_ prove you're a bot` }), _jsxs(Card, { title: "Device Code", badge: "agent required", children: [_jsx("p", { class: "text-muted mb-2", style: "font-size: 0.75rem;", children: "Your AI agent can generate a login code for you." }), _jsxs("a", { href: "/dashboard/code", class: "button btn", children: ["Enter Device Code ", '>'] }), _jsxs("div", { class: "hint", children: ["Agent: ", _jsx("code", { children: "POST /v1/auth/device-code" }), " then solve the challenge."] })] }), _jsx(Divider, { text: "or" }), _jsx("form", { method: "post", action: "/dashboard/email-login", children: _jsxs(Card, { title: "Email Login", badge: "returning users", children: [error === 'email_missing' && (_jsx("div", { class: "error-message", children: errorMap[error] })), _jsx("p", { class: "text-muted mb-2", style: "font-size: 0.75rem;", children: "Enter the email you used when creating your app. We'll send a login code to your inbox." }), _jsxs("div", { class: "form-group", children: [_jsx("label", { for: "email", children: "Email" }), _jsx("input", { type: "email", id: "email", name: "email", placeholder: "you@example.com", required: true, autocomplete: "email" })] }), _jsxs("button", { type: "submit", children: ["Email Me a Code ", '>'] })] }) }), _jsx(Divider, { text: "or sign in with credentials" }), _jsx("form", { method: "post", action: "/dashboard/login", children: _jsxs(Card, { title: "App Credentials", children: [error && error !== 'email_missing' && errorMap[error] && (_jsx("div", { class: "error-message", children: errorMap[error] })), _jsxs("div", { id: "create-result", children: [_jsx("div", { class: "warning", children: "Save these credentials now. The secret will not be shown again." }), _jsxs("div", { class: "credentials-box", children: [_jsx("span", { class: "label", children: "app_id: " }), _jsx("span", { class: "value", id: "new-app-id" }), _jsx("br", {}), _jsx("span", { class: "label", children: "secret: " }), _jsx("span", { class: "value", id: "new-app-secret" })] }), _jsxs("button", { type: "button", onclick: "fillAndLogin()", style: "width: 100%; margin-bottom: 1rem;", children: ["Login With New Credentials ", '>'] })] }), _jsxs("div", { class: "form-group", children: [_jsx("label", { for: "app_id", children: "App ID" }), _jsx("input", { type: "text", id: "app_id", name: "app_id", placeholder: "app_...", required: true, autocomplete: "username" })] }), _jsxs("div", { class: "form-group", children: [_jsx("label", { for: "app_secret", children: "App Secret" }), _jsx("input", { type: "password", id: "app_secret", name: "app_secret", placeholder: "sk_...", required: true, autocomplete: "current-password" })] }), _jsxs("div", { style: "display: flex; gap: 0.75rem; align-items: center;", children: [_jsxs("button", { type: "submit", children: ["Login ", '>'] }), _jsxs("button", { type: "button", id: "create-btn", class: "btn-secondary", onclick: "createApp()", children: ["Create App ", '>'] })] })] }) }), _jsx("script", { dangerouslySetInnerHTML: { __html: CREATE_APP_SCRIPT } })] }));
|
|
401
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BOTCHA Dashboard — Device Code Authentication
|
|
3
|
+
*
|
|
4
|
+
* OAuth2 Device Authorization Grant adapted for agent→human handoff.
|
|
5
|
+
* An agent generates a short-lived device code by solving a BOTCHA challenge.
|
|
6
|
+
* The human redeems the code at /dashboard/code to get a dashboard session.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Agent: POST /v1/auth/device-code → gets challenge
|
|
10
|
+
* 2. Agent: POST /v1/auth/device-code/verify → solves challenge, gets { code, login_url }
|
|
11
|
+
* 3. Human: visits /dashboard/code, enters code → dashboard session
|
|
12
|
+
*
|
|
13
|
+
* The agent MUST solve a challenge to generate a code. No agent, no code.
|
|
14
|
+
*/
|
|
15
|
+
import type { KVNamespace } from '../challenges';
|
|
16
|
+
export interface DeviceCodeData {
|
|
17
|
+
code: string;
|
|
18
|
+
app_id: string;
|
|
19
|
+
created_at: number;
|
|
20
|
+
expires_at: number;
|
|
21
|
+
redeemed: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Generate a human-friendly device code.
|
|
25
|
+
* Format: BOTCHA-XXXX (4 alphanumeric chars, no ambiguous chars)
|
|
26
|
+
*
|
|
27
|
+
* Uses a restricted alphabet that avoids 0/O, 1/I/l confusion.
|
|
28
|
+
*/
|
|
29
|
+
export declare function generateDeviceCode(): string;
|
|
30
|
+
/**
|
|
31
|
+
* Store a device code in KV with 10-minute TTL.
|
|
32
|
+
*/
|
|
33
|
+
export declare function storeDeviceCode(kv: KVNamespace, code: string, appId: string): Promise<DeviceCodeData>;
|
|
34
|
+
/**
|
|
35
|
+
* Look up a device code from KV.
|
|
36
|
+
* Returns null if not found, expired, or already redeemed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function lookupDeviceCode(kv: KVNamespace, code: string): Promise<DeviceCodeData | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Redeem a device code (mark as used so it can't be reused).
|
|
41
|
+
*/
|
|
42
|
+
export declare function redeemDeviceCode(kv: KVNamespace, code: string): Promise<DeviceCodeData | null>;
|
|
43
|
+
//# sourceMappingURL=device-code.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-code.d.ts","sourceRoot":"","sources":["../../src/dashboard/device-code.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAIjD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAM3C;AAED;;GAEG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,WAAW,EACf,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,cAAc,CAAC,CAiBzB;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAchC;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAahC"}
|