tina4ruby 3.13.63 → 3.13.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/cli.rb +640 -67
  3. data/lib/tina4/version.rb +1 -1
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51cd83415eaad0fc64019fcbab89e92fb0cfb90cd9a3e1a3db6122499c14c83f
4
- data.tar.gz: 9f1283a11d1f433d3ca39ae961db99bc73aa0e1f462336508c696ebbceaf9642
3
+ metadata.gz: 51edb881061c12c5202ab116cf7dc77ea41796f9c80ca3779efd7f276477e8c4
4
+ data.tar.gz: f90f1434a4516d9f2ac3307eade7ad8894293ad48a1f09490777b9b61ccd792c
5
5
  SHA512:
6
- metadata.gz: 59ceaebe4dea54ec1b9e4894efee36cb1d98af2e9bdfc919033a055f5824d12f37f523177003b4a0f2497d0ca0f0a4c3086628f8ae1e29e8035beda64a7ea4b5
7
- data.tar.gz: 6aa2471f6a38fe85d13e8832f68c23fd093ec0bbf9837f6ff6da220880428af9851c044cbf4d89f9cbd4ae64c1cc1c4f73e9373d3037902731018227fb6d0bbb
6
+ metadata.gz: e24b430ca3420fa69b13bc12dfb66a38f74b43c686fb662b68e45a8077d719b29380e970e20ae2620f321308f1bae692495605e302b703ad49ce8169ca3db55e
7
+ data.tar.gz: 30817fef46332e51ec88ee0938710047b95ac40b94ac6d1795db54bde3cae55392ebc99a1d65db9499ba704a6184068627a3f7cb5921c38e46091b7d7a5ab10d
data/lib/tina4/cli.rb CHANGED
@@ -83,10 +83,72 @@ module Tina4
83
83
  end.compact
84
84
  end
85
85
 
86
+ # Canonical AI-FILL placeholder block for a LOGIC-shaped stub.
87
+ #
88
+ # A tight, grounded *fill-spec* — not a vague ``# TODO`` — so a coding agent
89
+ # (or dev) completes it correctly and idiomatically. `raise
90
+ # NotImplementedError` makes an unfilled scaffold fail LOUD, and the
91
+ # greppable AI-FILL banner lets a human/agent jump to every gap in a file.
92
+ # <= 6 comment lines; `Use:` names only REAL tina4-ruby symbols (verified in
93
+ # the framework source, file:line).
94
+ #
95
+ # <indent># ─── AI-FILL: <fn> ───────────────────────────
96
+ # <indent># Intent: <what this must do>
97
+ # <indent># Given: <inputs + shape>
98
+ # <indent># Use: <named REAL Tina4 API — the idiomatic path>
99
+ # <indent># Return: <exact return value + status>
100
+ # <indent># Ground: tina4_context("<intent>", "ruby") · skill tina4-developer-ruby
101
+ # <indent>raise NotImplementedError, "<feature>: <what>" # remove when done
102
+ # <indent># ─────────────────────────────────────────────
103
+ def ai_fill(fn, intent, use, raise_msg, given: nil, ret: nil, ground: nil, indent: " ")
104
+ bar = "─" * 60
105
+ head = "#{indent}# ─── AI-FILL: #{fn} "
106
+ head += "─" * [4, 66 - head.length].max
107
+ lines = [head, "#{indent}# Intent: #{intent}"]
108
+ lines << "#{indent}# Given: #{given}" if given
109
+ lines << "#{indent}# Use: #{use}"
110
+ lines << "#{indent}# Return: #{ret}" if ret
111
+ lines << "#{indent}# Ground: #{ground}" if ground
112
+ lines << "#{indent}raise NotImplementedError, #{raise_msg.inspect} # remove when done"
113
+ lines << "#{indent}# #{bar}"
114
+ lines.join("\n") + "\n"
115
+ end
116
+
117
+ # Lighter EXTEND marker for CRUD-shaped WORKING code. Marks the natural
118
+ # extension point in generated code that already runs — NO
119
+ # NotImplementedError (the boilerplate IS the feature); just a greppable hint
120
+ # at where custom validation / business rules go.
121
+ def extend_marker(note, hint = "", indent: " ")
122
+ head = "#{indent}# ─── EXTEND: #{note} "
123
+ head += "─" * [4, 66 - head.length].max
124
+ out = head + "\n"
125
+ out += "#{indent}# #{hint}\n" unless hint.to_s.empty?
126
+ out
127
+ end
128
+
129
+ # Parse a --every duration ('5m', '30s', '2h', '1d', or bare seconds) ->
130
+ # seconds. Falls back to 60s on an empty/unparseable value so a scaffold
131
+ # always has a valid interval for Tina4.service(interval: ...).
132
+ def parse_every(every)
133
+ return 60 if every.nil? || every == true
134
+ every = every.to_s.strip.downcase
135
+ return 60 if every.empty?
136
+ units = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86400 }
137
+ unit = every[-1]
138
+ if units.key?(unit)
139
+ [1, (every[0..-2].to_f * units[unit]).to_i].max
140
+ else
141
+ n = every.to_f
142
+ n.positive? ? [1, n.to_i].max : 60
143
+ end
144
+ rescue StandardError
145
+ 60
146
+ end
147
+
86
148
  # Parse --key value and --flag from args. Returns [flags_hash, positional_array]
87
149
  def parse_flags(args)
88
150
  # Boolean-only flags that never take a value argument
89
- boolean_flags = %w[no-browser no-reload production managed all clear dev json]
151
+ boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration]
90
152
 
91
153
  flags = {}
92
154
  positional = []
@@ -593,13 +655,18 @@ module Tina4
593
655
 
594
656
  # ── generate ────────────────────────────────────────────────────────
595
657
 
658
+ ALL_GENERATORS = "model, route, crud, migration, middleware, test, form, view, auth, " \
659
+ "service, queue, validator, seeder, websocket, listener"
660
+
596
661
  def cmd_generate(argv)
597
662
  what = argv.shift
598
663
 
599
664
  unless what
600
665
  puts "Usage: tina4ruby generate <what> <name> [options]"
601
- puts " Generators: model, route, crud, migration, middleware, test, form, view, auth"
666
+ puts " Generators: #{ALL_GENERATORS}"
602
667
  puts ' Options: --fields "name:string,price:float" --model ModelName'
668
+ puts ' --public open a route'"'"'s writes (default: secure)'
669
+ puts ' --every 5m | --cron "..." service schedule'
603
670
  exit 1
604
671
  end
605
672
 
@@ -625,9 +692,15 @@ module Tina4
625
692
  when "form" then generate_form(name, flags)
626
693
  when "view" then generate_view(name, flags)
627
694
  when "auth" then generate_auth(name, flags)
695
+ when "service" then generate_service(name, flags)
696
+ when "queue" then generate_queue(name, flags)
697
+ when "validator" then generate_validator(name, flags)
698
+ when "seeder" then generate_seeder(name, flags)
699
+ when "websocket" then generate_websocket(name, flags)
700
+ when "listener" then generate_listener(name, flags)
628
701
  else
629
702
  puts "Unknown generator: #{what}"
630
- puts " Available: model, route, crud, migration, middleware, test, form, view, auth"
703
+ puts " Available: #{ALL_GENERATORS}"
631
704
  exit 1
632
705
  end
633
706
  end
@@ -679,10 +752,27 @@ module Tina4
679
752
 
680
753
  # ── Generator: route ─────────────────────────────────────────────────
681
754
 
755
+ # Generate a CRUD route file — SECURE BY DEFAULT.
756
+ #
757
+ # tina4ruby generate route products
758
+ # tina4ruby generate route products --model Product
759
+ # tina4ruby generate route products --model Product --public # open writes
760
+ #
761
+ # Writes (POST/PUT/DELETE) are Bearer-token-gated by default: the router sets
762
+ # auth_required on POST/PUT/PATCH/DELETE (lib/tina4/router.rb:24) and
763
+ # enforce_route_auth 401s a tokenless write (lib/tina4/rack_app.rb:1129).
764
+ # Reads (GET) are public by default, so no opt-out is emitted for them.
765
+ # `--public` chains `.no_auth` on the write handlers as the explicit opt-out
766
+ # — mirroring AutoCrud's `post_route.no_auth if is_public`
767
+ # (lib/tina4/auto_crud.rb:169). The generated routes register through
768
+ # `Tina4::Router.get/post/...` (no bearer auth_handler attached), so `.no_auth`
769
+ # genuinely opens the write on BOTH the live server and the TestClient — the
770
+ # same registration path AutoCrud uses.
682
771
  def generate_route(name, flags)
683
772
  route_path = name.sub(%r{^/}, "")
684
773
  singular = route_path.end_with?("s") ? route_path[0..-2] : route_path
685
774
  model = flags["model"]
775
+ public_writes = flags["public"] ? true : false
686
776
 
687
777
  dir = "src/routes"
688
778
  FileUtils.mkdir_p(dir)
@@ -692,89 +782,148 @@ module Tina4
692
782
  return
693
783
  end
694
784
 
785
+ # `.no_auth` is chained on the WRITE handlers only when writes go public.
786
+ no_auth = public_writes ? ".no_auth" : ""
787
+ write_doc =
788
+ if public_writes
789
+ "Public (--public): no token required."
790
+ else
791
+ "Secure by default: requires a Bearer token (use --public to open)."
792
+ end
793
+
695
794
  if model
696
795
  model_snake = to_snake_case(model)
796
+ # WORKING code (the boilerplate IS the feature) + EXTEND markers at the
797
+ # natural extension points (no NotImplementedError).
798
+ ext_create = extend_marker(
799
+ "validate / business rules before persist",
800
+ 'e.g. reject invalid input; ground: tina4_context("validate before create", "ruby")'
801
+ )
802
+ ext_update = extend_marker(
803
+ "guard which fields / who may update",
804
+ 'e.g. enforce ownership; ground: tina4_context("authorize update", "ruby")'
805
+ )
697
806
  content = <<~RUBY
698
807
  require_relative "../orm/#{model_snake}"
699
808
 
700
- Tina4.get "/api/#{route_path}" do |request, response|
701
- # List all #{route_path} with pagination
809
+ # List all #{route_path} with pagination — public read (GET is ungated).
810
+ Tina4::Router.get "/api/#{route_path}" do |request, response|
702
811
  page = (request.params["page"] || 1).to_i
703
812
  per_page = (request.params["per_page"] || 20).to_i
704
813
  offset = (page - 1) * per_page
705
- results = #{model}.all(limit: per_page, offset: offset)
706
- response.json({ data: results.map(&:to_h), page: page, per_page: per_page })
814
+ records = #{model}.all(limit: per_page, offset: offset)
815
+ response.json({ data: records.map(&:to_h), count: #{model}.count, page: page, per_page: per_page })
707
816
  end
708
817
 
709
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
710
- # Get a single #{singular} by ID
818
+ # Get a single #{singular} by ID — public read.
819
+ Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
711
820
  item = #{model}.find(request.params["id"])
712
- if item.nil?
713
- response.json({ error: "Not found" }, 404)
714
- else
715
- response.json(item.to_h)
716
- end
821
+ next response.json({ error: "Not found" }, 404) if item.nil?
822
+ response.json(item.to_h)
717
823
  end
718
824
 
719
- Tina4.post "/api/#{route_path}" do |request, response|
720
- # Create a new #{singular}
825
+ # Create a new #{singular}. #{write_doc}
826
+ Tina4::Router.post "/api/#{route_path}" do |request, response|
827
+ #{ext_create.chomp}
721
828
  item = #{model}.create(request.body)
722
829
  response.json(item.to_h, 201)
723
- end
830
+ end#{no_auth}
724
831
 
725
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
726
- # Update a #{singular} by ID
832
+ # Update a #{singular} by ID. #{write_doc}
833
+ Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
727
834
  item = #{model}.find(request.params["id"])
728
- if item.nil?
729
- response.json({ error: "Not found" }, 404)
730
- else
731
- request.body.each do |key, value|
732
- next if key.to_s == "id"
733
- setter = "#{'#'}{key}="
734
- item.send(setter, value) if item.respond_to?(setter)
735
- end
736
- item.save
737
- response.json(item.to_h)
835
+ next response.json({ error: "Not found" }, 404) if item.nil?
836
+ #{ext_update.chomp}
837
+ request.body.each do |key, value|
838
+ next if key.to_s == "id"
839
+ setter = "#{'#'}{key}="
840
+ item.send(setter, value) if item.respond_to?(setter)
738
841
  end
739
- end
842
+ item.save
843
+ response.json(item.to_h)
844
+ end#{no_auth}
740
845
 
741
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
742
- # Delete a #{singular} by ID
846
+ # Delete a #{singular} by ID. #{write_doc}
847
+ Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
743
848
  item = #{model}.find(request.params["id"])
744
- if item.nil?
745
- response.json({ error: "Not found" }, 404)
746
- else
747
- item.delete
748
- response.json(nil, 204)
749
- end
750
- end
849
+ next response.json({ error: "Not found" }, 404) if item.nil?
850
+ item.delete
851
+ response.json(nil, 204)
852
+ end#{no_auth}
751
853
  RUBY
752
854
  else
855
+ # CUSTOM route — no model. Every handler body is a LOGIC-shaped stub:
856
+ # AI-FILL fill-spec + raise NotImplementedError (fails loud until filled).
857
+ m = singular.split("_").map(&:capitalize).join # PascalCase hint
858
+ b_list = ai_fill(
859
+ "list_#{route_path}",
860
+ "return the #{route_path} collection (add pagination if it grows)",
861
+ "#{m}.all(limit:, offset:) or #{m}.where(sql, params) (require_relative \"../orm/#{to_snake_case(m)}\")",
862
+ "list_#{route_path}: query and return the records",
863
+ ret: 'response.json({ data: rows.map(&:to_h) })',
864
+ ground: 'tina4_context("list ORM records with pagination", "ruby")'
865
+ )
866
+ b_get = ai_fill(
867
+ "get_#{singular}",
868
+ "fetch one #{singular} by id",
869
+ "#{m}.find(request.params[\"id\"]) (require_relative \"../orm/#{to_snake_case(m)}\")",
870
+ "get_#{singular}: fetch by id or 404",
871
+ given: 'request.params["id"] -> Integer',
872
+ ret: 'response.json(item.to_h) or response.json({ error: "Not found" }, 404)',
873
+ ground: 'tina4_context("find ORM record by id", "ruby")'
874
+ )
875
+ b_create = ai_fill(
876
+ "create_#{singular}",
877
+ "validate the body and persist a new #{singular}",
878
+ "#{m}.create(request.body) (require_relative \"../orm/#{to_snake_case(m)}\")",
879
+ "create_#{singular}: persist and return the new record",
880
+ given: "request.body -> Hash of fields",
881
+ ret: "response.json(item.to_h, 201)",
882
+ ground: 'tina4_context("create ORM record and return 201", "ruby")'
883
+ )
884
+ b_update = ai_fill(
885
+ "update_#{singular}",
886
+ "load, mutate and save an existing #{singular}",
887
+ "#{m}.find(request.params[\"id\"]) then set fields and item.save",
888
+ "update_#{singular}: apply changes and return the record",
889
+ given: 'request.params["id"] -> Integer; request.body -> changed fields',
890
+ ret: "response.json(item.to_h) or 404",
891
+ ground: 'tina4_context("update ORM record", "ruby")'
892
+ )
893
+ b_delete = ai_fill(
894
+ "delete_#{singular}",
895
+ "delete a #{singular} by id",
896
+ "#{m}.find(request.params[\"id\"]) then item.delete",
897
+ "delete_#{singular}: delete and return 204",
898
+ given: 'request.params["id"] -> Integer',
899
+ ret: "response.json(nil, 204) or 404",
900
+ ground: 'tina4_context("delete ORM record", "ruby")'
901
+ )
753
902
  content = <<~RUBY
754
- Tina4.get "/api/#{route_path}" do |request, response|
755
- # List all #{route_path}
756
- response.json({ data: [] })
903
+ # List all #{route_path} public read (GET is ungated).
904
+ Tina4::Router.get "/api/#{route_path}" do |request, response|
905
+ #{b_list.chomp}
757
906
  end
758
907
 
759
- Tina4.get "/api/#{route_path}/{id:int}" do |request, response|
760
- # Get a single #{singular}
761
- response.json({ data: {} })
908
+ # Get a single #{singular} public read.
909
+ Tina4::Router.get "/api/#{route_path}/{id:int}" do |request, response|
910
+ #{b_get.chomp}
762
911
  end
763
912
 
764
- Tina4.post "/api/#{route_path}" do |request, response|
765
- # Create a new #{singular}
766
- response.json({ data: request.body }, 201)
767
- end
913
+ # Create a new #{singular}. #{write_doc}
914
+ Tina4::Router.post "/api/#{route_path}" do |request, response|
915
+ #{b_create.chomp}
916
+ end#{no_auth}
768
917
 
769
- Tina4.put "/api/#{route_path}/{id:int}" do |request, response|
770
- # Update a #{singular}
771
- response.json({ data: request.body })
772
- end
918
+ # Update a #{singular}. #{write_doc}
919
+ Tina4::Router.put "/api/#{route_path}/{id:int}" do |request, response|
920
+ #{b_update.chomp}
921
+ end#{no_auth}
773
922
 
774
- Tina4.delete "/api/#{route_path}/{id:int}" do |request, response|
775
- # Delete a #{singular}
776
- response.json(nil, 204)
777
- end
923
+ # Delete a #{singular}. #{write_doc}
924
+ Tina4::Router.delete "/api/#{route_path}/{id:int}" do |request, response|
925
+ #{b_delete.chomp}
926
+ end#{no_auth}
778
927
  RUBY
779
928
  end
780
929
 
@@ -787,14 +936,16 @@ module Tina4
787
936
  def generate_crud(name, flags)
788
937
  table = to_table_name(name)
789
938
  route_name = "#{table}s"
939
+ is_public = flags["public"] ? true : false
790
940
 
791
941
  puts "\n Generating CRUD for #{name}...\n"
792
942
 
793
943
  # 1. Model + migration
794
944
  generate_model(name, flags)
795
945
 
796
- # 2. Routes with model
797
- generate_route(route_name, { "model" => name })
946
+ # 2. Routes with model — secure-by-default; thread --public through so
947
+ # `generate crud X --public` opens the writes (mirrors AutoCrud public:).
948
+ generate_route(route_name, { "model" => name, "public" => is_public })
798
949
 
799
950
  # 3. Form
800
951
  generate_form(name, flags)
@@ -802,8 +953,8 @@ module Tina4
802
953
  # 4. View (list + detail)
803
954
  generate_view(name, flags)
804
955
 
805
- # 5. Test
806
- generate_test(route_name, { "model" => name })
956
+ # 5. Test — secure-by-default gate test (behavioural, real TestClient).
957
+ generate_test(route_name, { "model" => name, "secure_writes" => true, "public" => is_public })
807
958
 
808
959
  puts "\n CRUD generation complete for #{name}."
809
960
  puts " Run: tina4ruby migrate"
@@ -932,6 +1083,85 @@ module Tina4
932
1083
  return
933
1084
  end
934
1085
 
1086
+ # Secure-by-default CRUD spec (emitted by `generate crud`): proves the gate
1087
+ # by BEHAVIOR through the real Tina4::TestClient — reads public, writes
1088
+ # gated — instead of assuming an anonymous create returns 201. Grounded on
1089
+ # spec/test_client_auth_spec.rb (real Router, real enforce_route_auth, real
1090
+ # JWT via Tina4::Auth.get_token / valid_token signed with real RSA keys).
1091
+ if model && flags["secure_writes"]
1092
+ model_snake = to_snake_case(model)
1093
+ posture = flags["public"] ? "open (--public)" : "gated"
1094
+ write_examples =
1095
+ if flags["public"]
1096
+ <<~RUBY.chomp
1097
+ it "allows an anonymous POST (201) — --public opened the write" do
1098
+ res = client.post("/api/#{snake}", json: { name: "test" })
1099
+ expect(res.status).to eq(201)
1100
+ end
1101
+ RUBY
1102
+ else
1103
+ <<~RUBY.chomp
1104
+ it "rejects a tokenless POST with 401 (secure by default)" do
1105
+ expect(client.post("/api/#{snake}", json: { name: "test" }).status).to eq(401)
1106
+ end
1107
+
1108
+ it "creates with a valid Bearer token (201)" do
1109
+ token = Tina4::Auth.get_token({ "user_id" => 1 })
1110
+ res = client.post("/api/#{snake}", json: { name: "test" },
1111
+ headers: { "Authorization" => "Bearer #{'#'}{token}" })
1112
+ expect(res.status).to eq(201)
1113
+ end
1114
+ RUBY
1115
+ end
1116
+
1117
+ content = <<~RUBY
1118
+ # frozen_string_literal: true
1119
+ #
1120
+ # #{model} CRUD gate — reads public, writes #{posture} (secure by default).
1121
+ #
1122
+ # Real end-to-end via Tina4::TestClient: NO mocks — real Router, real auth
1123
+ # gate (Tina4::RackApp.enforce_route_auth), real JWT. A real SQLite DB +
1124
+ # table is bound in before(:each), so the create path is exercised for real.
1125
+ require "spec_helper"
1126
+ require "tmpdir"
1127
+ require_relative "../src/orm/#{model_snake}"
1128
+
1129
+ RSpec.describe "#{model} CRUD (reads public, writes #{posture})" do
1130
+ let(:client) { Tina4::TestClient.new }
1131
+
1132
+ before(:each) do
1133
+ @dir = Dir.mktmpdir("#{snake}_crud")
1134
+ # Fresh RSA key material so get_token / valid_token agree.
1135
+ Tina4::Auth.instance_variable_set(:@private_key, nil)
1136
+ Tina4::Auth.instance_variable_set(:@public_key, nil)
1137
+ Tina4::Auth.instance_variable_set(:@keys_dir, nil)
1138
+ Tina4::Auth.setup(@dir)
1139
+ # A stray API key would authorise every write regardless of the JWT.
1140
+ @prior_api_key = ENV.delete("TINA4_API_KEY")
1141
+ Tina4.bind_database(Tina4::Database.new("sqlite:///" + File.join(@dir, "test.db")))
1142
+ #{model}.create_table
1143
+ # `load` (not require) re-registers the routes after spec_helper's
1144
+ # after(:each) Router.clear! wipes them between examples.
1145
+ load File.expand_path("../src/routes/#{snake}.rb", __dir__)
1146
+ end
1147
+
1148
+ after(:each) do
1149
+ ENV["TINA4_API_KEY"] = @prior_api_key if @prior_api_key
1150
+ FileUtils.rm_rf(@dir)
1151
+ end
1152
+
1153
+ it "serves GET (read) publicly" do
1154
+ expect(client.get("/api/#{snake}").status).to eq(200)
1155
+ end
1156
+
1157
+ #{write_examples}
1158
+ end
1159
+ RUBY
1160
+ File.write(path, content)
1161
+ puts " Created #{path}"
1162
+ return
1163
+ end
1164
+
935
1165
  if model
936
1166
  content = <<~RUBY
937
1167
  # Tests for #{name} CRUD operations
@@ -1175,7 +1405,10 @@ module Tina4
1175
1405
  content = <<~'RUBY'
1176
1406
  require_relative "../orm/user"
1177
1407
 
1178
- Tina4.post "/api/auth/register" do |request, response|
1408
+ # PUBLIC: register mints accounts before any token exists — clear BOTH
1409
+ # write gates (auth: false drops the bearer auth_handler; .no_auth
1410
+ # clears the router's auth_required).
1411
+ Tina4.post "/api/auth/register", auth: false do |request, response|
1179
1412
  # Register a new user
1180
1413
  email = request.body["email"].to_s
1181
1414
  password = request.body["password"].to_s
@@ -1197,9 +1430,10 @@ module Tina4
1197
1430
  role: "user",
1198
1431
  })
1199
1432
  response.json({ message: "Registered", id: user.id }, 201)
1200
- end
1433
+ end.no_auth
1201
1434
 
1202
- Tina4.post "/api/auth/login" do |request, response|
1435
+ # PUBLIC: login mints the token — clear BOTH write gates (see register).
1436
+ Tina4.post "/api/auth/login", auth: false do |request, response|
1203
1437
  # Login with email and password
1204
1438
  email = request.body["email"].to_s
1205
1439
  password = request.body["password"].to_s
@@ -1216,7 +1450,7 @@ module Tina4
1216
1450
 
1217
1451
  token = Tina4::Auth.get_token({ user_id: user.id, email: user.email, role: user.role })
1218
1452
  response.json({ token: token })
1219
- end
1453
+ end.no_auth
1220
1454
 
1221
1455
  Tina4.get "/api/auth/me" do |request, response|
1222
1456
  # Get current authenticated user
@@ -1306,6 +1540,333 @@ module Tina4
1306
1540
  puts " GET /api/auth/me - get profile (requires token)"
1307
1541
  end
1308
1542
 
1543
+ # ── Scaffolding-first logic generators (wiring + AI-FILL placeholder) ─────
1544
+ #
1545
+ # Each grounds on the REAL current tina4-ruby API (verified against the
1546
+ # source, file:line) and drops the ai_fill() AI-FILL placeholder (raise
1547
+ # NotImplementedError) where the custom logic goes:
1548
+ # service -> lib/tina4/service_runner.rb Tina4::ServiceRunner.register/.discover/.list
1549
+ # (via the Tina4.service DSL, lib/tina4.rb:431)
1550
+ # queue -> lib/tina4/queue.rb Tina4::Queue#push / #consume
1551
+ # lib/tina4/job.rb Job#payload / #complete / #fail
1552
+ # validator -> lib/tina4/validator.rb Tina4::Validator (#required/#email/#is_valid?)
1553
+ # seeder -> lib/tina4/seeder.rb Tina4::FakeData / Tina4.seed_orm
1554
+ # websocket -> lib/tina4/router.rb Tina4.websocket (connection, event, data)
1555
+ # listener -> lib/tina4/events.rb Tina4::Events.on / .emit
1556
+
1557
+ # ── Generator: service ────────────────────────────────────────────────
1558
+ #
1559
+ # tina4ruby generate service Cleanup --every 5m
1560
+ # tina4ruby generate service Report --cron "0 3 * * *"
1561
+ def generate_service(name, flags = {})
1562
+ snake = to_snake_case(name)
1563
+ cron = flags["cron"]
1564
+ if cron && cron != true
1565
+ options_kw = "timing: #{cron.inspect}" # ServiceRunner cron key is :timing
1566
+ note = "cron '#{cron}'"
1567
+ else
1568
+ seconds = parse_every(flags["every"])
1569
+ options_kw = "interval: #{seconds}"
1570
+ note = "every #{seconds}s"
1571
+ end
1572
+
1573
+ dir = "src/services"
1574
+ FileUtils.mkdir_p(dir)
1575
+ path = File.join(dir, "#{snake}.rb")
1576
+ if File.exist?(path)
1577
+ puts " File already exists: #{path}"
1578
+ return
1579
+ end
1580
+
1581
+ body = ai_fill(
1582
+ "#{snake}_task",
1583
+ "do the scheduled work for this service",
1584
+ "context.name / context.running (Tina4::ServiceContext); call your ORM / app code",
1585
+ "service:#{snake}: implement the scheduled task",
1586
+ given: "context -> Tina4::ServiceContext (.name, .running, .last_run, .error_count)",
1587
+ ground: 'tina4_context("background service scheduled task", "ruby")'
1588
+ )
1589
+ content = <<~RUBY
1590
+ # #{name} background service — runs #{note} via Tina4::ServiceRunner.
1591
+ #
1592
+ # Registered on load by Tina4.service(...). `tina4ruby start` does NOT
1593
+ # auto-start services (src/services is not on the boot auto-discover path,
1594
+ # lib/tina4.rb:566). Wire a runner explicitly to run it:
1595
+ #
1596
+ # Tina4::ServiceRunner.discover("src/services") # loads this file
1597
+ # Tina4::ServiceRunner.start
1598
+ #
1599
+ # The block receives a Tina4::ServiceContext; a daemon-style task would
1600
+ # loop while context.running.
1601
+
1602
+ def #{snake}_task(context)
1603
+ #{body.chomp}
1604
+ end
1605
+
1606
+ # Wiring: registers "#{snake}" on the class-level ServiceRunner registry;
1607
+ # appears in Tina4::ServiceRunner.list.
1608
+ Tina4.service("#{snake}", #{options_kw}) { |context| #{snake}_task(context) }
1609
+ RUBY
1610
+ File.write(path, content)
1611
+ puts " Created #{path}"
1612
+ end
1613
+
1614
+ # ── Generator: queue ──────────────────────────────────────────────────
1615
+ #
1616
+ # tina4ruby generate queue order-emails
1617
+ def generate_queue(name, flags = {})
1618
+ topic = name.sub(%r{^/}, "")
1619
+ slug = to_snake_case(topic.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
1620
+ slug = "topic" if slug.empty?
1621
+
1622
+ dir = "src/services" # consumer runs as a daemon service (see below)
1623
+ FileUtils.mkdir_p(dir)
1624
+ path = File.join(dir, "#{slug}_consumer.rb")
1625
+ if File.exist?(path)
1626
+ puts " File already exists: #{path}"
1627
+ return
1628
+ end
1629
+
1630
+ body = ai_fill(
1631
+ "handle_#{slug}",
1632
+ "process ONE #{topic} job payload",
1633
+ "your ORM / Tina4::Messenger code; return to ack (job.complete), raise to nack (job.fail)",
1634
+ "queue:#{topic}: process the job payload",
1635
+ given: "payload -> the pushed Hash (job.payload)",
1636
+ ground: 'tina4_context("process a queue job", "ruby")'
1637
+ )
1638
+ content = <<~RUBY
1639
+ # #{topic} queue — producer + consumer worker.
1640
+ #
1641
+ # Produce from anywhere: publish_#{slug}({ ... })
1642
+ # The consumer is a long-running worker wired as a daemon service so
1643
+ # Tina4::ServiceRunner.discover("src/services"); Tina4::ServiceRunner.start
1644
+ # runs it without blocking boot (consume polls forever).
1645
+
1646
+ # Enqueue a #{topic} job for the worker below to process. Returns the Tina4::Job.
1647
+ def publish_#{slug}(payload)
1648
+ Tina4::Queue.new(topic: "#{topic}").push(payload)
1649
+ end
1650
+
1651
+ # Process ONE #{topic} job payload (`payload` is job.payload — the pushed data).
1652
+ def handle_#{slug}(payload)
1653
+ #{body.chomp}
1654
+ end
1655
+
1656
+ # Long-running #{topic} worker. consume yields Jobs; ack with job.complete,
1657
+ # nack with job.fail. `context` is the Tina4::ServiceContext under ServiceRunner.
1658
+ def consume_#{slug}(context = nil)
1659
+ Tina4::Queue.new(topic: "#{topic}").consume do |job|
1660
+ handle_#{slug}(job.payload)
1661
+ job.complete # ack — job done, removed from the queue
1662
+ rescue StandardError => e
1663
+ job.fail(e.message) # nack — retry / dead-letter
1664
+ end
1665
+ end
1666
+
1667
+ # Wiring: consumer runs as a daemon service (manages its own poll loop).
1668
+ # Discovered by Tina4::ServiceRunner.discover("src/services").
1669
+ Tina4.service("#{topic}-consumer", daemon: true) { |context| consume_#{slug}(context) }
1670
+ RUBY
1671
+ File.write(path, content)
1672
+ puts " Created #{path}"
1673
+ end
1674
+
1675
+ # ── Generator: validator ──────────────────────────────────────────────
1676
+ #
1677
+ # tina4ruby generate validator CreateUser
1678
+ def generate_validator(name, flags = {})
1679
+ snake = to_snake_case(name)
1680
+ dir = "src/validators"
1681
+ FileUtils.mkdir_p(dir)
1682
+ path = File.join(dir, "#{snake}.rb")
1683
+ if File.exist?(path)
1684
+ puts " File already exists: #{path}"
1685
+ return
1686
+ end
1687
+
1688
+ body = ai_fill(
1689
+ "validate_#{snake}",
1690
+ "declare the validation rules for a #{name} payload",
1691
+ 'validator.required("name").email("email").min_length("name", 2).integer("age")',
1692
+ "validator:#{snake}: add the rule set",
1693
+ given: "validator -> Tina4::Validator.new(data) (chainable)",
1694
+ ret: "the same validator (caller checks .is_valid? / .errors)",
1695
+ ground: 'tina4_context("validate request body with Validator", "ruby")'
1696
+ )
1697
+ content = <<~RUBY
1698
+ # #{name} request validator.
1699
+ #
1700
+ # Tina4::Validator is NOT part of the default `require "tina4"` surface
1701
+ # (unlike Queue / Events / ServiceRunner), so require it explicitly here.
1702
+ # NOT auto-loaded — require this file from the route that validates:
1703
+ # require_relative "../validators/#{snake}"
1704
+ # v = validate_#{snake}(request.body)
1705
+ # next response.json({ error: v.errors.first[:message] }, 400) unless v.is_valid?
1706
+ require "tina4/validator"
1707
+
1708
+ def validate_#{snake}(data)
1709
+ validator = Tina4::Validator.new(data)
1710
+ #{body.chomp}
1711
+ validator
1712
+ end
1713
+ RUBY
1714
+ File.write(path, content)
1715
+ puts " Created #{path}"
1716
+ end
1717
+
1718
+ # ── Generator: seeder ─────────────────────────────────────────────────
1719
+ #
1720
+ # tina4ruby generate seeder Product
1721
+ def generate_seeder(name, flags = {})
1722
+ table = to_table_name(name)
1723
+ model_snake = to_snake_case(name)
1724
+ dir = "seeds"
1725
+ FileUtils.mkdir_p(dir)
1726
+ path = File.join(dir, "#{table}_seeder.rb")
1727
+ if File.exist?(path)
1728
+ puts " File already exists: #{path}"
1729
+ return
1730
+ end
1731
+
1732
+ body = ai_fill(
1733
+ "#{table}_field_overrides",
1734
+ "map #{name} fields to fake-data generators (only those needing a specific shape)",
1735
+ "fake.name / fake.email / fake.integer(min: 1, max: 99) / fake.company (Tina4::FakeData)",
1736
+ "seeder:#{name}: return the field->value overrides Hash",
1737
+ given: "fake -> Tina4::FakeData instance",
1738
+ ret: '{ "email" => ->(f) { f.email }, "status" => "active" } (Hash)',
1739
+ ground: 'tina4_context("seed ORM model with FakeData", "ruby")'
1740
+ )
1741
+ content = <<~RUBY
1742
+ # Seeder for #{name} — run with: tina4ruby seed
1743
+ #
1744
+ # Executed top-to-bottom by `tina4ruby seed` (Tina4.seed_dir loads each
1745
+ # file in seeds/, lib/tina4/seeder.rb:890). Tina4.seed_orm auto-fills every
1746
+ # field by type/name; override the ones that need a specific shape via
1747
+ # #{table}_field_overrides.
1748
+ require_relative "../src/orm/#{model_snake}"
1749
+
1750
+ # Map #{name} fields -> Tina4::FakeData generators (or static values).
1751
+ # Each callable receives a FakeData instance:
1752
+ # { "email" => ->(fake) { fake.email }, "status" => "active" }
1753
+ def #{table}_field_overrides(fake)
1754
+ #{body.chomp}
1755
+ end
1756
+
1757
+ # Wiring: seed rows via Tina4.seed_orm. Guarded on a bound database so
1758
+ # merely LOADING this file (e.g. in a spec) does not run the unfilled
1759
+ # placeholder — `tina4ruby seed` binds the DB first, at which point the
1760
+ # override raises LOUD until filled.
1761
+ if Tina4.database
1762
+ fake = Tina4::FakeData.new
1763
+ summary = Tina4.seed_orm(#{name}, count: 20, overrides: #{table}_field_overrides(fake))
1764
+ puts "Seeded #{'#'}{summary.seeded} #{name} row(s), #{'#'}{summary.failed} failed"
1765
+ end
1766
+ RUBY
1767
+ File.write(path, content)
1768
+ puts " Created #{path}"
1769
+ end
1770
+
1771
+ # ── Generator: websocket ──────────────────────────────────────────────
1772
+ #
1773
+ # tina4ruby generate websocket chat
1774
+ # tina4ruby generate websocket /ws/rooms/{id}
1775
+ def generate_websocket(name, flags = {})
1776
+ raw = name.strip
1777
+ ws_path = raw.start_with?("/") ? raw : "/ws/#{raw.sub(%r{^/}, '')}"
1778
+ slug = to_snake_case(raw.gsub(%r{\A/+|/+\z}, "").gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
1779
+ slug = "ws" if slug.empty?
1780
+ base = slug.start_with?("ws_") ? slug[3..] : slug
1781
+ base = "ws" if base.nil? || base.empty?
1782
+ handler = "#{base}_ws"
1783
+
1784
+ dir = "src/routes" # Tina4.websocket auto-registers on load (src/routes is discovered)
1785
+ FileUtils.mkdir_p(dir)
1786
+ path = File.join(dir, "ws_#{base}.rb")
1787
+ if File.exist?(path)
1788
+ puts " File already exists: #{path}"
1789
+ return
1790
+ end
1791
+
1792
+ body = ai_fill(
1793
+ handler,
1794
+ %(handle an inbound "message" frame on #{ws_path}),
1795
+ "connection.broadcast(data) or connection.send(payload) (Tina4 WebSocketConnection)",
1796
+ "websocket:#{ws_path}: handle the inbound message",
1797
+ given: "connection -> WebSocketConnection; event -> :open/:message/:close; data -> String on :message",
1798
+ ground: 'tina4_context("websocket broadcast message", "ruby")',
1799
+ indent: " "
1800
+ )
1801
+ content = <<~RUBY
1802
+ # #{ws_path} WebSocket route.
1803
+ #
1804
+ # Registered on load by Tina4.websocket (src/routes is auto-discovered at
1805
+ # boot, lib/tina4.rb:566). The server invokes the block as
1806
+ # (connection, event, data) for each event: :open (connect), :message
1807
+ # (inbound frame), :close (disconnect). Use Tina4.secure_websocket instead
1808
+ # to require a JWT on the upgrade; connection.broadcast / connection.send
1809
+ # reach the other clients.
1810
+ Tina4.websocket "#{ws_path}" do |connection, event, data|
1811
+ case event
1812
+ when :open
1813
+ connection.send('{"type":"welcome"}')
1814
+ when :close
1815
+ # client disconnected — nothing to clean up yet
1816
+ else # :message
1817
+ #{body.chomp}
1818
+ end
1819
+ end
1820
+ RUBY
1821
+ File.write(path, content)
1822
+ puts " Created #{path}"
1823
+ end
1824
+
1825
+ # ── Generator: listener ───────────────────────────────────────────────
1826
+ #
1827
+ # tina4ruby generate listener user.created
1828
+ def generate_listener(name, flags = {})
1829
+ event = name.strip
1830
+ slug = to_snake_case(event.gsub(/[^0-9a-zA-Z]+/, "_")).gsub(/\A_+|_+\z/, "")
1831
+ slug = "event" if slug.empty?
1832
+
1833
+ dir = "src/listeners"
1834
+ FileUtils.mkdir_p(dir)
1835
+ path = File.join(dir, "#{slug}.rb")
1836
+ if File.exist?(path)
1837
+ puts " File already exists: #{path}"
1838
+ return
1839
+ end
1840
+
1841
+ body = ai_fill(
1842
+ "on_#{slug}",
1843
+ "react to the '#{event}' event",
1844
+ "your app code — Tina4::Messenger, an ORM write, or Tina4::Events.emit(...) a follow-up",
1845
+ "listener:#{event}: react to the event payload",
1846
+ given: %(data -> whatever Tina4::Events.emit("#{event}", data) passed),
1847
+ ground: 'tina4_context("event listener reaction", "ruby")'
1848
+ )
1849
+ content = <<~RUBY
1850
+ # Listener for the '#{event}' event.
1851
+ #
1852
+ # NOT auto-loaded at boot — src/listeners is not on the auto-discover path
1853
+ # (lib/tina4.rb:566 scans routes/api/orm only). Require this file from
1854
+ # app.rb (or an initializer) so Tina4::Events.on binds it:
1855
+ # require_relative "src/listeners/#{slug}"
1856
+ # Fires when something calls Tina4::Events.emit("#{event}", data). Listeners
1857
+ # are isolated — a raise is logged and the other listeners still run (pass
1858
+ # strict: true to emit to re-raise instead).
1859
+ def on_#{slug}(data = nil)
1860
+ #{body.chomp}
1861
+ end
1862
+
1863
+ # Wiring: bind the reaction on the real event bus.
1864
+ Tina4::Events.on("#{event}") { |data| on_#{slug}(data) }
1865
+ RUBY
1866
+ File.write(path, content)
1867
+ puts " Created #{path}"
1868
+ end
1869
+
1309
1870
  # ── help ──────────────────────────────────────────────────────────────
1310
1871
 
1311
1872
  def cmd_help
@@ -1333,14 +1894,26 @@ module Tina4
1333
1894
 
1334
1895
  Generators:
1335
1896
  generate model <Name> [--fields "name:string,price:float"]
1336
- generate route <name> [--model Name]
1337
- generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
1897
+ generate route <name> [--model Name] [--public] Writes secure by default; --public opens them
1898
+ generate crud <Name> [--fields "..."] [--public] Model + migration + routes + form + view + test
1338
1899
  generate migration <description>
1339
1900
  generate middleware <Name>
1340
1901
  generate test <name>
1341
1902
  generate form <Name> [--fields "..."] Form template with inputs matching model fields
1342
1903
  generate view <Name> [--fields "..."] List + detail templates for viewing records
1343
1904
  generate auth Login/register/logout routes + User model + templates
1905
+ generate service <Name> [--every 5m | --cron "..."] Scheduled ServiceRunner task (src/services/)
1906
+ generate queue <topic> Producer + consumer worker (src/services/)
1907
+ generate validator <Name> Request-body Validator (src/validators/)
1908
+ generate seeder <Model> FakeData + seed_orm seeder (seeds/)
1909
+ generate websocket <path> Tina4.websocket handler (src/routes/)
1910
+ generate listener <event> Tina4::Events.on listener (src/listeners/)
1911
+
1912
+ Scaffolding-first: logic-shaped generators (route without --model, service,
1913
+ queue, validator, seeder, websocket, listener) emit wiring + an AI-FILL
1914
+ placeholder (raise NotImplementedError) where the custom logic goes; CRUD-
1915
+ shaped ones emit working code. Writes are secure by default — use --public
1916
+ to open them.
1344
1917
 
1345
1918
  Metrics:
1346
1919
  metrics [--top N] [--json] [--fail-on warn|error] [--path DIR]
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.63"
4
+ VERSION = "3.13.64"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.63
4
+ version: 3.13.64
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team