tina4ruby 3.13.132 → 3.13.134
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 +97 -0
- data/README.md +2 -2
- data/lib/tina4/dev_admin.rb +256 -157
- data/lib/tina4/push.rb +195 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d0d4c7801e0766f00041e3d004a0318d7bd4e22d9b4a4d102d05516b89ebba1
|
|
4
|
+
data.tar.gz: 837234011a9a76e4d0733d8a05936bdb08a5bcb8681638679064b51d84dc2392
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bfd2d4b058f1366920dd46b66f1d59f504f1b30cc649b71ecc7f5d8f8d15a385eaa19b5de9078768c0f935aba9a3115e8e10efe72806cfa53be5aca73f5c2caf
|
|
7
|
+
data.tar.gz: 178596fd8db63262bc35aeb25ae13e11c609b3f50a85bc06d91ce12063b9739973f80ce5783ec5a330d115b3b07e9389718f2316d8c53590b47867ad777b193d
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,103 @@ 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.134
|
|
10
|
+
|
|
11
|
+
Feature 140 Web Push is now available with provider-neutral subscription delivery,
|
|
12
|
+
native result envelopes, fail-closed configuration, and the documented runtime
|
|
13
|
+
configuration. Developer skills now include the Web Push API and configuration
|
|
14
|
+
reference for all supported language stacks.
|
|
15
|
+
|
|
16
|
+
## 3.13.133
|
|
17
|
+
|
|
18
|
+
Maintainability pass, no behavior changes. Swagger.generate decomposed into cohesive
|
|
19
|
+
helpers (Python 64->6, PHP 83->6, Node 52->2 cyclomatic; Ruby already lean) with the
|
|
20
|
+
OpenAPI document byte-for-byte identical. Ruby dev-admin handle_request split from a
|
|
21
|
+
138-branch dispatcher into per-concern methods (CC 138->11); routes, responses, and
|
|
22
|
+
the localhost/auth gate unchanged. Full notes: https://tina4.com/python/36-releases
|
|
23
|
+
|
|
24
|
+
## 3.13.132
|
|
25
|
+
|
|
26
|
+
ORM reads return a ModelCollection carrying the query total (ADR-0064): where,
|
|
27
|
+
select, find (filter form), all, and with_trashed hand back the page of models AND
|
|
28
|
+
the total for the filter, so you paginate without a second query. get_total_records()
|
|
29
|
+
/ to_paginate() (Python, Ruby); getTotalRecords() / toPaginate() (PHP, Node). The
|
|
30
|
+
total reuses the count the query already ran - zero extra queries. Python's
|
|
31
|
+
where(..., with_count=True) tuple is removed; PHP's return type changes from a bare
|
|
32
|
+
array to an array-compatible ModelCollection object (toArray() escape hatch). Also:
|
|
33
|
+
the dev server binds dual-stack loopback so localhost works on Windows; the dev-admin
|
|
34
|
+
agent chat surface is removed and the dashboard no longer self-reloads; Firebird DDL
|
|
35
|
+
no longer reads a row count and Firebird adapters close cleanly on GC (Python); the
|
|
36
|
+
Valkey session handler subclasses Redis and shared fetch/execute/Frond loops are
|
|
37
|
+
hoisted (behaviour unchanged). Full notes: https://tina4.com/python/36-releases
|
|
38
|
+
|
|
39
|
+
## 3.13.131
|
|
40
|
+
|
|
41
|
+
AI skills for a global team: plain English, short replies, effort matched to the
|
|
42
|
+
task, ask-before-guess, and a grounding fallback for tools without the coder server.
|
|
43
|
+
|
|
44
|
+
## 3.13.130
|
|
45
|
+
|
|
46
|
+
AI skills discipline pass: objective on ideas (disagree when a design is weak),
|
|
47
|
+
claim only what was verified, and chat instead of narrate.
|
|
48
|
+
|
|
49
|
+
## 3.13.129
|
|
50
|
+
|
|
51
|
+
tina4 generate model stops silently pluralising a reserved-word table. It prints a
|
|
52
|
+
note and offers --table-name, so a downstream route or query does not fail with no
|
|
53
|
+
clue the table was renamed.
|
|
54
|
+
|
|
55
|
+
## 3.13.128
|
|
56
|
+
|
|
57
|
+
tina4 lint: one command runs the project linter (ruff, phpcs, rubocop, or eslint),
|
|
58
|
+
installs it as a dev dependency on demand, and falls back to a zero-dependency syntax
|
|
59
|
+
baseline with --no-install.
|
|
60
|
+
|
|
61
|
+
## 3.13.127
|
|
62
|
+
|
|
63
|
+
A bare TRUE or FALSE now reaches SQL Server and Firebird as 1/0 in every framework.
|
|
64
|
+
Node, PHP and Ruby had skipped the boolean translation on SQL Server, and Ruby on
|
|
65
|
+
Firebird too.
|
|
66
|
+
|
|
67
|
+
## 3.13.126
|
|
68
|
+
|
|
69
|
+
The fake-data seeder reads the column name: a products.name column fills with product
|
|
70
|
+
names, not person names.
|
|
71
|
+
|
|
72
|
+
## 3.13.125
|
|
73
|
+
|
|
74
|
+
Scaffolded migrations apply on every engine. tina4 generate migration wrote
|
|
75
|
+
SQLite-only DDL (TEXT, REAL, CREATE TABLE IF NOT EXISTS); the generators now emit
|
|
76
|
+
engine-correct types so a migration runs on Firebird and the rest.
|
|
77
|
+
|
|
78
|
+
## 3.13.124
|
|
79
|
+
|
|
80
|
+
The database session backend works on Firebird in all four frameworks, with a
|
|
81
|
+
session-engine test in each so the gap cannot reopen.
|
|
82
|
+
|
|
83
|
+
## 3.13.123
|
|
84
|
+
|
|
85
|
+
tina4 generate queue <topic> returns the generate_v1_1 envelope: edit_hints[] naming
|
|
86
|
+
the handler line to fill and next[] naming what to run after.
|
|
87
|
+
|
|
88
|
+
## 3.13.122
|
|
89
|
+
|
|
90
|
+
The default Content-Security-Policy (default-src 'self') no longer fails silently:
|
|
91
|
+
when an app relies on something the policy blocks, the framework surfaces it instead
|
|
92
|
+
of leaving a blank page.
|
|
93
|
+
|
|
94
|
+
## 3.13.121
|
|
95
|
+
|
|
96
|
+
Every migration surface speaks the ADR-0063 generate_v1_1 envelope - the CLI
|
|
97
|
+
migrate:create, the MCP migration_create, and the rest, not just tina4 generate
|
|
98
|
+
migration. Adds a pre-tag release guard.
|
|
99
|
+
|
|
100
|
+
## 3.13.120
|
|
101
|
+
|
|
102
|
+
Scaffolding envelope v1.1 (ADR-0063): every tina4 generate verb reports what to edit
|
|
103
|
+
next and what to do after, not just where files went. Introduces the tina4-architect
|
|
104
|
+
skill.
|
|
105
|
+
|
|
9
106
|
## 3.13.119
|
|
10
107
|
|
|
11
108
|
Skill repair by @MichaelC8E (#44, merged as c1b8c43d5) plus a
|
data/README.md
CHANGED
|
@@ -102,7 +102,7 @@ Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
|
102
102
|
| **Tina4 Ruby** | **10,243** | 0 | 55 |
|
|
103
103
|
| Sinatra | 9,548 | 5+ | ~4 |
|
|
104
104
|
|
|
105
|
-
Tina4 Ruby outperforms Sinatra while delivering **
|
|
105
|
+
Tina4 Ruby outperforms Sinatra while delivering **140 cataloged features vs ~4**, with zero runtime dependencies.
|
|
106
106
|
|
|
107
107
|
**Across all 4 Tina4 implementations:**
|
|
108
108
|
|
|
@@ -116,7 +116,7 @@ Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero
|
|
|
116
116
|
|
|
117
117
|
## Cross-Framework Parity
|
|
118
118
|
|
|
119
|
-
Tina4 ships identical features across four languages: same architecture, same conventions, same
|
|
119
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, the same 140 cataloged features:
|
|
120
120
|
|
|
121
121
|
| | Python | PHP | Ruby | Node.js |
|
|
122
122
|
|---|--------|-----|------|---------|
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -395,6 +395,34 @@ module Tina4
|
|
|
395
395
|
denial = dev_mutation_denial(env)
|
|
396
396
|
return denial if denial
|
|
397
397
|
|
|
398
|
+
# Exact [method, path] dispatch, split by concern. Every route is an
|
|
399
|
+
# exact pair (no overlaps), so the groups form a partition: the first
|
|
400
|
+
# group that matches answers, and a group returns nil when it holds no
|
|
401
|
+
# matching route. Behaviour is identical to the former single 138-branch
|
|
402
|
+
# case — the only handler that legitimately returns nil is an MCP route
|
|
403
|
+
# with the capability disabled (with_mcp_gate), for which the original
|
|
404
|
+
# case also fell through to nil (RackApp then 404s it).
|
|
405
|
+
dispatch_dashboard(method, path, env) ||
|
|
406
|
+
dispatch_inspectors(method, path, env) ||
|
|
407
|
+
dispatch_mailbox(method, path, env) ||
|
|
408
|
+
dispatch_queue(method, path, env) ||
|
|
409
|
+
dispatch_data(method, path, env) ||
|
|
410
|
+
dispatch_config(method, path, env) ||
|
|
411
|
+
dispatch_files(method, path, env) ||
|
|
412
|
+
dispatch_mcp(method, path, env)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
private
|
|
417
|
+
|
|
418
|
+
# ── /__dev request dispatch (split from a single 138-CC case) ──────
|
|
419
|
+
# Each dispatch_* owns one concern's exact [method, path] routes and
|
|
420
|
+
# returns a Rack triple, or nil when it holds no matching route. Branch
|
|
421
|
+
# bodies are the former case's, moved verbatim.
|
|
422
|
+
|
|
423
|
+
# Dashboard shell, injected-toolbar assets, the reload trigger and the
|
|
424
|
+
# core status / routes / system panels.
|
|
425
|
+
def dispatch_dashboard(method, path, env)
|
|
398
426
|
case [method, path]
|
|
399
427
|
when ["GET", "/__dev"], ["GET", "/__dev/"]
|
|
400
428
|
serve_dashboard
|
|
@@ -413,54 +441,24 @@ module Tina4
|
|
|
413
441
|
when ["GET", "/__dev/api/mtime"]
|
|
414
442
|
json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
|
|
415
443
|
when ["POST", "/__dev/api/reload"]
|
|
416
|
-
|
|
417
|
-
@reload_mtime = Time.now.to_i
|
|
418
|
-
@reload_file = body["file"] || ""
|
|
419
|
-
reload_type = body["type"] || "reload"
|
|
420
|
-
Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
|
|
421
|
-
# Re-discover so files dropped into src/routes/ register without
|
|
422
|
-
# a server restart. Idempotent — already-loaded files are skipped,
|
|
423
|
-
# changed files are re-loaded (mtime-tracked).
|
|
424
|
-
begin
|
|
425
|
-
Tina4::Router.rescan_routes!
|
|
426
|
-
rescue StandardError => e
|
|
427
|
-
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
428
|
-
end
|
|
429
|
-
# Keep the code Context index LIVE on the same reload trigger: reindex
|
|
430
|
-
# just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
431
|
-
# the edit immediately. Only touches an already-built index
|
|
432
|
-
# (existing_context never creates one); guarded so a context failure
|
|
433
|
-
# never breaks the reload.
|
|
434
|
-
begin
|
|
435
|
-
unless @reload_file.to_s.empty?
|
|
436
|
-
ctx = Tina4::Context.existing_context
|
|
437
|
-
ctx&.reindex_file(@reload_file)
|
|
438
|
-
end
|
|
439
|
-
rescue StandardError => e
|
|
440
|
-
Tina4::Log.error("Context reindex on reload failed: #{e.message}")
|
|
441
|
-
end
|
|
442
|
-
# WebSocket-primary reload: push an instant message to every browser
|
|
443
|
-
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
444
|
-
# dashboard) act on this immediately — the mtime poll above is only a
|
|
445
|
-
# fallback for when the socket is down. CSS changes swap stylesheets;
|
|
446
|
-
# everything else triggers a full page reload. We normalise the wire
|
|
447
|
-
# `type` to "css"/"reload" (the clients only react to css/reload/change)
|
|
448
|
-
# but still echo the caller's original type in the HTTP response.
|
|
449
|
-
# Wrapped so a broadcast failure — or zero connected clients — never
|
|
450
|
-
# 500s the reload endpoint.
|
|
451
|
-
begin
|
|
452
|
-
ws_type = reload_type == "css" ? "css" : "reload"
|
|
453
|
-
Tina4::DevReload.broadcast(
|
|
454
|
-
JSON.generate({ type: ws_type, file: @reload_file, mtime: @reload_mtime })
|
|
455
|
-
)
|
|
456
|
-
rescue StandardError => e
|
|
457
|
-
Tina4::Log.error("Dev-reload WebSocket broadcast failed: #{e.message}")
|
|
458
|
-
end
|
|
459
|
-
json_response({ ok: true, type: reload_type })
|
|
444
|
+
handle_reload(env)
|
|
460
445
|
when ["GET", "/__dev/api/status"]
|
|
461
446
|
json_response(status_payload)
|
|
462
447
|
when ["GET", "/__dev/api/routes"]
|
|
463
448
|
json_response(routes_payload)
|
|
449
|
+
when ["GET", "/__dev/api/system"]
|
|
450
|
+
json_response(system_payload)
|
|
451
|
+
when ["GET", "/__dev/api/version-check"]
|
|
452
|
+
json_response(version_check_payload)
|
|
453
|
+
when ["GET", "/__dev/api/git/status"]
|
|
454
|
+
json_response(git_status_payload)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Observability panels: message log, request inspector and live
|
|
459
|
+
# WebSocket connections. (Mailbox + error tracker live in dispatch_mailbox.)
|
|
460
|
+
def dispatch_inspectors(method, path, env)
|
|
461
|
+
case [method, path]
|
|
464
462
|
when ["GET", "/__dev/api/messages"]
|
|
465
463
|
category = query_param(env, "category")
|
|
466
464
|
messages = message_log.get(category: category)
|
|
@@ -471,68 +469,31 @@ module Tina4
|
|
|
471
469
|
category = body["category"] if body
|
|
472
470
|
message_log.clear(category: category)
|
|
473
471
|
json_response({ cleared: true })
|
|
472
|
+
when ["GET", "/__dev/api/messages/search"]
|
|
473
|
+
keyword = query_param(env, "q") || query_param(env, "keyword") || ""
|
|
474
|
+
all_messages = message_log.get
|
|
475
|
+
filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
|
|
476
|
+
json_response({ messages: filtered, count: filtered.size, keyword: keyword })
|
|
474
477
|
when ["GET", "/__dev/api/requests"]
|
|
475
478
|
limit = (query_param(env, "limit") || 50).to_i
|
|
476
479
|
json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
|
|
477
480
|
when ["POST", "/__dev/api/requests/clear"]
|
|
478
481
|
request_inspector.clear
|
|
479
482
|
json_response({ cleared: true })
|
|
480
|
-
when ["GET", "/__dev/api/system"]
|
|
481
|
-
json_response(system_payload)
|
|
482
|
-
when ["GET", "/__dev/api/queue/topics"]
|
|
483
|
-
json_response({ topics: queue_topics })
|
|
484
|
-
when ["GET", "/__dev/api/queue/dead-letters"]
|
|
485
|
-
topic = query_param(env, "topic") || "default"
|
|
486
|
-
jobs = begin
|
|
487
|
-
queue_dead_letters(topic)
|
|
488
|
-
rescue StandardError
|
|
489
|
-
[]
|
|
490
|
-
end
|
|
491
|
-
json_response({ jobs: jobs, count: jobs.size, topic: topic })
|
|
492
|
-
when ["GET", "/__dev/api/queue"]
|
|
493
|
-
topic = query_param(env, "topic") || "default"
|
|
494
|
-
stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
|
|
495
|
-
jobs = []
|
|
496
|
-
begin
|
|
497
|
-
if defined?(Tina4::Queue)
|
|
498
|
-
queue = Tina4::Queue.new(backend: :file, topic: topic)
|
|
499
|
-
# Queue#size is keyword-only (def size(status:)). Calling it
|
|
500
|
-
# positionally raises ArgumentError, which the rescue below
|
|
501
|
-
# swallows — silently zeroing every stat. Use keyword form.
|
|
502
|
-
stats = {
|
|
503
|
-
pending: queue.size(status: "pending"),
|
|
504
|
-
completed: queue.size(status: "completed"),
|
|
505
|
-
failed: queue.size(status: "failed"),
|
|
506
|
-
reserved: queue.size(status: "reserved"),
|
|
507
|
-
}
|
|
508
|
-
jobs = queue_jobs(topic, query_param(env, "status"))
|
|
509
|
-
end
|
|
510
|
-
rescue StandardError
|
|
511
|
-
# fall through to empty stats
|
|
512
|
-
end
|
|
513
|
-
json_response({ jobs: jobs, stats: stats })
|
|
514
|
-
when ["GET", "/__dev/api/mailbox"]
|
|
515
|
-
messages = mailbox.inbox
|
|
516
|
-
json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
|
|
517
|
-
when ["GET", "/__dev/api/broken"]
|
|
518
|
-
errors = error_tracker.get(include_resolved: true)
|
|
519
|
-
h = error_tracker.health
|
|
520
|
-
json_response({ errors: errors, count: errors.size, health: h })
|
|
521
|
-
when ["POST", "/__dev/api/broken/resolve"]
|
|
522
|
-
body = read_json_body(env)
|
|
523
|
-
id = body && body["id"]
|
|
524
|
-
resolved = id ? error_tracker.resolve(id) : false
|
|
525
|
-
json_response({ resolved: resolved, id: id })
|
|
526
|
-
when ["POST", "/__dev/api/broken/clear"]
|
|
527
|
-
# "Clear All" button — flush every tracked error, not only the
|
|
528
|
-
# ones individually marked resolved. Matches PHP/Python.
|
|
529
|
-
error_tracker.clear_all
|
|
530
|
-
json_response({ cleared: true })
|
|
531
483
|
when ["GET", "/__dev/api/websockets"]
|
|
532
484
|
json_response(websockets_payload)
|
|
533
485
|
when ["POST", "/__dev/api/websockets/disconnect"]
|
|
534
486
|
body = read_json_body(env) || {}
|
|
535
487
|
json_response(websockets_disconnect(body))
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Dev mailbox (email capture) and the error tracker panel.
|
|
492
|
+
def dispatch_mailbox(method, path, env)
|
|
493
|
+
case [method, path]
|
|
494
|
+
when ["GET", "/__dev/api/mailbox"]
|
|
495
|
+
messages = mailbox.inbox
|
|
496
|
+
json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
|
|
536
497
|
when ["GET", "/__dev/api/mailbox/read"]
|
|
537
498
|
message_id = query_param(env, "id")
|
|
538
499
|
message = mailbox.read(message_id)
|
|
@@ -550,11 +511,39 @@ module Tina4
|
|
|
550
511
|
when ["POST", "/__dev/api/mailbox/clear"]
|
|
551
512
|
mailbox.clear
|
|
552
513
|
json_response({ cleared: true })
|
|
553
|
-
when ["GET", "/__dev/api/
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
514
|
+
when ["GET", "/__dev/api/broken"]
|
|
515
|
+
errors = error_tracker.get(include_resolved: true)
|
|
516
|
+
h = error_tracker.health
|
|
517
|
+
json_response({ errors: errors, count: errors.size, health: h })
|
|
518
|
+
when ["POST", "/__dev/api/broken/resolve"]
|
|
519
|
+
body = read_json_body(env)
|
|
520
|
+
id = body && body["id"]
|
|
521
|
+
resolved = id ? error_tracker.resolve(id) : false
|
|
522
|
+
json_response({ resolved: resolved, id: id })
|
|
523
|
+
when ["POST", "/__dev/api/broken/clear"]
|
|
524
|
+
# "Clear All" button — flush every tracked error, not only the
|
|
525
|
+
# ones individually marked resolved. Matches PHP/Python.
|
|
526
|
+
error_tracker.clear_all
|
|
527
|
+
json_response({ cleared: true })
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Queue panel: topic list, dead letters, per-topic overview and the
|
|
532
|
+
# retry / purge / replay mutations.
|
|
533
|
+
def dispatch_queue(method, path, env)
|
|
534
|
+
case [method, path]
|
|
535
|
+
when ["GET", "/__dev/api/queue/topics"]
|
|
536
|
+
json_response({ topics: queue_topics })
|
|
537
|
+
when ["GET", "/__dev/api/queue/dead-letters"]
|
|
538
|
+
topic = query_param(env, "topic") || "default"
|
|
539
|
+
jobs = begin
|
|
540
|
+
queue_dead_letters(topic)
|
|
541
|
+
rescue StandardError
|
|
542
|
+
[]
|
|
543
|
+
end
|
|
544
|
+
json_response({ jobs: jobs, count: jobs.size, topic: topic })
|
|
545
|
+
when ["GET", "/__dev/api/queue"]
|
|
546
|
+
queue_overview(env)
|
|
558
547
|
when ["POST", "/__dev/api/queue/retry"]
|
|
559
548
|
body = read_json_body(env) || {}
|
|
560
549
|
json_response(queue_retry(body))
|
|
@@ -564,18 +553,18 @@ module Tina4
|
|
|
564
553
|
when ["POST", "/__dev/api/queue/replay"]
|
|
565
554
|
body = read_json_body(env) || {}
|
|
566
555
|
json_response(queue_replay(body))
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Data + tooling: table browser, seeding, the run-chips (migrate / test /
|
|
560
|
+
# seed), the SQL console, table list, the gallery and the metrics panels.
|
|
561
|
+
def dispatch_data(method, path, env)
|
|
562
|
+
case [method, path]
|
|
567
563
|
when ["GET", "/__dev/api/table"]
|
|
568
564
|
table_name = query_param(env, "name")
|
|
569
565
|
json_response(table_detail_payload(table_name))
|
|
570
566
|
when ["POST", "/__dev/api/seed"]
|
|
571
|
-
|
|
572
|
-
table_name = (body && body["table"]) || ""
|
|
573
|
-
count = (body && body["count"]) || 10
|
|
574
|
-
seed = body && body["seed"]
|
|
575
|
-
seed = (Integer(seed) rescue nil) unless seed.nil?
|
|
576
|
-
clear = body && (body["clear"] == true || body["clear"].to_s == "true")
|
|
577
|
-
strict = body && (body["strict"] == true || body["strict"].to_s == "true")
|
|
578
|
-
json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
|
|
567
|
+
handle_seed(env)
|
|
579
568
|
when ["POST", "/__dev/api/tool"]
|
|
580
569
|
body = read_json_body(env)
|
|
581
570
|
tool = (body && body["tool"]) || ""
|
|
@@ -590,6 +579,28 @@ module Tina4
|
|
|
590
579
|
json_response(run_tests_payload)
|
|
591
580
|
when ["POST", "/__dev/api/seed/run"]
|
|
592
581
|
json_response(run_seeds_payload)
|
|
582
|
+
when ["POST", "/__dev/api/query"]
|
|
583
|
+
body = read_json_body(env)
|
|
584
|
+
sql = (body && (body["query"] || body["sql"])) || ""
|
|
585
|
+
json_response(run_query(sql))
|
|
586
|
+
when ["GET", "/__dev/api/tables"]
|
|
587
|
+
json_response(tables_payload)
|
|
588
|
+
when ["GET", "/__dev/api/gallery"]
|
|
589
|
+
json_response(gallery_list)
|
|
590
|
+
when ["POST", "/__dev/api/gallery/deploy"]
|
|
591
|
+
body = read_json_body(env)
|
|
592
|
+
name = (body && body["name"]) || ""
|
|
593
|
+
json_response(gallery_deploy(name))
|
|
594
|
+
when ["GET", "/__dev/api/metrics/full"]
|
|
595
|
+
metrics_full_response
|
|
596
|
+
when ["GET", "/__dev/api/metrics/file"]
|
|
597
|
+
metrics_file_response(env)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Grounding token panel and the database-connection editor.
|
|
602
|
+
def dispatch_config(method, path, env)
|
|
603
|
+
case [method, path]
|
|
593
604
|
# Grounding panel — configure the tina4-coder MCP token used for
|
|
594
605
|
# live-docs grounding. Self-contained (.env read/write); no proxy.
|
|
595
606
|
when ["GET", "/__dev/api/grounding/status"]
|
|
@@ -605,40 +616,13 @@ module Tina4
|
|
|
605
616
|
when ["POST", "/__dev/api/connections/save"]
|
|
606
617
|
body = read_json_body(env)
|
|
607
618
|
handle_connections_save(body)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
json_response(gallery_list)
|
|
616
|
-
when ["POST", "/__dev/api/gallery/deploy"]
|
|
617
|
-
body = read_json_body(env)
|
|
618
|
-
name = (body && body["name"]) || ""
|
|
619
|
-
json_response(gallery_deploy(name))
|
|
620
|
-
when ["GET", "/__dev/api/version-check"]
|
|
621
|
-
json_response(version_check_payload)
|
|
622
|
-
when ["GET", "/__dev/api/metrics/full"]
|
|
623
|
-
# No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
|
|
624
|
-
# install command, never zeros that read as a healthy codebase.
|
|
625
|
-
begin
|
|
626
|
-
json_response(Tina4::Metrics.full_analysis)
|
|
627
|
-
rescue Tina4::MetricsEngineError => e
|
|
628
|
-
json_response({ "error" => e.message }, 503)
|
|
629
|
-
end
|
|
630
|
-
when ["GET", "/__dev/api/metrics/file"]
|
|
631
|
-
file_path = (query_param(env, "path") || "").to_s
|
|
632
|
-
begin
|
|
633
|
-
json_response(Tina4::Metrics.file_detail(file_path))
|
|
634
|
-
rescue Tina4::MetricsEngineError => e
|
|
635
|
-
# A bad path is the caller's mistake (404); anything else is the
|
|
636
|
-
# engine being unavailable (503).
|
|
637
|
-
bad_path = e.message.include?("no such file") ||
|
|
638
|
-
e.message.include?("not a file") ||
|
|
639
|
-
e.message.include?("needs a path")
|
|
640
|
-
json_response({ "error" => e.message }, bad_path ? 404 : 503)
|
|
641
|
-
end
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# File browser + editor (read / raw / save / rename / delete) and the
|
|
623
|
+
# dependency search / install surface.
|
|
624
|
+
def dispatch_files(method, path, env)
|
|
625
|
+
case [method, path]
|
|
642
626
|
when ["GET", "/__dev/api/files"]
|
|
643
627
|
json_response(files_list(env))
|
|
644
628
|
when ["GET", "/__dev/api/file"]
|
|
@@ -662,8 +646,13 @@ module Tina4
|
|
|
662
646
|
when ["POST", "/__dev/api/deps/install"]
|
|
663
647
|
body = read_json_body(env) || {}
|
|
664
648
|
json_response(deps_install(body))
|
|
665
|
-
|
|
666
|
-
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# The MCP surfaces (REST shim + JSON-RPC + SSE), scaffolding, and the
|
|
653
|
+
# live-docs (Live API RAG) + GraphQL-schema endpoints.
|
|
654
|
+
def dispatch_mcp(method, path, env)
|
|
655
|
+
case [method, path]
|
|
667
656
|
# All four MCP surfaces (REST shim + JSON-RPC + SSE) go through
|
|
668
657
|
# with_mcp_gate: capability (Tina4.mcp_enabled?) decides whether MCP
|
|
669
658
|
# runs at all (off → route behaves as unmounted, nil → RackApp 404,
|
|
@@ -705,23 +694,133 @@ module Tina4
|
|
|
705
694
|
when ["GET", "/__dev/api/docs/.well-known.json"]
|
|
706
695
|
json_response(docs_well_known_payload)
|
|
707
696
|
when ["GET", "/__dev/api/graphql/schema"]
|
|
708
|
-
|
|
709
|
-
gql = Tina4::GraphQL.new
|
|
710
|
-
# Auto-discover and register all ORM subclasses
|
|
711
|
-
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
|
|
712
|
-
gql.from_orm(model_class.new)
|
|
713
|
-
end
|
|
714
|
-
json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
|
|
715
|
-
rescue => e
|
|
716
|
-
json_response({ error: e.message }, 400)
|
|
717
|
-
end
|
|
718
|
-
else
|
|
719
|
-
nil
|
|
697
|
+
graphql_schema_response
|
|
720
698
|
end
|
|
721
699
|
end
|
|
722
700
|
|
|
701
|
+
# POST /__dev/api/reload — external reload trigger. Bumps the mtime
|
|
702
|
+
# counter, re-discovers routes, keeps the live docs index fresh, and
|
|
703
|
+
# broadcasts a WebSocket reload to connected browsers.
|
|
704
|
+
def handle_reload(env)
|
|
705
|
+
body = read_json_body(env) || {}
|
|
706
|
+
@reload_mtime = Time.now.to_i
|
|
707
|
+
@reload_file = body["file"] || ""
|
|
708
|
+
reload_type = body["type"] || "reload"
|
|
709
|
+
Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
|
|
710
|
+
# Re-discover so files dropped into src/routes/ register without
|
|
711
|
+
# a server restart. Idempotent — already-loaded files are skipped,
|
|
712
|
+
# changed files are re-loaded (mtime-tracked).
|
|
713
|
+
begin
|
|
714
|
+
Tina4::Router.rescan_routes!
|
|
715
|
+
rescue StandardError => e
|
|
716
|
+
Tina4::Log.error("Re-discover on reload failed: #{e.message}")
|
|
717
|
+
end
|
|
718
|
+
# Keep the code Context index LIVE on the same reload trigger: reindex
|
|
719
|
+
# just the changed file (UPSERT) so the dev-MCP code_search reflects
|
|
720
|
+
# the edit immediately. Only touches an already-built index
|
|
721
|
+
# (existing_context never creates one); guarded so a context failure
|
|
722
|
+
# never breaks the reload.
|
|
723
|
+
begin
|
|
724
|
+
unless @reload_file.to_s.empty?
|
|
725
|
+
ctx = Tina4::Context.existing_context
|
|
726
|
+
ctx&.reindex_file(@reload_file)
|
|
727
|
+
end
|
|
728
|
+
rescue StandardError => e
|
|
729
|
+
Tina4::Log.error("Context reindex on reload failed: #{e.message}")
|
|
730
|
+
end
|
|
731
|
+
# WebSocket-primary reload: push an instant message to every browser
|
|
732
|
+
# connected on /__dev_reload. The toolbar client (and the dev-admin
|
|
733
|
+
# dashboard) act on this immediately — the mtime poll above is only a
|
|
734
|
+
# fallback for when the socket is down. CSS changes swap stylesheets;
|
|
735
|
+
# everything else triggers a full page reload. We normalise the wire
|
|
736
|
+
# `type` to "css"/"reload" (the clients only react to css/reload/change)
|
|
737
|
+
# but still echo the caller's original type in the HTTP response.
|
|
738
|
+
# Wrapped so a broadcast failure — or zero connected clients — never
|
|
739
|
+
# 500s the reload endpoint.
|
|
740
|
+
begin
|
|
741
|
+
ws_type = reload_type == "css" ? "css" : "reload"
|
|
742
|
+
Tina4::DevReload.broadcast(
|
|
743
|
+
JSON.generate({ type: ws_type, file: @reload_file, mtime: @reload_mtime })
|
|
744
|
+
)
|
|
745
|
+
rescue StandardError => e
|
|
746
|
+
Tina4::Log.error("Dev-reload WebSocket broadcast failed: #{e.message}")
|
|
747
|
+
end
|
|
748
|
+
json_response({ ok: true, type: reload_type })
|
|
749
|
+
end
|
|
723
750
|
|
|
724
|
-
|
|
751
|
+
# GET /__dev/api/queue — per-topic stats + job list. Queue#size is
|
|
752
|
+
# keyword-only; the rescue swallows a backend error into empty stats.
|
|
753
|
+
def queue_overview(env)
|
|
754
|
+
topic = query_param(env, "topic") || "default"
|
|
755
|
+
stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
|
|
756
|
+
jobs = []
|
|
757
|
+
begin
|
|
758
|
+
if defined?(Tina4::Queue)
|
|
759
|
+
queue = Tina4::Queue.new(backend: :file, topic: topic)
|
|
760
|
+
# Queue#size is keyword-only (def size(status:)). Calling it
|
|
761
|
+
# positionally raises ArgumentError, which the rescue below
|
|
762
|
+
# swallows — silently zeroing every stat. Use keyword form.
|
|
763
|
+
stats = {
|
|
764
|
+
pending: queue.size(status: "pending"),
|
|
765
|
+
completed: queue.size(status: "completed"),
|
|
766
|
+
failed: queue.size(status: "failed"),
|
|
767
|
+
reserved: queue.size(status: "reserved"),
|
|
768
|
+
}
|
|
769
|
+
jobs = queue_jobs(topic, query_param(env, "status"))
|
|
770
|
+
end
|
|
771
|
+
rescue StandardError
|
|
772
|
+
# fall through to empty stats
|
|
773
|
+
end
|
|
774
|
+
json_response({ jobs: jobs, stats: stats })
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# POST /__dev/api/seed — seed fake rows into a table via the shared
|
|
778
|
+
# resilient seed_table helper.
|
|
779
|
+
def handle_seed(env)
|
|
780
|
+
body = read_json_body(env)
|
|
781
|
+
table_name = (body && body["table"]) || ""
|
|
782
|
+
count = (body && body["count"]) || 10
|
|
783
|
+
seed = body && body["seed"]
|
|
784
|
+
seed = (Integer(seed) rescue nil) unless seed.nil?
|
|
785
|
+
clear = body && (body["clear"] == true || body["clear"].to_s == "true")
|
|
786
|
+
strict = body && (body["strict"] == true || body["strict"].to_s == "true")
|
|
787
|
+
json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
# GET /__dev/api/metrics/full — native metrics (ADR-0002). No fallback:
|
|
791
|
+
# a missing/stale CLI is a 503 naming the install command, never zeros
|
|
792
|
+
# that read as a healthy codebase.
|
|
793
|
+
def metrics_full_response
|
|
794
|
+
json_response(Tina4::Metrics.full_analysis)
|
|
795
|
+
rescue Tina4::MetricsEngineError => e
|
|
796
|
+
json_response({ "error" => e.message }, 503)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# GET /__dev/api/metrics/file — metrics for one file. A bad path is the
|
|
800
|
+
# caller's mistake (404); anything else is the engine being unavailable
|
|
801
|
+
# (503).
|
|
802
|
+
def metrics_file_response(env)
|
|
803
|
+
file_path = (query_param(env, "path") || "").to_s
|
|
804
|
+
json_response(Tina4::Metrics.file_detail(file_path))
|
|
805
|
+
rescue Tina4::MetricsEngineError => e
|
|
806
|
+
bad_path = e.message.include?("no such file") ||
|
|
807
|
+
e.message.include?("not a file") ||
|
|
808
|
+
e.message.include?("needs a path")
|
|
809
|
+
json_response({ "error" => e.message }, bad_path ? 404 : 503)
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
# GET /__dev/api/graphql/schema — introspect every ORM subclass into a
|
|
813
|
+
# GraphQL schema + SDL.
|
|
814
|
+
def graphql_schema_response
|
|
815
|
+
gql = Tina4::GraphQL.new
|
|
816
|
+
# Auto-discover and register all ORM subclasses
|
|
817
|
+
ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
|
|
818
|
+
gql.from_orm(model_class.new)
|
|
819
|
+
end
|
|
820
|
+
json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
|
|
821
|
+
rescue => e
|
|
822
|
+
json_response({ error: e.message }, 400)
|
|
823
|
+
end
|
|
725
824
|
|
|
726
825
|
def query_param(env, key)
|
|
727
826
|
qs = env["QUERY_STRING"] || ""
|
data/lib/tina4/push.rb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Tina4
|
|
10
|
+
class PushError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Provider-neutral Web Push sender using Ruby's stdlib OpenSSL and Net::HTTP.
|
|
13
|
+
# Web Push remains optional at use time; a Ruby build without OpenSSL fails
|
|
14
|
+
# loudly when the feature is selected rather than silently sending plaintext.
|
|
15
|
+
class Push
|
|
16
|
+
RECORD_SIZE = 4096
|
|
17
|
+
MAX_PAYLOAD = RECORD_SIZE - 17
|
|
18
|
+
|
|
19
|
+
def self.generate_vapid_keys
|
|
20
|
+
require_openssl
|
|
21
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
22
|
+
{
|
|
23
|
+
"publicKey" => b64(key.public_key.to_bn.to_s(2)),
|
|
24
|
+
"privateKey" => b64(key.private_key.to_s(2))
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
|
|
29
|
+
@subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
|
|
30
|
+
@public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
|
|
31
|
+
@private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
|
|
32
|
+
@ttl = ttl
|
|
33
|
+
@urgency = urgency
|
|
34
|
+
if %w[0 false off no].include?(ENV.fetch("TINA4_WEB_PUSH", "").strip.downcase)
|
|
35
|
+
raise PushError, "Web Push is disabled by TINA4_WEB_PUSH"
|
|
36
|
+
end
|
|
37
|
+
configuration if [@subject, @public_key, @private_key].any? { |value| !value.empty? }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def send(subscription, payload)
|
|
41
|
+
endpoint, uri = endpoint_for(subscription)
|
|
42
|
+
subject, public_key, private_key = configuration
|
|
43
|
+
public, private = vapid_keys(public_key, private_key)
|
|
44
|
+
deliver(endpoint, uri, subject, public_key, private, public, encrypt(payload_bytes(payload), subscription))
|
|
45
|
+
rescue URI::InvalidURIError => e
|
|
46
|
+
raise PushError, "Push subscription endpoint must be a valid URL: #{e.message}"
|
|
47
|
+
rescue Net::HTTPError, SocketError, SystemCallError => e
|
|
48
|
+
raise PushError, "Web Push request failed: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def endpoint_for(subscription)
|
|
54
|
+
endpoint = subscription.is_a?(Hash) ? (subscription["endpoint"] || subscription[:endpoint]) : nil
|
|
55
|
+
raise PushError, "A Web Push subscription with an endpoint is required" unless endpoint.is_a?(String) && !endpoint.empty?
|
|
56
|
+
uri = URI.parse(endpoint)
|
|
57
|
+
raise PushError, "Push subscription endpoint must use HTTP or HTTPS" unless %w[http https].include?(uri.scheme) && uri.host
|
|
58
|
+
[endpoint, uri]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def vapid_keys(public_key, private_key)
|
|
62
|
+
public = decode(public_key, "TINA4_VAPID_PUBLIC")
|
|
63
|
+
private = decode(private_key, "TINA4_VAPID_PRIVATE")
|
|
64
|
+
raise PushError, "TINA4_VAPID_PUBLIC must be a 65-byte P-256 public key" unless public.bytesize == 65 && public.getbyte(0) == 4
|
|
65
|
+
raise PushError, "TINA4_VAPID_PRIVATE must be a 32-byte P-256 private key" unless private.bytesize == 32
|
|
66
|
+
begin
|
|
67
|
+
derived = private_key_from_raw(private, public).public_key.to_bn.to_s(2)
|
|
68
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
69
|
+
raise PushError, "TINA4_VAPID_PRIVATE is not a valid P-256 private key: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
raise PushError, "TINA4_VAPID_PUBLIC does not match TINA4_VAPID_PRIVATE" unless derived == public
|
|
72
|
+
[public, private]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deliver(endpoint, uri, subject, public_key, private, public, body)
|
|
76
|
+
request = Net::HTTP::Post.new(uri)
|
|
77
|
+
request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
|
|
78
|
+
request["Content-Encoding"] = "aes128gcm"
|
|
79
|
+
request["Content-Type"] = "application/octet-stream"
|
|
80
|
+
request["TTL"] = @ttl.to_i.to_s
|
|
81
|
+
request["Urgency"] = @urgency if @urgency && !@urgency.empty?
|
|
82
|
+
request.body = body
|
|
83
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
84
|
+
http.use_ssl = uri.scheme == "https"
|
|
85
|
+
response = http.start { |client| client.request(request) }
|
|
86
|
+
status = response.code.to_i
|
|
87
|
+
{ "ok" => status < 400, "status" => status, "dead" => [404, 410].include?(status), "retryable" => [408, 429].include?(status) || status >= 500, "endpoint" => endpoint, "response" => response.body.to_s }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def configuration
|
|
91
|
+
missing = []
|
|
92
|
+
missing << "TINA4_VAPID_SUBJECT" if @subject.empty?
|
|
93
|
+
missing << "TINA4_VAPID_PUBLIC" if @public_key.empty?
|
|
94
|
+
missing << "TINA4_VAPID_PRIVATE" if @private_key.empty?
|
|
95
|
+
raise PushError, "Web Push is configured but missing: #{missing.join(', ')}" unless missing.empty?
|
|
96
|
+
self.class.send(:require_openssl)
|
|
97
|
+
[@subject, @public_key, @private_key]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.require_openssl
|
|
101
|
+
return if defined?(OpenSSL::PKey::EC)
|
|
102
|
+
|
|
103
|
+
raise PushError, "Web Push requires Ruby's OpenSSL stdlib capability; rebuild Ruby with OpenSSL support"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.b64(value)
|
|
107
|
+
Base64.urlsafe_encode64(value, padding: false)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def decode(value, name)
|
|
111
|
+
raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
112
|
+
|
|
113
|
+
Base64.urlsafe_decode64(value)
|
|
114
|
+
rescue ArgumentError => e
|
|
115
|
+
raise PushError, "#{name} must be base64url encoded: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def payload_bytes(payload)
|
|
119
|
+
return payload.b if payload.is_a?(String)
|
|
120
|
+
JSON.generate(payload)
|
|
121
|
+
rescue JSON::GeneratorError => e
|
|
122
|
+
raise PushError, "Push payload is not JSON serializable: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def private_key_from_raw(private, public)
|
|
126
|
+
body = OpenSSL::ASN1::Sequence.new([
|
|
127
|
+
OpenSSL::ASN1::Integer.new(1),
|
|
128
|
+
OpenSSL::ASN1::OctetString.new(private),
|
|
129
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId.new("prime256v1")], 0, :CONTEXT_SPECIFIC),
|
|
130
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(public)], 1, :CONTEXT_SPECIFIC)
|
|
131
|
+
])
|
|
132
|
+
OpenSSL::PKey.read(body.to_der)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def public_key_from_raw(public)
|
|
136
|
+
algorithm = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"), OpenSSL::ASN1::ObjectId.new("prime256v1")])
|
|
137
|
+
OpenSSL::PKey.read(OpenSSL::ASN1::Sequence.new([algorithm, OpenSSL::ASN1::BitString.new(public)]).to_der)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def encrypt(payload, subscription)
|
|
141
|
+
raise PushError, "Push payload is too large; maximum is #{MAX_PAYLOAD} bytes" if payload.bytesize > MAX_PAYLOAD
|
|
142
|
+
keys = subscription.is_a?(Hash) ? (subscription["keys"] || subscription[:keys] || {}) : {}
|
|
143
|
+
p256dh = keys["p256dh"] || keys[:p256dh]
|
|
144
|
+
auth = keys["auth"] || keys[:auth]
|
|
145
|
+
client = decode(p256dh.to_s, "subscription.keys.p256dh")
|
|
146
|
+
auth_secret = decode(auth.to_s, "subscription.keys.auth")
|
|
147
|
+
raise PushError, "subscription.keys.p256dh must be a 65-byte P-256 public key" unless client.bytesize == 65 && client.getbyte(0) == 4
|
|
148
|
+
raise PushError, "subscription.keys.auth must be a 16-byte authentication secret" unless auth_secret.bytesize == 16
|
|
149
|
+
|
|
150
|
+
ephemeral = OpenSSL::PKey::EC.generate("prime256v1")
|
|
151
|
+
server = ephemeral.public_key.to_bn.to_s(2)
|
|
152
|
+
shared = ephemeral.derive(public_key_from_raw(client))
|
|
153
|
+
ikm = hkdf(hmac(auth_secret, shared), "WebPush: info\0" + client + server, 32)
|
|
154
|
+
salt = OpenSSL::Random.random_bytes(16)
|
|
155
|
+
prk = hmac(salt, ikm)
|
|
156
|
+
cek = hkdf(prk, "Content-Encoding: aes128gcm\0", 16)
|
|
157
|
+
nonce = hkdf(prk, "Content-Encoding: nonce\0", 12)
|
|
158
|
+
cipher = OpenSSL::Cipher.new("aes-128-gcm")
|
|
159
|
+
cipher.encrypt
|
|
160
|
+
cipher.key = cek
|
|
161
|
+
cipher.iv = nonce
|
|
162
|
+
ciphertext = cipher.update(payload + "\x02") + cipher.final
|
|
163
|
+
tag = cipher.auth_tag
|
|
164
|
+
salt + [RECORD_SIZE].pack("N") + [server.bytesize].pack("C") + server + ciphertext + tag
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def hmac(key, value)
|
|
168
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, key, value)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def hkdf(prk, info, length)
|
|
172
|
+
output = +""
|
|
173
|
+
previous = +""
|
|
174
|
+
counter = 1
|
|
175
|
+
while output.bytesize < length
|
|
176
|
+
previous = hmac(prk, previous + info + [counter].pack("C"))
|
|
177
|
+
output << previous
|
|
178
|
+
counter += 1
|
|
179
|
+
raise PushError, "HKDF output is too large" if counter > 255
|
|
180
|
+
end
|
|
181
|
+
output.byteslice(0, length)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def vapid_token(uri, subject, private, public)
|
|
185
|
+
aud = "#{uri.scheme}://#{uri.host}#{uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""}"
|
|
186
|
+
header = self.class.send(:b64, JSON.generate({ typ: "JWT", alg: "ES256" }))
|
|
187
|
+
claims = self.class.send(:b64, JSON.generate({ aud: aud, exp: Time.now.to_i + 43_200, sub: subject }))
|
|
188
|
+
input = "#{header}.#{claims}"
|
|
189
|
+
signature = private_key_from_raw(private, public).sign(OpenSSL::Digest::SHA256.new, input)
|
|
190
|
+
asn = OpenSSL::ASN1.decode(signature)
|
|
191
|
+
raw = [asn.value[0].value.to_s(2), asn.value[1].value.to_s(2)].map { |value| value.rjust(32, "\0") }.join
|
|
192
|
+
"#{input}.#{self.class.send(:b64, raw)}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -169,6 +169,8 @@ module Tina4
|
|
|
169
169
|
autoload :MqttError, File.expand_path("tina4/mqtt", __dir__)
|
|
170
170
|
autoload :MqttTimeoutError, File.expand_path("tina4/mqtt", __dir__)
|
|
171
171
|
autoload :MqttMessage, File.expand_path("tina4/mqtt_message", __dir__)
|
|
172
|
+
autoload :Push, File.expand_path("tina4/push", __dir__)
|
|
173
|
+
autoload :PushError, File.expand_path("tina4/push", __dir__)
|
|
172
174
|
autoload :Testing, File.expand_path("tina4/testing", __dir__)
|
|
173
175
|
# Queue / Messenger / DocStore were the last three optional subsystems still
|
|
174
176
|
# loaded eagerly, so every `require "tina4"` paid for a queue backend, an
|
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.
|
|
4
|
+
version: 3.13.134
|
|
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-09-
|
|
11
|
+
date: 2026-09-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -405,6 +405,7 @@ files:
|
|
|
405
405
|
- lib/tina4/public/js/tina4js.min.js
|
|
406
406
|
- lib/tina4/public/swagger/index.html
|
|
407
407
|
- lib/tina4/public/swagger/oauth2-redirect.html
|
|
408
|
+
- lib/tina4/push.rb
|
|
408
409
|
- lib/tina4/query_builder.rb
|
|
409
410
|
- lib/tina4/queue.rb
|
|
410
411
|
- lib/tina4/queue_backends/kafka_backend.rb
|