1tube 0.1.5 → 0.1.7
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/README.md +23 -2
- package/deno.json +4 -1
- package/deno.lock +8 -0
- package/package.json +1 -1
- package/src/discovery.ts +152 -30
- package/src/env-scope.ts +120 -0
- package/src/gateway/auth.ts +143 -8
- package/src/gateway/body-watchdog.ts +150 -0
- package/src/gateway/cors.ts +82 -11
- package/src/gateway/logging.ts +38 -9
- package/src/gateway/rate-limit.ts +64 -6
- package/src/health.ts +65 -5
- package/src/log-buffer.ts +155 -0
- package/src/manifest.ts +169 -0
- package/src/registry.ts +174 -8
- package/src/server.ts +554 -104
- package/src/supervisor.ts +184 -0
- package/src/version.ts +5 -2
package/README.md
CHANGED
|
@@ -35,9 +35,30 @@ Deno does not run npm `postinstall` / build scripts unless you allow them. `deno
|
|
|
35
35
|
|
|
36
36
|
| Path | Description |
|
|
37
37
|
|---|---|
|
|
38
|
+
| `GET /` | Liveness probe (`{"status":"ok"}`) — intentionally minimal so unauthenticated callers don't see the function map. |
|
|
38
39
|
| `POST /functions/v1/:name` | Invoke an edge function |
|
|
39
|
-
| `GET /health` |
|
|
40
|
-
| `GET /metrics` |
|
|
40
|
+
| `GET /health` | Auth-gated health (`Authorization: Bearer $INTERNAL_KEY`); without auth returns the same minimal `{"status":"ok"}`. |
|
|
41
|
+
| `GET /metrics` | Auth-gated Prometheus exposition (same scheme). |
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
All knobs default to safe-but-backwards-compatible values. The TS gateway (`src/server.ts`) reads:
|
|
46
|
+
|
|
47
|
+
| Env / flag | Default | Notes |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `--port` / `PORT` | `3100` | Listen port |
|
|
50
|
+
| `--host` / `1TUBE_HOST` | `127.0.0.1` | Loopback by default — pass `--host 0.0.0.0` to expose. |
|
|
51
|
+
| `--functions` / `FUNCTIONS_PATH` | `./supabase/functions` | Functions root |
|
|
52
|
+
| `--timeout` / `FUNCTION_TIMEOUT_MS` | `150000` | Per-request wall-clock cap (also overridable per function) |
|
|
53
|
+
| `--dev` / `1TUBE_DEV` | off | Applies the well-known local Supabase JWT/secrets. **Refuses to start in prod when JWT_SECRET / SUPABASE_SERVICE_ROLE_KEY are missing or are the public dev defaults.** |
|
|
54
|
+
| `--hmr` / `1TUBE_HMR` | off | File-watch + per-function reload (dev only). |
|
|
55
|
+
| `1TUBE_BODY_LIMIT_MB` | `30` | Hono `bodyLimit`; matches Supabase. Returns 413 before the handler runs. |
|
|
56
|
+
| `1TUBE_BODY_READ_MS` | `30000` | Slow-loris guard. Max idle gap (ms) between body chunks before the request is aborted with **408**. NOT a total body-read deadline — large but fast uploads pass through. Set `0` to disable. |
|
|
57
|
+
| `1TUBE_CORS_ORIGIN` | `*` (dev only) | Comma-separated allowlist or `*`. In prod, leaving unset disables CORS. |
|
|
58
|
+
| `1TUBE_TRUSTED_PROXIES` | empty | Comma-separated list of remote IPs whose `X-Forwarded-For` is honored. Anything else uses the raw socket address — XFF spoofing no longer mints fresh rate-limit buckets. |
|
|
59
|
+
| `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | SIGINT/SIGTERM drain budget. |
|
|
60
|
+
| `INTERNAL_KEY` | unset | Required to read detailed `/health` and `/metrics`. Header-only: `Authorization: Bearer $INTERNAL_KEY`. |
|
|
61
|
+
```
|
|
41
62
|
|
|
42
63
|
## .NET integration
|
|
43
64
|
|
package/deno.json
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
},
|
|
6
6
|
"tasks": {
|
|
7
7
|
"dev": "deno run --allow-all --watch src/server.ts",
|
|
8
|
-
"serve": "deno run --allow-all src/server.ts"
|
|
8
|
+
"serve": "deno run --allow-all src/server.ts",
|
|
9
|
+
"play": "deno run --allow-all --watch src/server.ts --dev --hmr --functions ./playground --port 3199",
|
|
10
|
+
"test": "deno test --allow-all tests/"
|
|
9
11
|
},
|
|
10
12
|
"imports": {
|
|
11
13
|
"@std/path": "jsr:@std/path@^1",
|
|
14
|
+
"@std/assert": "jsr:@std/assert@^1",
|
|
12
15
|
"hono": "npm:hono@4"
|
|
13
16
|
},
|
|
14
17
|
"nodeModulesDir": "auto",
|
package/deno.lock
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "5",
|
|
3
3
|
"specifiers": {
|
|
4
|
+
"jsr:@std/assert@1": "1.0.19",
|
|
4
5
|
"jsr:@std/internal@^1.0.12": "1.0.12",
|
|
5
6
|
"jsr:@std/path@1": "1.1.4",
|
|
6
7
|
"npm:@ai-sdk/mcp@0": "0.0.15_zod@3.25.76",
|
|
@@ -12,6 +13,12 @@
|
|
|
12
13
|
"npm:zod@3": "3.25.76"
|
|
13
14
|
},
|
|
14
15
|
"jsr": {
|
|
16
|
+
"@std/assert@1.0.19": {
|
|
17
|
+
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
|
18
|
+
"dependencies": [
|
|
19
|
+
"jsr:@std/internal"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
15
22
|
"@std/internal@1.0.12": {
|
|
16
23
|
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
|
17
24
|
},
|
|
@@ -494,6 +501,7 @@
|
|
|
494
501
|
},
|
|
495
502
|
"workspace": {
|
|
496
503
|
"dependencies": [
|
|
504
|
+
"jsr:@std/assert@1",
|
|
497
505
|
"jsr:@std/path@1",
|
|
498
506
|
"npm:hono@4"
|
|
499
507
|
],
|
package/package.json
CHANGED
package/src/discovery.ts
CHANGED
|
@@ -3,72 +3,194 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Scans `functionsDir` for subdirectories containing an `index.ts`.
|
|
5
5
|
* Skips directories starting with `_` or ending with `_shared`.
|
|
6
|
-
* Each discovered module is dynamically imported — the top-level
|
|
7
|
-
* in the module registers the handler via the global
|
|
6
|
+
* Each discovered module is dynamically imported in parallel — the top-level
|
|
7
|
+
* `serve()` call in the module registers the handler via the global
|
|
8
|
+
* FunctionRegistry. Concurrent imports are kept registration-safe via the
|
|
9
|
+
* registry's async-context binding.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
// node: specifiers so this module loads cleanly from node_modules in any
|
|
13
|
+
// host Deno project (no shared import-map entry required).
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
11
16
|
import type { FunctionRegistry } from "./registry.ts";
|
|
17
|
+
import { loadManifest } from "./manifest.ts";
|
|
18
|
+
|
|
19
|
+
export interface DiscoveryProgress {
|
|
20
|
+
name: string;
|
|
21
|
+
index: number;
|
|
22
|
+
total: number;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DiscoveryOptions {
|
|
29
|
+
/** When set, appended as `?v=` to module URLs to force re-transpilation. */
|
|
30
|
+
cacheBust?: string;
|
|
31
|
+
/** Restrict loading to this set of function names (others are skipped entirely). */
|
|
32
|
+
only?: ReadonlySet<string>;
|
|
33
|
+
/** Max number of concurrent dynamic imports. Defaults to 8. */
|
|
34
|
+
concurrency?: number;
|
|
35
|
+
/** Invoked once per function as it finishes (success or failure). */
|
|
36
|
+
onProgress?: (p: DiscoveryProgress) => void;
|
|
37
|
+
/**
|
|
38
|
+
* When true, candidates are registered for lazy import on first dispatch.
|
|
39
|
+
* Functions whose manifest sets `warm: true` are still imported eagerly
|
|
40
|
+
* (in parallel, alongside other warm functions). Significantly cuts
|
|
41
|
+
* startup wall-clock and resident memory when many functions exist.
|
|
42
|
+
*/
|
|
43
|
+
lazy?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DiscoveryResult {
|
|
47
|
+
loaded: string[];
|
|
48
|
+
skipped: string[];
|
|
49
|
+
errors: Array<{ name: string; error: string }>;
|
|
50
|
+
/** Function names whose `index.ts` no longer exists (caller should evict from registry). */
|
|
51
|
+
removed: string[];
|
|
52
|
+
/** Names registered as lazy candidates (only populated when `options.lazy` is true). */
|
|
53
|
+
deferred: string[];
|
|
54
|
+
}
|
|
12
55
|
|
|
13
56
|
export async function discoverAndLoad(
|
|
14
57
|
functionsDir: string,
|
|
15
58
|
registry: FunctionRegistry,
|
|
16
|
-
options?:
|
|
17
|
-
): Promise<
|
|
59
|
+
options?: DiscoveryOptions,
|
|
60
|
+
): Promise<DiscoveryResult> {
|
|
18
61
|
const loaded: string[] = [];
|
|
19
62
|
const skipped: string[] = [];
|
|
63
|
+
const removed: string[] = [];
|
|
64
|
+
const deferred: string[] = [];
|
|
20
65
|
const errors: Array<{ name: string; error: string }> = [];
|
|
21
66
|
|
|
22
67
|
const resolvedDir = await Deno.realPath(functionsDir);
|
|
23
68
|
|
|
24
|
-
const
|
|
69
|
+
const candidates: string[] = [];
|
|
25
70
|
for await (const entry of Deno.readDir(resolvedDir)) {
|
|
26
71
|
if (!entry.isDirectory) continue;
|
|
27
72
|
if (entry.name.startsWith("_") || entry.name.endsWith("_shared")) {
|
|
28
73
|
skipped.push(entry.name);
|
|
29
74
|
continue;
|
|
30
75
|
}
|
|
31
|
-
|
|
76
|
+
candidates.push(entry.name);
|
|
32
77
|
}
|
|
78
|
+
candidates.sort();
|
|
33
79
|
|
|
34
|
-
|
|
80
|
+
// Build the work list: exists check + `only` filter.
|
|
81
|
+
const work: string[] = [];
|
|
82
|
+
for (const name of candidates) {
|
|
83
|
+
if (options?.only && !options.only.has(name)) continue;
|
|
35
84
|
|
|
36
|
-
for (const name of entries) {
|
|
37
85
|
const indexPath = join(resolvedDir, name, "index.ts");
|
|
38
|
-
|
|
39
86
|
try {
|
|
40
87
|
await Deno.stat(indexPath);
|
|
88
|
+
work.push(name);
|
|
41
89
|
} catch {
|
|
42
|
-
|
|
43
|
-
|
|
90
|
+
// No index.ts. If we were asked to reload this name explicitly, treat as
|
|
91
|
+
// a deletion so the caller can evict it. Otherwise just skip.
|
|
92
|
+
if (options?.only?.has(name)) {
|
|
93
|
+
removed.push(name);
|
|
94
|
+
} else {
|
|
95
|
+
skipped.push(name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Also detect deletions from `only` that didn't appear as candidates at all
|
|
101
|
+
// (e.g. the whole directory was removed).
|
|
102
|
+
if (options?.only) {
|
|
103
|
+
const present = new Set(candidates);
|
|
104
|
+
for (const name of options.only) {
|
|
105
|
+
if (!present.has(name) && !removed.includes(name)) {
|
|
106
|
+
removed.push(name);
|
|
107
|
+
}
|
|
44
108
|
}
|
|
109
|
+
}
|
|
45
110
|
|
|
46
|
-
|
|
111
|
+
const total = work.length;
|
|
112
|
+
let completed = 0;
|
|
47
113
|
|
|
114
|
+
const loadOne = async (name: string): Promise<void> => {
|
|
115
|
+
const indexPath = join(resolvedDir, name, "index.ts");
|
|
116
|
+
// pathToFileURL is the node:url equivalent of std/path's toFileUrl;
|
|
117
|
+
// both produce a `file://` URL whose searchParams we mutate below for
|
|
118
|
+
// HMR cache-busting.
|
|
119
|
+
const moduleUrl = pathToFileURL(indexPath);
|
|
120
|
+
if (options?.cacheBust) {
|
|
121
|
+
moduleUrl.searchParams.set("v", options.cacheBust);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const start = performance.now();
|
|
48
125
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
126
|
+
// Load the manifest first so it is attached to the registry before the
|
|
127
|
+
// module's top-level `serve()` call captures the handler. Manifest
|
|
128
|
+
// failures degrade to defaults inside loadManifest().
|
|
129
|
+
const manifest = await loadManifest(resolvedDir, name);
|
|
130
|
+
|
|
131
|
+
// Lazy mode: skip the dynamic import unless the manifest opts in via
|
|
132
|
+
// `warm: true`. Register a candidate so the first dispatch can import
|
|
133
|
+
// on demand. This is the single biggest startup-time win for sites
|
|
134
|
+
// with many rarely-called functions.
|
|
135
|
+
if (options?.lazy && !manifest.warm) {
|
|
136
|
+
registry.registerCandidate({ name, moduleUrl: moduleUrl.href, manifest });
|
|
137
|
+
deferred.push(name);
|
|
138
|
+
const durationMs = performance.now() - start;
|
|
139
|
+
options?.onProgress?.({
|
|
140
|
+
name,
|
|
141
|
+
index: ++completed,
|
|
142
|
+
total,
|
|
143
|
+
durationMs,
|
|
144
|
+
ok: true,
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
52
147
|
}
|
|
53
|
-
|
|
148
|
+
|
|
149
|
+
registry.attachManifest(name, manifest);
|
|
150
|
+
// Even in lazy mode, eagerly-warm functions still register a candidate
|
|
151
|
+
// so HMR reloads can re-import via the same URL.
|
|
152
|
+
if (options?.lazy && manifest.warm) {
|
|
153
|
+
registry.registerCandidate({ name, moduleUrl: moduleUrl.href, manifest });
|
|
154
|
+
}
|
|
155
|
+
await registry.runWithCurrentFunction(name, async () => {
|
|
156
|
+
await import(moduleUrl.href);
|
|
157
|
+
});
|
|
54
158
|
loaded.push(name);
|
|
159
|
+
const durationMs = performance.now() - start;
|
|
160
|
+
options?.onProgress?.({
|
|
161
|
+
name,
|
|
162
|
+
index: ++completed,
|
|
163
|
+
total,
|
|
164
|
+
durationMs,
|
|
165
|
+
ok: true,
|
|
166
|
+
});
|
|
55
167
|
} catch (err) {
|
|
56
168
|
const msg = err instanceof Error ? err.message : String(err);
|
|
57
169
|
errors.push({ name, error: msg });
|
|
170
|
+
const durationMs = performance.now() - start;
|
|
171
|
+
options?.onProgress?.({
|
|
172
|
+
name,
|
|
173
|
+
index: ++completed,
|
|
174
|
+
total,
|
|
175
|
+
durationMs,
|
|
176
|
+
ok: false,
|
|
177
|
+
error: msg,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
58
181
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
console.error(`[1tube] Failed to load "${name}": ${msg}`);
|
|
69
|
-
}
|
|
182
|
+
// Bounded parallel loader. Deno's module loader serializes some FS work
|
|
183
|
+
// internally, but we still benefit from overlapping transpile + JSR fetches.
|
|
184
|
+
const concurrency = Math.max(1, options?.concurrency ?? 8);
|
|
185
|
+
let cursor = 0;
|
|
186
|
+
const workers = Array.from({ length: Math.min(concurrency, work.length) }, async () => {
|
|
187
|
+
while (true) {
|
|
188
|
+
const i = cursor++;
|
|
189
|
+
if (i >= work.length) return;
|
|
190
|
+
await loadOne(work[i]);
|
|
70
191
|
}
|
|
71
|
-
}
|
|
192
|
+
});
|
|
193
|
+
await Promise.all(workers);
|
|
72
194
|
|
|
73
|
-
return { loaded, skipped, errors };
|
|
195
|
+
return { loaded, skipped, errors, removed, deferred };
|
|
74
196
|
}
|
package/src/env-scope.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-function env-var scoping.
|
|
3
|
+
*
|
|
4
|
+
* When `1TUBE_ENFORCE_MANIFEST=1`, `installEnvScope()` replaces `Deno.env` with
|
|
5
|
+
* a wrapper that filters reads (`get`, `has`, `toObject`) against the active
|
|
6
|
+
* function's manifest `permissions.env` allowlist, using AsyncLocalStorage to
|
|
7
|
+
* know which function is currently executing. Outside any function context
|
|
8
|
+
* (e.g. server bootstrap, gateway middleware) all env access passes through
|
|
9
|
+
* unchanged so the gateway itself never gets sandboxed.
|
|
10
|
+
*
|
|
11
|
+
* Writes (`set`, `delete`) are always rejected from inside a function context
|
|
12
|
+
* — functions should not be able to mutate process env on each other.
|
|
13
|
+
*
|
|
14
|
+
* `1TUBE_ALLOW_ALL=1` short-circuits the scope and is the documented escape
|
|
15
|
+
* hatch for legacy functions whose env usage hasn't been audited yet.
|
|
16
|
+
*
|
|
17
|
+
* This is opt-in, monitored on bootstrap, and idempotent.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
21
|
+
|
|
22
|
+
export interface EnvScopeContext {
|
|
23
|
+
functionName: string;
|
|
24
|
+
/** Allowlisted env var names (manifest.permissions.env). */
|
|
25
|
+
allow: ReadonlySet<string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const envScopeStorage = new AsyncLocalStorage<EnvScopeContext>();
|
|
29
|
+
|
|
30
|
+
let _installed = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace Deno.env with a filtering wrapper. Safe to call multiple times;
|
|
34
|
+
* only the first call has an effect.
|
|
35
|
+
*/
|
|
36
|
+
export function installEnvScope(): void {
|
|
37
|
+
if (_installed) return;
|
|
38
|
+
_installed = true;
|
|
39
|
+
|
|
40
|
+
const original = Deno.env;
|
|
41
|
+
const allowAll = (Deno.env.get("1TUBE_ALLOW_ALL") ?? "") === "1";
|
|
42
|
+
|
|
43
|
+
const wrap: typeof Deno.env = {
|
|
44
|
+
get(key: string) {
|
|
45
|
+
const ctx = envScopeStorage.getStore();
|
|
46
|
+
if (!ctx || allowAll) return original.get(key);
|
|
47
|
+
if (ctx.allow.has(key)) return original.get(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
},
|
|
50
|
+
has(key: string) {
|
|
51
|
+
const ctx = envScopeStorage.getStore();
|
|
52
|
+
if (!ctx || allowAll) return original.has(key);
|
|
53
|
+
if (!ctx.allow.has(key)) return false;
|
|
54
|
+
return original.has(key);
|
|
55
|
+
},
|
|
56
|
+
set(key: string, value: string) {
|
|
57
|
+
const ctx = envScopeStorage.getStore();
|
|
58
|
+
if (ctx && !allowAll) {
|
|
59
|
+
throw new Deno.errors.PermissionDenied(
|
|
60
|
+
`Function "${ctx.functionName}" attempted Deno.env.set(${JSON.stringify(key)}); ` +
|
|
61
|
+
`env writes are not permitted from function code.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
original.set(key, value);
|
|
65
|
+
},
|
|
66
|
+
delete(key: string) {
|
|
67
|
+
const ctx = envScopeStorage.getStore();
|
|
68
|
+
if (ctx && !allowAll) {
|
|
69
|
+
throw new Deno.errors.PermissionDenied(
|
|
70
|
+
`Function "${ctx.functionName}" attempted Deno.env.delete(${JSON.stringify(key)}); ` +
|
|
71
|
+
`env writes are not permitted from function code.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
original.delete(key);
|
|
75
|
+
},
|
|
76
|
+
toObject() {
|
|
77
|
+
const ctx = envScopeStorage.getStore();
|
|
78
|
+
if (!ctx || allowAll) return original.toObject();
|
|
79
|
+
const all = original.toObject();
|
|
80
|
+
const filtered: Record<string, string> = {};
|
|
81
|
+
for (const k of ctx.allow) {
|
|
82
|
+
if (k in all) filtered[k] = all[k];
|
|
83
|
+
}
|
|
84
|
+
return filtered;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
Object.defineProperty(Deno, "env", {
|
|
90
|
+
value: wrap,
|
|
91
|
+
writable: false,
|
|
92
|
+
configurable: true,
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
// Deno.env property is locked down on this runtime — patch the methods on
|
|
96
|
+
// the existing object instead. This still gives us the same scoping
|
|
97
|
+
// behaviour because the wrapper closes over `original`.
|
|
98
|
+
(original as { get: typeof wrap.get }).get = wrap.get;
|
|
99
|
+
(original as { has: typeof wrap.has }).has = wrap.has;
|
|
100
|
+
(original as { set: typeof wrap.set }).set = wrap.set;
|
|
101
|
+
(original as { delete: typeof wrap.delete }).delete = wrap.delete;
|
|
102
|
+
(original as { toObject: typeof wrap.toObject }).toObject = wrap.toObject;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run `fn` with the env-scope context bound to `name` + `allow`. Any nested
|
|
108
|
+
* `Deno.env.*` call sees only the allowlisted variables.
|
|
109
|
+
*/
|
|
110
|
+
export function runWithEnvScope<T>(
|
|
111
|
+
ctx: EnvScopeContext,
|
|
112
|
+
fn: () => T | Promise<T>,
|
|
113
|
+
): T | Promise<T> {
|
|
114
|
+
return envScopeStorage.run(ctx, fn);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Test-only: allow re-installation in unit tests. */
|
|
118
|
+
export function _resetEnvScopeForTests(): void {
|
|
119
|
+
_installed = false;
|
|
120
|
+
}
|
package/src/gateway/auth.ts
CHANGED
|
@@ -3,25 +3,71 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Validates HMAC-SHA256 JWTs using the same secret as Supabase,
|
|
5
5
|
* producing tokens fully compatible with Supabase RLS policies.
|
|
6
|
+
*
|
|
7
|
+
* The JWT secret is read lazily on first use so that env defaults applied
|
|
8
|
+
* by the server bootstrap (e.g. dev mode) take effect even though this
|
|
9
|
+
* module is imported transitively before bootstrap runs.
|
|
10
|
+
*
|
|
11
|
+
* Verified payloads are cached in a bounded LRU keyed by the raw token
|
|
12
|
+
* string. Cache hits skip the SubtleCrypto verify (~50–200µs each on a
|
|
13
|
+
* realistic workload) and the JSON.parse, which is the single biggest
|
|
14
|
+
* single-process throughput win on the hot path.
|
|
15
|
+
*
|
|
16
|
+
* - Cache is invalidated implicitly when JWT_SECRET rotates
|
|
17
|
+
* (see `getSecret()`), so a stale entry can never validate against
|
|
18
|
+
* a new key.
|
|
19
|
+
* - Each entry stores the token's `exp` so we never serve a payload
|
|
20
|
+
* past its TTL.
|
|
21
|
+
* - The map is bounded; oldest insertions are evicted first
|
|
22
|
+
* (insertion-order LRU; bumped on each hit).
|
|
6
23
|
*/
|
|
7
24
|
|
|
8
25
|
import type { AuthContext, JWTPayload } from "../registry.ts";
|
|
9
26
|
|
|
10
|
-
const JWT_SECRET = Deno.env.get("JWT_SECRET") || "";
|
|
11
|
-
if (!JWT_SECRET) {
|
|
12
|
-
console.warn("[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
27
|
const encoder = new TextEncoder();
|
|
16
28
|
const decoder = new TextDecoder();
|
|
17
29
|
|
|
18
30
|
let _cachedKey: CryptoKey | null = null;
|
|
31
|
+
let _cachedSecret: string | null = null;
|
|
32
|
+
let _warnedNoSecret = false;
|
|
33
|
+
|
|
34
|
+
const VERIFY_CACHE_DEFAULT_MAX = 5_000;
|
|
35
|
+
let _verifyCacheMax = VERIFY_CACHE_DEFAULT_MAX;
|
|
36
|
+
|
|
37
|
+
interface VerifiedEntry {
|
|
38
|
+
payload: JWTPayload;
|
|
39
|
+
expiresAtMs: number;
|
|
40
|
+
}
|
|
19
41
|
|
|
20
|
-
|
|
42
|
+
const verifyCache = new Map<string, VerifiedEntry>();
|
|
43
|
+
let _verifyCacheHits = 0;
|
|
44
|
+
let _verifyCacheMisses = 0;
|
|
45
|
+
|
|
46
|
+
function getSecret(): string {
|
|
47
|
+
const current = Deno.env.get("JWT_SECRET") || "";
|
|
48
|
+
if (current !== _cachedSecret) {
|
|
49
|
+
_cachedSecret = current;
|
|
50
|
+
_cachedKey = null;
|
|
51
|
+
// Any cached verifications were authorised against the previous key;
|
|
52
|
+
// drop them so a rotated secret cannot accidentally accept old tokens.
|
|
53
|
+
verifyCache.clear();
|
|
54
|
+
if (!current && !_warnedNoSecret) {
|
|
55
|
+
console.warn(
|
|
56
|
+
"[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests",
|
|
57
|
+
);
|
|
58
|
+
_warnedNoSecret = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getKey(): Promise<CryptoKey | null> {
|
|
65
|
+
const secret = getSecret();
|
|
66
|
+
if (!secret) return null;
|
|
21
67
|
if (_cachedKey) return _cachedKey;
|
|
22
68
|
_cachedKey = await crypto.subtle.importKey(
|
|
23
69
|
"raw",
|
|
24
|
-
encoder.encode(
|
|
70
|
+
encoder.encode(secret),
|
|
25
71
|
{ name: "HMAC", hash: "SHA-256" },
|
|
26
72
|
false,
|
|
27
73
|
["verify"],
|
|
@@ -42,13 +88,54 @@ function parseBearer(header: string | null): string | null {
|
|
|
42
88
|
return match ? match[1] : null;
|
|
43
89
|
}
|
|
44
90
|
|
|
91
|
+
function cacheGet(token: string, nowMs: number): JWTPayload | null {
|
|
92
|
+
const hit = verifyCache.get(token);
|
|
93
|
+
if (!hit) return null;
|
|
94
|
+
if (hit.expiresAtMs <= nowMs) {
|
|
95
|
+
verifyCache.delete(token);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
// LRU bump: re-insert so this entry becomes the most recently used.
|
|
99
|
+
verifyCache.delete(token);
|
|
100
|
+
verifyCache.set(token, hit);
|
|
101
|
+
_verifyCacheHits++;
|
|
102
|
+
return hit.payload;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cacheSet(token: string, payload: JWTPayload): void {
|
|
106
|
+
// Evict insertion-oldest while we're over capacity. Loop because the cap
|
|
107
|
+
// can be lowered at runtime via `_setVerifyCacheMaxForTests()`.
|
|
108
|
+
while (verifyCache.size >= _verifyCacheMax) {
|
|
109
|
+
const oldest = verifyCache.keys().next().value;
|
|
110
|
+
if (oldest === undefined) break;
|
|
111
|
+
verifyCache.delete(oldest);
|
|
112
|
+
}
|
|
113
|
+
verifyCache.set(token, {
|
|
114
|
+
payload,
|
|
115
|
+
expiresAtMs: payload.exp * 1000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
45
119
|
async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|
120
|
+
// Resolve the secret BEFORE consulting the cache. getSecret() detects
|
|
121
|
+
// rotation and clears stale entries; if we read the cache first, a token
|
|
122
|
+
// signed with the previous key would be returned to the caller before the
|
|
123
|
+
// rotation took effect.
|
|
124
|
+
const secret = getSecret();
|
|
125
|
+
if (!secret) return null;
|
|
126
|
+
|
|
127
|
+
const nowMs = Date.now();
|
|
128
|
+
const cached = cacheGet(token, nowMs);
|
|
129
|
+
if (cached) return cached;
|
|
130
|
+
|
|
131
|
+
_verifyCacheMisses++;
|
|
46
132
|
try {
|
|
47
133
|
const parts = token.split(".");
|
|
48
134
|
if (parts.length !== 3) return null;
|
|
49
135
|
|
|
50
136
|
const [headerB64, payloadB64, signatureB64] = parts;
|
|
51
137
|
const key = await getKey();
|
|
138
|
+
if (!key) return null;
|
|
52
139
|
const sigBytes = base64UrlDecode(signatureB64);
|
|
53
140
|
|
|
54
141
|
const isValid = await crypto.subtle.verify(
|
|
@@ -61,8 +148,15 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
|
|
|
61
148
|
|
|
62
149
|
const payload = JSON.parse(decoder.decode(base64UrlDecode(payloadB64))) as JWTPayload;
|
|
63
150
|
|
|
64
|
-
|
|
151
|
+
// `exp` is a NumericDate (seconds since epoch). Per RFC 7519 the token
|
|
152
|
+
// MUST NOT be accepted past `exp`; we treat the second the token expires
|
|
153
|
+
// as "past" so a 1-second TTL token can't be replayed within the same
|
|
154
|
+
// wall-clock second.
|
|
155
|
+
if (typeof payload.exp !== "number" || payload.exp * 1000 <= nowMs) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
65
158
|
|
|
159
|
+
cacheSet(token, payload);
|
|
66
160
|
return payload;
|
|
67
161
|
} catch {
|
|
68
162
|
return null;
|
|
@@ -86,3 +180,44 @@ export async function validateRequest(req: Request): Promise<AuthContext | null>
|
|
|
86
180
|
rawToken: token,
|
|
87
181
|
};
|
|
88
182
|
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Diagnostics & test hooks
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export interface JwtVerifyCacheStats {
|
|
189
|
+
size: number;
|
|
190
|
+
capacity: number;
|
|
191
|
+
hits: number;
|
|
192
|
+
misses: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getVerifyCacheStats(): JwtVerifyCacheStats {
|
|
196
|
+
return {
|
|
197
|
+
size: verifyCache.size,
|
|
198
|
+
capacity: _verifyCacheMax,
|
|
199
|
+
hits: _verifyCacheHits,
|
|
200
|
+
misses: _verifyCacheMisses,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Test-only: clear the cache + counters and reset key/secret memoisation. */
|
|
205
|
+
export function _resetAuthForTests(): void {
|
|
206
|
+
verifyCache.clear();
|
|
207
|
+
_verifyCacheHits = 0;
|
|
208
|
+
_verifyCacheMisses = 0;
|
|
209
|
+
_cachedKey = null;
|
|
210
|
+
_cachedSecret = null;
|
|
211
|
+
_warnedNoSecret = false;
|
|
212
|
+
_verifyCacheMax = VERIFY_CACHE_DEFAULT_MAX;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Test-only: shrink the cache so eviction is observable in unit tests. */
|
|
216
|
+
export function _setVerifyCacheMaxForTests(max: number): void {
|
|
217
|
+
_verifyCacheMax = Math.max(1, Math.floor(max));
|
|
218
|
+
while (verifyCache.size > _verifyCacheMax) {
|
|
219
|
+
const oldest = verifyCache.keys().next().value;
|
|
220
|
+
if (oldest === undefined) break;
|
|
221
|
+
verifyCache.delete(oldest);
|
|
222
|
+
}
|
|
223
|
+
}
|