tina4ruby 3.13.104 → 3.13.107

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2e628488f2b0de1d7ec0d271cc07562ff66aa22c198adfea5fb2d9f61581987
4
- data.tar.gz: 1416a35dac038e0a20befbec32f68d5e2cdd4d160ec440c1c18d40c38a3b2034
3
+ metadata.gz: ab8e75446e8ed41f28be0078b1f16de1e1c0be39ff83cefea98f2a5cc9a78cc2
4
+ data.tar.gz: b8d786867f6a37c87bbc0e242a615c7df8962ae8fd188d8bb8c381061ecba319
5
5
  SHA512:
6
- metadata.gz: 118eb151f7ceab9069f13f2e147a81c9e24468fb97d850acddb0054b15acd6e922369c59a83127a09e09605cf8460b0cc606268911325e63651bbda64c315b36
7
- data.tar.gz: 6045f620f3e0b1a6f489cd9215b1c8d52dba5ea3eb4f6db15ee768963a763ac6deb51f84e1809ea10de660cd89ad035a8cca06779f3373796ebf455b324631a2
6
+ metadata.gz: 7badcea95d972dfbc0171ed400722626d7d6565ff1b9328b1183dcedebe591c2c4fb2510fa67f8851614c4cd866c5b4db021351fdeea1bd016ce639ea1cb185f
7
+ data.tar.gz: b22a17a9907c81b37f1c22cb2d40e63c77eb62802f9db33ae749a747aa3d5eac5427523b2d5cec0a7ebda2b5d3c62ba88219fd906f0fab62da748a076f4b01e2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,94 @@ 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
+ ## 3.13.107
10
+
11
+ Feature: RBAC role and permission guards (parity across all four frameworks).
12
+ `role(...)` / `can(...)` route guards read the cryptographically-verified JWT
13
+ `roles` / `permissions` claims — OR within a guard, AND by stacking guards, with
14
+ granted-side wildcards (`posts.*`, `*`) and a legacy singular `role` claim coerced
15
+ to a list. A guarded route implies auth: no or invalid token → 401, authenticated
16
+ but unauthorised → 403; the handler never runs on a miss. Feature 138 / ADR-0058.
17
+
18
+ ## 3.13.105
19
+
20
+ Bug release. Route inspection stops touching the app; Firebird's migration
21
+ ledger tolerates whatever case the driver hands back; PHP loses a colon-in-
22
+ filename that broke Windows checkouts.
23
+
24
+ ### Route inspection scans, never boots
25
+
26
+ - `tina4 routes` now walks canonical route files and never executes the
27
+ application entrypoint or starts the server. Feature 115 / ADR-0058.
28
+ - Fixes the case where `tina4 routes --override` would boot the app on the
29
+ same port and kill whatever process was already holding it (tina4-python
30
+ issue #104).
31
+
32
+ ### Firebird migration ledger is case-agnostic
33
+
34
+ - `tina4_migration` reads and writes work regardless of the case the
35
+ Firebird driver returns for the `migration_name` column.
36
+ - Uses the atomic sequence table pattern already in place for other engines.
37
+
38
+ ### Queue and ORM audit bugs closed
39
+
40
+ - **`Model.clear_cache()` cascades to `db.cache_clear()`.** Under both
41
+ `TINA4_AUTO_CACHING=true` and `TINA4_DB_CACHE=true`, a manual `clear_cache()`
42
+ used to leave the DB-layer cache holding stale rows (PY-06-22).
43
+ - **`Queue.retry()` revives every dead letter.** The no-arg form used a
44
+ generator inside `any(...)` and short-circuited after the first success.
45
+ Now materialised so all N dead letters revive (PY-12-04).
46
+ - **`Job.retry()` unlinks the dead-letter file.** Iterating `dead_letters()`
47
+ and calling `.retry()` on each used to leave the failed directory carrying
48
+ every revived job, so the next `dead_letters()` call re-reported them
49
+ (PY-12-05).
50
+ - **MongoDB `retry_job(id)` searches the dead-letter namespace by data.id,
51
+ deletes the DL doc, and upserts the original back to pending.** The old
52
+ filter `{_id, self._topic, "failed"}` could never match a dead letter.
53
+ - **MongoDB `purge(status)` returns `deleted_count`, honours every status, and
54
+ routes dead/failed/dead_letter aliases to the `.dead_letter` namespace.**
55
+ Previously returned None and only handled `pending` — via `clear()`, which
56
+ nuked every doc under the topic regardless of status.
57
+
58
+ ### Test-harness fixes
59
+
60
+ - **SIGTERM port probe checks both interfaces.** The graceful-shutdown test
61
+ probed only `0.0.0.0`; on macOS with the framework's default `127.0.0.1`
62
+ listener holding the port, that bind succeeded and the pre-assertion
63
+ concluded the port was free. Now probes both interfaces.
64
+ - **MySQL connect-timeout assertion parses the reported elapsed via regex**
65
+ and checks it against the configured bound instead of the outer clock;
66
+ mysql-connector's post-abort cleanup adds ~0.7s that the two stopwatches
67
+ legitimately disagreed on.
68
+ - **AI installer tests sandbox `HOME` per-test.** `install_context()` writes
69
+ the global skills bundle to `Path.home()/.claude/skills` by design; in the
70
+ test suite that raced other tests and hit stale root-owned files from prior
71
+ sudo runs. `monkeypatch.setenv("HOME", ...)` redirects the global install
72
+ to a throwaway dir.
73
+
74
+ ### Developer skills — announce and 💩
75
+
76
+ - **Announce before you act.** All four framework developer skills now
77
+ instruct: say what you are about to do in one line before doing it —
78
+ Plan / Next / Done. Never write more than two files between announcements.
79
+ Never run a schema migration, install a dependency, or edit the boot file
80
+ without a preceding "About to:" line.
81
+ - **💩 stale-skill detection.** All four framework developer skills gained
82
+ `updated_for_version: 3.13.105` in the frontmatter and a self-check that
83
+ compares this to the latest published skill version at
84
+ `https://tina4.com/skills/<name>/version` (never against the project's
85
+ framework version — the framework version is the developer's call). If a
86
+ newer skill is available, 💩 rides beside 🤖 on every reply plus a one-time
87
+ update instruction.
88
+
89
+ ### Doc parity
90
+
91
+ - `Queue.size()`, `Queue.failed()` and `Queue.dead_letters()` gained matching
92
+ in-source docstrings across Python, PHP, Ruby and Node: `size("failed")` /
93
+ `size("dead")` / `size("dead_letter")` are aliases for the dead-letter
94
+ count (== length of `dead_letters()`); `failed()` lists retryable-but-
95
+ attempted jobs, counted under `size("pending")`, NOT `size("failed")`.
96
+
9
97
  ## 3.13.103
10
98
 
11
99
  ### Metrics reports what it can prove
data/lib/tina4/cli.rb CHANGED
@@ -1013,8 +1013,8 @@ module Tina4
1013
1013
 
1014
1014
  def cmd_routes(_argv = nil)
1015
1015
  require_relative "../tina4"
1016
- Tina4.initialize!(Dir.pwd)
1017
- load_routes(Dir.pwd)
1016
+ Tina4::Env.load_env(Dir.pwd)
1017
+ load_routes(Dir.pwd, entrypoints: false)
1018
1018
 
1019
1019
  puts "\nRegistered Routes:"
1020
1020
  puts "-" * 60
@@ -2959,7 +2959,7 @@ module Tina4
2959
2959
 
2960
2960
  # ── shared helpers ────────────────────────────────────────────────────
2961
2961
 
2962
- def load_routes(root_dir)
2962
+ def load_routes(root_dir, entrypoints: true)
2963
2963
  route_dirs = %w[src/routes routes src/api api src/orm orm]
2964
2964
  route_dirs.each do |dir|
2965
2965
  route_dir = File.join(root_dir, dir)
@@ -2967,7 +2967,10 @@ module Tina4
2967
2967
  Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
2968
2968
  end
2969
2969
 
2970
- # Also load app.rb if it exists
2970
+ return unless entrypoints
2971
+
2972
+ # Runtime and console boot load the application entrypoint. Inspection
2973
+ # commands opt out so they can never start a server or take over a port.
2971
2974
  app_file = File.join(root_dir, "app.rb")
2972
2975
  load app_file if File.exist?(app_file)
2973
2976
 
data/lib/tina4/orm.rb CHANGED
@@ -480,13 +480,25 @@ module Tina4
480
480
 
481
481
  # Invalidate every cached query that touches this model's table.
482
482
  #
483
- # Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that
484
- # reads this table is busted too (it carries this table's tag), while a
485
- # query that never touches this table is left intact. Called after every
486
- # ORM write (save/delete/force_delete/restore) so a read-after-write never
487
- # serves a stale/deleted row (CACHE-DEC-01).
483
+ # Tag-scoped in the ORM layer (a cached JOIN on another model that reads
484
+ # this table is busted too because it carries this table's tag; a query
485
+ # that never touches this table is left intact), then cascaded to the
486
+ # DB layer on this model's bound connection so an out-of-band write /
487
+ # deliberate refresh / race-with-another-process cannot leave stale rows
488
+ # in db.fetch's persistent cache. Called after every ORM write
489
+ # (save/delete/force_delete/restore) so a read-after-write never serves
490
+ # a stale/deleted row (CACHE-DEC-01). 3.13.105 (parity port of PY-06-22)
491
+ # added the DB-layer cascade — previously the two cache layers disagreed
492
+ # under TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
488
493
  def clear_cache
489
494
  query_cache.clear_tag(table_name.to_s.downcase)
495
+ begin
496
+ get_db.cache_clear
497
+ rescue StandardError
498
+ # A resolvable DB is not guaranteed at every clear_cache call site
499
+ # (module-import time in odd bootstraps, tests that mutate bindings);
500
+ # never let a cache-clear crash a save/delete.
501
+ end
490
502
  nil
491
503
  end
492
504
 
data/lib/tina4/queue.rb CHANGED
@@ -171,7 +171,14 @@ module Tina4
171
171
  @backend.close
172
172
  end
173
173
 
174
- # Get jobs that failed but are still eligible for retry (under max_retries).
174
+ # Get jobs that failed at least once but are still being retried
175
+ # (0 < attempts < max_retries). These live in the pending queue under
176
+ # the auto-retry lifecycle (fail() re-queues them with an incremented
177
+ # attempts count and a retry_backoff delay) so pop() picks them up
178
+ # again. They are NOT counted by size("failed") — that alias counts
179
+ # the dead-letter store, matching dead_letters(). To include retryable-
180
+ # failed jobs in a total, use size("pending"). Terminal failures are
181
+ # returned by dead_letters().
175
182
  def failed # -> list[dict]
176
183
  refuse_operation!("failed()") unless @backend.respond_to?(:failed)
177
184
  @backend.failed(@topic, max_retries: @max_retries)
@@ -179,13 +186,24 @@ module Tina4
179
186
 
180
187
  # Retry a specific failed job by ID, or all dead-letter jobs if no id given.
181
188
  # Returns true if re-queued.
189
+ #
190
+ # The no-arg branch is materialised by the backend (LiteBackend#retry_job
191
+ # iterates every dead-letter file with .each, MongoBackend materialises
192
+ # find(...).to_a before iterating) — no generator inside any() /
193
+ # short-circuit reduce can silently leave dead letters behind (parity port
194
+ # of PY-12-04, 3.13.105).
182
195
  def retry(job_id = nil, delay_seconds: 0) # -> bool
183
196
  refuse_operation!("retry()") unless @backend.respond_to?(:retry_job)
184
197
  @backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
185
198
  end
186
199
 
187
- # Get dead letter jobs — messages that exceeded max retries.
188
- # Pass max_retries to override the queue's default.
200
+ # Get jobs that exceeded max_retries — terminal failures.
201
+ #
202
+ # Same set counted by size("failed") / size("dead") / size("dead_letter")
203
+ # (three aliases for the dead-letter store). To LIST retryable-but-
204
+ # attempted jobs (attempts > 0 AND attempts < max_retries) that are still
205
+ # being auto-retried, use failed() — those live in the pending queue and
206
+ # are NOT dead letters. Pass max_retries to override the queue's default.
189
207
  def dead_letters(max_retries: nil) # -> list[dict]
190
208
  refuse_operation!("dead_letters()") unless @backend.respond_to?(:dead_letters)
191
209
  @backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
@@ -328,9 +346,21 @@ module Tina4
328
346
  attach(@backend.find_by_id(topic || @topic, id))
329
347
  end
330
348
 
331
- # Get the number of messages by status.
332
- # status: "pending" (default) counts pending messages in the topic queue.
333
- # status: "failed" or "dead" counts messages in the dead_letter directory.
349
+ # Count jobs by status.
350
+ #
351
+ # "pending" counts jobs waiting to be popped INCLUDES retryable-but-
352
+ # attempted ones, because they live in the pending queue under the
353
+ # auto-retry lifecycle (see failed()).
354
+ # "reserved" counts jobs a consumer has popped but not yet
355
+ # completed/failed (in-flight against the visibility timeout).
356
+ # "completed" counts jobs the consumer has finished successfully
357
+ # (0 on the lite/file backend which deletes on complete; backends that
358
+ # track completion expose completed_count).
359
+ # "failed", "dead", "dead_letter" are ALIASES that all count the
360
+ # dead-letter store — jobs whose attempts >= max_retries and that have
361
+ # given up. Use dead_letters() to list them. Retryable-but-attempted
362
+ # jobs are NOT counted by size("failed"); use failed() to list them or
363
+ # size("pending") to include them in a total.
334
364
  def size(status: "pending")
335
365
  case status.to_s
336
366
  when "pending"
@@ -164,9 +164,26 @@ module Tina4
164
164
 
165
165
  # Explicit re-queue requested by the caller (job.retry()). Always
166
166
  # re-enqueues regardless of the retry limit — a manual override, distinct
167
- # from the automatic fail() path. Increments attempts, clears the error.
167
+ # from the automatic fail() path. Cleans up BOTH the reservation record
168
+ # and any dead-letter file for this id, so a caller who iterates
169
+ # dead_letters and calls job.retry on each doesn't leave the dead-letter
170
+ # directory carrying duplicates (3.13.105, parity port of PY-12-05).
171
+ # Aligns with retry_job(id) which had always unlinked the dead-letter
172
+ # file — two spellings of the same intent that previously diverged.
173
+ # Increments attempts, clears the error.
168
174
  def retry(job, delay_seconds: 0)
169
175
  clear_reservation(job.topic, job.id)
176
+ # Drop any dead-letter file for this id BEFORE the re-queue: if this
177
+ # job came from dead_letters it lives in @dead_letter_dir and would
178
+ # otherwise stay on disk while a fresh pending file appears in
179
+ # topic_path, so the next dead_letters call reports the job again
180
+ # and a consumer processes it twice.
181
+ begin
182
+ File.unlink(job_file(@dead_letter_dir, job.id))
183
+ rescue Errno::ENOENT
184
+ # No dead-letter file for this id (manual .retry on a live job) —
185
+ # nothing to clean up.
186
+ end
170
187
  job.attempts += 1
171
188
  requeue_job(job, delay_seconds: delay_seconds, error: nil)
172
189
  end
@@ -291,8 +291,19 @@ module Tina4
291
291
  .map { |doc| job_from_doc(doc) }
292
292
  end
293
293
 
294
+ # Delete every doc in this queue whose status matches.
295
+ #
296
+ # 3.13.105 (parity port): route the dead-letter statuses ("dead",
297
+ # "failed", "dead_letter") to the ".dead_letter" topic namespace where
298
+ # they actually live — pre-fix, purge("dead") delete_many'd on
299
+ # {topic: topic, status: "dead"}, matching zero docs and silently
300
+ # returning 0 while the dead letters sat untouched in the sibling
301
+ # namespace. Every path scopes by status so a purge never nukes docs it
302
+ # was not asked to remove, and every path returns deleted_count so a
303
+ # caller can log/assert what was purged.
294
304
  def purge(topic, status)
295
- result = collection.delete_many(topic: topic, status: status.to_s)
305
+ namespace = dead_status?(status) ? "#{topic}.dead_letter" : topic
306
+ result = collection.delete_many(topic: namespace, status: status.to_s)
296
307
  result.deleted_count
297
308
  end
298
309
 
@@ -326,20 +337,31 @@ module Tina4
326
337
  result.modified_count
327
338
  end
328
339
 
329
- # Move ONE dead-lettered job back to its main topic as pending.
330
- # Returns true when a job was revived, false when the id was not found.
340
+ # Move dead-lettered jobs back to their main topic as pending.
341
+ #
342
+ # With +job_id+, revives that ONE dead letter and returns true when
343
+ # found (false otherwise). With no id, iterates every dead letter for
344
+ # the topic and revives ALL — materialised into an array before
345
+ # reducing, so a truthy first result never short-circuits away the
346
+ # rest (the class of bug the parity port of PY-12-04 pins). Ruby's
347
+ # dead-letter model stores the SAME document with topic flipped to
348
+ # "<topic>.dead_letter", so reviving is the reverse in-place update:
349
+ # flip the topic back, reset status/available_at/error. Mirrors
350
+ # LiteBackend#retry_job.
331
351
  def retry_job(topic, job_id: nil, delay_seconds: 0)
332
352
  filter = { topic: "#{topic}.dead_letter", status: "dead" }
333
353
  filter[:_id] = job_id if job_id
334
- doc = collection.find(filter).first
335
- return false unless doc
354
+ docs = collection.find(filter).to_a
355
+ return false if docs.empty?
336
356
 
337
357
  available = delay_seconds.to_f > 0 ? (Time.now.utc + delay_seconds.to_f) : Time.now.utc
338
- collection.update_one(
339
- { _id: doc["_id"] },
340
- { "$set" => { topic: topic, status: "pending", error: nil,
341
- reserved_at: nil, available_at: available } }
342
- )
358
+ docs.each do |doc|
359
+ collection.update_one(
360
+ { _id: doc["_id"] },
361
+ { "$set" => { topic: topic, status: "pending", error: nil,
362
+ reserved_at: nil, available_at: available } }
363
+ )
364
+ end
343
365
  true
344
366
  end
345
367
 
@@ -358,6 +380,12 @@ module Tina4
358
380
 
359
381
  private
360
382
 
383
+ DEAD_STATES = %w[failed dead dead_letter].freeze
384
+
385
+ def dead_status?(status)
386
+ DEAD_STATES.include?(status.to_s)
387
+ end
388
+
361
389
  # A stored document as a plain Hash with string keys, CARRYING attempts
362
390
  # and error.
363
391
  #
@@ -1178,6 +1178,9 @@ module Tina4
1178
1178
  identity = sso.is_a?(Hash) ? sso["identity"] : nil
1179
1179
  if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1180
1180
  env["tina4.auth_payload"] = identity
1181
+ deny = rbac_forbidden(route, identity)
1182
+ return deny if deny
1183
+
1181
1184
  return nil
1182
1185
  end
1183
1186
  session_token = session.get("token")
@@ -1207,9 +1210,63 @@ module Tina4
1207
1210
  return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1208
1211
  end
1209
1212
 
1213
+ # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1214
+ # Auth has passed (401 ruled out above). If the route carries role/
1215
+ # permission guards, the verified payload must satisfy them, else 403.
1216
+ deny = rbac_forbidden(route, env["tina4.auth_payload"])
1217
+ return deny if deny
1218
+
1210
1219
  nil
1211
1220
  end
1212
1221
 
1222
+ # Build a 403 Rack tuple when a route's RBAC guards are not satisfied by the
1223
+ # verified payload, else nil. AND across guard groups, OR within a group.
1224
+ # Roles read the `roles` claim (legacy singular `role` coerced); permissions
1225
+ # read `permissions` with granted-side wildcards. Feature 138 / ADR-0058.
1226
+ def self.rbac_forbidden(route, payload)
1227
+ required_roles = route.respond_to?(:roles) ? Array(route.roles) : []
1228
+ required_perms = route.respond_to?(:perms) ? Array(route.perms) : []
1229
+ return nil if required_roles.empty? && required_perms.empty?
1230
+
1231
+ subject = payload.is_a?(Hash) ? payload : {}
1232
+ roles = _rbac_claim_list(subject, "roles", "role")
1233
+ required_roles.each do |group|
1234
+ return _rbac_forbidden_tuple unless group.any? { |r| roles.include?(r) }
1235
+ end
1236
+ perms = _rbac_claim_list(subject, "permissions", nil)
1237
+ required_perms.each do |group|
1238
+ return _rbac_forbidden_tuple unless group.any? { |req| _rbac_perm_granted(perms, req) }
1239
+ end
1240
+ nil
1241
+ end
1242
+
1243
+ def self._rbac_forbidden_tuple
1244
+ [403, { "content-type" => "application/json" }, [JSON.generate({ error: "Forbidden" })]]
1245
+ end
1246
+
1247
+ # Read a claim as a list of strings; coerce a legacy singular string.
1248
+ def self._rbac_claim_list(subject, key, legacy)
1249
+ out = _rbac_coerce_list(subject[key])
1250
+ out = _rbac_coerce_list(subject[legacy]) if out.empty? && legacy
1251
+ out
1252
+ end
1253
+
1254
+ def self._rbac_coerce_list(val)
1255
+ case val
1256
+ when String then val.empty? ? [] : [val]
1257
+ when Array then val.map(&:to_s).reject(&:empty?)
1258
+ else []
1259
+ end
1260
+ end
1261
+
1262
+ # True if any GRANTED permission satisfies the concrete REQUIRED one.
1263
+ # `*` grants everything; `posts.*` grants `posts.<...>` on the dot boundary.
1264
+ def self._rbac_perm_granted(granted, required)
1265
+ granted.any? do |g|
1266
+ g == "*" || g == required || (g.end_with?(".*") && required.start_with?(g[0..-2]))
1267
+ end
1268
+ end
1269
+
1213
1270
  def self._read_rack_body(env)
1214
1271
  # Route through the capped reader so the running per-chunk upload cap is
1215
1272
  # enforced on the form-token / body path too: an over-limit body raises
data/lib/tina4/router.rb CHANGED
@@ -8,6 +8,9 @@ module Tina4
8
8
  attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
9
9
  :path_regex, :param_names, :template
10
10
  attr_accessor :auth_required, :cached
11
+ # RBAC guard groups (Feature 138 / ADR-0058). Each is an array of arrays:
12
+ # OR within a group, AND across groups (stacking).
13
+ attr_reader :roles, :perms
11
14
 
12
15
  def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
13
16
  @method = method.to_s.upcase.freeze
@@ -23,6 +26,8 @@ module Tina4
23
26
  # PY-10-02. Call .no_auth on the route to opt out explicitly.
24
27
  @auth_required = %w[POST PUT PATCH DELETE].include?(@method)
25
28
  @cached = false
29
+ @roles = []
30
+ @perms = []
26
31
  @param_names = []
27
32
  @path_regex = compile_pattern(@path)
28
33
  @param_names.freeze
@@ -42,6 +47,31 @@ module Tina4
42
47
  self
43
48
  end
44
49
 
50
+ # RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
51
+ # claim. Stack .role/.can for AND. Implies auth. Feature 138 / ADR-0058.
52
+ # Returns self for chaining: Router.get("/admin") { ... }.role("admin")
53
+ def role(*names)
54
+ names = names.flatten.map(&:to_s).reject(&:empty?)
55
+ unless names.empty?
56
+ @roles << names
57
+ @auth_required = true
58
+ end
59
+ self
60
+ end
61
+
62
+ # RBAC: require ONE of the named permissions (OR). Reads the verified JWT
63
+ # `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
64
+ # concrete requirement. Stack for AND. Implies auth. Feature 138.
65
+ # Returns self for chaining: Router.delete("/p") { ... }.can("posts.delete")
66
+ def can(*permissions)
67
+ permissions = permissions.flatten.map(&:to_s).reject(&:empty?)
68
+ unless permissions.empty?
69
+ @perms << permissions
70
+ @auth_required = true
71
+ end
72
+ self
73
+ end
74
+
45
75
  # Mark this route as cacheable.
46
76
  # Returns self for chaining: Router.get("/path") { ... }.cache
47
77
  def cache
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.104"
4
+ VERSION = "3.13.107"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.104
4
+ version: 3.13.107
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-17 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack