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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +444 -0
- data/exe/edgeway +6 -0
- data/js/cgi.js +157 -0
- data/js/cloudflare.js +103 -0
- data/js/cors.js +87 -0
- data/js/jwt.js +279 -0
- data/js/runtime.js +99 -0
- data/js/wasi-shim.js +191 -0
- data/lib/edgeway/application.rb +70 -0
- data/lib/edgeway/cli/app_config.rb +35 -0
- data/lib/edgeway/cli/app_files.rb +37 -0
- data/lib/edgeway/cli/build.rb +90 -0
- data/lib/edgeway/cli/generate.rb +160 -0
- data/lib/edgeway/cli/new.rb +43 -0
- data/lib/edgeway/cli/package_manager.rb +21 -0
- data/lib/edgeway/cli/precheck.rb +45 -0
- data/lib/edgeway/cli/routes.rb +22 -0
- data/lib/edgeway/cli/test.rb +33 -0
- data/lib/edgeway/cli/toolchain.rb +35 -0
- data/lib/edgeway/cli.rb +59 -0
- data/lib/edgeway/config.rb +9 -0
- data/lib/edgeway/controller/authentication.rb +42 -0
- data/lib/edgeway/controller/base.rb +95 -0
- data/lib/edgeway/controller/callbacks.rb +29 -0
- data/lib/edgeway/controller/dispatcher.rb +39 -0
- data/lib/edgeway/controller/rendering.rb +65 -0
- data/lib/edgeway/controller/rescuable.rb +26 -0
- data/lib/edgeway/db/adapter.rb +32 -0
- data/lib/edgeway/db/memory.rb +73 -0
- data/lib/edgeway/db/sqlite.rb +190 -0
- data/lib/edgeway/errors.rb +61 -0
- data/lib/edgeway/generators/app/templates/.gitignore +6 -0
- data/lib/edgeway/generators/app/templates/Gemfile.tt +4 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +22 -0
- data/lib/edgeway/generators/app/templates/app/controllers/application_controller.rb +9 -0
- data/lib/edgeway/generators/app/templates/app/controllers/health_controller.rb +3 -0
- data/lib/edgeway/generators/app/templates/app/models/application_model.rb +3 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +7 -0
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +10 -0
- data/lib/edgeway/generators/app/templates/bin/setup.tt +38 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +14 -0
- data/lib/edgeway/generators/app/templates/config/routes.rb +4 -0
- data/lib/edgeway/generators/app/templates/db/migrations/.keep +0 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +24 -0
- data/lib/edgeway/generators/app/templates/package.json.tt +7 -0
- data/lib/edgeway/generators/app/templates/src/worker.js +4 -0
- data/lib/edgeway/generators/app/templates/test/controllers/health_test.rb +16 -0
- data/lib/edgeway/generators/app/templates/test/test_helper.rb +1 -0
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +23 -0
- data/lib/edgeway/http/auth/jwt.rb +127 -0
- data/lib/edgeway/http/auth/stub.rb +29 -0
- data/lib/edgeway/http/json_body.rb +37 -0
- data/lib/edgeway/http/parameters.rb +42 -0
- data/lib/edgeway/http/request.rb +115 -0
- data/lib/edgeway/http/response.rb +46 -0
- data/lib/edgeway/inflector.rb +16 -0
- data/lib/edgeway/logger.rb +21 -0
- data/lib/edgeway/model/attributes.rb +85 -0
- data/lib/edgeway/model/base.rb +173 -0
- data/lib/edgeway/model/macros.rb +60 -0
- data/lib/edgeway/model/persistence.rb +154 -0
- data/lib/edgeway/model/relation.rb +295 -0
- data/lib/edgeway/model/validations.rb +136 -0
- data/lib/edgeway/routing/mapper.rb +57 -0
- data/lib/edgeway/routing/route.rb +39 -0
- data/lib/edgeway/routing/route_set.rb +27 -0
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +101 -0
- data/lib/edgeway/service/cloudflare/jwt.rb +44 -0
- data/lib/edgeway/test.rb +97 -0
- data/lib/edgeway/version.rb +5 -0
- data/lib/edgeway.rb +30 -0
- 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,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,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
|
|
File without changes
|
|
@@ -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,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
|