1tube 0.1.51 → 0.1.52

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.51",
3
+ "version": "0.1.52",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -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 will reject all requests",
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 token = parseBearer(req.headers.get("Authorization"));
191
- if (!token) return null;
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
  // ---------------------------------------------------------------------------
@@ -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 = "x-1tube-warming, x-1tube-stale, retry-after";
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 { validateRequest } from "./gateway/auth.ts";
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 {
@@ -1842,13 +1842,15 @@ app.use(fnRouteWildcard, async (c, next) => {
1842
1842
  // unauthenticated requests here. Public functions need to pass through and
1843
1843
  // rate-limit by IP; protected functions are gated below in the dispatcher.
1844
1844
  try {
1845
- const auth = await validateRequest(c.req.raw);
1846
- if (auth) {
1847
- c.set("userId", auth.userId);
1848
- (c as any).set("__authContext", auth);
1845
+ const probed = await probeRequest(c.req.raw);
1846
+ (c as any).set("__authProbe", probed.status);
1847
+ if (probed.status === "ok") {
1848
+ c.set("userId", probed.auth.userId);
1849
+ (c as any).set("__authContext", probed.auth);
1849
1850
  }
1850
1851
  } catch {
1851
- // Ignore validation errors at probe time — dispatcher will return 401.
1852
+ // Ignore validation errors at probe time — dispatcher will 401/503.
1853
+ (c as any).set("__authProbe", "invalid");
1852
1854
  }
1853
1855
  await next();
1854
1856
  });
@@ -2024,6 +2026,15 @@ app.all(routeDispatchPattern(), async (c) => {
2024
2026
  duplex: "half",
2025
2027
  });
2026
2028
  const auth = (c as any).get("__authContext") ?? null;
2029
+ const knownPublic = registry.get(name)?.isPublic ??
2030
+ registry.workerHandle(name)?.isPublic;
2031
+ if (knownPublic === false && !auth && (c as any).get("__authProbe") === "unavailable") {
2032
+ return c.json(
2033
+ { error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
2034
+ 503,
2035
+ { "X-1tube-Auth-Unavailable": "1" },
2036
+ );
2037
+ }
2027
2038
 
2028
2039
  // Per-function timeout from the manifest, falling back to the
2029
2040
  // gateway-wide default. Same precedence the Deno path uses.
@@ -2248,6 +2259,13 @@ app.all(routeDispatchPattern(), async (c) => {
2248
2259
 
2249
2260
  const auth = (c as any).get("__authContext") ?? null;
2250
2261
  if (!handle.isPublic && !auth) {
2262
+ if ((c as any).get("__authProbe") === "unavailable") {
2263
+ return c.json(
2264
+ { error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
2265
+ 503,
2266
+ { "X-1tube-Auth-Unavailable": "1" },
2267
+ );
2268
+ }
2251
2269
  return c.json({ error: "Unauthorized" }, 401);
2252
2270
  }
2253
2271