1tube 0.1.51 → 0.1.53
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/package.json +1 -1
- package/src/gateway/auth.ts +61 -34
- package/src/gateway/body-watchdog.ts +1 -1
- package/src/gateway/cors.ts +2 -1
- package/src/server.ts +28 -7
package/package.json
CHANGED
package/src/gateway/auth.ts
CHANGED
|
@@ -53,7 +53,7 @@ function getSecret(): string {
|
|
|
53
53
|
verifyCache.clear();
|
|
54
54
|
if (!current && !_warnedNoSecret) {
|
|
55
55
|
console.warn(
|
|
56
|
-
"[1tube] JWT_SECRET not set — authenticated endpoints
|
|
56
|
+
"[1tube] JWT_SECRET not set — authenticated endpoints return 503, not 401",
|
|
57
57
|
);
|
|
58
58
|
_warnedNoSecret = true;
|
|
59
59
|
}
|
|
@@ -165,6 +165,64 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
export type RequestAuthProbe =
|
|
169
|
+
| { status: "ok"; auth: AuthContext }
|
|
170
|
+
| { status: "missing" }
|
|
171
|
+
| { status: "invalid" }
|
|
172
|
+
| { status: "unavailable" };
|
|
173
|
+
|
|
174
|
+
function serviceRoleAuth(token: string): AuthContext {
|
|
175
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
176
|
+
return {
|
|
177
|
+
userId: "",
|
|
178
|
+
email: "",
|
|
179
|
+
payload: {
|
|
180
|
+
sub: "",
|
|
181
|
+
email: "",
|
|
182
|
+
role: "service_role",
|
|
183
|
+
iss: "supabase",
|
|
184
|
+
aud: "service_role",
|
|
185
|
+
iat: nowSec,
|
|
186
|
+
// Synthetic exp matching JWTPayload shape; the token has no real
|
|
187
|
+
// expiry of its own — rotation is governed by env reload.
|
|
188
|
+
exp: nowSec + 60 * 60,
|
|
189
|
+
},
|
|
190
|
+
rawToken: token,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Probe the Authorization header without collapsing every miss into `null`.
|
|
196
|
+
*
|
|
197
|
+
* A missing `JWT_SECRET` is `unavailable` — the gateway cannot tell a live
|
|
198
|
+
* session from a bad one, so the dispatcher must return 503 rather than 401.
|
|
199
|
+
* Treating that as Unauthorized signs SPAs out during deploys.
|
|
200
|
+
*/
|
|
201
|
+
export async function probeRequest(req: Request): Promise<RequestAuthProbe> {
|
|
202
|
+
const token = parseBearer(req.headers.get("Authorization"));
|
|
203
|
+
if (!token) return { status: "missing" };
|
|
204
|
+
|
|
205
|
+
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
|
206
|
+
if (serviceKey && token === serviceKey) {
|
|
207
|
+
return { status: "ok", auth: serviceRoleAuth(token) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!getSecret()) return { status: "unavailable" };
|
|
211
|
+
|
|
212
|
+
const payload = await verifyToken(token);
|
|
213
|
+
if (!payload) return { status: "invalid" };
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
status: "ok",
|
|
217
|
+
auth: {
|
|
218
|
+
userId: payload.sub,
|
|
219
|
+
email: payload.email,
|
|
220
|
+
payload,
|
|
221
|
+
rawToken: token,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
168
226
|
/**
|
|
169
227
|
* Validate the Authorization header and return an AuthContext, or null if invalid.
|
|
170
228
|
*
|
|
@@ -187,39 +245,8 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|
|
187
245
|
export async function validateRequest(
|
|
188
246
|
req: Request,
|
|
189
247
|
): Promise<AuthContext | null> {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
|
194
|
-
if (serviceKey && token === serviceKey) {
|
|
195
|
-
const nowSec = Math.floor(Date.now() / 1000);
|
|
196
|
-
return {
|
|
197
|
-
userId: "",
|
|
198
|
-
email: "",
|
|
199
|
-
payload: {
|
|
200
|
-
sub: "",
|
|
201
|
-
email: "",
|
|
202
|
-
role: "service_role",
|
|
203
|
-
iss: "supabase",
|
|
204
|
-
aud: "service_role",
|
|
205
|
-
iat: nowSec,
|
|
206
|
-
// Synthetic exp matching JWTPayload shape; the token has no real
|
|
207
|
-
// expiry of its own — rotation is governed by env reload.
|
|
208
|
-
exp: nowSec + 60 * 60,
|
|
209
|
-
},
|
|
210
|
-
rawToken: token,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const payload = await verifyToken(token);
|
|
215
|
-
if (!payload) return null;
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
userId: payload.sub,
|
|
219
|
-
email: payload.email,
|
|
220
|
-
payload,
|
|
221
|
-
rawToken: token,
|
|
222
|
-
};
|
|
248
|
+
const probed = await probeRequest(req);
|
|
249
|
+
return probed.status === "ok" ? probed.auth : null;
|
|
223
250
|
}
|
|
224
251
|
|
|
225
252
|
// ---------------------------------------------------------------------------
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `Deno.serve` does not enforce a per-request body-read timeout. A client that
|
|
5
5
|
* dribbles bytes at a few per second can occupy a request slot for the full
|
|
6
|
-
* `FUNCTION_TIMEOUT_MS` (default
|
|
6
|
+
* `FUNCTION_TIMEOUT_MS` (default 800 s), starving real traffic. We don't want
|
|
7
7
|
* to *cap total body read time* — large but fast uploads are fine — we want
|
|
8
8
|
* to detect *stalled progress*: too long a gap between consecutive chunks.
|
|
9
9
|
*
|
package/src/gateway/cors.ts
CHANGED
|
@@ -36,7 +36,8 @@ const DEFAULT_ALLOWED_METHODS = "POST, GET, OPTIONS, PUT, PATCH, DELETE";
|
|
|
36
36
|
// its HMR respawn is still in flight; `retry-after` lets clients
|
|
37
37
|
// honour the gateway's suggested backoff. These are always exposed —
|
|
38
38
|
// operator config only ADDS to them.
|
|
39
|
-
const INTERNAL_EXPOSED_HEADERS =
|
|
39
|
+
const INTERNAL_EXPOSED_HEADERS =
|
|
40
|
+
"x-1tube-warming, x-1tube-stale, x-1tube-auth-unavailable, retry-after";
|
|
40
41
|
|
|
41
42
|
interface CorsConfig {
|
|
42
43
|
/** "*" means allow any. Empty array means CORS disabled. */
|
package/src/server.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { type Context, Hono } from "npm:hono@4";
|
|
20
20
|
import { bodyLimit } from "npm:hono@4/body-limit";
|
|
21
21
|
import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { probeRequest } from "./gateway/auth.ts";
|
|
23
23
|
import { watchdogBody } from "./gateway/body-watchdog.ts";
|
|
24
24
|
import { corsMiddleware } from "./gateway/cors.ts";
|
|
25
25
|
import {
|
|
@@ -426,8 +426,11 @@ function parseArgs(): CliOpts {
|
|
|
426
426
|
// Normalized at the end via setRoutePrefix(); kept raw here so an
|
|
427
427
|
// explicit CLI flag can still override the env var.
|
|
428
428
|
let routePrefix = Deno.env.get("1TUBE_ROUTE_PREFIX") || "/functions/v1";
|
|
429
|
+
// Matches Vercel Fluid's maxDuration cap. Per-function `serve({ timeoutMs })`
|
|
430
|
+
// / `1tube.json` still override. `FUNCTION_TIMEOUT_MS` / `--timeout` win last.
|
|
431
|
+
const DEFAULT_FUNCTION_TIMEOUT_MS = 800_000;
|
|
429
432
|
let defaultTimeoutMs = parseInt(
|
|
430
|
-
Deno.env.get("FUNCTION_TIMEOUT_MS") ||
|
|
433
|
+
Deno.env.get("FUNCTION_TIMEOUT_MS") || String(DEFAULT_FUNCTION_TIMEOUT_MS),
|
|
431
434
|
10,
|
|
432
435
|
);
|
|
433
436
|
const bodyLimitMb = parseFloat(Deno.env.get("1TUBE_BODY_LIMIT_MB") || "30");
|
|
@@ -1842,13 +1845,15 @@ app.use(fnRouteWildcard, async (c, next) => {
|
|
|
1842
1845
|
// unauthenticated requests here. Public functions need to pass through and
|
|
1843
1846
|
// rate-limit by IP; protected functions are gated below in the dispatcher.
|
|
1844
1847
|
try {
|
|
1845
|
-
const
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1848
|
+
const probed = await probeRequest(c.req.raw);
|
|
1849
|
+
(c as any).set("__authProbe", probed.status);
|
|
1850
|
+
if (probed.status === "ok") {
|
|
1851
|
+
c.set("userId", probed.auth.userId);
|
|
1852
|
+
(c as any).set("__authContext", probed.auth);
|
|
1849
1853
|
}
|
|
1850
1854
|
} catch {
|
|
1851
|
-
// Ignore validation errors at probe time — dispatcher will
|
|
1855
|
+
// Ignore validation errors at probe time — dispatcher will 401/503.
|
|
1856
|
+
(c as any).set("__authProbe", "invalid");
|
|
1852
1857
|
}
|
|
1853
1858
|
await next();
|
|
1854
1859
|
});
|
|
@@ -2024,6 +2029,15 @@ app.all(routeDispatchPattern(), async (c) => {
|
|
|
2024
2029
|
duplex: "half",
|
|
2025
2030
|
});
|
|
2026
2031
|
const auth = (c as any).get("__authContext") ?? null;
|
|
2032
|
+
const knownPublic = registry.get(name)?.isPublic ??
|
|
2033
|
+
registry.workerHandle(name)?.isPublic;
|
|
2034
|
+
if (knownPublic === false && !auth && (c as any).get("__authProbe") === "unavailable") {
|
|
2035
|
+
return c.json(
|
|
2036
|
+
{ error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
|
|
2037
|
+
503,
|
|
2038
|
+
{ "X-1tube-Auth-Unavailable": "1" },
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2027
2041
|
|
|
2028
2042
|
// Per-function timeout from the manifest, falling back to the
|
|
2029
2043
|
// gateway-wide default. Same precedence the Deno path uses.
|
|
@@ -2248,6 +2262,13 @@ app.all(routeDispatchPattern(), async (c) => {
|
|
|
2248
2262
|
|
|
2249
2263
|
const auth = (c as any).get("__authContext") ?? null;
|
|
2250
2264
|
if (!handle.isPublic && !auth) {
|
|
2265
|
+
if ((c as any).get("__authProbe") === "unavailable") {
|
|
2266
|
+
return c.json(
|
|
2267
|
+
{ error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
|
|
2268
|
+
503,
|
|
2269
|
+
{ "X-1tube-Auth-Unavailable": "1" },
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2251
2272
|
return c.json({ error: "Unauthorized" }, 401);
|
|
2252
2273
|
}
|
|
2253
2274
|
|