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
@@ -0,0 +1,190 @@
1
+ # SQLite through Spinel's FFI (libsqlite3). Only Spinel builds load this file:
2
+ # ffi_lib is Spinel's, so CRuby can't read it (like service/cloudflare/d1; the
3
+ # CRuby tests use edgeway/db/memory). Needs libsqlite3 (libsqlite3-dev on
4
+ # Debian/Ubuntu) at link time.
5
+ require "json"
6
+ require_relative "adapter"
7
+
8
+ module Edgeway
9
+ module DB
10
+ module SQLiteFFI
11
+ ffi_lib "sqlite3"
12
+
13
+ ffi_const :OK, 0
14
+ ffi_const :ROW, 100
15
+ ffi_const :DONE, 101
16
+
17
+ ffi_func :sqlite3_open, [:str, :ptr], :int
18
+ ffi_func :sqlite3_close, [:ptr], :int
19
+ ffi_func :sqlite3_exec, [:ptr, :str, :ptr, :ptr, :ptr], :int
20
+ ffi_func :sqlite3_prepare_v2, [:ptr, :str, :int, :ptr, :ptr], :int
21
+ ffi_func :sqlite3_bind_text, [:ptr, :int, :str, :int, :ptr], :int
22
+ ffi_func :sqlite3_bind_int64, [:ptr, :int, :long], :int
23
+ ffi_func :sqlite3_bind_double, [:ptr, :int, :double], :int
24
+ ffi_func :sqlite3_bind_null, [:ptr, :int], :int
25
+ ffi_func :sqlite3_step, [:ptr], :int
26
+ ffi_func :sqlite3_finalize, [:ptr], :int
27
+ ffi_func :sqlite3_column_count, [:ptr], :int
28
+ ffi_func :sqlite3_column_name, [:ptr, :int], :str
29
+ ffi_func :sqlite3_column_type, [:ptr, :int], :int
30
+ ffi_func :sqlite3_column_int64, [:ptr, :int], :long
31
+ ffi_func :sqlite3_column_double, [:ptr, :int], :double
32
+ ffi_func :sqlite3_column_text, [:ptr, :int], :str
33
+ ffi_func :sqlite3_errmsg, [:ptr], :str
34
+ ffi_func :sqlite3_last_insert_rowid, [:ptr], :long
35
+ ffi_func :sqlite3_changes, [:ptr], :int
36
+ ffi_func :sqlite3_libversion_number, [], :int
37
+
38
+ ffi_buffer :db_out, 8
39
+ ffi_buffer :stmt_out, 8
40
+ ffi_read_ptr :read_ptr, 0
41
+ end
42
+
43
+ class SQLiteAdapter < Adapter
44
+ INTEGER = 1
45
+ FLOAT = 2
46
+ NULL = 5
47
+
48
+ attr_reader :log
49
+
50
+ def initialize(path = ":memory:")
51
+ @log = []
52
+ rc = SQLiteFFI.sqlite3_open(path, SQLiteFFI.db_out)
53
+ raise DatabaseError, "sqlite3_open failed: #{rc}" if rc != SQLiteFFI::OK
54
+ @db = SQLiteFFI.read_ptr(SQLiteFFI.db_out)
55
+ run_script("PRAGMA foreign_keys = ON")
56
+ @returning = SQLiteFFI.sqlite3_libversion_number >= 3035000
57
+ end
58
+
59
+ def close
60
+ SQLiteFFI.sqlite3_close(@db)
61
+ end
62
+
63
+ def supports_returning? = @returning
64
+
65
+ def select_all(sql, binds)
66
+ stmt = start(sql, binds)
67
+ rows = []
68
+ rc = SQLiteFFI.sqlite3_step(stmt)
69
+ while rc == SQLiteFFI::ROW
70
+ rows << read_row(stmt)
71
+ rc = SQLiteFFI.sqlite3_step(stmt)
72
+ end
73
+ finish(stmt, rc)
74
+ rows
75
+ end
76
+
77
+ def execute(sql, binds)
78
+ stmt = start(sql, binds)
79
+ rc = SQLiteFFI.sqlite3_step(stmt)
80
+ while rc == SQLiteFFI::ROW
81
+ rc = SQLiteFFI.sqlite3_step(stmt)
82
+ end
83
+ finish(stmt, rc)
84
+ { "changes" => SQLiteFFI.sqlite3_changes(@db), "last_insert_id" => SQLiteFFI.sqlite3_last_insert_rowid(@db) }
85
+ end
86
+
87
+ def batch(statements)
88
+ @log << "BATCH #{statements.size}"
89
+ run_script("BEGIN")
90
+ results = []
91
+ begin
92
+ statements.each { |s| results << execute(s[0], s[1]) }
93
+ rescue => e
94
+ run_script("ROLLBACK")
95
+ raise e
96
+ end
97
+ run_script("COMMIT")
98
+ results
99
+ end
100
+
101
+ # Runs SQL text of one or more statements (a schema or a migration).
102
+ def load_schema(sql_text)
103
+ @log << sql_text
104
+ run_script(sql_text)
105
+ end
106
+
107
+ # Applies dir/*.sql in file name order (db/migrations); the file names.
108
+ def apply_migrations(dir)
109
+ files = Dir.glob(File.join(dir, "*.sql")).sort
110
+ files.each { |f| load_schema(File.read(f)) }
111
+ files
112
+ end
113
+
114
+ private
115
+
116
+ def run_script(sql_text)
117
+ rc = SQLiteFFI.sqlite3_exec(@db, sql_text, nil, nil, nil)
118
+ fail_with(SQLiteFFI.sqlite3_errmsg(@db)) if rc != SQLiteFFI::OK
119
+ end
120
+
121
+ # A prepared statement with the binds set. On failure the statement is
122
+ # finalized before the error is raised.
123
+ def start(sql, binds)
124
+ @log << "#{sql} | #{JSON.generate(binds)}"
125
+ rc = SQLiteFFI.sqlite3_prepare_v2(@db, sql, -1, SQLiteFFI.stmt_out, nil)
126
+ if rc != SQLiteFFI::OK
127
+ message = SQLiteFFI.sqlite3_errmsg(@db)
128
+ fail_with(message)
129
+ end
130
+ stmt = SQLiteFFI.read_ptr(SQLiteFFI.stmt_out)
131
+ i = 0
132
+ while i < binds.size
133
+ rc = bind(stmt, i + 1, binds[i])
134
+ if rc != SQLiteFFI::OK
135
+ message = SQLiteFFI.sqlite3_errmsg(@db)
136
+ SQLiteFFI.sqlite3_finalize(stmt)
137
+ fail_with(message)
138
+ end
139
+ i += 1
140
+ end
141
+ stmt
142
+ end
143
+
144
+ def bind(stmt, n, value)
145
+ if value.nil?
146
+ SQLiteFFI.sqlite3_bind_null(stmt, n)
147
+ elsif value.is_a?(Integer)
148
+ SQLiteFFI.sqlite3_bind_int64(stmt, n, value)
149
+ elsif value.is_a?(Float)
150
+ SQLiteFFI.sqlite3_bind_double(stmt, n, value)
151
+ elsif value == true
152
+ SQLiteFFI.sqlite3_bind_int64(stmt, n, 1)
153
+ elsif value == false
154
+ SQLiteFFI.sqlite3_bind_int64(stmt, n, 0)
155
+ else
156
+ # -1: SQLITE_TRANSIENT, SQLite copies the text.
157
+ SQLiteFFI.sqlite3_bind_text(stmt, n, value.to_s, -1, -1)
158
+ end
159
+ end
160
+
161
+ # Finalizes the statement; raises if its last step failed.
162
+ def finish(stmt, rc)
163
+ message = rc == SQLiteFFI::DONE ? "" : SQLiteFFI.sqlite3_errmsg(@db)
164
+ SQLiteFFI.sqlite3_finalize(stmt)
165
+ fail_with(message) if rc != SQLiteFFI::DONE
166
+ end
167
+
168
+ def read_row(stmt)
169
+ row = {}
170
+ n = SQLiteFFI.sqlite3_column_count(stmt)
171
+ i = 0
172
+ while i < n
173
+ name = SQLiteFFI.sqlite3_column_name(stmt, i)
174
+ type = SQLiteFFI.sqlite3_column_type(stmt, i)
175
+ if type == NULL
176
+ row[name] = nil
177
+ elsif type == INTEGER
178
+ row[name] = SQLiteFFI.sqlite3_column_int64(stmt, i)
179
+ elsif type == FLOAT
180
+ row[name] = SQLiteFFI.sqlite3_column_double(stmt, i)
181
+ else
182
+ row[name] = SQLiteFFI.sqlite3_column_text(stmt, i)
183
+ end
184
+ i += 1
185
+ end
186
+ row
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,61 @@
1
+ module Edgeway
2
+ # The base of Edgeway's errors.
3
+ class Error < StandardError; end
4
+
5
+ # A request that can't be read (bad JSON, invalid UTF-8 in the query or a
6
+ # form body): 400.
7
+ class BadRequest < Error; end
8
+
9
+ # params.require(:x) found nothing: 400.
10
+ class ParameterMissing < BadRequest; end
11
+
12
+ # Unpermitted Parameters given to a model (Todo.create!(params) without
13
+ # permit): 500, with the details only in the log, since it is a bug in the
14
+ # app.
15
+ class ForbiddenAttributes < Error; end
16
+
17
+ # No route for the method and path: 404.
18
+ class RoutingError < Error; end
19
+
20
+ # The request has no credentials (no Authorization: Bearer token): 401 with
21
+ # "WWW-Authenticate: Bearer". The default answer has a fixed body; the
22
+ # message is for the log.
23
+ class Unauthorized < Error; end
24
+
25
+ # The token is not accepted (bad signature, expired, wrong issuer or
26
+ # audience...): 401 with "WWW-Authenticate: Bearer error=\"invalid_token\"".
27
+ # The message says why, for the log: the default answer has a fixed body
28
+ # ({"error":"invalid token"}) and the challenge no description.
29
+ class InvalidToken < Unauthorized; end
30
+
31
+ # A record that doesn't exist: 404.
32
+ class RecordNotFound < Error; end
33
+
34
+ # An unknown column or attribute named in a query: 400.
35
+ class UnknownColumn < BadRequest; end
36
+
37
+ # The app is set up wrongly (no database connection and so on): 500.
38
+ class ConfigurationError < Error; end
39
+
40
+ # A response header with a bad name or a CR/LF in its value: 500, with the
41
+ # details only in the log.
42
+ class InvalidHeader < Error; end
43
+
44
+ # A failure in the database adapter: 500.
45
+ class DatabaseError < Error; end
46
+
47
+ # A write the database refused for a UNIQUE (or PRIMARY KEY) constraint: 409
48
+ # with a fixed body. The message names the table and columns, so it goes to
49
+ # the log only.
50
+ class RecordNotUnique < DatabaseError; end
51
+
52
+ # A record that failed its validations: 422 with {"errors": [...]}.
53
+ class RecordInvalid < Error
54
+ attr_reader :errors
55
+
56
+ def initialize(errors)
57
+ @errors = errors
58
+ super("validation failed: #{errors.join(", ")}")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,6 @@
1
+ /build/
2
+ /node_modules/
3
+ /.wrangler/
4
+ /tmp/
5
+ /.dev.vars
6
+ /.env*
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "edgeway"
4
+ gem "spinel_test-unit", "~> 0.1", group: :test
@@ -0,0 +1,22 @@
1
+ # <%= @name %>
2
+
3
+ An Edgeway app.
4
+
5
+ ```sh
6
+ bin/setup # mise, bundle, <%= PackageManager.name %> and the local D1 migrations, in one go
7
+
8
+ # or step by step:
9
+ mise install # Spinel and the WASI SDK (mise.toml)
10
+ bundle install
11
+ <%= PackageManager.install %>
12
+ bundle exec edgeway build # build/app.wasm (or bin/build)
13
+ <%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local
14
+ <%= PackageManager.exec %> wrangler dev # http://localhost:8787/health
15
+ bundle exec edgeway routes
16
+ ```
17
+
18
+ Without mise, set `SPINEL` to the Spinel compiler and `WASI_SDK` to the WASI SDK.
19
+
20
+ Tests (`test/`) run with `bundle exec edgeway test [ARGS]`. Always use it rather than `spinel_test-unit` directly: it rewrites `tmp/test_app.rb` (git-ignored) from `app/` on every run, so new files are picked up. Each test gets a fresh in-memory SQLite database with `db/migrations/*.sql` applied, so running the tests requires SQLite (sqlite3): its development library has to be installed to link (`apt install libsqlite3-dev` on Debian/Ubuntu, `dnf install sqlite-devel` on Fedora; macOS has it).
21
+
22
+ Model tests inherit `Edgeway::UnitTest` (a database, no requests), request tests `Edgeway::IntegrationTest` (a `UnitTest` that also has `get`, `post`, `json`, `assert_response`).
@@ -0,0 +1,9 @@
1
+ class ApplicationController < Edgeway::Controller::Base
2
+ rescue_from Edgeway::RecordNotFound, with: :not_found
3
+
4
+ private
5
+
6
+ def not_found(e)
7
+ render json: { "error" => "not found" }, status: :not_found
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ class HealthController < ApplicationController
2
+ def show = render json: { "status" => "ok" }
3
+ end
@@ -0,0 +1,3 @@
1
+ class ApplicationModel < Edgeway::Model::Base
2
+ extend Edgeway::Model::Macros
3
+ end
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Compiles the app to build/app.wasm, like `bundle exec edgeway build`.
3
+ # Arguments go to it (--env ENV).
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")/.."
6
+
7
+ exec bundle exec edgeway build "$@"
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # Builds the app, applies pending D1 migrations to the remote database, and
3
+ # deploys it to Cloudflare. Needs a Cloudflare login and the D1 database's id
4
+ # in wrangler.toml (see bin/setup). Arguments go to `wrangler deploy`.
5
+ set -euo pipefail
6
+ cd "$(dirname "$0")/.."
7
+
8
+ bin/build
9
+ <%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --remote
10
+ <%= PackageManager.exec %> wrangler deploy "$@"
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Sets up the app for development. Safe to run again.
3
+ set -euo pipefail
4
+ cd "$(dirname "$0")/.."
5
+
6
+ # Spinel and the WASI SDK, from mise.toml. `edgeway test` also links libsqlite3
7
+ # (libsqlite3-dev on Debian/Ubuntu; part of macOS).
8
+ if command -v mise >/dev/null 2>&1; then
9
+ mise trust --quiet
10
+ mise install
11
+ else
12
+ echo "bin/setup: mise is not installed (https://mise.jdx.dev/)." >&2
13
+ echo "Install Spinel and the WASI SDK yourself, and set SPINEL and WASI_SDK." >&2
14
+ fi
15
+
16
+ bundle install
17
+
18
+ # wrangler, from package.json.
19
+ if ! command -v <%= PackageManager.name %> >/dev/null 2>&1; then
20
+ echo "bin/setup: <%= PackageManager.name %> is not installed; install it to get wrangler." >&2
21
+ exit 1
22
+ fi
23
+ <%= PackageManager.install %>
24
+
25
+ # The local D1 database `wrangler dev` uses.
26
+ <%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local
27
+
28
+ # Deploying needs a Cloudflare login and a D1 database; neither is required
29
+ # for development, so only say what is missing.
30
+ if ! <%= PackageManager.exec %> wrangler whoami 2>/dev/null | grep -qi "associated with the email"; then
31
+ echo
32
+ echo "To deploy, log in to Cloudflare: <%= PackageManager.exec %> wrangler login"
33
+ echo "Then create the database: <%= PackageManager.exec %> wrangler d1 create <%= @name %>-db"
34
+ echo "and put its database_id in wrangler.toml."
35
+ fi
36
+
37
+ echo
38
+ echo "Done. Next: bin/build && <%= PackageManager.exec %> wrangler dev"
@@ -0,0 +1,14 @@
1
+ # The database of each environment (edgeway build --env ENV), compiled into
2
+ # the wasm. adapter: d1 uses the D1 binding named by binding:, which must
3
+ # match a binding in wrangler.toml (wrangler.toml says which D1 database it
4
+ # is); memory is for tests.
5
+ development:
6
+ adapter: d1
7
+ binding: DB
8
+
9
+ production:
10
+ adapter: d1
11
+ binding: DB
12
+
13
+ test:
14
+ adapter: memory
@@ -0,0 +1,4 @@
1
+ Edgeway.application.routes.draw do |r|
2
+ r.get "/health", to: "health#show"
3
+ r.root to: "health#show"
4
+ end
@@ -0,0 +1,24 @@
1
+ # The tools `edgeway build` needs, and Node.js and <%= PackageManager.name %> for wrangler: `mise install`.
2
+
3
+ [plugins]
4
+ spinel = "https://github.com/y-yagi/mise-spinel"
5
+
6
+ [tools]
7
+ # Its bin/ (clang, ar, ...) must not shadow the system's compilers, so
8
+ # bin_path points at a directory that doesn't exist and only WASI_SDK below
9
+ # says where it is.
10
+ "github:WebAssembly/wasi-sdk" = { version = "wasi-sdk-34", bin_path = "no-bin" }
11
+
12
+ # A commit of matz/spinel (mise-spinel builds any git ref from source):
13
+ # Edgeway needs fixes newer than the 2026.09.12 release. A commit rather than
14
+ # master, so an install (and CI's cache of it) is the Spinel that was tested.
15
+ # depends: the wasi-sdk is installed first, so the build also makes Spinel's
16
+ # wasm32-wasi runtime (mise-spinel does when WASI_SDK, set below, is there).
17
+ spinel = { version = "530ba2b17", depends = ["github:WebAssembly/wasi-sdk"] }
18
+
19
+ # wrangler (package.json) runs on Node.js.
20
+ node = "lts"
21
+ <%= PackageManager.name %> = "latest"
22
+
23
+ [env]
24
+ WASI_SDK = { value = "{{ tools['github:WebAssembly/wasi-sdk'].path | default(value='') }}", tools = true }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "<%= @name %>",
3
+ "private": true,
4
+ "devDependencies": {
5
+ "wrangler": "4.149.0"
6
+ }
7
+ }
@@ -0,0 +1,4 @@
1
+ import app from "../build/app.wasm";
2
+ import { createWorker } from "../build/edgeway/cloudflare.js";
3
+
4
+ export default createWorker(app);
@@ -0,0 +1,16 @@
1
+ require "edgeway/test"
2
+ require "test_helper"
3
+
4
+ class HealthTest < Edgeway::IntegrationTest
5
+ def test_health
6
+ get "/health"
7
+ assert_response 200
8
+ assert_equal "ok", json["status"]
9
+ end
10
+
11
+ def test_root_is_the_health_check
12
+ get "/"
13
+ assert_response 200
14
+ assert_equal "ok", json["status"]
15
+ end
16
+ end
@@ -0,0 +1 @@
1
+ require "edgeway/test"
@@ -0,0 +1,23 @@
1
+ name = "<%= @name %>"
2
+ main = "src/worker.js"
3
+ compatibility_date = "2026-10-01"
4
+
5
+ # CORS, for a frontend on another origin (see the README's "CORS"): the
6
+ # origins allowed, comma-separated and compared exactly (no trailing slash).
7
+ # Unset, CORS is off. CORS_HEADERS defaults to "Authorization, Content-Type",
8
+ # CORS_MAX_AGE to 600. Variables are not inherited by an [env.*]: write them
9
+ # again under [env.<name>.vars]. The app sees only the framework's settings
10
+ # (JWT_*) and variables named APP_*, read with Edgeway.config("APP_NAME").
11
+ # [vars]
12
+ # CORS_ORIGINS = "http://localhost:3000, https://app.example"
13
+
14
+ # Simulated locally by `wrangler dev`. Create tables with
15
+ # `<%= PackageManager.exec %> wrangler d1 migrations apply <%= @name %>-db --local`.
16
+ # database_id is a placeholder (all zeros): that is enough for --local and
17
+ # `wrangler dev`, which ignore it. Before a deploy, create the database
18
+ # (`<%= PackageManager.exec %> wrangler d1 create <%= @name %>-db`) and put the id it prints here.
19
+ [[d1_databases]]
20
+ binding = "DB"
21
+ database_name = "<%= @name %>-db"
22
+ database_id = "00000000-0000-0000-0000-000000000000"
23
+ migrations_dir = "db/migrations"
@@ -0,0 +1,127 @@
1
+ module Edgeway
2
+ module Auth
3
+ # Checks a token's signature and algorithm. verify answers JSON text:
4
+ #
5
+ # {"header": {...}, "payload": {...}} the token is genuine
6
+ # {"error": "message"} it is not
7
+ # {"error": "message", "unavailable": true} it could not be checked
8
+ # (a missing setting, no JWKS)
9
+ #
10
+ # The base answers "unavailable", for an app that set no verifier.
11
+ class Verifier
12
+ def verify(token)
13
+ JSON.generate({ "error" => "no JWT verifier is set", "unavailable" => true })
14
+ end
15
+ end
16
+
17
+ class JWT
18
+ # Seconds of clock skew allowed when JWT_LEEWAY is not set.
19
+ DEFAULT_LEEWAY = 5
20
+ # The most JWT_LEEWAY may be: a larger skew would keep expired tokens alive.
21
+ MAX_LEEWAY = 300
22
+
23
+ # The settings come from the Worker's variables: JWT_ISSUER and
24
+ # JWT_AUDIENCE (both required: decode fails closed when either is
25
+ # empty, see decode), JWT_LEEWAY (seconds of clock skew to allow,
26
+ # default DEFAULT_LEEWAY; "0" for none; a whole number of seconds, at
27
+ # most MAX_LEEWAY: anything else is a ConfigurationError).
28
+ def self.from_config
29
+ new(Auth.verifier, issuer: Edgeway.config("JWT_ISSUER"), audience: Edgeway.config("JWT_AUDIENCE"),
30
+ leeway: parse_leeway(Edgeway.config("JWT_LEEWAY", DEFAULT_LEEWAY.to_s)))
31
+ end
32
+
33
+ # JWT_LEEWAY as an Integer. Fails closed: a typo must not become 0 or
34
+ # a huge skew.
35
+ def self.parse_leeway(text)
36
+ unless text.match?(/\A[0-9]+\z/) && text.length <= 6 && text.to_i <= MAX_LEEWAY
37
+ raise ConfigurationError, "jwt: JWT_LEEWAY must be a whole number of seconds, 0 to #{MAX_LEEWAY}"
38
+ end
39
+ text.to_i
40
+ end
41
+
42
+ # now: the time as Unix seconds, for tests (0: the clock).
43
+ def initialize(verifier, issuer: "", audience: "", leeway: DEFAULT_LEEWAY, now: 0)
44
+ @verifier = verifier
45
+ @issuer = issuer
46
+ @audience = audience
47
+ @leeway = leeway
48
+ @now = now
49
+ end
50
+
51
+ # The token's claims (a Hash). Raises InvalidToken when the token is
52
+ # not genuine or a claim fails, ConfigurationError when it could not
53
+ # be checked. An empty issuer or audience is a misconfiguration (500,
54
+ # never a pass), found before the token is looked at, for every
55
+ # algorithm: without them a token from the same IdP for another API
56
+ # (or, for Firebase, another project) would be accepted.
57
+ def decode(token)
58
+ raise ConfigurationError, "jwt: JWT_ISSUER is not set" if @issuer.empty?
59
+ raise ConfigurationError, "jwt: JWT_AUDIENCE is not set" if @audience.empty?
60
+ unless @leeway.is_a?(Integer) && @leeway >= 0 && @leeway <= MAX_LEEWAY
61
+ raise ConfigurationError, "jwt: JWT_LEEWAY must be a whole number of seconds, 0 to #{MAX_LEEWAY}"
62
+ end
63
+ res = JSON.parse(@verifier.verify(token))
64
+ if res.key?("error")
65
+ raise ConfigurationError, "jwt: #{res["error"]}" if res.key?("unavailable")
66
+ raise InvalidToken, res["error"].to_s
67
+ end
68
+ claims = res["payload"]
69
+ check_claims(claims)
70
+ claims
71
+ end
72
+
73
+ private
74
+
75
+ def check_claims(claims)
76
+ now = (@now == 0 ? Time.now.to_i : @now).to_f
77
+ # A token that never expires is not accepted.
78
+ raise InvalidToken, "exp is missing" unless claims.key?("exp")
79
+ raise InvalidToken, "token expired" if now - @leeway >= number(claims, "exp")
80
+ raise InvalidToken, "token is not valid yet" if claims.key?("nbf") && now + @leeway < number(claims, "nbf")
81
+ raise InvalidToken, "token is issued in the future" if claims.key?("iat") && now + @leeway < number(claims, "iat")
82
+ # auth_time (OIDC) is optional; when there, the login must not be in the future.
83
+ raise InvalidToken, "authenticated in the future" if claims.key?("auth_time") && now + @leeway < number(claims, "auth_time")
84
+ raise InvalidToken, "iss is missing" unless claims.key?("iss")
85
+ raise InvalidToken, "issuer is not accepted" if claims["iss"] != @issuer
86
+ raise InvalidToken, "aud is missing" unless claims.key?("aud")
87
+ raise InvalidToken, "audience is not accepted" unless audience?(claims["aud"])
88
+ # Apps scope their data by sub, so a missing or empty one must not pass
89
+ # (a missing sub would match "user_id IS NULL", an empty one is shared).
90
+ raise InvalidToken, "sub is missing" unless claims.key?("sub")
91
+ raise InvalidToken, "sub is not a non-empty string" unless claims["sub"].is_a?(String) && !claims["sub"].empty?
92
+ end
93
+
94
+ # A time claim as a Float; anything else is a bad token. (JSON numbers
95
+ # parse as Integer or Float.)
96
+ def number(claims, name)
97
+ v = claims[name]
98
+ raise InvalidToken, "#{name} is not a number" unless v.is_a?(Integer) || v.is_a?(Float)
99
+ v.to_f
100
+ end
101
+
102
+ # aud is a string or an array of strings.
103
+ def audience?(aud)
104
+ if aud.is_a?(String)
105
+ aud == @audience
106
+ elsif aud.is_a?(Array)
107
+ aud.include?(@audience)
108
+ else
109
+ false
110
+ end
111
+ end
112
+ end
113
+
114
+ @verifier = Verifier.new
115
+
116
+ def self.verifier = @verifier
117
+
118
+ def self.verifier=(verifier)
119
+ @verifier = verifier
120
+ end
121
+
122
+ # The claims of a token, checked with the app's settings.
123
+ def self.decode(token)
124
+ JWT.from_config.decode(token)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,29 @@
1
+ require_relative "jwt"
2
+
3
+ module Edgeway
4
+ module Auth
5
+ class StubVerifier < Verifier
6
+ def initialize
7
+ @answers = {}
8
+ end
9
+
10
+ def allow(token, payload)
11
+ @answers[token] = JSON.generate({ "header" => { "alg" => "stub" }, "payload" => payload })
12
+ end
13
+
14
+ def reject(token, message)
15
+ # .dup: since matz/spinel 44119ab30 (Strings shared by reference),
16
+ # Spinel refuses this String as it is, or emits C that doesn't compile.
17
+ @answers[token] = JSON.generate({ "error" => message.dup })
18
+ end
19
+
20
+ def unavailable(token, message)
21
+ @answers[token] = JSON.generate({ "error" => message, "unavailable" => true })
22
+ end
23
+
24
+ def verify(token)
25
+ @answers[token] || JSON.generate({ "error" => "signature is not valid" })
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ module Edgeway
2
+ module HTTP
3
+ class JSONBody
4
+ attr_reader :value, :params
5
+
6
+ def initialize(text)
7
+ @value = parse(text)
8
+ @params = {}
9
+ flatten if @value.is_a?(Hash)
10
+ end
11
+
12
+ private
13
+
14
+ def parse(text)
15
+ JSON.parse(text)
16
+ rescue JSON::ParserError
17
+ raise BadRequest, "bad JSON body"
18
+ end
19
+
20
+ def flatten
21
+ @value.each do |k, v|
22
+ if v.is_a?(Hash)
23
+ v.each do |k2, v2|
24
+ put("#{k}[#{k2}]", v2) unless v2.is_a?(Hash) || v2.is_a?(Array)
25
+ end
26
+ elsif !v.is_a?(Array)
27
+ put(k.to_s, v)
28
+ end
29
+ end
30
+ end
31
+
32
+ def put(key, value)
33
+ @params[key] = value.nil? ? "" : value.to_s
34
+ end
35
+ end
36
+ end
37
+ end