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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f7271c053efc7a3ce130edd6347262ddc8166650a6bc70cc172c30befe3c5372
4
+ data.tar.gz: d0b25d4d34619575a7933aad50bed1c4885da6d8078bfa2308e0c8004da6ab8a
5
+ SHA512:
6
+ metadata.gz: 7859d0d22cb99f390c079bdd265567da3cba5c10f0a417a185f62551f322b118972107be922a79960c95b39b0be718a0999acb2130ddccf51fa38ede989ee391
7
+ data.tar.gz: 8da347d44a4e0debb86aa4b0aa3a247fdca6063da45315b3bfddc28ac112a966c1ef2dd2020165f7ba98c1f5e789a8929424eef9a1d0c37efb607a70305d15db
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yuji Yaginuma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,444 @@
1
+ # Edgeway
2
+
3
+ A library for running Ruby compiled to wasm by Spinel on Edge Workers.
4
+
5
+ ```ruby
6
+ # config/routes.rb
7
+ Edgeway.application.routes.draw do |r|
8
+ r.resources :todos, only: [:index, :show, :create] do |t|
9
+ t.resources :comments, only: [:index, :create]
10
+ end
11
+ r.get "/health", to: "health#show"
12
+ end
13
+
14
+ # app/models/application_model.rb
15
+ class ApplicationModel < Edgeway::Model::Base
16
+ extend Edgeway::Model::Macros
17
+ end
18
+
19
+ # app/models/todo.rb
20
+ class Todo < ApplicationModel
21
+ attribute :title, :string
22
+ attribute :done, :boolean, default: false
23
+ timestamps
24
+ has_many :comments
25
+ validates :title, presence: true, length: { maximum: 200 }
26
+ end
27
+
28
+ # app/controllers/todos_controller.rb
29
+ class TodosController < ApplicationController
30
+ def index = render json: Todo.order(:id).limit(50)
31
+
32
+ def create
33
+ todo = Todo.create!(params.require(:todo).permit(:title, :done))
34
+ render json: todo, status: :created
35
+ end
36
+ end
37
+ ```
38
+
39
+ ## Contents
40
+
41
+ - [Models](#models)
42
+ - [Strong parameters](#strong-parameters)
43
+ - [Uniqueness](#uniqueness)
44
+ - [Ordering and deleting](#ordering-and-deleting)
45
+ - [Batch](#batch)
46
+ - [Associations](#associations)
47
+ - [Eager loading](#eager-loading)
48
+ - [Usage](#usage)
49
+ - [Generators](#generators)
50
+ - [Building](#building)
51
+ - [Spinel version](#spinel-version)
52
+ - [Known limitations](#known-limitations)
53
+ - [Configuration](#configuration)
54
+ - [Authentication](#authentication)
55
+ - [CORS](#cors)
56
+ - [Testing an app](#testing-an-app)
57
+ - [Development](#development)
58
+ - [License](#license)
59
+
60
+ ## Models
61
+
62
+ ### Strong parameters
63
+
64
+ Params must go through `permit` before a model takes them.
65
+
66
+ `Todo.new`, `create`, `update` and their bang forms raise `Edgeway::ForbiddenAttributes`
67
+ (a 500, the details only in the log) for `params` or for `params.require(:todo)` alone.
68
+ A Hash is assigned as is.
69
+
70
+ ### Uniqueness
71
+
72
+ ```ruby
73
+ # Unique per owner, as UNIQUE (owner_id, name) in the table:
74
+ class Project < ApplicationModel
75
+ attribute :owner_id, :string
76
+ attribute :name, :string
77
+ # scope: a Symbol or an Array
78
+ validates :name, presence: true, uniqueness: { scope: :owner_id }
79
+ end
80
+
81
+ # The record if there is one, else a new saved one
82
+ # (find_or_create_by! raises RecordInvalid instead of answering the unsaved record).
83
+ # It also works on a relation, which sets owner_id too.
84
+ Project.where(owner_id: uid).find_or_create_by(name: "default")
85
+ ```
86
+
87
+ `uniqueness:` is a query, so two requests can both pass it. What guarantees it is the table's `UNIQUE` index:
88
+
89
+ - A write it refuses raises `Edgeway::RecordNotUnique` (a `DatabaseError`),
90
+ which `find_or_create_by` handles by finding the row the other request made.
91
+ - Left alone, it is answered 409 `{"error":"record not unique"}`;
92
+ the database's message (table and columns) goes only to the log.
93
+ - A controller can `rescue_from Edgeway::RecordNotUnique` to answer otherwise.
94
+
95
+ ### Ordering and deleting
96
+
97
+ ```ruby
98
+ # order adds terms, reorder replaces them: ORDER BY updated_at DESC, id DESC
99
+ Todo.order(:updated_at, :desc).order(:id, :desc)
100
+ Todo.order(:title).reorder(:id)
101
+
102
+ # One DELETE (no records, no dependents); the number of rows deleted.
103
+ # limit/offset raise ArgumentError.
104
+ Todo.where(done: true).delete_all
105
+
106
+ # Loads the records and destroys each, so dependents run; the destroyed records.
107
+ Todo.where(done: true).destroy_all
108
+
109
+ # destroy deletes the comments first
110
+ # (dependent: :destroy runs their own dependents; :delete_all is one DELETE).
111
+ # The comments' SELECTs run at once and all the DELETEs go in one Model.batch (below),
112
+ # so it is atomic.
113
+ class Todo < ApplicationModel
114
+ has_many :comments, dependent: :destroy
115
+ end
116
+ ```
117
+
118
+ ### Batch
119
+
120
+ `Edgeway::Model.batch` makes several writes atomic.
121
+
122
+ D1 has no interactive transactions, but its `batch()` runs a list of statements as one SQL transaction,
123
+ so the writes in the block are queued and sent together at the end:
124
+ all of them are applied, or none (a failure raises, such as `RecordNotUnique`).
125
+ It answers the results, one `{"changes" => n, "last_insert_id" => id}` per write.
126
+
127
+ ```ruby
128
+ Edgeway::Model.batch do
129
+ from.update!(balance: from.balance - 10)
130
+ to.update!(balance: to.balance + 10)
131
+ Transfer.where(expired: true).delete_all
132
+ end
133
+ ```
134
+
135
+ - Only writes (`update`, `destroy`, `delete_all`) are in the batch. Reads run at once and don't see the queued writes.
136
+ - A `create` is refused with `ArgumentError`, because it needs the new row's id.
137
+ - The counts of a queued write are not known (`delete_all` answers 0).
138
+ - A block that raises sends nothing, and a nested `batch` joins the outer one.
139
+
140
+ ### Associations
141
+
142
+ `has_many` and `belongs_to` take `foreign_key:` and `primary_key:` for columns
143
+ that don't follow the `todo_id` / `id` convention.
144
+
145
+ - `has_many` uses `foreign_key:` on the other model and `primary_key:` on this one.
146
+ - `belongs_to` uses `foreign_key:` on this model and `primary_key:` on the parent.
147
+
148
+ A foreign key is an integer unless you declare it first, which is how to make a String one:
149
+
150
+ ```ruby
151
+ class Device < ApplicationModel
152
+ attribute :owner_uid, :string # declared first, so it stays a String
153
+ belongs_to :account, foreign_key: :owner_uid, primary_key: :uid
154
+ end
155
+
156
+ class Account < ApplicationModel
157
+ attribute :uid, :string
158
+ has_many :devices, foreign_key: :owner_uid, primary_key: :uid, dependent: :destroy
159
+ end
160
+ ```
161
+
162
+ ### Eager loading
163
+
164
+ `eager_load(:name, ...)` loads one or more `has_many` or `belongs_to` for the whole list with one query,
165
+ always a `LEFT JOIN` per association (there is no separate preload query).
166
+ Calls accumulate (`eager_load(:a).eager_load(:b)` is `eager_load(:a, :b)`).
167
+ It chains with `where`, `order` and `limit`, which apply to the model's own records:
168
+
169
+ ```ruby
170
+ Todo.where(done: false).order(:id).limit(20).eager_load(:comments).each do |todo|
171
+ todo.comments.to_a # from memory, no query
172
+ end
173
+
174
+ Todo.eager_load(:comments, :tags) # several at once, still one query
175
+ ```
176
+
177
+ - Joining two `has_many` repeats rows per parent; each parent still gets each child once, in id order.
178
+ - An unknown name raises `ArgumentError`.
179
+ - Only `to_a` and what is built on it (`each`, `map`, `size`) read the loaded records;
180
+ `comments.where(...)`, `count` and `first` query as usual.
181
+
182
+ ## Usage
183
+
184
+ ```sh
185
+ gem install edgeway
186
+
187
+ edgeway new myapp
188
+
189
+ # mise, bundle, pnpm and the local D1 migrations
190
+ cd myapp && bin/setup
191
+
192
+ # the app's tests, natively (needs libsqlite3-dev, see "Testing an app")
193
+ bundle exec edgeway test
194
+
195
+ # edgeway build: writes build/app.wasm and build/edgeway/ (the JS)
196
+ bin/build
197
+
198
+ pnpm exec wrangler dev
199
+ edgeway routes
200
+
201
+ # bin/build, remote D1 migrations, then pnpm exec wrangler deploy
202
+ bin/deploy
203
+ ```
204
+
205
+ `edgeway` commands: `new`, `build`, `test`, `generate` (`g`), `routes`, `version` (`edgeway help` lists them).
206
+
207
+ Before `bin/deploy`:
208
+
209
+ 1. Log in (`pnpm exec wrangler login`).
210
+ 2. Create the D1 database (`pnpm exec wrangler d1 create <name>-db`).
211
+ 3. Put its `database_id` in `wrangler.toml`.
212
+
213
+ `bin/setup` prints these steps when they are missing.
214
+
215
+ - A generated app's Gemfile takes `edgeway` and `spinel_test-unit` from RubyGems.
216
+ - An app uses [pnpm](https://pnpm.io/) for wrangler (`package.json`, `bin/setup`, `mise.toml`);
217
+ `edgeway new` names it in one place (`Edgeway::CLI::PackageManager`).
218
+ - `SPINEL=/path/to/spinel bin/build` uses a Spinel other than the one on `PATH`.
219
+ - `bin/deploy` applies pending remote D1 migrations (`wrangler d1 migrations apply <name>-db --remote`) before deploying.
220
+
221
+ ### Generators
222
+
223
+ Inside an app made by `edgeway new`, `edgeway generate` (or `edgeway g`) writes the files and never overwrites one:
224
+
225
+ ```sh
226
+ # app/models/todo.rb, db/migrations/0001_create_todos.sql, test/models/todo_test.rb
227
+ edgeway generate model Todo title:string done:boolean
228
+
229
+ # app/controllers/todos_controller.rb, test/controllers/todos_test.rb
230
+ edgeway generate controller Todos index show
231
+ ```
232
+
233
+ - Field types are `string` (the default), `text`, `integer`, `float`, `boolean` and `datetime`.
234
+ - A model gets `timestamps` and an `id`, `created_at` and `updated_at` column.
235
+ - The migration is numbered one above the highest in `db/migrations`.
236
+ - Each action of a controller is a stub that renders `{}`.
237
+ - `config/routes.rb` is not changed: the command prints the route to add (`r.resources :todos`).
238
+
239
+ ### Building
240
+
241
+ `edgeway build` needs Spinel and the WASI SDK.
242
+
243
+ - An app made by `edgeway new` has a `mise.toml` for both (`mise install`):
244
+ Spinel comes from the [mise-spinel](https://github.com/y-yagi/mise-spinel) plugin and is put on `PATH`,
245
+ and the WASI SDK is pointed at by `WASI_SDK`.
246
+ - Without mise, set `SPINEL` (or put `spinel` on `PATH`) and `WASI_SDK`.
247
+ - `SPINEL` wins over `spinel` on `PATH`, which is how to use a Spinel checkout instead of a release.
248
+
249
+ See `todo-api` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples) for an example (checked with its `check.sh`).
250
+
251
+ ### Spinel version
252
+
253
+ Edgeway needs a Spinel newer than the 2026.09.12 release
254
+ (that release refuses `send` with a computed name, which `Callbacks#run_before_actions` uses).
255
+
256
+ - `mise.toml` (Edgeway's own and the one `edgeway new` writes) pins a commit of [matz/spinel](https://github.com/matz/spinel),
257
+ `530ba2b17`, which mise-spinel builds from source.
258
+ A commit rather than `master`, so that an install is the Spinel that was tested.
259
+ - The WASI SDK (`wasi-sdk-34`) is installed first,
260
+ because mise-spinel builds Spinel's wasm32-wasi runtime only when it finds one.
261
+ - Changing the pin means re-running `rake test` and `todo-api/check.sh` in [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
262
+
263
+ ### Known limitations
264
+
265
+ - Only the subset of Ruby that Spinel supports can be used.
266
+ - `has_many` does not work with namespaced models (`Admin::Post`);
267
+ `belongs_to` and `has_many` look models up by their full name, and that case is not handled.
268
+ - Cloudflare Access is not supported in this release: its token comes in `Cf-Access-Jwt-Assertion`,
269
+ and only `Authorization: Bearer` is read (see [Authentication](#authentication)).
270
+ - Tests run natively, not in wasm: D1 and JSPI (`Edgeway::Cloudflare::JWTVerifier`) are not covered by them,
271
+ and the production wasm never loads the SQLite adapter (see [Testing an app](#testing-an-app)).
272
+ - Not verified on a production Cloudflare deploy yet: CPU and wall time on a cold start and on a JWKS cache miss.
273
+ Everything was checked under `wrangler dev`.
274
+
275
+ ## Configuration
276
+
277
+ Two files configure the database, each for its own side:
278
+
279
+ - `config/database.yml` is Edgeway's: for each environment (`edgeway build --env ENV`),
280
+ the adapter (`d1`, or `memory` for tests) and the name of the D1 binding the app uses. It is compiled into the wasm.
281
+ - `wrangler.toml` is Cloudflare's: which D1 database the binding is (`database_name`, `database_id`),
282
+ the migrations, and the Worker. `binding` in it must match `binding` in `config/database.yml`.
283
+
284
+ `database_id` is not a secret: it only names the database, and using it takes an API token of the account,
285
+ so it is committed with `wrangler.toml` as Cloudflare's own examples do.
286
+ Secrets the app needs go in Worker secrets (`pnpm exec wrangler secret put NAME`),
287
+ which the app reads with `Edgeway.config("NAME")`.
288
+
289
+ Not every Worker variable reaches the app. Only these are passed into the wasm:
290
+
291
+ - the framework's own settings (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_LEEWAY`, `JWT_ALGORITHMS`, `JWT_JWKS_URL`)
292
+ - the variables whose name starts with `APP_`
293
+
294
+ Name your own settings `APP_...` (`APP_PAGE_SIZE = "20"` in `[vars]`, `Edgeway.config("APP_PAGE_SIZE", "20")` in Ruby),
295
+ and likewise your secrets (`wrangler secret put APP_NAME`).
296
+ Anything else, such as a key the Worker holds for another purpose, is never visible to the app.
297
+ `JWT_SECRET` is never passed.
298
+
299
+ ## Authentication
300
+
301
+ Edgeway verifies JWTs that an external IdP issued (Auth0, Firebase, Cognito, ...) and that come in `Authorization: Bearer`;
302
+ it does not issue them.
303
+
304
+ - Cloudflare Access, which sends its token in `Cf-Access-Jwt-Assertion`, is not supported in the first release,
305
+ nor are Cognito access tokens, which carry `client_id` instead of `aud` (Cognito ID tokens work).
306
+ - `sub` is required and must be a non-empty string.
307
+ - `auth_time`, when present, must not be in the future (leeway applies).
308
+
309
+ The signature is checked in JS with WebCrypto, the claims in Ruby:
310
+
311
+ ```ruby
312
+ class ApplicationController < Edgeway::Controller::Base
313
+ before_action :authenticate! # or only: / except:
314
+ end
315
+
316
+ class MeController < ApplicationController
317
+ def show = render json: current_claims # the token's payload, a Hash
318
+ end
319
+ ```
320
+
321
+ Without a valid `Authorization: Bearer <token>`, the request is answered 401 with a fixed body:
322
+
323
+ - no token: `{"error":"authentication required"}` with `WWW-Authenticate: Bearer` (`Edgeway::Unauthorized`)
324
+ - a bad token: `{"error":"invalid token"}` with `WWW-Authenticate: Bearer error="invalid_token"` (`Edgeway::InvalidToken`)
325
+
326
+ `rescue_from` them to answer differently.
327
+ The reason ("token expired", "unknown kid", ...) is only in the log (stderr), never the token,
328
+ so a client learns nothing from the wording.
329
+
330
+ Settings are Worker variables in `wrangler.toml`, and the secret is a Worker secret (`.dev.vars` locally):
331
+
332
+ | Variable | |
333
+ |---|---|
334
+ | `JWT_ALGORITHMS` | allowed algorithms, comma-separated (default `RS256`; `HS256/384/512`, `RS*`, `PS*`, `ES256/384`, `EdDSA`). `none` is never accepted |
335
+ | `JWT_JWKS_URL` | the IdP's JWKS, for the asymmetric algorithms. Must be `https` (`http` only for `localhost`, `127.0.0.1`, `[::1]`), otherwise the request is answered 500. Cached for 10 minutes, fetched again when a `kid` is unknown (at most every 30 seconds); an expired JWKS is never used, so when the fetch fails the answer is 500. The fetch has a 5 second timeout, follows no redirect, needs status 200 and a body of at most 64 KB. A token needs a `kid`; a JWK with `key_ops` must list `verify` |
336
+ | `JWT_SECRET` | the shared secret, for `HS*` (a secret; it never enters the wasm). At least 32 bytes for `HS256`, 48 for `HS384`, 64 for `HS512` (UTF-8 bytes, RFC 7518 3.2), otherwise the request is answered 500 |
337
+ | `JWT_ISSUER`, `JWT_AUDIENCE` | **required**: `iss` and `aud` of the token must match. If either is empty the request is answered 500 (a misconfiguration, details only in the log), never let through; a token without `iss` or `aud` is 401. Without them a token the IdP issued for another API would pass: Firebase's signing keys are shared by all Firebase projects (so any project's user token), and an Auth0 Management API token of the same tenant. Applies to every algorithm, `HS*` too |
338
+ | `JWT_LEEWAY` | seconds of clock skew allowed for `exp`, `nbf`, `iat` and `auth_time` (default 5; `0` for none; a whole number up to 300, anything else is answered 500) |
339
+
340
+ - `exp` is required, and a token with a `crit` header is refused (no extension is understood, RFC 7515 4.1.11).
341
+ - In tests, use `Edgeway::Auth::StubVerifier` (`edgeway/http/auth/stub`; see `test/auth.rb`).
342
+ - Like D1, this needs JSPI.
343
+ - An example is `GET /me` in `todo-api` of [edgeway-examples](https://github.com/y-yagi/edgeway-examples).
344
+
345
+ > **Note:** the todos/tags/comments endpoints of `todo-api` (in [edgeway-examples](https://github.com/y-yagi/edgeway-examples))
346
+ > are intentionally public, to keep the example small.
347
+ > A real app should `before_action :authenticate!` and scope records by the token's `sub`
348
+ > (for example an `owner_uid` attribute, queried with `where("owner_uid" => current_claims["sub"])` and set on create).
349
+ > See the comment in `todo-api/app/controllers/application_controller.rb`.
350
+
351
+ ## CORS
352
+
353
+ A browser app on another origin needs CORS to call the API with `Authorization: Bearer`.
354
+ Edgeway does it in the Worker, before the app:
355
+
356
+ - A preflight (`OPTIONS` with `Origin` and `Access-Control-Request-Method`) is answered without starting the app,
357
+ so it needs no `OPTIONS` routes.
358
+ - The headers are also added to responses the Worker makes itself (the 500 of a trap),
359
+ so the browser sees the real status.
360
+
361
+ Settings are Worker variables in `wrangler.toml`:
362
+
363
+ | Variable | |
364
+ |---|---|
365
+ | `CORS_ORIGINS` | allowed origins, comma-separated, compared as exact strings with the request's `Origin` (`https://app.example, http://localhost:3000`; no trailing slash). `*` allows any origin. Unset or empty means CORS is off: nothing is added and `OPTIONS` goes to the app. The origin `null` (sandboxed iframes, `file:` pages) is never allowed, even with `*` |
366
+ | `CORS_HEADERS` | request headers a preflight allows (default `Authorization, Content-Type`). Listed one by one, as `*` does not cover `Authorization` |
367
+ | `CORS_MAX_AGE` | seconds a browser may cache a preflight (default `600`). Not a non-negative integer: every request is answered 500, as with a bad JWT setting |
368
+ | `CORS_EXPOSE_HEADERS` | response headers the page may read (`Access-Control-Expose-Headers`, e.g. `X-Total-Count`); none by default |
369
+
370
+ - A preflight from an allowed origin is answered 204 with `Access-Control-Allow-Origin` (the request's origin),
371
+ `Access-Control-Allow-Methods` (`GET, HEAD, POST, PUT, PATCH, DELETE`), `Access-Control-Allow-Headers` and `Access-Control-Max-Age`.
372
+ - A preflight from another origin is 403 `{"error":"origin not allowed"}`.
373
+ - Other responses get `Access-Control-Allow-Origin` when the `Origin` is allowed, and `Origin` is added to `Vary`.
374
+ - Cookies (credentials) are not supported: `Access-Control-Allow-Credentials` is never sent,
375
+ and a token that `fetch` puts in `Authorization` does not need it.
376
+
377
+ Serving the frontend from the same origin as the API avoids CORS altogether.
378
+
379
+ ## Testing an app
380
+
381
+ ```sh
382
+ bundle exec edgeway test [ARGS] # ARGS go to spinel_test-unit
383
+ ```
384
+
385
+ `edgeway test` is the only supported way to run an app's tests.
386
+
387
+ - `spinel_test-unit` is the test framework (on RubyGems;
388
+ `edgeway new` puts `gem "spinel_test-unit", "~> 0.1", group: :test` in the Gemfile).
389
+ - It rewrites `tmp/test_app.rb` (git-ignored; `build/` belongs to `edgeway build`) on every run,
390
+ listing the app's files in build order, because Spinel resolves `require` at compile time.
391
+ Running `spinel_test-unit` directly fails when that file is missing and uses a stale one when app files changed.
392
+
393
+ ### Writing tests
394
+
395
+ - Tests `require "edgeway/test"` in `test/test_helper.rb`.
396
+ - Model tests inherit `Edgeway::UnitTest` (the database, no requests).
397
+ - `Edgeway::IntegrationTest` inherits it and adds `get`/`post`/..., `json` and `assert_response`.
398
+ - Both inherit `SpinelTest::Unit::TestCase`.
399
+
400
+ Each test runs against a real in-memory SQLite database (`Edgeway::DB::SQLiteAdapter`, through Spinel's FFI)
401
+ with `db/migrations/*.sql` applied in file name order, so requests really write and read rows.
402
+
403
+ ### SQLite
404
+
405
+ Running the tests requires SQLite (sqlite3). This needs the SQLite library at link time:
406
+ `libsqlite3-dev` on Debian/Ubuntu, `sqlite-devel` on Fedora (preinstalled on macOS).
407
+
408
+ - `edgeway test` first tries to link `-lsqlite3` with `$CC` (or `cc`/`gcc`/`clang`) and stops with the fix if that fails.
409
+ - Point `LIBRARY_PATH` at a directory holding `libsqlite3.so` if it lives elsewhere.
410
+ - Set `EDGEWAY_SKIP_PRECHECK=1` to skip the check.
411
+
412
+ Edgeway's own tests use `MemoryAdapter`, which stores nothing; `SQLiteAdapter` is Spinel-only (`test/spinel/*.rb`).
413
+
414
+ ### Differences from production
415
+
416
+ The tests are native builds, while production is wasm32-wasi on Workers.
417
+
418
+ - D1 (`Edgeway::Cloudflare::D1Adapter`) and JSPI are not exercised,
419
+ and neither is a JWT's signature check (use `Edgeway::Auth::StubVerifier`).
420
+ - `SQLiteAdapter` is loaded only by `edgeway/test`: `edgeway build` never requires `edgeway/db/sqlite`,
421
+ because libsqlite3 can't be linked into the wasm.
422
+ - SQLite and D1 (itself SQLite) take the same SQL, but D1's own limits
423
+ (such as the number of bound parameters) are not reproduced.
424
+
425
+ ## Development
426
+
427
+ ```sh
428
+ # mise install (Spinel, the WASI SDK) and bundle install
429
+ bin/setup
430
+
431
+ # needs libsqlite3-dev (test/spinel/sqlite.rb);
432
+ # runs test/*.rb under CRuby and as Spinel native builds, and compares with .expected
433
+ bundle exec rake test
434
+
435
+ # CRuby only
436
+ bundle exec rake test:cruby
437
+ ```
438
+
439
+ CI runs `rake test` (both legs, the CLI and the JS tests) on Ubuntu and macOS, with Spinel from `mise.toml`.
440
+ To test against a Spinel checkout instead of the release, set `SPINEL` in a `mise.local.toml` (see `mise.toml`).
441
+
442
+ ## License
443
+
444
+ MIT
data/exe/edgeway ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "edgeway/cli"
5
+
6
+ Edgeway::CLI.start(ARGV)
data/js/cgi.js ADDED
@@ -0,0 +1,157 @@
1
+ // Turns a CGI program's stdout into a Response. The output is kept as bytes:
2
+ // "Name: value" header lines (LF or CRLF), a blank line, then the body,
3
+ // which goes out unchanged, so binary bodies (images, gzip...) survive.
4
+ // A "Status: 404 Not Found" line sets the status, and a Location line
5
+ // without one means 302; without a blank line the whole output is the body.
6
+ // A non-empty body without a Content-Type is sent as UTF-8 text, so binary
7
+ // output needs its own Content-Type line.
8
+ // Bad output never throws: an invalid status answers a fixed JSON 500, and a header line
9
+ // Headers refuses is skipped.
10
+
11
+ // Joins Uint8Array chunks into one.
12
+ export function concatBytes(chunks) {
13
+ if (chunks.length === 1) return chunks[0];
14
+ const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
15
+ let pos = 0;
16
+ for (const c of chunks) {
17
+ out.set(c, pos);
18
+ pos += c.length;
19
+ }
20
+ return out;
21
+ }
22
+
23
+ // Answers [end of the headers, start of the body], or null when there is no
24
+ // blank line. "\n\n", "\r\n\r\n" and a mix of the two all end the headers;
25
+ // a blank first line means no headers.
26
+ function headerEnd(out) {
27
+ if (out[0] === 10) return [0, 1];
28
+ if (out[0] === 13 && out[1] === 10) return [0, 2];
29
+ for (let i = out.indexOf(10); i >= 0; i = out.indexOf(10, i + 1)) {
30
+ if (out[i + 1] === 10) return [i, i + 2];
31
+ if (out[i + 1] === 13 && out[i + 2] === 10) return [i, i + 3];
32
+ }
33
+ return null;
34
+ }
35
+
36
+ // Headers hold byte strings, so each byte becomes one char (not UTF-8, and
37
+ // not TextDecoder("latin1"), which is windows-1252): any bytes go out as is.
38
+ function byteString(bytes) {
39
+ let s = "";
40
+ for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192));
41
+ return s;
42
+ }
43
+
44
+ // Statuses whose response must have no body.
45
+ const NULL_BODY = new Set([204, 205, 304]);
46
+ const TEXT = "text/plain; charset=utf-8";
47
+
48
+ export function cgiResponse(out) {
49
+ const end = headerEnd(out);
50
+ if (!end) return new Response(out, { headers: out.length ? { "Content-Type": TEXT } : {} });
51
+ const headers = new Headers();
52
+ let status = null;
53
+ for (const line of byteString(out.subarray(0, end[0])).split(/\r?\n/)) {
54
+ const i = line.indexOf(":");
55
+ if (i < 0) continue;
56
+ const name = line.slice(0, i).trim(), value = line.slice(i + 1).trim();
57
+ if (name.toLowerCase() === "status") {
58
+ // Only the first Status counts: a later one is a header injected into
59
+ // the app's output.
60
+ if (status === null) status = Number.parseInt(value, 10);
61
+ continue;
62
+ }
63
+ try {
64
+ headers.append(name, value);
65
+ } catch {
66
+ console.error(`cgi: skipped a bad header line: ${JSON.stringify(line)}`);
67
+ }
68
+ }
69
+ status ??= headers.has("Location") ? 302 : 200;
70
+ if (!(Number.isInteger(status) && status >= 200 && status <= 599)) {
71
+ console.error(`cgi: bad status ${status}`);
72
+ return new Response('{"error":"internal error"}\n', { status: 500, headers: { "Content-Type": "application/json" } });
73
+ }
74
+ if (NULL_BODY.has(status)) return new Response(null, { status, headers });
75
+ const body = out.subarray(end[1]);
76
+ if (body.length && !headers.has("Content-Type")) headers.set("Content-Type", TEXT);
77
+ return new Response(body, { status, headers });
78
+ }
79
+
80
+ // ---- The request side: a Request as CGI meta-variables (RFC 3875) ----
81
+
82
+ // PATH_INFO is not URL-encoded in CGI, so %XX is decoded. Two escapes stay
83
+ // as they are: %2F (decoding it would make "a%2Fb" look like "a/b") and %00
84
+ // (a NUL cannot live in an environment string). A run of escapes that is not
85
+ // valid UTF-8 also stays encoded. Nothing here throws.
86
+ function decodePath(path) {
87
+ return path.replace(/(?:%[0-9a-fA-F]{2})+/g, (run) =>
88
+ run.split(/(%2f|%00)/i).map((piece, i) => {
89
+ if (i % 2) return piece;
90
+ try {
91
+ return decodeURIComponent(piece);
92
+ } catch {
93
+ return piece;
94
+ }
95
+ }).join(""));
96
+ }
97
+
98
+ // The request.cf fields passed on, as non-standard CF_* variables.
99
+ const CF_FIELDS = {
100
+ CF_COUNTRY: "country", CF_CONTINENT: "continent", CF_REGION: "region", CF_REGION_CODE: "regionCode",
101
+ CF_CITY: "city", CF_TIMEZONE: "timezone", CF_COLO: "colo", CF_ASN: "asn",
102
+ };
103
+ // Precise location (latitude, longitude, postal code) is deliberately not passed;
104
+ // an app that needs it can read request.cf in the worker.
105
+
106
+ // The CGI environment for a request: an object of strings (no undefined or
107
+ // null; WASI env needs strings). `body` is the request body as a Uint8Array.
108
+ // - REMOTE_ADDR comes from CF-Connecting-IP; it is "" when that is missing
109
+ // (local wrangler dev may not send it), so the variable is always set.
110
+ // - CONTENT_LENGTH is always set ("0" without a body); CONTENT_TYPE only
111
+ // when the request has one.
112
+ // - Header values go in as the JS strings workerd gives (a UTF-8 value from the
113
+ // client arrives decoded; the WASI shim encodes env as UTF-8, so the app
114
+ // sees the client's bytes). Content-Type and Content-Length are not HTTP_* (RFC 3875); every other
115
+ // header is HTTP_<NAME> (upper case, "-" -> "_"; a name that already holds
116
+ // "_" is dropped, as nginx and Apache do). Repeated headers come
117
+ // joined with ", ". Cookie and Authorization are passed too: the app is
118
+ // the one that handles the request.
119
+ // - The Proxy header is not passed: HTTP_PROXY is read by HTTP clients as
120
+ // proxy config (httpoxy, CVE-2016-5385).
121
+ // - CF_* are not CGI; they come from request.cf, which is absent locally
122
+ // in some setups, and absent fields are skipped.
123
+ export function cgiEnv(request, body) {
124
+ const url = new URL(request.url);
125
+ const https = url.protocol === "https:";
126
+ const env = {
127
+ GATEWAY_INTERFACE: "CGI/1.1",
128
+ SERVER_SOFTWARE: "Cloudflare-Workers",
129
+ SERVER_PROTOCOL: request.cf?.httpProtocol ? String(request.cf.httpProtocol) : "HTTP/1.1",
130
+ SERVER_NAME: url.hostname,
131
+ SERVER_PORT: url.port || (https ? "443" : "80"),
132
+ REQUEST_SCHEME: https ? "https" : "http",
133
+ REQUEST_METHOD: request.method,
134
+ SCRIPT_NAME: "",
135
+ PATH_INFO: decodePath(url.pathname),
136
+ QUERY_STRING: url.search.slice(1),
137
+ REQUEST_URI: url.pathname + url.search,
138
+ REMOTE_ADDR: request.headers.get("cf-connecting-ip") ?? "",
139
+ CONTENT_LENGTH: String(body.length),
140
+ };
141
+ if (https) env.HTTPS = "on";
142
+ const type = request.headers.get("content-type");
143
+ if (type != null) env.CONTENT_TYPE = type;
144
+ request.headers.forEach((value, name) => {
145
+ if (name === "content-type" || name === "content-length") return;
146
+ if (name === "proxy") return; // httpoxy: HTTP_PROXY would be read as proxy config
147
+ // "X_Foo" and "X-Foo" would both become HTTP_X_FOO, and a client could
148
+ // pass off one as the other; like nginx and Apache, drop names with "_".
149
+ if (name.includes("_")) return;
150
+ env["HTTP_" + name.toUpperCase().replace(/-/g, "_")] = value;
151
+ });
152
+ for (const [key, field] of Object.entries(CF_FIELDS)) {
153
+ const v = request.cf?.[field];
154
+ if (v != null && v !== "") env[key] = String(v);
155
+ }
156
+ return env;
157
+ }