tina4ruby 3.13.94 → 3.13.96
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 +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/CHANGELOG.md
CHANGED
|
@@ -6,14 +6,897 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
### Breaking: Messenger `inbox()` / `read()` item shapes (3.13.96 parity)
|
|
10
|
+
|
|
11
|
+
The IMAP read path is aligned to the settled cross-framework shape (Python is the
|
|
12
|
+
reference). Measured against live GreenMail.
|
|
13
|
+
|
|
14
|
+
**`inbox()` item is EXACTLY `{uid, subject, from, to, date, snippet, seen}`.**
|
|
15
|
+
- `from` and `to` are header STRINGS (`"Name <email>"`), not arrays of
|
|
16
|
+
`{name, email}`.
|
|
17
|
+
- the `read` key is renamed `seen` (Boolean).
|
|
18
|
+
- `date` is ISO-8601.
|
|
19
|
+
- `flags` and `size` are dropped.
|
|
20
|
+
- a new `snippet` field carries decoded, transfer-decoded, tag-stripped plain
|
|
21
|
+
text, truncated to 200 chars (no more raw base64 / no more absent field).
|
|
22
|
+
|
|
23
|
+
**`read()` item uses `body_text` / `body_html`** (renamed from `body` / `html`),
|
|
24
|
+
returns `from`/`to`/`cc` as STRINGS, an `attachments` array of
|
|
25
|
+
`{filename, content_type, size}`, and a `headers` Hash (Message-ID lives there).
|
|
26
|
+
The old `flags` / `read` / `raw` / `message_id` top-level keys are dropped
|
|
27
|
+
(`headers["Message-ID"]` replaces `message_id`).
|
|
28
|
+
|
|
29
|
+
**`inbox` and `read` are callable POSITIONALLY** — `inbox("INBOX", 10, 0)`,
|
|
30
|
+
`read(uid, "INBOX")` — as well as by keyword (both forms work).
|
|
31
|
+
|
|
32
|
+
**New methods:** `mark_unread`, `send_template` (renders a Frond template string
|
|
33
|
+
to HTML and sends), and `delete` (flags `\Deleted` + expunge; fails loud like the
|
|
34
|
+
other reads).
|
|
35
|
+
|
|
36
|
+
**`TINA4_MAIL_IMAP_USERNAME` / `_PASSWORD` are honoured**, falling back to
|
|
37
|
+
`TINA4_MAIL_USERNAME` / `_PASSWORD`. Ruby authenticated IMAP to the SMTP account,
|
|
38
|
+
so an app whose reading mailbox differed from its SMTP relay account read the
|
|
39
|
+
wrong mailbox. There are matching `imap_username:` / `imap_password:` constructor
|
|
40
|
+
args (explicit beats env, ADR-0041).
|
|
41
|
+
|
|
42
|
+
**Migration.** Code reading `item[:from].first[:email]` becomes
|
|
43
|
+
`item[:from]` (a string); `item[:read]` becomes `item[:seen]`; `msg[:body]` /
|
|
44
|
+
`msg[:html]` become `msg[:body_text]` / `msg[:body_html]`; `msg[:message_id]`
|
|
45
|
+
becomes `msg[:headers]["Message-ID"]`.
|
|
46
|
+
|
|
47
|
+
### Breaking: Swagger defaults, response codes, and operationId (3.13.96 parity)
|
|
48
|
+
|
|
49
|
+
Four measured cross-framework divergences in the OpenAPI generator, settled to
|
|
50
|
+
match the Python/PHP reference.
|
|
51
|
+
|
|
52
|
+
**`info.version` defaults to `1.0.0`, `info.description` to `""`.** Ruby defaulted
|
|
53
|
+
`info.version` to the FRAMEWORK version (`Tina4::VERSION`), so an undocumented app
|
|
54
|
+
claimed API `v3.13.x`. `info.version` is the APPLICATION's API version; it now
|
|
55
|
+
defaults to `1.0.0`. `info.description` defaults to empty instead of the canned
|
|
56
|
+
"Auto-generated API documentation". `TINA4_SWAGGER_VERSION` /
|
|
57
|
+
`TINA4_SWAGGER_DESCRIPTION` still override.
|
|
58
|
+
|
|
59
|
+
**An undecorated route emits only `200`; `401` appears only on a secured route.**
|
|
60
|
+
Ruby stamped `200/400/401/404/500` on EVERY operation, including a public GET,
|
|
61
|
+
advertising status codes nothing in the framework produces. Now an undecorated
|
|
62
|
+
route carries only `200`, and a route documented as secured also carries `401`.
|
|
63
|
+
|
|
64
|
+
**`operationId` preserves the path's underscores.** `/__health` and `/health`
|
|
65
|
+
both collapsed to `get_health` and then one got a `_2` suffix by registration
|
|
66
|
+
order. They now produce distinct `get___health` / `get_health` — an
|
|
67
|
+
operationId is a generated client's method name and must be stable and unique.
|
|
68
|
+
|
|
69
|
+
**AutoCrud emits `components.schemas`.** Generated write routes referenced a bare
|
|
70
|
+
`{"type":"object"}` request body. They now `$ref` a `components.schemas` entry
|
|
71
|
+
keyed by the model CLASS name, with a `required` array derived from column
|
|
72
|
+
nullability.
|
|
73
|
+
|
|
74
|
+
**Migration.** A generated client that hard-coded the app version from
|
|
75
|
+
`info.version`, or generated error handlers from the phantom `400/404/500`
|
|
76
|
+
responses, or a method name from the collapsed `get_health`, will regenerate to
|
|
77
|
+
the corrected shape. No runtime behaviour of a Tina4 server changes.
|
|
78
|
+
|
|
79
|
+
### Breaking: Messenger `uid` is a String, and `inbox()` pages newest first
|
|
80
|
+
|
|
81
|
+
Two cross-framework divergences, both MEASURED 2026-08-06 against live GreenMail
|
|
82
|
+
with all four frameworks asked the same question about the same mailbox.
|
|
83
|
+
|
|
84
|
+
uid type python str php string node string ruby Integer
|
|
85
|
+
page order python P3,P2 php P3,P2 node P3,P2 ruby P2,P3
|
|
86
|
+
|
|
87
|
+
Ruby was the outlier on both.
|
|
88
|
+
|
|
89
|
+
**`uid` is now a String.** The documented contract says String in all four, so a
|
|
90
|
+
caller comparing `uid == "3"` got false in Ruby alone. `read()` / `mark_read()` /
|
|
91
|
+
`delete()` still accept a String or an Integer, so passing an id back in is
|
|
92
|
+
unchanged.
|
|
93
|
+
|
|
94
|
+
**`inbox()` and `search()` now page newest first.** `uid_search` was already
|
|
95
|
+
reversed and the page sliced correctly, but `uid_fetch` returns rows in SERVER
|
|
96
|
+
(ascending) order however the uids were asked for, silently re-sorting the page.
|
|
97
|
+
So `inbox(limit: 1)` returned the OLDEST message in Ruby and the NEWEST in the
|
|
98
|
+
other three - the most common inbox call there is.
|
|
99
|
+
|
|
100
|
+
**Migration.** Code doing `uid == 3` or `uid.to_i` keeps working; code relying on
|
|
101
|
+
`uid.is_a?(Integer)` does not. Code that took `inbox(limit: n).first` as the
|
|
102
|
+
oldest message in that page now gets the newest, which is what the other three
|
|
103
|
+
frameworks always returned.
|
|
104
|
+
|
|
105
|
+
|
|
9
106
|
This file is deliberately NOT a copy of those notes. Duplicating them is exactly how a
|
|
10
107
|
changelog rots into claiming a version that was never cut, so this file records only
|
|
11
108
|
UNRELEASED work. When a version ships, its notes go to the release notes above.
|
|
12
109
|
|
|
110
|
+
### Breaking (the queue store moved to the canonical cross-framework layout)
|
|
111
|
+
|
|
112
|
+
**Breaking: the file-backed queue's default store moved from
|
|
113
|
+
`<cwd>/.queue/<topic>/<id>.json` to
|
|
114
|
+
`<TINA4_QUEUE_PATH|data/queue>/<topic>/<id>.queue-data`. The DIRECTORY and the
|
|
115
|
+
FILE EXTENSION both changed.** No job is lost - see the migration note below.
|
|
116
|
+
|
|
117
|
+
Ruby was the odd one out. Python, PHP and Node all store jobs at
|
|
118
|
+
`<TINA4_QUEUE_PATH|data/queue>/<topic>/*.queue-data`. Ruby wrote
|
|
119
|
+
`<cwd>/.queue/<topic>/<id>.json` and read `TINA4_QUEUE_PATH` **nowhere**, so the
|
|
120
|
+
variable `.env.example` has documented all along ("Queue storage path (file
|
|
121
|
+
backend)") did nothing whatsoever: an operator who pointed the store at a
|
|
122
|
+
mounted volume kept writing to the container's ephemeral filesystem and lost
|
|
123
|
+
every queued job on restart, with no error and no warning.
|
|
124
|
+
|
|
125
|
+
The store's layout - root, topic segment, job extension, dead-letter directory -
|
|
126
|
+
is now defined once on `Tina4::Queue` (`base_path`, `topic_dirname`,
|
|
127
|
+
`topic_path`, `job_files`, `job_file`, `JOB_EXTENSION`,
|
|
128
|
+
`DEAD_LETTER_DIRNAME`) and is the single answer both the backend and the
|
|
129
|
+
dev-admin queue panel ask. The topic is sanitised to one path segment there,
|
|
130
|
+
in the one place that resolves it, because the panel takes its topic straight
|
|
131
|
+
off a query string.
|
|
132
|
+
|
|
133
|
+
**MIGRATION NOTE.** Nothing is required of you, and no job is stranded.
|
|
134
|
+
|
|
135
|
+
- The first time the file backend resolves its own store, it **moves** every
|
|
136
|
+
`*.json` under `<cwd>/.queue/` into the new store, renaming to
|
|
137
|
+
`*.queue-data` and preserving the `<topic>/`, `<topic>/reserved/` and
|
|
138
|
+
`dead_letter/` structure. It logs `Queue: moved N job(s) ...` at INFO.
|
|
139
|
+
- It is a MOVE, never a copy, so a job cannot be delivered twice; it never
|
|
140
|
+
overwrites a file already at the destination, so concurrent processes cannot
|
|
141
|
+
clobber each other; and emptied legacy directories are removed, so the check
|
|
142
|
+
costs one `Dir.exist?` afterwards.
|
|
143
|
+
- **If you had `TINA4_QUEUE_PATH` set:** it previously did nothing in Ruby, so
|
|
144
|
+
your jobs are in `<cwd>/.queue` too. They move into the path you configured,
|
|
145
|
+
which now works as it does in the other three frameworks.
|
|
146
|
+
- **If you did not:** your jobs move to `<cwd>/data/queue`.
|
|
147
|
+
- **If you construct `LiteBackend.new(dir: ...)` yourself:** that store is yours
|
|
148
|
+
and is never touched.
|
|
149
|
+
- Add `data/queue/` to your `.gitignore`. Newly scaffolded projects get it.
|
|
150
|
+
- The rescue triggers on the legacy directory EXISTING, not on the new store
|
|
151
|
+
being absent, so a rolling upgrade in which an old instance keeps writing to
|
|
152
|
+
`.queue` still has those jobs collected.
|
|
153
|
+
|
|
154
|
+
Pending jobs and reservations now interoperate with a store written by Python,
|
|
155
|
+
PHP or Node. Dead letters still do not: Ruby keeps them in a shared
|
|
156
|
+
`<base>/dead_letter/` tagged by topic, where Python and Node use a per-topic
|
|
157
|
+
`<base>/<topic>/failed/`. That divergence is unchanged here.
|
|
158
|
+
|
|
159
|
+
### Fixed (the dev-admin queue panel listed a different store from the one it counted)
|
|
160
|
+
|
|
161
|
+
The panel's job list and its stats read different sources, so the two disagreed.
|
|
162
|
+
`GET /__dev/api/queue/topics` scanned a hardcoded `<cwd>/data/queue` that no
|
|
163
|
+
Ruby app ever wrote to, so it could not name a real topic. `GET /__dev/api/queue`
|
|
164
|
+
never listed pending or reserved jobs at all - only `queue.failed()` and
|
|
165
|
+
`queue.dead_letters()` - while its stats counted all four buckets, and it ignored
|
|
166
|
+
the `?status=` filter the panel's own badges have always sent.
|
|
167
|
+
|
|
168
|
+
Four defects, all the same shape - the list describes a different set from the
|
|
169
|
+
counts:
|
|
170
|
+
|
|
171
|
+
1. **The directory.** Both handlers re-derived the path instead of asking where
|
|
172
|
+
the store is.
|
|
173
|
+
2. **The set.** Pending and reserved jobs were counted and never listed. A
|
|
174
|
+
failed-but-retryable job - which lives in the PENDING directory - was counted
|
|
175
|
+
by `stats.pending` and listed as `"failed"`: one job, two contradictory
|
|
176
|
+
answers.
|
|
177
|
+
3. **max_retries.** Dead letters were listed via `queue.dead_letters()`, which
|
|
178
|
+
filters on the max_retries of the Queue the dev admin itself constructed (3).
|
|
179
|
+
An app configured `max_retries: 1` dead-letters at ONE attempt, and every one
|
|
180
|
+
of those jobs was counted by `stats.failed` and never shown. The list now
|
|
181
|
+
uses `max_retries: 0` - the same "no attempt-count filter" spelling
|
|
182
|
+
`tina4ruby queue retry` already used.
|
|
183
|
+
4. **Duplicate JSON keys.** `job.merge(status: "...")` added a SYMBOL key beside
|
|
184
|
+
the record's existing STRING one, so every job went out as
|
|
185
|
+
`{"status":"dead", ..., "status":"dead_letter"}` - two `status` names in one
|
|
186
|
+
object.
|
|
187
|
+
|
|
188
|
+
Every job now appears exactly once, in the bucket its own stat counts it in, so
|
|
189
|
+
`sum(pending, completed, failed, reserved) == jobs.length` by construction on a
|
|
190
|
+
quiescent store, and every `?status=` filter returns exactly what its stat
|
|
191
|
+
counts.
|
|
192
|
+
|
|
193
|
+
### Fixed (the documented sort spelling raised on real MongoDB, ADR-0036)
|
|
194
|
+
|
|
195
|
+
`cursor.sort("total", -1)` - the spelling this file documents - raised
|
|
196
|
+
`ArgumentError: wrong number of arguments (given 2, expected 0..1)` on a real
|
|
197
|
+
MongoDB, because `Mongo::Collection::View#sort` takes ONE spec document. The
|
|
198
|
+
list-of-pairs form was worse: it reached the server as an ARRAY and came back
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
[14:TypeMismatch]: Expected field sort to be of type object
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
so of the three spellings the fallback accepted, only the hash form survived the
|
|
205
|
+
swap - and it was not the documented one.
|
|
206
|
+
|
|
207
|
+
`sort` now normalises all three to the driver's single spec document, so
|
|
208
|
+
`sort("total", -1)`, `sort({ "total" => -1 })` and `sort([["total", -1]])` are
|
|
209
|
+
equivalent on both providers. Ruby's fallback `Cursor` already accepted all
|
|
210
|
+
three; it is the DRIVER side that was narrow, and `MongoView` now bridges it.
|
|
211
|
+
|
|
212
|
+
NOT affected: Ruby's chain was already lazy on both providers, because a
|
|
213
|
+
`Mongo::Collection::View` is lazy and immutable. PHP's was not, and ADR-0036
|
|
214
|
+
fixes that separately.
|
|
215
|
+
|
|
216
|
+
MEASURED 2026-08-04 against a real MongoDB 7.0.39: 4 chain cases x 2 providers
|
|
217
|
+
x 4 frameworks = 32 combinations, of which **10 failed** before this change and
|
|
218
|
+
0 fail after. Pinned by the substitutability suite in all four frameworks,
|
|
219
|
+
which asserts every spelling on BOTH providers, that `skip` composes, that an
|
|
220
|
+
ASCENDING sort actually ascends (a direction ignored outright would pass a
|
|
221
|
+
descending-only test), and that the chain is LAZY - a document inserted after
|
|
222
|
+
the chain is built but before it is iterated must appear.
|
|
223
|
+
|
|
224
|
+
### Fixed (the uniform DocStore spellings now work on the real provider, ADR-0035)
|
|
225
|
+
|
|
226
|
+
- `collection.find_one(filter)` and `cursor.to_list` worked on the SQLite
|
|
227
|
+
fallback and did not exist on `Mongo::Collection`, so code that used them
|
|
228
|
+
raised `NoMethodError` the moment `TINA4_MONGO_URI` was set. They now work on
|
|
229
|
+
BOTH providers.
|
|
230
|
+
|
|
231
|
+
`get_collection` returns `Tina4::DocStore::MongoCollection` on the Mongo path -
|
|
232
|
+
a `SimpleDelegator` that adds the two methods and forwards the entire driver
|
|
233
|
+
surface untouched. `aggregate`, `bulk_write`, `indexes`, `watch`, sessions and
|
|
234
|
+
transactions are all still reachable; measured 2026-08-04 against a real
|
|
235
|
+
MongoDB 7.0.39, with 0 fallback-only collection methods and 0 fallback-only
|
|
236
|
+
cursor methods.
|
|
237
|
+
|
|
238
|
+
ADDITIVE, not a replacement. `find(filter).first` and `to_a` are the driver's
|
|
239
|
+
spellings, they are unchanged, and both forms return the same answer.
|
|
240
|
+
|
|
241
|
+
ADR-0025 corollary 1 said to DELETE a method the driver lacks. ADR-0035
|
|
242
|
+
supersedes that corollary only: a method may exist on the fallback when it
|
|
243
|
+
also exists on what `get_collection` RETURNS, and Tina4 may supply it on both
|
|
244
|
+
sides. The core rule and corollaries 2, 3 and 4 stand.
|
|
245
|
+
|
|
246
|
+
Pinned by `spec/docstore_substitutability_spec.rb`, which reads a document
|
|
247
|
+
back through every spelling on BOTH providers and measures the fallback's
|
|
248
|
+
public methods against the wrapped driver rather than a hand-kept list.
|
|
249
|
+
|
|
250
|
+
**Breaking: on the Mongo path `get_collection` now returns a delegator, so a
|
|
251
|
+
CLASS check answers differently.** Every method call, `==` against the raw
|
|
252
|
+
collection, and the whole driver surface behave exactly as before, but
|
|
253
|
+
|
|
254
|
+
Tina4::DocStore.get_collection("x").is_a?(Mongo::Collection) # was true, now false
|
|
255
|
+
Tina4::DocStore.get_collection("x").class # was Mongo::Collection
|
|
256
|
+
|
|
257
|
+
**Migration:** stop type-checking the return, or reach the real object with
|
|
258
|
+
`__getobj__`:
|
|
259
|
+
|
|
260
|
+
collection.__getobj__.is_a?(Mongo::Collection) # true
|
|
261
|
+
|
|
262
|
+
Nothing in the framework type-checks it; this is stated because a user
|
|
263
|
+
application might.
|
|
264
|
+
|
|
265
|
+
KNOWN and NOT fixed here: the fallback `Cursor#sort(key, direction)` takes two
|
|
266
|
+
arguments and `Mongo::Collection::View#sort` takes one. Use the hash spelling
|
|
267
|
+
`sort({ "total" => -1 })`, which both providers accept.
|
|
268
|
+
|
|
269
|
+
### Breaking (DocStore: a missing MongoDB driver now raises)
|
|
270
|
+
|
|
271
|
+
`TINA4_MONGO_URI` set with the `mongo` gem NOT installed used to return the local SQLite collection. It now
|
|
272
|
+
raises `Tina4::DocStore::DocStoreDriverMissing`, naming the provider and what is missing (ADR-0033,
|
|
273
|
+
applying ADR-0024 rule 3).
|
|
274
|
+
|
|
275
|
+
Re-measured 2026-08-04 at `v3` HEAD in a REAL driverless environment - no mock, no
|
|
276
|
+
faked import - one env produced two shapes and four messages across the family:
|
|
277
|
+
Python, PHP and Ruby silently returned the local SQLite store, Node threw a bare
|
|
278
|
+
`ERR_MODULE_NOT_FOUND`. Silent degradation here means production writes landing in a
|
|
279
|
+
container-local file nobody reads, which vanishes on the next deploy, with no error at
|
|
280
|
+
any point.
|
|
281
|
+
|
|
282
|
+
**Migration - one of two lines:**
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
gem install mongo # use the real provider
|
|
286
|
+
unset TINA4_MONGO_URI # or use the local SQLite store, explicitly
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Also changed: `serverless?` is now CONFIGURATION ONLY. It used to also return true when the gem was absent, which is
|
|
290
|
+
what routed the call into the local branch; without this an app branching on it would
|
|
291
|
+
take the local path and never reach the raise. The error message names the env var that
|
|
292
|
+
supplied the URI and never its VALUE, because a Mongo URI routinely carries
|
|
293
|
+
`user:password@` and an error string is the most-logged text a framework emits.
|
|
294
|
+
|
|
295
|
+
### Breaking (the query-cache key carried no database identity)
|
|
296
|
+
|
|
297
|
+
**Breaking: every existing persistent query-cache entry becomes a miss on upgrade.**
|
|
298
|
+
|
|
299
|
+
`Database#cache_key` was `sha256(sql + params)` with nothing naming the connection, so
|
|
300
|
+
on ANY shared cache backend two databases cross-served each other's rows. Two apps
|
|
301
|
+
pointed at one Redis, or one app with a primary and an analytics connection, silently
|
|
302
|
+
read each other's data. Identical SQL text across tenants is the COMMON case in a
|
|
303
|
+
multi-tenant deployment, not an edge case, so the collision was the normal outcome.
|
|
304
|
+
This is a data-isolation failure, not a caching inefficiency.
|
|
305
|
+
|
|
306
|
+
The key is now `sha256(engine://host:port/database \0 sql \0 params)`. Credentials are
|
|
307
|
+
deliberately excluded: a password in the key would cold-start the cache on every
|
|
308
|
+
rotation, and a shared backend's key namespace is readable by every tenant of that
|
|
309
|
+
backend. Nothing per-process is included either (no pid, no object_id, no salt) - that
|
|
310
|
+
would isolate the databases by accident and destroy the point of a shared cache, since
|
|
311
|
+
no instance would ever hit another instance's entry.
|
|
312
|
+
|
|
313
|
+
**Migration:** the key format changed, so entries written by an earlier version are
|
|
314
|
+
unreachable and simply miss. A cold cache is safe - it costs one repopulating read per
|
|
315
|
+
key. Cross-served rows are not safe, which is why this ships as a break rather than a
|
|
316
|
+
compatibility shim. Nothing needs to be run: no config change, no manual flush. If you
|
|
317
|
+
would rather not carry the dead entries until their TTL expires, call `db.cache_clear`
|
|
318
|
+
once after deploying.
|
|
319
|
+
|
|
320
|
+
### Fixed (queue operations acted on the local file store, not the configured backend)
|
|
321
|
+
|
|
322
|
+
Every operation must act on the CONFIGURED backend. These calls appeared to succeed
|
|
323
|
+
while operating on the wrong data, which is the worst failure class because nothing
|
|
324
|
+
surfaces it. `pop_by_id` was broken in ALL FOUR frameworks.
|
|
325
|
+
|
|
326
|
+
- `clear()` and `pop_by_id()` returned `0`/`nil` because `MongoBackend` had NEITHER
|
|
327
|
+
method and `Queue` guarded on `respond_to?`. Clearing a mongo-backed queue was a
|
|
328
|
+
no-op that looked exactly like an already-empty queue. Both are implemented on
|
|
329
|
+
mongodb now, and the guards raise a named refusal instead of a silent 0/nil.
|
|
330
|
+
|
|
331
|
+
### Fixed (a queue method could be a fatal error instead of resolving)
|
|
332
|
+
|
|
333
|
+
Every public `Queue` method must RESOLVE on every backend the framework offers. A
|
|
334
|
+
method that does not exist cannot even reach a refusal, so the upgrade path is
|
|
335
|
+
severed rather than degraded.
|
|
336
|
+
|
|
337
|
+
- `queue.size` raised `NoMethodError` on the kafka backend, which simply had no
|
|
338
|
+
`size` method. It now answers `0` - the value ADR-0022 decision 5 already records,
|
|
339
|
+
and the one Python and PHP already gave. A log has no queue depth, and computing
|
|
340
|
+
one means an admin round-trip per call.
|
|
341
|
+
|
|
342
|
+
### Fixed (queue priority was ignored on every backend but file)
|
|
343
|
+
|
|
344
|
+
- `push(..., priority)` is now honoured on the `mongodb` backend: priority is stored
|
|
345
|
+
top-level and the dequeue sorts highest-first, ties oldest-first — the same policy
|
|
346
|
+
the file backend already applied. An urgent job queued behind a backlog used to wait
|
|
347
|
+
for all of it in production while prioritising correctly in development. Here the Mongo backend never WROTE the field its own `dequeue` read back
|
|
348
|
+
(`doc["priority"] || 0`), so every job scored 0, and it sorted on `created_at`
|
|
349
|
+
alone.
|
|
350
|
+
- **Breaking:** pushing with a priority to `rabbitmq` or `kafka` now RAISES, naming the
|
|
351
|
+
backend and the operation, instead of silently discarding it. A RabbitMQ queue is
|
|
352
|
+
FIFO: native priority needs the queue DECLARED with `x-max-priority`, and an existing
|
|
353
|
+
queue cannot be redeclared with one (the broker answers PRECONDITION_FAILED), so
|
|
354
|
+
switching it on would break every queue already in service. Kafka has no priority
|
|
355
|
+
concept at all. Migration: use the `file` or `mongodb` backend for prioritised jobs.
|
|
356
|
+
A push with priority 0 (the default) is unaffected.
|
|
357
|
+
|
|
358
|
+
### Fixed (a queue delay was silently dropped on every non-file backend)
|
|
359
|
+
|
|
360
|
+
- `push(..., delay)` is now honoured on the `mongodb` backend. It was silently DROPPED
|
|
361
|
+
on every non-file backend in ALL FOUR frameworks, so a scheduled job fired immediately
|
|
362
|
+
in production and on time in development. Here the Mongo backend never WROTE the `available_at` that `Queue#push` had already
|
|
363
|
+
computed, AND its `dequeue` never FILTERED on it. Writing the field alone would
|
|
364
|
+
have changed nothing.
|
|
365
|
+
- **Breaking:** pushing with a delay to `rabbitmq` or `kafka` now RAISES, naming the
|
|
366
|
+
backend and the operation, instead of silently discarding the delay. Neither broker
|
|
367
|
+
has a per-message delay: RabbitMQ's delayed-message-exchange is a non-core plugin and
|
|
368
|
+
the TTL + dead-letter workaround head-of-line blocks, and Kafka reads a partition in
|
|
369
|
+
offset order. Migration: use the `file` or `mongodb` backend for delayed jobs, or
|
|
370
|
+
schedule the push itself. A push with no delay is unaffected.
|
|
371
|
+
|
|
372
|
+
### Fixed (an unknown queue backend name silently used the file store)
|
|
373
|
+
|
|
374
|
+
- An unrecognised `TINA4_QUEUE_BACKEND` now RAISES, naming the bad value and the
|
|
375
|
+
valid set, instead of falling through to the local file store. The name is also
|
|
376
|
+
normalised (trimmed + lowercased), so ` RabbitMQ ` resolves.
|
|
377
|
+
|
|
378
|
+
WHY: MEASURED 2026-08-03. A typo in `TINA4_QUEUE_BACKEND` produced a RUNNING app
|
|
379
|
+
writing every job to local disk while the operator believed they were in
|
|
380
|
+
RabbitMQ - jobs nothing consumes, on a container filesystem that vanishes on
|
|
381
|
+
the next deploy, with no error at any point.
|
|
382
|
+
|
|
383
|
+
python raised, named the valid set <- already correct
|
|
384
|
+
ruby raised, named the valid set <- already correct
|
|
385
|
+
php SILENT FALLBACK to file
|
|
386
|
+
nodejs SILENT FALLBACK to file
|
|
387
|
+
|
|
388
|
+
This is the same rule the SESSION backend already adopted, for the same
|
|
389
|
+
reason, so two of four were simply behind.
|
|
390
|
+
|
|
391
|
+
Ruby-specific, found while proving the shared case: `.downcase.strip` bound
|
|
392
|
+
only to the `ENV.fetch` branch, so an explicit `Queue.new(backend: "FILE")`
|
|
393
|
+
raised while the identical spelling in `TINA4_QUEUE_BACKEND` resolved - and
|
|
394
|
+
while python, php and nodejs all accepted it. Both sources are normalised now.
|
|
395
|
+
Python is master on internal API design.
|
|
396
|
+
|
|
397
|
+
Pinned by `spec/queue_backend_validation_spec.rb`, with a negative case asserting the guard still accepts
|
|
398
|
+
every documented name - without it, "make everything raise" would pass.
|
|
399
|
+
Mutation-proved in both directions (guard disabled, normalisation removed).
|
|
400
|
+
|
|
401
|
+
### Fixed (array queries diverged from MongoDB, ADR-0025 clause 4)
|
|
402
|
+
|
|
403
|
+
- A query against an ARRAY field now behaves the way MongoDB behaves. The rule is
|
|
404
|
+
one sentence: a condition on an array-valued field matches when ANY ELEMENT
|
|
405
|
+
matches it (or the whole array equals the operand), and a negation matches when
|
|
406
|
+
NO element does. Implemented over SQLite's `json_each`.
|
|
407
|
+
|
|
408
|
+
WHY: MEASURED 2026-08-03 against a real MongoDB with an 18-case matrix. EIGHT
|
|
409
|
+
behaviours diverged IDENTICALLY in all four frameworks, which is the signature
|
|
410
|
+
of a contract nobody had written down:
|
|
411
|
+
|
|
412
|
+
tags = "x" against ["x","y"] mongo 1, fallback 0 (containment)
|
|
413
|
+
tags $in ["x"] mongo 1, fallback 0
|
|
414
|
+
nums = 1 against [1,2,3] mongo 1, fallback 0
|
|
415
|
+
nums $lt 2 against [1,2,3] mongo 1, fallback 0
|
|
416
|
+
tags $regex "^x$" mongo 1, fallback 0
|
|
417
|
+
tags $nin ["x"] mongo 0, fallback 1 <- FALSE POSITIVE
|
|
418
|
+
tags $ne "x" mongo 0, fallback 1 <- FALSE POSITIVE
|
|
419
|
+
nums $gt 9 against [1,2,3] mongo 0, fallback 1 <- FALSE POSITIVE
|
|
420
|
+
|
|
421
|
+
The three false positives are the worst of it: the fallback returned documents
|
|
422
|
+
Mongo EXCLUDES. `nums $gt 9` matched [1,2,3] because json_extract of an array
|
|
423
|
+
returns its JSON TEXT and SQLite sorts any text above any number - a wrong
|
|
424
|
+
answer, not a missing feature.
|
|
425
|
+
|
|
426
|
+
Also fixed in the same pass: an object field is no longer matched by one of its
|
|
427
|
+
values, and IS matched by the whole object.
|
|
428
|
+
|
|
429
|
+
Pinned by `spec/docstore_substitutability_spec.rb`, which runs a 20-case matrix against BOTH providers and
|
|
430
|
+
asserts they return the SAME counts - not a hard-coded number, so the test
|
|
431
|
+
cannot drift towards whatever the fallback happens to do. Mutation-proved by
|
|
432
|
+
removing the array branch from equality.
|
|
433
|
+
|
|
434
|
+
### Fixed (DocStore leaked a Mongo client per call, ADR-0025)
|
|
435
|
+
|
|
436
|
+
- `get_collection` cached the connected client instead of building a new one on every
|
|
437
|
+
call. Added `Tina4::DocStore.close_doc_store` to close every Mongo client and the SQLite store.
|
|
438
|
+
|
|
439
|
+
WHY: MEASURED 2026-08-03 against a real MongoDB - 20 calls left 60 server
|
|
440
|
+
connections open, growing LINEARLY and without bound. It was invisible in
|
|
441
|
+
development, because the SQLite fallback opens no connections at all: a
|
|
442
|
+
resource leak that existed ONLY after the swap to the real provider, and that
|
|
443
|
+
exhausts the server rather than erroring.
|
|
444
|
+
|
|
445
|
+
The cache is keyed per (uri, database) so a reconfigure gets its own client, and it is
|
|
446
|
+
guarded against the check-then-act race in which two concurrent first-callers
|
|
447
|
+
both build a client and one is orphaned - the same leak, just rarer.
|
|
448
|
+
|
|
449
|
+
Pinned by `spec/docstore_substitutability_spec.rb`, which drives three identical rounds plus 100 further
|
|
450
|
+
calls and asserts the growth PLATEAUS. That is the distinction that matters: a
|
|
451
|
+
pool legitimately opens several connections and then flattens; a leak keeps
|
|
452
|
+
climbing. Mutation-proved by restoring one-client-per-call.
|
|
453
|
+
|
|
454
|
+
NOT affected: PHP. Its ext-mongodb driver pools at the libmongoc level, so
|
|
455
|
+
many Client objects sharing a URI share one pool - measured 0 growth over 60
|
|
456
|
+
calls. It gets the same named test anyway, because correct-for-a-reason-we-did-
|
|
457
|
+
not-choose is exactly what regresses silently.
|
|
458
|
+
|
|
13
459
|
## Unreleased
|
|
14
460
|
|
|
461
|
+
### Breaking: the rate limiter keys on the socket peer, not X-Forwarded-For
|
|
462
|
+
|
|
463
|
+
`X-Forwarded-For` is written by whoever sends it. Reading it unconditionally let
|
|
464
|
+
any client pick its own rate-limit bucket, and - worse - pick SOMEONE ELSE'S,
|
|
465
|
+
exhausting a third party's quota. Measured with `TINA4_RATE_LIMIT=3`: a rotating
|
|
466
|
+
`X-Forwarded-For` scored 200,200,200,200,200,200 where a fixed one correctly
|
|
467
|
+
scored 200,200,200,429,429,429.
|
|
468
|
+
|
|
469
|
+
`X-Forwarded-For` and `X-Real-IP` are now read ONLY when the raw socket peer is
|
|
470
|
+
listed in the new `TINA4_TRUSTED_PROXIES`. Within the chain the RIGHTMOST hop
|
|
471
|
+
that is not itself a trusted proxy wins, matching Rack and Express (a client can
|
|
472
|
+
prepend its own hop, so the leftmost entry is attacker-controlled even behind a
|
|
473
|
+
real proxy).
|
|
474
|
+
|
|
475
|
+
**Migration.** If your app runs behind a proxy, load balancer or ingress, set
|
|
476
|
+
`TINA4_TRUSTED_PROXIES` to that proxy's address or range. It accepts a
|
|
477
|
+
comma-separated mix of exact addresses and CIDR ranges, IPv4 and IPv6:
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
TINA4_TRUSTED_PROXIES=10.0.0.0/8
|
|
481
|
+
TINA4_TRUSTED_PROXIES=192.168.1.5, ::1, fd00::/8
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
It is EMPTY by default, which means trust nothing. If you leave it unset behind a
|
|
485
|
+
proxy, every client is bucketed under the proxy's address and you will
|
|
486
|
+
over-limit. That is deliberate: over-limiting is a degraded service, while the
|
|
487
|
+
previous behaviour was an open door. Direct-to-internet apps need no change.
|
|
488
|
+
|
|
489
|
+
See ADR-0019.
|
|
490
|
+
### Auth: the `jwt` gem is gone, RS256 is opt-in stdlib OpenSSL
|
|
491
|
+
|
|
492
|
+
`tina4ruby` no longer depends on the `jwt` gem. Runtime dependencies drop from 12 to 11.
|
|
493
|
+
Every JWT is now signed and verified with stdlib OpenSSL: `OpenSSL::HMAC` for the standard
|
|
494
|
+
HS256/HS384/HS512 family, and `OpenSSL::PKey::RSA#sign`/`#verify` for the opt-in RS256. The
|
|
495
|
+
gem was only ever wrapping the base64url `header.payload.signature` envelope that
|
|
496
|
+
`lib/tina4/auth.rb` already builds for its HMAC path, so it bought nothing and cost a
|
|
497
|
+
dependency. Tokens are unchanged on the wire: an RS256 token minted by the new code verifies
|
|
498
|
+
under PHP `openssl_verify` and Node `crypto.createVerify`, and a tampered payload is rejected
|
|
499
|
+
by both.
|
|
500
|
+
|
|
501
|
+
HMAC (HS256/HS384/HS512) is the standard algorithm family across all four frameworks and is
|
|
502
|
+
zero-dependency in each. RS256 stays available in Ruby, opt-in via key presence
|
|
503
|
+
(`.keys/private.pem` + `.keys/public.pem` with a blank `TINA4_SECRET`), and needs no library.
|
|
504
|
+
If a Ruby build somehow lacks `OpenSSL::PKey::RSA`, the RS256 path raises
|
|
505
|
+
`NotImplementedError` naming what is missing and the remedy, instead of failing silently.
|
|
506
|
+
|
|
507
|
+
Note that HMAC is symmetric: every service that VERIFIES a token holds the secret that SIGNS
|
|
508
|
+
it, so any verifier can also mint tokens. That is fine for one app or a trusted fleet, and
|
|
509
|
+
wrong when handing tokens to a third party you do not control. Use RS256 there, so a verifier
|
|
510
|
+
can hold only the public key.
|
|
511
|
+
|
|
512
|
+
Algorithm pinning is unchanged and now explicitly covered: under an HMAC configuration a
|
|
513
|
+
token whose header claims `RS256` is rejected, and `alg: "none"` is rejected, even when the
|
|
514
|
+
accompanying signature is a genuinely valid HMAC. See `spec/auth_rs256_optin_spec.rb`.
|
|
515
|
+
|
|
516
|
+
**Breaking:** `Tina4::Auth.valid_token_detail` (alias `validate_token`) previously reported
|
|
517
|
+
the `jwt` gem's own wording on the RS256 path - `{ valid: false, error: "Token expired" }`
|
|
518
|
+
for an expired token, and the gem's raw decode message for anything else. Both paths now
|
|
519
|
+
report the single HMAC-path wording, `{ valid: false, error: "Invalid or expired token" }`.
|
|
520
|
+
*Migration:* do not branch on the `error` STRING. Test `result[:valid]`, which is unchanged,
|
|
521
|
+
and read `result[:payload]` on success. Applications that matched `error == "Token expired"`
|
|
522
|
+
to distinguish expiry from a bad signature must instead inspect the `exp` claim themselves
|
|
523
|
+
via `Tina4::Auth.get_payload(token)`. This aligns the RS256 detail shape with the HMAC one,
|
|
524
|
+
so the response no longer depends on which algorithm signed.
|
|
525
|
+
|
|
526
|
+
### Security: the auth + session contract (ADR-0021)
|
|
527
|
+
|
|
528
|
+
**Breaking:** the API-key auth result is now `{ "_auth" => "api_key" }` instead of
|
|
529
|
+
`{ "api_key" => true }`, in `Tina4::Auth.authenticate_request`, the `env["tina4.auth"]`
|
|
530
|
+
set by `Tina4::Auth.bearer_auth`, and the `env["tina4.auth_payload"]` set by the
|
|
531
|
+
write-route gate. *Migration:* replace `payload["api_key"]` with
|
|
532
|
+
`payload["_auth"] == "api_key"`. PHP and Node already used `_auth`; Python moved to it
|
|
533
|
+
in the same change, so the same successful auth no longer reads three different ways
|
|
534
|
+
across the four frameworks.
|
|
535
|
+
|
|
536
|
+
Also in this change:
|
|
537
|
+
|
|
538
|
+
- **Breaking: strict session mode.** A session id is now adopted only when it is BOTH a well-formed opaque id
|
|
539
|
+
(`Tina4::Session.valid_session_id?`, `/\A[A-Za-z0-9_-]{1,128}\z/` — the constraint is
|
|
540
|
+
the alphabet, there is deliberately no entropy floor) AND an id the backend already
|
|
541
|
+
holds a session under. Anything else is DISCARDED and a fresh `SecureRandom.hex(32)`
|
|
542
|
+
minted. This is OWASP strict mode / PHP's `session.use_strict_mode=1`, and it closes
|
|
543
|
+
session fixation: Ruby previously adopted any cookie id, so an attacker could plant
|
|
544
|
+
one, wait for the victim to log in under it, and already hold the authenticated
|
|
545
|
+
session id. A backend OUTAGE is deliberately not treated as "unknown" — it logs and
|
|
546
|
+
still adopts, so one Redis blip cannot rotate every id at once.
|
|
547
|
+
|
|
548
|
+
Strict mode on its own logs NOBODY out: an id the backend already holds is still
|
|
549
|
+
adopted, on every backend. (An earlier draft of this bullet claimed a one-time
|
|
550
|
+
logout for everyone. That was wrong. The one-time logout is caused by the filename
|
|
551
|
+
change in the next bullet, and it reaches the FILE backend only.)
|
|
552
|
+
- **Breaking: session filenames are a SHA-256 of the id.** `FileHandler#session_path`
|
|
553
|
+
did `gsub(/[^a-zA-Z0-9_-]/, "")` — traversal-safe but LOSSY, so `a/b` and `ab` both
|
|
554
|
+
became `sess_ab.json` and one user's session data surfaced under another user's id.
|
|
555
|
+
It is now `sess_<sha256(id)>.json`, parity with Python's `FileSessionHandler._file`.
|
|
556
|
+
|
|
557
|
+
**This invalidates live sessions on the FILE backend ONLY.** An existing
|
|
558
|
+
`sess_<id>.json` is not found under the new name, so those users are handed a fresh
|
|
559
|
+
session and sign in again once. Do not tell operators on the other backends that
|
|
560
|
+
everyone is logged out - it is not true for them. Verified backend by backend
|
|
561
|
+
against this release:
|
|
562
|
+
|
|
563
|
+
| session backend | live sessions after upgrade | why |
|
|
564
|
+
| --- | --- | --- |
|
|
565
|
+
| file | LOST, sign in again once | filename moved from `sess_<stripped-id>.json` to `sess_<sha256(id)>.json` |
|
|
566
|
+
| redis | survive | key is still `tina4:session:<id>`, the raw id |
|
|
567
|
+
| valkey | survive | key is still `tina4:session:<id>`, the raw id |
|
|
568
|
+
| mongodb | survive | `_id` is still the raw id |
|
|
569
|
+
| database | survive | `tina4_session.session_id` is still the raw id |
|
|
570
|
+
| memcached | not applicable | new session backend in this release, no prior sessions to lose |
|
|
571
|
+
|
|
572
|
+
The memcached handler hashes a key only when the composed key would exceed
|
|
573
|
+
memcached's 250-byte limit or carry a control character, so a normal id is stored
|
|
574
|
+
under the raw value there too.
|
|
575
|
+
|
|
576
|
+
*Migration:* nothing to run. On the file backend, expect one round of sign-ins on
|
|
577
|
+
the deploy, so avoid shipping it in the middle of a checkout flow or alongside a
|
|
578
|
+
change that assumes a warm session. Delete stale `sessions/sess_*.json` at your
|
|
579
|
+
leisure; they are unreadable, not dangerous.
|
|
580
|
+
- **A malformed `exp`/`nbf` no longer reads as "no constraint".** RFC 7519 s2 defines
|
|
581
|
+
them as NumericDate; the check was `payload["exp"] && ...`, so `exp: null` or
|
|
582
|
+
`exp: false` skipped it entirely and the token never expired. A present-but-non-numeric
|
|
583
|
+
claim now rejects the token. A token with no `exp`/`nbf` key at all stays
|
|
584
|
+
unconstrained (non-breaking).
|
|
585
|
+
- **`authenticate_request` / `bearer_auth` check the JWT BEFORE the API key**, matching
|
|
586
|
+
Python, PHP and Node.
|
|
587
|
+
- **The write-route gate compares the API key timing-safely.** `RackApp.enforce_route_auth`
|
|
588
|
+
used a plain `token == api_key`, which returns as soon as two bytes differ and leaks
|
|
589
|
+
the key prefix through response timing; it now routes through
|
|
590
|
+
`Tina4::Auth.validate_api_key` (`OpenSSL.fixed_length_secure_compare`).
|
|
591
|
+
|
|
592
|
+
Locked in by `spec/auth_session_contract_spec.rb`, whose example names are identical in
|
|
593
|
+
all four frameworks.
|
|
594
|
+
|
|
595
|
+
### Breaking: `Session#get` returns a STORED false instead of the default
|
|
596
|
+
|
|
597
|
+
`Tina4::Session#get` was `@data[key.to_s] || default`, and `||` hands back the
|
|
598
|
+
caller's default for ANY falsy stored value. A legitimately stored `false` read back
|
|
599
|
+
as `true` whenever the caller passed `true` as the default - so
|
|
600
|
+
`session.get("marketing_opt_in", true)` reported opted-IN for a user who had
|
|
601
|
+
explicitly opted OUT. It is now `value.nil? ? default : value`, which keys off
|
|
602
|
+
PRESENCE, matching Python's `dict.get(key, default)`.
|
|
603
|
+
|
|
604
|
+
The `nil?` form is deliberate rather than `@data.key?(k) ? @data[k] : default`: both
|
|
605
|
+
fix the `false` case, but the `key?` form would also make a stored `nil` win over the
|
|
606
|
+
default, which is a second behaviour change nobody asked for.
|
|
607
|
+
|
|
608
|
+
**Migration.** Read the call sites where you pass a TRUTHY default and can store a
|
|
609
|
+
`false` under that key - typically feature flags, consent and opt-out flags. Those
|
|
610
|
+
now return the stored `false` where they used to return your default. A key that was
|
|
611
|
+
never stored still returns the default, unchanged. Python, PHP and Node were already
|
|
612
|
+
correct here; only Ruby moves.
|
|
613
|
+
|
|
614
|
+
Locked by the cross-framework example `session get returns a stored false instead of
|
|
615
|
+
the default`, whose name is identical in all four repos.
|
|
616
|
+
|
|
617
|
+
### Breaking: the response cache obeys RFC 9111 (Authorization and Vary)
|
|
618
|
+
|
|
619
|
+
The response cache keyed entries on method plus URL, with NO request header in
|
|
620
|
+
the key. It is a shared, server-side store, so on a secured GET route the first
|
|
621
|
+
caller's body was served to every later caller of the same URL. Measured
|
|
622
|
+
end-to-end on a real secured route: a valid token for `bob` returned alice's
|
|
623
|
+
private body with `X-Cache: HIT`. In Node, where route middleware runs before
|
|
624
|
+
the auth gate, an ANONYMOUS request returned 200 with alice's body.
|
|
625
|
+
|
|
626
|
+
Two RFC 9111 rules now apply, as they do in Varnish, nginx and Rails:
|
|
627
|
+
|
|
628
|
+
- Section 3 / 3.5: a response to a request carrying `Authorization` is NOT
|
|
629
|
+
stored unless the response carries `Cache-Control: public`, `s-maxage` or
|
|
630
|
+
`must-revalidate`.
|
|
631
|
+
- Section 4.1: `Vary` is honoured. The nominated request headers are recorded
|
|
632
|
+
with the entry and must match on lookup; an absent field matches only an
|
|
633
|
+
absent field. `Vary: *` is never stored.
|
|
634
|
+
|
|
635
|
+
**Migration.** Authenticated GETs are no longer cached by default. If a
|
|
636
|
+
response body is genuinely identical for every caller, opt back in per
|
|
637
|
+
response:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
response.headers["Cache-Control"] = "public"
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
Only add it where the body carries nothing user-specific. Public GET caching is
|
|
644
|
+
unchanged. See ADR-0020 and `plan/v3/features/043-caching.md`.
|
|
645
|
+
|
|
646
|
+
### Breaking: an unknown TINA4_CACHE_BACKEND raises instead of falling back to memory
|
|
647
|
+
|
|
648
|
+
An unrecognised name silently became an in-process memory cache, so a typo
|
|
649
|
+
(`TINA4_CACHE_BACKEND=redsi`) produced a running app that shared nothing while the
|
|
650
|
+
operator believed it was Redis. It now raises, naming the bad value and the valid
|
|
651
|
+
set - the contract `TINA4_SESSION_BACKEND` already uses.
|
|
652
|
+
|
|
653
|
+
**Migration.** Fix the spelling. Valid: `memory`, `file`, `redis`, `valkey`,
|
|
654
|
+
`memcached`, `mongodb`, `database` (plus the aliases `memcache`, `mongo`, `db`).
|
|
655
|
+
|
|
656
|
+
### Security: a before hook that refuses without returning the pair no longer runs the handler
|
|
657
|
+
|
|
658
|
+
A `before_*` middleware hook that set a 4xx status and returned `nil` did NOT
|
|
659
|
+
short-circuit - the response carried the 403 but the route handler ran anyway.
|
|
660
|
+
The `status_code >= 400` check was nested INSIDE the "did the hook return a
|
|
661
|
+
2-element Array" branch, so refusal was only honoured for hooks that also
|
|
662
|
+
returned `[request, response]`. Any auth/guard middleware written as
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
def self.before_auth(request, response)
|
|
666
|
+
response.json({ error: "denied" }, 403) unless authorised?(request)
|
|
667
|
+
end
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
executed its protected handler. Reproduced with a real `Tina4::Request` /
|
|
671
|
+
`Tina4::Response`: the pair-returning form returned `false` (correct), the
|
|
672
|
+
nil-returning form returned `true`. The check is now unconditional after EVERY
|
|
673
|
+
hook call, matching Python, PHP and Node (Rails short-circuits on the response
|
|
674
|
+
STATE, not on what the filter returned).
|
|
675
|
+
|
|
676
|
+
Regression test: `a before hook that sets 4xx and returns nothing skips the
|
|
677
|
+
handler` in `spec/middleware_pipeline_characterisation_spec.rb`.
|
|
678
|
+
|
|
679
|
+
### Fixed: per-route class middleware now runs its before_*/after_* hooks
|
|
680
|
+
|
|
681
|
+
`Route#run_middleware` called `mw.call(request, response)` on every attached
|
|
682
|
+
middleware. A class declaring `def self.before_auth` does not respond to
|
|
683
|
+
`.call`, so per-route class middleware raised `NoMethodError` and the
|
|
684
|
+
dispatcher turned every such request into a clean 500. **In Ruby this is
|
|
685
|
+
broken-to-working, not inert-to-active**: the documented per-route
|
|
686
|
+
`before_*`/`after_*` mechanism did not silently do nothing, it 500'd. (In PHP
|
|
687
|
+
and Node the equivalent fix makes previously-INERT middleware start running -
|
|
688
|
+
that difference matters when reading those changelogs.)
|
|
689
|
+
|
|
690
|
+
Per-route class middleware now goes through `Tina4::Middleware.run_before` /
|
|
691
|
+
`run_after` - the SAME orchestrator, hook discovery and return-value table as
|
|
692
|
+
global middleware, not a second parallel runner. 2-arg callable ("filter")
|
|
693
|
+
middleware and 3-arg function middleware are unchanged.
|
|
694
|
+
|
|
695
|
+
**Migration:** if you attached a class with `before_*`/`after_*` hooks to a
|
|
696
|
+
route, those hooks now RUN. Routes that were returning 500 will start serving,
|
|
697
|
+
and a hook that refuses will now actually refuse. Audit any such middleware
|
|
698
|
+
before upgrading - it has never executed in production.
|
|
699
|
+
|
|
700
|
+
**Also breaking:** a halting per-route middleware used to answer with a
|
|
701
|
+
hardcoded `[403, text/html, "403 Forbidden"]`, discarding whatever the
|
|
702
|
+
middleware had set. The response the middleware SET is now sent - a 401 with
|
|
703
|
+
`WWW-Authenticate`, a 302 to `/login`, or a JSON error body all survive. A
|
|
704
|
+
middleware that halts having set nothing still gets a 403 (now
|
|
705
|
+
`{"error":"Forbidden","status":403}`, the same shape as the middleware 500).
|
|
706
|
+
|
|
707
|
+
### Middleware hook return values are one documented table
|
|
708
|
+
|
|
709
|
+
Applied to EVERY `before_*`/`after_*` hook at EVERY scope (global and
|
|
710
|
+
per-route):
|
|
711
|
+
|
|
712
|
+
| return value | meaning |
|
|
713
|
+
| --- | --- |
|
|
714
|
+
| a `Tina4::Response` | SHORT-CIRCUIT; that object IS the response, at ANY status |
|
|
715
|
+
| `[request, response]` | rebind both, continue |
|
|
716
|
+
| `false` | SHORT-CIRCUIT; send the response as set, 403 only if still default/empty |
|
|
717
|
+
| `nil` | continue |
|
|
718
|
+
|
|
719
|
+
The `Tina4::Response` row is the PRIMARY rule and is new: it is the only one
|
|
720
|
+
that can express a 302 redirect from middleware. The `status >= 400`
|
|
721
|
+
short-circuit is retained as a documented LEGACY COMPATIBILITY PATH so
|
|
722
|
+
middleware written before it keeps working.
|
|
723
|
+
|
|
724
|
+
Note for Ruby specifically: Ruby has implicit returns, so a hook whose last
|
|
725
|
+
expression is a chainable response call (`response.add_header(...)` returns
|
|
726
|
+
`self`) now short-circuits. End such hooks with `[request, response]` or `nil`.
|
|
727
|
+
Block-based handlers registered with `Tina4::Middleware.before(pattern) { }`
|
|
728
|
+
are deliberately NOT covered by the table - a block's value is its last
|
|
729
|
+
expression, so reading a returned Response as a refusal there would fire
|
|
730
|
+
constantly. They keep their "`false` halts" contract.
|
|
731
|
+
|
|
732
|
+
### String-form route middleware (Python/PHP/Node parity)
|
|
733
|
+
|
|
734
|
+
`middleware: ["ResponseCache"]` and `middleware: ["ResponseCache:300"]` now
|
|
735
|
+
resolve, via `Tina4::Router.resolve_string_middleware`. Ruby was the only one
|
|
736
|
+
of the four frameworks without the mechanism - a String reached
|
|
737
|
+
`mw.call(request, response)` and raised `NoMethodError`. One instance is
|
|
738
|
+
memoised per spec so the cache can actually hit across requests (PHP does the
|
|
739
|
+
same; Python gets it by resolving at registration). An unknown name raises
|
|
740
|
+
`ArgumentError` naming the known set - never a silent skip, which for an auth
|
|
741
|
+
middleware would mean serving the route unprotected. The registry holds
|
|
742
|
+
`ResponseCache` only, matching PHP and Node; unifying it with Python's larger
|
|
743
|
+
name list is scheduled separately.
|
|
744
|
+
### CORS denies by default, and never pairs the wildcard with credentials
|
|
745
|
+
|
|
746
|
+
**Breaking:** `TINA4_CORS_ORIGINS` defaulted to `*`, which allowed every origin
|
|
747
|
+
on a fresh install. It now defaults to UNSET, which denies every cross-origin
|
|
748
|
+
request: no `Access-Control-Allow-Origin` is sent, and the browser's own CORS
|
|
749
|
+
check blocks the request. Django, Rails and ASP.NET all require an explicit
|
|
750
|
+
policy before emitting any CORS header, and now so does Tina4.
|
|
751
|
+
|
|
752
|
+
**Migration:** name the origins your frontend runs on.
|
|
753
|
+
|
|
754
|
+
```
|
|
755
|
+
TINA4_CORS_ORIGINS=https://app.example.com
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Comma-separate several. `TINA4_CORS_ORIGINS=*` restores the old allow-any
|
|
759
|
+
behaviour for anyone who wants it: only the DEFAULT changed, not the capability.
|
|
760
|
+
Non-browser clients (curl, server-to-server) never consult CORS and are
|
|
761
|
+
unaffected. The status code of a denied preflight is unchanged at 204.
|
|
762
|
+
|
|
763
|
+
Also in this change:
|
|
764
|
+
|
|
765
|
+
- `Access-Control-Allow-Origin: *` is never sent alongside
|
|
766
|
+
`Access-Control-Allow-Credentials: true`. The Fetch Standard's CORS check
|
|
767
|
+
treats `*` as a literal once the request carries credentials, so every browser
|
|
768
|
+
rejects the pair. When both are configured the wildcard wins, credentials are
|
|
769
|
+
dropped, and a warning names the fix.
|
|
770
|
+
- `Vary: Origin` is now sent whenever the allowed origin is computed from the
|
|
771
|
+
request's `Origin` header, including when the origin is REJECTED. Without it a
|
|
772
|
+
shared cache can store one origin's response and serve it to another
|
|
773
|
+
(RFC 9110 s12.5.5). It is not sent for a constant `*`, which does not vary.
|
|
774
|
+
- Every rejected cross-origin request logs an actionable warning naming the
|
|
775
|
+
origin, the environment variable, and the fix. Silence was the common thread
|
|
776
|
+
in every defect this audit found.
|
|
777
|
+
|
|
778
|
+
See ADR-0018.
|
|
779
|
+
|
|
780
|
+
### Fixed: CORS headers now reach the actual response, not only the preflight
|
|
781
|
+
|
|
782
|
+
`Tina4::CorsMiddleware.apply_headers` was never called from the dispatch path.
|
|
783
|
+
Only the preflight was answered. A browser sent its preflight, got a 204 saying
|
|
784
|
+
yes, sent the real request, and received no `Access-Control-Allow-Origin`, so it
|
|
785
|
+
blocked the response. Cross-origin browser access did not work in ANY
|
|
786
|
+
configuration. `apply_cors` now runs as an `ALWAYS_STAGE`, so the headers also
|
|
787
|
+
survive a short-circuited 401 and the early-returning swagger and static paths.
|
|
788
|
+
|
|
789
|
+
Ruby was also the only framework of the four that emitted
|
|
790
|
+
`Access-Control-Allow-Origin: *` together with
|
|
791
|
+
`Access-Control-Allow-Credentials: true`. It no longer does.
|
|
792
|
+
|
|
793
|
+
`Tina4::CorsClassMiddleware` is now a thin adapter over `Tina4::CorsMiddleware`
|
|
794
|
+
instead of a second copy of the rules. The copy had already drifted three ways:
|
|
795
|
+
no wildcard guard, a `Referer` fallback (a `Referer` is a URL, not an origin),
|
|
796
|
+
and an allow-list miss that returned the FIRST allowed origin, stamping another
|
|
797
|
+
site's origin onto a rejected caller's response. The `Referer` fallback is gone.
|
|
798
|
+
|
|
799
|
+
### CORS preflight responses now carry `Allow`
|
|
800
|
+
|
|
801
|
+
A CORS preflight (`OPTIONS` with an `Origin`) returned 204 with the
|
|
802
|
+
`Access-Control-*` headers but no `Allow`, while a bare `OPTIONS` to the same
|
|
803
|
+
path returned `Allow`. A preflight IS an OPTIONS response, so it now carries
|
|
804
|
+
`Allow` too, derived from the router's real method set (RFC 9110 s9.3.7).
|
|
805
|
+
|
|
806
|
+
This is conformance, not a deviation - see ADR-0013. The frameworks' own
|
|
807
|
+
OPTIONS handlers already emit `Allow` (Django's `View.options()`, Express's
|
|
808
|
+
router). The add-on CORS libraries omit it only because they short-circuit
|
|
809
|
+
ahead of the framework and skip its OPTIONS handler. Tina4 owns both paths in
|
|
810
|
+
one dispatcher.
|
|
811
|
+
|
|
812
|
+
`Allow` and `Access-Control-Allow-Methods` are NOT interchangeable: `Allow` is
|
|
813
|
+
what the RESOURCE supports, `Access-Control-Allow-Methods` is what the CORS
|
|
814
|
+
POLICY permits cross-origin (`TINA4_CORS_METHODS`, a static list as in every
|
|
815
|
+
mainstream library). A policy naming DELETE on a GET-only route is still a 405.
|
|
816
|
+
|
|
817
|
+
Non-breaking: one added response header on a 204; no existing header changes.
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
### Breaking: global middleware now runs before the auth gate
|
|
821
|
+
|
|
822
|
+
Dispatch order is now identical in all four frameworks:
|
|
823
|
+
|
|
824
|
+
```
|
|
825
|
+
pre-match globals -> match -> post-match globals -> auth gate -> route middleware -> handler
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Ruby (and Python) previously ran the auth gate FIRST, so a global middleware
|
|
829
|
+
never saw a rejected request. That made a global rate limiter unable to throttle
|
|
830
|
+
a brute-force login, and dropped every 401 from an access log. Node and PHP
|
|
831
|
+
already ran the globals first; every mainstream framework does the same (Django
|
|
832
|
+
ships `CsrfViewMiddleware` ahead of `AuthenticationMiddleware` and enforces auth
|
|
833
|
+
in a view decorator after all `MIDDLEWARE`; Laravel runs the `web` group before
|
|
834
|
+
the `auth` route middleware; ASP.NET puts `UseAuthorization` last before the
|
|
835
|
+
endpoint). See ADR-0012.
|
|
836
|
+
|
|
837
|
+
**Migration:** a global middleware (registered via `Tina4::Middleware.use` /
|
|
838
|
+
`Router.use`) now runs on requests that are about to be rejected, including
|
|
839
|
+
401s. If yours assumes an authenticated request, check for it - `request.user`
|
|
840
|
+
is only populated after the gate. A middleware that must NOT see rejected
|
|
841
|
+
requests should be attached to the route instead of registered globally; route
|
|
842
|
+
middleware still runs after the gate.
|
|
843
|
+
|
|
844
|
+
|
|
15
845
|
### Changed
|
|
16
846
|
|
|
847
|
+
- **Breaking: the metrics payload is now the native engine's shape.** `full_analysis` no
|
|
848
|
+
longer returns a `violations` key. The ranked `offenders` list replaces it and
|
|
849
|
+
`--fail-on` reads that same list, so one concept has one name instead of two.
|
|
850
|
+
Verified before removal: zero consumers outside the tests.
|
|
851
|
+
|
|
852
|
+
- **Breaking: `file_detail` returns the engine's per-file shape.** It no longer returns
|
|
853
|
+
`total_lines`, `classes`, `imports` or `warnings`, and `functions` is now a COUNT rather
|
|
854
|
+
than a list. Anything reading those keys must move to the engine's fields, or call
|
|
855
|
+
`full_analysis` and read `most_complex_functions` for per-function detail.
|
|
856
|
+
|
|
857
|
+
- **Breaking: the empty-class warning is gone and is not coming back.** The old
|
|
858
|
+
hand-rolled analyzer flagged `class Foo {}` with no members. An empty class is usually
|
|
859
|
+
CORRECT rather than a defect: marker classes, base exception types, DTO placeholders.
|
|
860
|
+
Tina4 itself ships `MetricsEngineError` as exactly that, so the check flagged the
|
|
861
|
+
framework's own correct code. A check that fires on correct code is noise, and noise is
|
|
862
|
+
why the offenders list went unread for months. The engine's vocabulary stays the four
|
|
863
|
+
things that are actionable: complexity, large file, low maintainability, untested.
|
|
864
|
+
|
|
865
|
+
- **Breaking: the column-metadata primary-key flag is `primary_key`.** Ruby and Python use `primary_key`; PHP and Node use `primaryKey`. Each follows its own
|
|
866
|
+
language's paradigm because this is framework API surface, not data. A dead `:primary`
|
|
867
|
+
fallback that nothing ever set was deleted.
|
|
868
|
+
|
|
869
|
+
- **Breaking: metrics REQUIRE the `tina4` CLI on PATH, with no fallback.** All four
|
|
870
|
+
frameworks deleted their own hand-rolled analyzer, so `full_analysis`, `offenders` and
|
|
871
|
+
`file_detail` now shell out to `tina4 metrics --json` (ADR-0002: one engine, so a number
|
|
872
|
+
measured in one language is comparable with the same number measured in another). A
|
|
873
|
+
missing or stale CLI raises and names the install command instead of quietly returning
|
|
874
|
+
worse numbers; the dev-admin endpoints answer 503, or 404 for an unknown file path.
|
|
875
|
+
Previously a failure fell back to the local analyzer, which is exactly how four
|
|
876
|
+
frameworks came to disagree about the same file. The file census behind the dashboard
|
|
877
|
+
(`quick_metrics`) stays in-process and needs no CLI: it is a glob-and-count, and the
|
|
878
|
+
engine is 8x to 37x slower on that path.
|
|
879
|
+
|
|
880
|
+
- **Breaking: every ORM read path that takes a `limit:` now defaults to 100 rows, and
|
|
881
|
+
three of them were returning EVERY ROW.** `where`, `all` and `select` defaulted to
|
|
882
|
+
`limit: nil`, and `Database#fetch` skips `apply_limit` entirely when the limit is nil,
|
|
883
|
+
so all three read the whole table. `with_trashed` and a `scope`-generated method
|
|
884
|
+
defaulted to 20. All five now default to 100.
|
|
885
|
+
|
|
886
|
+
Migration: this one can change results in both directions. A caller relying (knowingly
|
|
887
|
+
or not) on an unbounded read must now ask for it: `Model.all(limit: 10_000)`. A caller
|
|
888
|
+
relying on the old 20 gets 100. Code that already passes a limit is unaffected.
|
|
889
|
+
|
|
890
|
+
`QueryBuilder#get` and `fetch_all` are deliberately UNCHANGED and stay uncapped.
|
|
891
|
+
Neither takes a `limit:`, so a cap there can only ever be silent, and that silent
|
|
892
|
+
`LIMIT 100` was the data-loss-on-read footgun removed in 3.13.39 (ruby#4). The rule: a
|
|
893
|
+
path that advertises `limit:` caps at 100, a path without one never caps.
|
|
894
|
+
|
|
895
|
+
- **Fixed** a `scope`-generated method accepted `limit:` and `offset:` and then discarded
|
|
896
|
+
both. It called `where(filter_sql, params)` without passing either, so
|
|
897
|
+
`User.active(limit: 5)` returned the whole table: 150 rows came back from a 5-row
|
|
898
|
+
request against a 150-row table. Both arguments now reach `where`.
|
|
899
|
+
|
|
17
900
|
- Internal: the SQL dialect-translation file is renamed
|
|
18
901
|
`lib/tina4/sql_translation.rb` -> `lib/tina4/sql_translator.rb`, so the filename matches the
|
|
19
902
|
`Tina4::SQLTranslator` class it defines (and the sibling frameworks). The class name, its
|