Skip to content

Commit d909a67

Browse files
authored
feat: implement artifact service with upload/download endpoints (#211)
- Add POST /api/artifacts for uploading binary data to R2 - Add GET /api/artifacts/{id} for downloading stored artifacts - Fix entry.ts routing to support artifacts endpoint - Update wrangler.toml to use entry.ts for local development - Add fallback page for missing ASSETS fetcher in dev mode - Fix health endpoint routing - Add basic test suite for artifact service
1 parent e967891 commit d909a67

7 files changed

Lines changed: 479 additions & 191 deletions

File tree

src/backend/artifact.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Env } from "./types.ts";
2+
import { validateAuthPayload } from "./auth";
3+
4+
export default {
5+
fetch: async (
6+
request: Request,
7+
env: Env,
8+
_ctx: ExecutionContext
9+
): Promise<Response> => {
10+
const url = new URL(request.url);
11+
if (!url.pathname.startsWith("/api/artifacts")) {
12+
return new Response(
13+
JSON.stringify({
14+
error: "Not Found",
15+
}),
16+
{ status: 404 }
17+
);
18+
}
19+
20+
// Don't process deletes
21+
if (request.method === "DELETE") {
22+
return new Response(
23+
JSON.stringify({
24+
error: "Method Not Allowed",
25+
}),
26+
{ status: 405 }
27+
);
28+
}
29+
30+
if (url.pathname === "/api/artifacts" && request.method === "POST") {
31+
console.log("✅ Handling POST request to /api/artifacts");
32+
33+
const notebookId = request.headers.get("x-notebook-id");
34+
const authToken =
35+
request.headers.get("authorization")?.replace("Bearer ", "") ||
36+
request.headers.get("x-auth-token");
37+
const mimeType =
38+
request.headers.get("content-type") || "application/octet-stream";
39+
40+
if (!authToken) {
41+
return new Response(
42+
JSON.stringify({
43+
error: "Unauthorized",
44+
}),
45+
{ status: 401 }
46+
);
47+
}
48+
try {
49+
await validateAuthPayload({ authToken }, env);
50+
} catch (error) {
51+
return new Response(
52+
JSON.stringify({
53+
error: "Unauthorized",
54+
}),
55+
{ status: 401 }
56+
);
57+
}
58+
// TODO: Validate the notebook ID
59+
// TODO: Validate that the user has permission to add artifacts to this notebook
60+
// TODO: Validate that the artifact name is unique within the notebook
61+
//
62+
// TODO: Compute hash of data on the fly
63+
// For now we'll just accept a random UUID as the artifact ID
64+
// TODO: Rely on multipart upload for large files
65+
const uuidv4 = () =>
66+
// @ts-ignore
67+
([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
68+
(
69+
c ^
70+
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
71+
).toString(16)
72+
);
73+
74+
const artifactId = `${notebookId}/${uuidv4()}`;
75+
await env.ARTIFACT_BUCKET.put(artifactId, await request.arrayBuffer(), {
76+
httpMetadata: {
77+
contentType: mimeType,
78+
},
79+
});
80+
81+
return new Response(JSON.stringify({ artifactId }), {
82+
status: 200,
83+
headers: {
84+
"Content-Type": "application/json",
85+
},
86+
});
87+
}
88+
89+
// TODO: Rely on cookies for authenticating the GET request
90+
// primarily so that images can be loaded without a token
91+
// _OR_ we set up a way to get presigned URLs that go into the
92+
// livestore sync
93+
94+
// Check for a GET, then assume we're fetching it
95+
if (request.method === "GET") {
96+
// Extract the full artifact ID from the path after /api/artifacts/
97+
const artifactId = url.pathname.replace("/api/artifacts/", "");
98+
if (!artifactId) {
99+
return new Response(
100+
JSON.stringify({
101+
error: "Bad Request",
102+
}),
103+
{ status: 400 }
104+
);
105+
}
106+
107+
const artifact = await env.ARTIFACT_BUCKET.get(artifactId);
108+
if (!artifact) {
109+
return new Response(
110+
JSON.stringify({
111+
error: "Not Found",
112+
}),
113+
{ status: 404 }
114+
);
115+
}
116+
117+
const contentType =
118+
artifact.httpMetadata?.contentType || "application/octet-stream";
119+
120+
return new Response(artifact.body, {
121+
status: 200,
122+
headers: {
123+
"Content-Type": contentType,
124+
},
125+
});
126+
}
127+
128+
// Options and other pre-flight are fine
129+
// Since this endpoint is used for images as direct urls...
130+
if (request.method === "OPTIONS") {
131+
// Handle CORS preflight requests
132+
return new Response(null, {
133+
status: 204,
134+
headers: {
135+
"Access-Control-Allow-Origin": "*",
136+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
137+
"Access-Control-Allow-Headers": "Content-Type",
138+
},
139+
});
140+
}
141+
142+
return new Response(
143+
JSON.stringify({
144+
error: "Unknown Method",
145+
}),
146+
{ status: 405 }
147+
);
148+
},
149+
};

src/backend/auth.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Validate production environment requirements at startup
2+
export function validateProductionEnvironment(env: any): void {
3+
if (env.DEPLOYMENT_ENV === "production") {
4+
if (!env.GOOGLE_CLIENT_ID) {
5+
throw new Error(
6+
"STARTUP_ERROR: GOOGLE_CLIENT_ID is required when DEPLOYMENT_ENV is production"
7+
);
8+
}
9+
if (!env.GOOGLE_CLIENT_SECRET) {
10+
console.warn(
11+
"⚠️ GOOGLE_CLIENT_SECRET not set in production - Google OAuth validation may be limited"
12+
);
13+
}
14+
console.log("✅ Production environment validation passed");
15+
}
16+
}
17+
18+
interface AuthPayload {
19+
authToken: string;
20+
}
21+
22+
interface GoogleJWTPayload {
23+
iss: string;
24+
aud: string;
25+
sub: string;
26+
email?: string;
27+
email_verified?: boolean;
28+
name?: string;
29+
picture?: string;
30+
exp: number;
31+
iat: number;
32+
}
33+
34+
// Validate Google ID token using Google's tokeninfo endpoint
35+
async function validateGoogleToken(
36+
token: string,
37+
clientId: string
38+
): Promise<GoogleJWTPayload | null> {
39+
try {
40+
// Use Google's tokeninfo endpoint to validate the token
41+
const response = await fetch(
42+
`https://oauth2.googleapis.com/tokeninfo?id_token=${token}`
43+
);
44+
45+
if (!response.ok) {
46+
console.error(
47+
"Token validation failed:",
48+
response.status,
49+
response.statusText
50+
);
51+
return null;
52+
}
53+
54+
const tokenInfo = (await response.json()) as GoogleJWTPayload;
55+
56+
// Validate the audience (client ID)
57+
if (tokenInfo.aud !== clientId) {
58+
console.error("Invalid audience:", tokenInfo.aud, "expected:", clientId);
59+
return null;
60+
}
61+
62+
// Validate the issuer
63+
if (
64+
tokenInfo.iss !== "https://accounts.google.com" &&
65+
tokenInfo.iss !== "accounts.google.com"
66+
) {
67+
console.error("Invalid issuer:", tokenInfo.iss);
68+
return null;
69+
}
70+
71+
// Check expiration (Google's endpoint already validates this, but double-check)
72+
const now = Math.floor(Date.now() / 1000);
73+
if (tokenInfo.exp < now) {
74+
const expirationTime = new Date(tokenInfo.exp * 1000).toISOString();
75+
const currentTime = new Date(now * 1000).toISOString();
76+
console.error(
77+
`Token expired at ${expirationTime}, current time: ${currentTime}`
78+
);
79+
return null;
80+
}
81+
82+
return tokenInfo;
83+
} catch (error) {
84+
console.error("Google token validation failed:", error);
85+
return null;
86+
}
87+
}
88+
89+
export async function validateAuthPayload(
90+
payload: AuthPayload & { runtime?: boolean },
91+
env: any
92+
): Promise<void> {
93+
console.log("🔐 Starting auth validation:", {
94+
hasPayload: !!payload,
95+
hasAuthToken: !!payload?.authToken,
96+
isRuntime: payload?.runtime === true,
97+
hasGoogleClientId: !!env.GOOGLE_CLIENT_ID,
98+
hasEnvAuthToken: !!env.AUTH_TOKEN,
99+
});
100+
101+
if (!payload?.authToken) {
102+
console.error("❌ Missing auth token in payload");
103+
throw new Error(
104+
"MISSING_AUTH_TOKEN: No authentication token provided. Please sign in to continue."
105+
);
106+
}
107+
108+
const token = payload.authToken;
109+
console.log("🎫 Token info:", {
110+
tokenLength: token.length,
111+
tokenStart: token.substring(0, 10) + "...",
112+
isJWT: token.startsWith("eyJ"),
113+
});
114+
115+
// For runtime agents, always allow service token authentication
116+
if (payload.runtime === true) {
117+
console.log("🤖 Validating runtime agent token");
118+
if (env.AUTH_TOKEN && token === env.AUTH_TOKEN) {
119+
console.log("✅ Authenticated runtime agent with service token");
120+
return;
121+
}
122+
console.error("❌ Invalid service token for runtime agent");
123+
throw new Error(
124+
"INVALID_SERVICE_TOKEN: Runtime agent authentication failed. Check AUTH_TOKEN configuration."
125+
);
126+
}
127+
128+
// For regular users, try Google OAuth first if enabled
129+
if (env.GOOGLE_CLIENT_ID) {
130+
console.log("🔍 Attempting Google OAuth validation");
131+
const googlePayload = await validateGoogleToken(
132+
token,
133+
env.GOOGLE_CLIENT_ID
134+
);
135+
if (googlePayload) {
136+
// Google token is valid
137+
console.log(
138+
"✅ Authenticated user via Google OAuth:",
139+
googlePayload.email
140+
);
141+
return;
142+
}
143+
console.log("⚠️ Google OAuth validation failed, trying fallback");
144+
}
145+
146+
// Fallback to simple token validation (for local development)
147+
if (env.AUTH_TOKEN && token === env.AUTH_TOKEN) {
148+
console.log("✅ Authenticated with fallback token");
149+
return;
150+
}
151+
152+
console.error("❌ All authentication methods failed");
153+
154+
// Provide specific error based on token type
155+
if (token.startsWith("eyJ")) {
156+
throw new Error(
157+
"GOOGLE_TOKEN_INVALID: Google authentication token expired or invalid. Please refresh the page to sign in again."
158+
);
159+
} else {
160+
throw new Error(
161+
"INVALID_AUTH_TOKEN: Authentication failed. Please check your credentials and try again."
162+
);
163+
}
164+
}

0 commit comments

Comments
 (0)