edgeway 0.1.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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +444 -0
  4. data/exe/edgeway +6 -0
  5. data/js/cgi.js +157 -0
  6. data/js/cloudflare.js +103 -0
  7. data/js/cors.js +87 -0
  8. data/js/jwt.js +279 -0
  9. data/js/runtime.js +99 -0
  10. data/js/wasi-shim.js +191 -0
  11. data/lib/edgeway/application.rb +70 -0
  12. data/lib/edgeway/cli/app_config.rb +35 -0
  13. data/lib/edgeway/cli/app_files.rb +37 -0
  14. data/lib/edgeway/cli/build.rb +90 -0
  15. data/lib/edgeway/cli/generate.rb +160 -0
  16. data/lib/edgeway/cli/new.rb +43 -0
  17. data/lib/edgeway/cli/package_manager.rb +21 -0
  18. data/lib/edgeway/cli/precheck.rb +45 -0
  19. data/lib/edgeway/cli/routes.rb +22 -0
  20. data/lib/edgeway/cli/test.rb +33 -0
  21. data/lib/edgeway/cli/toolchain.rb +35 -0
  22. data/lib/edgeway/cli.rb +59 -0
  23. data/lib/edgeway/config.rb +9 -0
  24. data/lib/edgeway/controller/authentication.rb +42 -0
  25. data/lib/edgeway/controller/base.rb +95 -0
  26. data/lib/edgeway/controller/callbacks.rb +29 -0
  27. data/lib/edgeway/controller/dispatcher.rb +39 -0
  28. data/lib/edgeway/controller/rendering.rb +65 -0
  29. data/lib/edgeway/controller/rescuable.rb +26 -0
  30. data/lib/edgeway/db/adapter.rb +32 -0
  31. data/lib/edgeway/db/memory.rb +73 -0
  32. data/lib/edgeway/db/sqlite.rb +190 -0
  33. data/lib/edgeway/errors.rb +61 -0
  34. data/lib/edgeway/generators/app/templates/.gitignore +6 -0
  35. data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
  36. data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
  37. data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
  38. data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
  39. data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
  40. data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
  41. data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
  42. data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
  43. data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
  44. data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
  45. data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
  46. data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
  47. data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
  48. data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
  49. data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
  50. data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
  51. data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
  52. data/lib/edgeway/http/auth/jwt.rb +127 -0
  53. data/lib/edgeway/http/auth/stub.rb +29 -0
  54. data/lib/edgeway/http/json_body.rb +37 -0
  55. data/lib/edgeway/http/parameters.rb +42 -0
  56. data/lib/edgeway/http/request.rb +115 -0
  57. data/lib/edgeway/http/response.rb +46 -0
  58. data/lib/edgeway/inflector.rb +16 -0
  59. data/lib/edgeway/logger.rb +21 -0
  60. data/lib/edgeway/model/attributes.rb +85 -0
  61. data/lib/edgeway/model/base.rb +173 -0
  62. data/lib/edgeway/model/macros.rb +60 -0
  63. data/lib/edgeway/model/persistence.rb +154 -0
  64. data/lib/edgeway/model/relation.rb +295 -0
  65. data/lib/edgeway/model/validations.rb +136 -0
  66. data/lib/edgeway/routing/mapper.rb +57 -0
  67. data/lib/edgeway/routing/route.rb +39 -0
  68. data/lib/edgeway/routing/route_set.rb +27 -0
  69. data/lib/edgeway/server.rb +12 -0
  70. data/lib/edgeway/service/cloudflare/d1.rb +101 -0
  71. data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
  72. data/lib/edgeway/test.rb +97 -0
  73. data/lib/edgeway/version.rb +5 -0
  74. data/lib/edgeway.rb +30 -0
  75. metadata +115 -0
data/js/cloudflare.js ADDED
@@ -0,0 +1,103 @@
1
+ // The Cloudflare Workers side of an Edgeway app. A worker is three lines:
2
+ //
3
+ // import app from "../build/app.wasm";
4
+ // import { createWorker } from "../build/edgeway/cloudflare.js";
5
+ // export default createWorker(app);
6
+ //
7
+ // build/edgeway/config.js is written by `edgeway build` from
8
+ // config/database.yml: the environment name and the D1 binding.
9
+ import { runCgi } from "./runtime.js";
10
+ import config from "./config.js";
11
+ import { jwtImports } from "./jwt.js";
12
+ import { withCors } from "./cors.js";
13
+
14
+ // The wasm import module "edgeway_d1" (lib/edgeway/service/cloudflare/d1.rb). Each call
15
+ // answers the byte length of its JSON result; take() copies it out.
16
+ // query(binding, sql, binds) -> {"rows": [...]}
17
+ // execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id}
18
+ // batch(binding, statements) -> {"results": [{"changes": n, "last_insert_id": id}, ...]}
19
+ // (statements: [[sql, binds], ...], one atomic D1 batch)
20
+ // An error is {"error": "message"}.
21
+ export function d1Imports(env, getInstance) {
22
+ const mem = () => new Uint8Array(getInstance().exports.memory.buffer);
23
+ const cstr = (p) => {
24
+ const b = mem();
25
+ let e = p;
26
+ while (b[e]) e++;
27
+ return new TextDecoder().decode(b.subarray(p, e));
28
+ };
29
+ let pending = new Uint8Array(0);
30
+
31
+ // fn(db, ...pointers) answers the result object; a failure is {error}.
32
+ const suspending = (fn) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
33
+ let res;
34
+ try {
35
+ const name = cstr(bindingPtr) || config.d1Binding;
36
+ const db = env[name];
37
+ if (!db) throw new Error(`no D1 binding ${name}`);
38
+ res = await fn(db, ...ptrs);
39
+ } catch (e) {
40
+ res = { error: String(e?.message ?? e) };
41
+ }
42
+ pending = new TextEncoder().encode(JSON.stringify(res));
43
+ return pending.length;
44
+ });
45
+ const run = (fn) => suspending((db, sqlPtr, bindsPtr) =>
46
+ fn(db.prepare(cstr(sqlPtr)).bind(...JSON.parse(cstr(bindsPtr)))));
47
+
48
+ return {
49
+ edgeway_d1: {
50
+ query: run(async (st) => ({ rows: (await st.all()).results })),
51
+ execute: run(async (st) => {
52
+ const meta = (await st.run()).meta;
53
+ return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0 };
54
+ }),
55
+ // db.batch() is one transaction: a failing statement rejects it and
56
+ // none of the statements stay applied.
57
+ batch: suspending(async (db, statementsPtr) => {
58
+ const statements = JSON.parse(cstr(statementsPtr));
59
+ const results = await db.batch(statements.map(([sql, binds]) => db.prepare(sql).bind(...binds)));
60
+ return {
61
+ results: results.map((r) => ({ changes: r.meta.changes ?? 0, last_insert_id: r.meta.last_row_id ?? 0 })),
62
+ };
63
+ }),
64
+ take(buf) {
65
+ mem().set(pending, buf);
66
+ },
67
+ },
68
+ };
69
+ }
70
+
71
+ // The settings of the framework that Edgeway.config reads in wasm. The CORS_*
72
+ // ones and JWT_SECRET are read from env in JS (cors.js, jwt.js), so they stay
73
+ // out.
74
+ const FRAMEWORK_VARS = new Set([
75
+ "JWT_ISSUER", "JWT_AUDIENCE", "JWT_LEEWAY", "JWT_ALGORITHMS", "JWT_JWKS_URL",
76
+ ]);
77
+
78
+ // The Worker's variables (strings) become CGI environment variables, which
79
+ // Edgeway.config reads. Only an allow-list goes in, so a secret that the
80
+ // Worker holds for something else is not readable by the app (or its logs):
81
+ // the framework's settings, and the names that start with APP_, for the app's
82
+ // own. JWT_SECRET never does: js/jwt.js reads it from env, so the key never
83
+ // enters wasm.
84
+ export function varsOf(env) {
85
+ const vars = {};
86
+ for (const [k, v] of Object.entries(env)) {
87
+ if (typeof v !== "string" || k === "JWT_SECRET") continue;
88
+ if (FRAMEWORK_VARS.has(k) || k.startsWith("APP_")) vars[k] = v;
89
+ }
90
+ return vars;
91
+ }
92
+
93
+ // appModule: the WebAssembly.Module that `import app from "...app.wasm"` gives.
94
+ export function createWorker(appModule) {
95
+ return {
96
+ fetch: withCors((request, env) => runCgi(appModule, request, {
97
+ extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
98
+ // Both modules are always supplied, whether or not the app uses them:
99
+ // the wasm instance needs every import it declares.
100
+ imports: (getInstance) => ({ ...d1Imports(env, getInstance), ...jwtImports(env, getInstance) }),
101
+ })),
102
+ };
103
+ }
data/js/cors.js ADDED
@@ -0,0 +1,87 @@
1
+ // CORS, done in the Worker around the app (see README "CORS"). A preflight is
2
+ // answered without starting the wasm, and a response made by the JS layer
3
+ // itself (the 500 of a trap) gets the headers too, so a browser sees the real
4
+ // status instead of an opaque CORS error. Nothing here is specific to a
5
+ // platform. Cookies are not supported: Access-Control-Allow-Credentials is
6
+ // never sent.
7
+ const DEFAULT_HEADERS = "Authorization, Content-Type";
8
+ const DEFAULT_MAX_AGE = "600";
9
+ const METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE";
10
+
11
+ function json(status, body, headers = {}) {
12
+ return new Response(body, { status, headers: { "Content-Type": "application/json", ...headers } });
13
+ }
14
+
15
+ // The settings from the Worker's variables, or null when CORS is off.
16
+ // A bad CORS_MAX_AGE is { error }: like JWT, a misconfiguration is a 500, not
17
+ // a silent default.
18
+ function settings(env) {
19
+ const list = (env.CORS_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
20
+ if (list.length === 0) return null;
21
+ const maxAge = (env.CORS_MAX_AGE ?? "").trim() || DEFAULT_MAX_AGE;
22
+ if (!/^\d+$/.test(maxAge)) return { error: `CORS_MAX_AGE must be a non-negative integer, got "${env.CORS_MAX_AGE}"` };
23
+ return {
24
+ any: list.includes("*"),
25
+ origins: new Set(list),
26
+ headers: (env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS,
27
+ maxAge,
28
+ expose: (env.CORS_EXPOSE_HEADERS ?? "").trim(),
29
+ };
30
+ }
31
+
32
+ // "null" is what sandboxed iframes and file: pages send; it names no site, so
33
+ // it is refused even for "*".
34
+ function allowed(cfg, origin) {
35
+ if (origin === null || origin === "null") return false;
36
+ return cfg.any || cfg.origins.has(origin);
37
+ }
38
+
39
+ // Appends Origin to Vary unless it (or *) is already there.
40
+ function addVary(headers, ...names) {
41
+ const have = (headers.get("Vary") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
42
+ if (have.includes("*")) return;
43
+ for (const n of names) {
44
+ if (!have.some((h) => h.toLowerCase() === n.toLowerCase())) have.push(n);
45
+ }
46
+ headers.set("Vary", have.join(", "));
47
+ }
48
+
49
+ // Wraps a fetch handler (request, env, ctx) -> Response.
50
+ export function withCors(handler) {
51
+ return async (request, env, ctx) => {
52
+ const cfg = settings(env);
53
+ if (!cfg) return handler(request, env, ctx);
54
+ if (cfg.error) {
55
+ console.error(cfg.error);
56
+ return json(500, '{"error":"internal error"}\n');
57
+ }
58
+
59
+ const origin = request.headers.get("Origin");
60
+ const ok = origin !== null && allowed(cfg, origin);
61
+
62
+ if (request.method === "OPTIONS" && origin !== null && request.headers.has("Access-Control-Request-Method")) {
63
+ if (!ok) return json(403, '{"error":"origin not allowed"}\n', { Vary: "Origin" });
64
+ return new Response(null, {
65
+ status: 204,
66
+ headers: {
67
+ "Access-Control-Allow-Origin": origin,
68
+ "Access-Control-Allow-Methods": METHODS,
69
+ // Listed, not *: a * does not cover Authorization.
70
+ "Access-Control-Allow-Headers": cfg.headers,
71
+ "Access-Control-Max-Age": cfg.maxAge,
72
+ Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
73
+ },
74
+ });
75
+ }
76
+
77
+ // Copied so the headers can be changed whatever the handler returned.
78
+ const res = await handler(request, env, ctx);
79
+ const out = new Response(res.body, res);
80
+ if (ok) {
81
+ out.headers.set("Access-Control-Allow-Origin", origin);
82
+ if (cfg.expose) out.headers.set("Access-Control-Expose-Headers", cfg.expose);
83
+ }
84
+ addVary(out.headers, "Origin");
85
+ return out;
86
+ };
87
+ }
data/js/jwt.js ADDED
@@ -0,0 +1,279 @@
1
+ // JWT signature verification with WebCrypto, for tokens an external IdP
2
+ // issued (Auth0, Firebase, Cognito, ...; Cloudflare Access is not supported yet). Nothing here is
3
+ // specific to a platform: cloudflare.js hands jwtImports the Worker's env.
4
+ // This checks the signature and the algorithm only; the claims (exp, iss,
5
+ // aud, ...) are checked in Ruby (lib/edgeway/http/auth/jwt.rb).
6
+ //
7
+ // Settings (strings in the env):
8
+ // JWT_ALGORITHMS comma-separated allow-list, default "RS256". The token's
9
+ // alg must be in it, whatever the token says ("none" never is)
10
+ // JWT_JWKS_URL where the public keys are, for RS*/PS*/ES*/EdDSA; https,
11
+ // except http://localhost, 127.0.0.1 and [::1]
12
+ // JWT_SECRET the shared secret, for HS*; at least as long as the hash
13
+ // (RFC 7518 3.2): 32 bytes for HS256, 48 for HS384, 64 for HS512
14
+ // A key never serves the other family: an HS* token is checked only with
15
+ // JWT_SECRET, and any other only with a key of the JWKS (so a public key
16
+ // can't be passed off as an HMAC secret).
17
+ //
18
+ // Hardened as jose does (and a little more): a JWKS is never used past its TTL
19
+ // (no stale fallback), is fetched with a timeout, no redirect, only on status
20
+ // 200 and at most MAX_JWKS bytes; a token with a `crit` header is refused
21
+ // (RFC 7515 4.1.11: no extension is understood), and a JWKS key needs a `kid`
22
+ // on the token and, if it has key_ops, "verify" in them.
23
+
24
+ const MAX_TOKEN = 8192;
25
+ // A JWKS is kept this long, and re-fetched early (for an unknown kid) at
26
+ // most this often.
27
+ const JWKS_TTL_MS = 10 * 60 * 1000;
28
+ const JWKS_MIN_REFETCH_MS = 30 * 1000;
29
+ const JWKS_TIMEOUT_MS = 5000;
30
+ const MAX_JWKS = 64 * 1024;
31
+
32
+ // alg => how WebCrypto imports the key and verifies. The hash size is
33
+ // the RSA-PSS salt length.
34
+ const HASHES = { 256: "SHA-256", 384: "SHA-384", 512: "SHA-512" };
35
+ function algorithm(alg) {
36
+ const m = /^(HS|RS|PS|ES)(256|384|512)$/.exec(alg);
37
+ if (m) {
38
+ const [, family, bits] = m;
39
+ const hash = HASHES[bits];
40
+ switch (family) {
41
+ case "HS": return { family: "HS", import: { name: "HMAC", hash }, verify: { name: "HMAC" } };
42
+ case "RS": return { family: "RS", kty: "RSA", import: { name: "RSASSA-PKCS1-v1_5", hash }, verify: { name: "RSASSA-PKCS1-v1_5" } };
43
+ case "PS": return {
44
+ family: "PS", kty: "RSA", import: { name: "RSA-PSS", hash },
45
+ verify: { name: "RSA-PSS", saltLength: Number(bits) / 8 },
46
+ };
47
+ case "ES": {
48
+ const crv = { 256: "P-256", 384: "P-384" }[bits]; // ES512 (P-521) is left out
49
+ if (!crv) return null;
50
+ return { family: "ES", kty: "EC", crv, import: { name: "ECDSA", namedCurve: crv }, verify: { name: "ECDSA", hash } };
51
+ }
52
+ }
53
+ }
54
+ if (alg === "EdDSA") return { family: "EdDSA", kty: "OKP", crv: "Ed25519", import: { name: "Ed25519" }, verify: { name: "Ed25519" } };
55
+ return null;
56
+ }
57
+
58
+ // "invalid": the token is bad (401). "unavailable": it could not be checked
59
+ // (a missing setting, the JWKS can't be fetched), which is the server's
60
+ // problem (500).
61
+ class JwtError extends Error {
62
+ constructor(message, unavailable = false) {
63
+ super(message);
64
+ this.unavailable = unavailable;
65
+ }
66
+ }
67
+
68
+ function b64urlDecode(s) {
69
+ // A length of 1 mod 4 is never valid base64 (and makes atob throw a plain
70
+ // Error, which would be reported as "unavailable" instead of a bad token).
71
+ if (!/^[A-Za-z0-9_-]*$/.test(s) || s.length % 4 === 1) throw new JwtError("bad base64url");
72
+ const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/"));
73
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
74
+ }
75
+
76
+ function parseJsonPart(s, what) {
77
+ let v;
78
+ try {
79
+ v = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(b64urlDecode(s)));
80
+ } catch {
81
+ throw new JwtError(`${what} is not valid`);
82
+ }
83
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new JwtError(`${what} is not an object`);
84
+ return v;
85
+ }
86
+
87
+ // JWKS cache: url => { keys, at, imported: Map }. It lives at module scope:
88
+ // a wasm instance is made per request, but the isolate (and so this module)
89
+ // is reused. Only data is kept, never a Promise, because a Worker can't
90
+ // await I/O that another request started.
91
+ const sharedCache = new Map();
92
+
93
+ // Only https, so a JWKS can't be swapped on the way; a local server (tests,
94
+ // `wrangler dev`) may use http.
95
+ function jwksUrlAllowed(url) {
96
+ let u;
97
+ try {
98
+ u = new URL(url);
99
+ } catch {
100
+ return false;
101
+ }
102
+ if (u.protocol === "https:") return true;
103
+ return u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
104
+ }
105
+
106
+ // The body of res as text, at most MAX_JWKS bytes (checked on Content-Length
107
+ // when there is one, and again while reading, because a chunked body has none).
108
+ async function limitedText(res) {
109
+ const declared = res.headers?.get("content-length");
110
+ if (declared !== null && declared !== undefined && Number(declared) > MAX_JWKS) throw new Error("response is too large");
111
+ if (!res.body) throw new Error("no body");
112
+ const reader = res.body.getReader();
113
+ const chunks = [];
114
+ let size = 0;
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done) break;
118
+ size += value.byteLength;
119
+ if (size > MAX_JWKS) {
120
+ await reader.cancel().catch(() => {});
121
+ throw new Error("response is too large");
122
+ }
123
+ chunks.push(value);
124
+ }
125
+ const all = new Uint8Array(size);
126
+ let at = 0;
127
+ for (const c of chunks) {
128
+ all.set(c, at);
129
+ at += c.byteLength;
130
+ }
131
+ return new TextDecoder("utf-8", { fatal: true }).decode(all);
132
+ }
133
+
134
+ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cache = sharedCache, timeoutMs = JWKS_TIMEOUT_MS } = {}) {
135
+ const allowed = String(env.JWT_ALGORITHMS || "RS256").split(",").map((s) => s.trim()).filter(Boolean);
136
+
137
+ async function loadKeys(url, force) {
138
+ const entry = cache.get(url);
139
+ const t = now();
140
+ if (entry && (force ? t - entry.at < JWKS_MIN_REFETCH_MS : t - entry.at < JWKS_TTL_MS)) return entry;
141
+ // No stale JWKS: past its TTL (or when a forced refetch fails) a failed
142
+ // fetch is "unavailable", never an old key.
143
+ try {
144
+ const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
145
+ if (res.status !== 200) throw new Error(`status ${res.status}`);
146
+ const keys = JSON.parse(await limitedText(res)).keys;
147
+ if (!Array.isArray(keys)) throw new Error("no keys");
148
+ const fresh = { keys, at: t, imported: new Map() };
149
+ cache.set(url, fresh);
150
+ return fresh;
151
+ } catch (e) {
152
+ throw new JwtError(`cannot fetch the JWKS: ${e?.message ?? e}`, true);
153
+ }
154
+ }
155
+
156
+ // A key of the JWKS that can verify alg, selected by kid.
157
+ function pick(entry, alg, algo, kid) {
158
+ const fits = (k) => k && k.kty === algo.kty && (k.use === undefined || k.use === "sig")
159
+ && (k.key_ops === undefined || (Array.isArray(k.key_ops) && k.key_ops.includes("verify")))
160
+ && (k.alg === undefined || k.alg === alg || (alg === "EdDSA" && k.alg === "Ed25519")) && (algo.crv === undefined || k.crv === algo.crv);
161
+ return entry.keys.find((k) => k.kid === kid && fits(k));
162
+ }
163
+
164
+ async function importJwk(entry, jwk, algo) {
165
+ // imported: jwk => Map(alg name => CryptoKey). A JWK without `alg` can be
166
+ // used for several (RS256, RS384...), and each needs its own hash.
167
+ let keys = entry.imported.get(jwk);
168
+ if (!keys) entry.imported.set(jwk, (keys = new Map()));
169
+ const id = algo.import.name + (algo.import.hash ?? algo.import.namedCurve ?? "");
170
+ let key = keys.get(id);
171
+ if (!key) {
172
+ // pick() accepted "Ed25519" as the alg of an EdDSA key, but workerd's
173
+ // importKey refuses a JWK whose alg is not "EdDSA" (node accepts it).
174
+ const usable = jwk.alg === "Ed25519" ? { ...jwk, alg: "EdDSA" } : jwk;
175
+ key = await crypto.subtle.importKey("jwk", usable, algo.import, false, ["verify"]);
176
+ keys.set(id, key);
177
+ }
178
+ return key;
179
+ }
180
+
181
+ async function keyFor(alg, algo, kid) {
182
+ if (algo.family === "HS") {
183
+ if (!env.JWT_SECRET) throw new JwtError("JWT_SECRET is not set", true);
184
+ const secret = new TextEncoder().encode(env.JWT_SECRET);
185
+ // RFC 7518 3.2: a key at least as long as the hash output.
186
+ const need = Number(alg.slice(2)) / 8;
187
+ if (secret.length < need) throw new JwtError(`JWT_SECRET is too short for ${alg} (${need} bytes at least)`, true);
188
+ return crypto.subtle.importKey("raw", secret, algo.import, false, ["verify"]);
189
+ }
190
+ const url = env.JWT_JWKS_URL;
191
+ if (!url) throw new JwtError("JWT_JWKS_URL is not set", true);
192
+ if (!jwksUrlAllowed(url)) throw new JwtError("JWT_JWKS_URL must be https (http only for localhost)", true);
193
+ if (kid === undefined) throw new JwtError("kid is required");
194
+ let entry = await loadKeys(url, false);
195
+ let jwk = pick(entry, alg, algo, kid);
196
+ if (!jwk) {
197
+ // A key rotated in: look again, but not for every bad token.
198
+ entry = await loadKeys(url, true);
199
+ jwk = pick(entry, alg, algo, kid);
200
+ }
201
+ if (!jwk) throw new JwtError("unknown kid");
202
+ return importJwk(entry, jwk, algo);
203
+ }
204
+
205
+ // => { header, payload }; throws JwtError.
206
+ async function verify(token) {
207
+ if (token.length > MAX_TOKEN) throw new JwtError("token is too long");
208
+ const parts = token.split(".");
209
+ if (parts.length !== 3) throw new JwtError("token is not a JWT");
210
+ const header = parseJsonPart(parts[0], "header");
211
+ // Edgeway understands no extension, so a token that insists on one
212
+ // ("crit", RFC 7515 4.1.11) is refused.
213
+ if ("crit" in header) throw new JwtError("crit header is not supported");
214
+ const alg = header.alg;
215
+ if (typeof alg !== "string" || !allowed.includes(alg)) throw new JwtError("algorithm is not allowed");
216
+ const algo = algorithm(alg);
217
+ if (!algo) throw new JwtError("algorithm is not supported");
218
+ if (header.kid !== undefined && typeof header.kid !== "string") throw new JwtError("kid is not a string");
219
+ // Decoded before keyFor, so a malformed token costs no JWKS fetch.
220
+ const signature = b64urlDecode(parts[2]);
221
+ const key = await keyFor(alg, algo, header.kid);
222
+ const signed = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
223
+ let ok = false;
224
+ try {
225
+ ok = await crypto.subtle.verify(algo.verify, key, signature, signed);
226
+ } catch {
227
+ // A malformed signature (the wrong length...) is just not valid.
228
+ }
229
+ if (!ok) throw new JwtError("signature is not valid");
230
+ return { header, payload: parseJsonPart(parts[1], "payload") };
231
+ }
232
+
233
+ // The JSON the wasm side reads.
234
+ return async (token) => {
235
+ try {
236
+ return await verify(token);
237
+ } catch (e) {
238
+ if (!(e instanceof JwtError)) throw e;
239
+ return e.unavailable ? { error: e.message, unavailable: true } : { error: e.message };
240
+ }
241
+ };
242
+ }
243
+
244
+ // The wasm import module "edgeway_jwt" (lib/edgeway/service/cloudflare/jwt.rb):
245
+ // verify(token) -> {"header": {...}, "payload": {...}}
246
+ // | {"error": "message"} the token is bad
247
+ // | {"error": "message", "unavailable": true} it can't be checked
248
+ // Like D1, a call answers the byte length of the result and take() copies it
249
+ // out; verify is wrapped in WebAssembly.Suspending (the WebCrypto and fetch
250
+ // Promises are awaited with the wasm stack suspended).
251
+ export function jwtImports(env, getInstance, options = {}) {
252
+ const mem = () => new Uint8Array(getInstance().exports.memory.buffer);
253
+ const cstr = (p) => {
254
+ const b = mem();
255
+ let e = p;
256
+ while (b[e]) e++;
257
+ return new TextDecoder().decode(b.subarray(p, e));
258
+ };
259
+ const verifier = jwtVerifier(env, options);
260
+ let pending = new Uint8Array(0);
261
+
262
+ return {
263
+ edgeway_jwt: {
264
+ verify: new WebAssembly.Suspending(async (tokenPtr) => {
265
+ let res;
266
+ try {
267
+ res = await verifier(cstr(tokenPtr));
268
+ } catch (e) {
269
+ res = { error: String(e?.message ?? e), unavailable: true };
270
+ }
271
+ pending = new TextEncoder().encode(JSON.stringify(res));
272
+ return pending.length;
273
+ }),
274
+ take(buf) {
275
+ mem().set(pending, buf);
276
+ },
277
+ },
278
+ };
279
+ }
data/js/runtime.js ADDED
@@ -0,0 +1,99 @@
1
+ // Runs a Spinel-built app.wasm once per request, CGI style: the request
2
+ // becomes CGI environment variables and STDIN, the program's stdout becomes
3
+ // the Response. Nothing here is specific to a platform; cloudflare.js
4
+ // supplies the imports a platform adds (D1, ...) and the environment.
5
+ import { WASI } from "./wasi-shim.js";
6
+ import { cgiEnv, cgiResponse, concatBytes } from "./cgi.js";
7
+
8
+ // SPINEL_GC_SLAB_MB: the size of the GC slab, read from the WASI env at start.
9
+ // The default 64 MB makes an instance 77 MiB; with 8 MB it is ~21 MiB, so
10
+ // more requests fit in the 128 MB of an isolate.
11
+ const SLAB_MB = "8";
12
+
13
+ // The largest request body accepted. The body is copied into the instance's
14
+ // STDIN (and the CGI env), so an unbounded one would exhaust the isolate.
15
+ const MAX_BODY = 1024 * 1024;
16
+
17
+ function payloadTooLarge() {
18
+ return new Response('{"error":"payload too large"}\n', {
19
+ status: 413,
20
+ headers: { "Content-Type": "application/json" },
21
+ });
22
+ }
23
+
24
+ // The request's body as bytes, or null when it is over MAX_BODY. Content-Length
25
+ // is checked first, but a client can lie or send chunked, so the stream is
26
+ // read with a cap as well.
27
+ async function readBody(request) {
28
+ const declared = request.headers.get("content-length");
29
+ if (declared !== null && Number(declared) > MAX_BODY) return null;
30
+ if (!request.body) return new Uint8Array(0);
31
+ const chunks = [];
32
+ let total = 0;
33
+ const reader = request.body.getReader();
34
+ for (;;) {
35
+ const { done, value } = await reader.read();
36
+ if (done) break;
37
+ total += value.byteLength;
38
+ if (total > MAX_BODY) {
39
+ await reader.cancel();
40
+ return null;
41
+ }
42
+ chunks.push(value);
43
+ }
44
+ return concatBytes(chunks);
45
+ }
46
+
47
+ function internalError() {
48
+ return new Response('{"error":"internal error"}\n', {
49
+ status: 500,
50
+ headers: { "Content-Type": "application/json" },
51
+ });
52
+ }
53
+
54
+ // Runs appModule (a WebAssembly.Module) for one request and answers the
55
+ // Response.
56
+ // extraEnv: more CGI environment variables (strings), such as the app's
57
+ // settings and EDGEWAY_ENV.
58
+ // imports: (getInstance) => { moduleName: { name: fn } }, the wasm import
59
+ // modules besides WASI. A function wrapped in
60
+ // WebAssembly.Suspending may await (JSPI).
61
+ export async function runCgi(appModule, request, { extraEnv = {}, imports = () => ({}) } = {}) {
62
+ const body = request.method === "GET" || request.method === "HEAD"
63
+ ? new Uint8Array(0)
64
+ : await readBody(request);
65
+ if (body === null) return payloadTooLarge();
66
+
67
+ const out = [];
68
+ let err = "";
69
+ const wasi = new WASI({
70
+ args: ["app"],
71
+ env: { ...extraEnv, ...cgiEnv(request, body), SPINEL_GC_SLAB_MB: SLAB_MB },
72
+ stdin: body,
73
+ onStdoutBytes: (b) => out.push(b),
74
+ onStderr: (s) => (err += s),
75
+ });
76
+ // Whatever goes wrong inside the app (a trap, a non-zero exit) is logged
77
+ // and answered with a fixed body: stderr and error messages never reach
78
+ // the client.
79
+ let status;
80
+ try {
81
+ // A fresh instance per request: no state leaks between requests.
82
+ let instance;
83
+ instance = new WebAssembly.Instance(appModule, {
84
+ wasi_snapshot_preview1: wasi.wasiImport,
85
+ ...imports(() => instance),
86
+ });
87
+ status = await wasi.startAsync(instance);
88
+ } catch (e) {
89
+ console.error("app trapped:", e, err);
90
+ return internalError();
91
+ }
92
+ if (err) console.error(err);
93
+ if (status !== 0) {
94
+ console.error(`app exited with ${status}`);
95
+ return internalError();
96
+ }
97
+
98
+ return cgiResponse(concatBytes(out));
99
+ }