gemstack 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. metadata +251 -39
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Production settings: applied after config/app.rb when GEMSTACK_ENV=production.
4
+ # Each line shows the default value — uncomment one to change it.
5
+ #
6
+ # Secrets and connections come from environment variables, never from files:
7
+ # SECRET_KEY_BASE, <% if database? %>DATABASE_URL, <% end %>SMTP_URL, MAIL_FROM, APP_URL, REDIS_URL…
8
+ # `gemstack doctor --production` checks what's missing (docs/deployment.md).
9
+ GemStack.configure do |config|
10
+ # ── Code ────────────────────────────────────────────────────────────────
11
+ # config.eager_load = true # load all code at boot (copy-on-write friendly with Puma workers)
12
+ # config.jit = :yjit # Ruby's JIT (or GEMSTACK_JIT=yjit|zjit|off)
13
+
14
+ # ── Logging ─────────────────────────────────────────────────────────────
15
+ # config.logger.level = :info
16
+ # config.logger.format = :json # one JSON object per line, with the request id
17
+
18
+ # ── HTTP ────────────────────────────────────────────────────────────────
19
+ # config.http.show_exceptions = false # never send exception details to clients
20
+ # config.http.hsts = "max-age=63072000; includeSubDomains" # on HTTPS requests; nil if your proxy sets it
21
+ # config.http.cors.origins = ["https://app.example.com"] # only if the frontend is on another domain
22
+ # config.http.max_body_size = 10 * 1024 * 1024 # larger requests get 413
23
+ <% if database? -%>
24
+
25
+ # ── Database ────────────────────────────────────────────────────────────
26
+ # config.db.pool_size = 5 # connections per process (GEMSTACK_MAX_THREADS)
27
+ # config.db.statement_timeout = 5_000 # ms; stop runaway queries (PostgreSQL, MySQL)
28
+ # config.db.slow_query_ms = 500 # slower queries are logged at WARN
29
+ <% end -%>
30
+
31
+ # ── Cache: :memory is per process — use Redis with several processes or servers ──
32
+ # config.cache.store = :redis # add gem "redis-client"; REDIS_URL
33
+
34
+ # ── Background jobs (`gemstack jobs`) ───────────────────────────────────
35
+ # config.jobs.concurrency = 5 # threads per worker (or GEMSTACK_JOB_CONCURRENCY)
36
+
37
+ # ── Mail ────────────────────────────────────────────────────────────────
38
+ # config.mail.delivery = :smtp # SMTP_URL=smtp://user:password@smtp.example.com:587
39
+ # config.mail.default_from = "App <hello@example.com>" # or MAIL_FROM
40
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test settings: applied after config/app.rb when tests run (`gemstack test`).
4
+ # Each line shows the default value — uncomment one to change it.
5
+ GemStack.configure do |config|
6
+ # ── Logging ─────────────────────────────────────────────────────────────
7
+ # config.logger.level = :debug # logs are discarded in tests unless this (or GEMSTACK_LOG_LEVEL) is set
8
+
9
+ # ── HTTP ────────────────────────────────────────────────────────────────
10
+ # config.http.show_exceptions = true # a failing request's JSON includes the exception
11
+ <% if database? -%>
12
+
13
+ # ── Database (connection: the test section of config/database.yml, or TEST_DATABASE_URL) ──
14
+ # config.db.log_queries = false
15
+ <% end -%>
16
+
17
+ # ── Background work is recorded, not performed ──────────────────────────
18
+ # config.jobs.adapter = :test # assert_enqueued / perform_enqueued_jobs; :inline runs jobs at once
19
+ # config.mail.delivery = :test # assert_emails / last_email
20
+ # config.cache.store = :null # no caching, so tests can't leak into each other; :memory to test it
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Puma configuration. Every value can be set through the environment.
4
+ # `gemstack dev` sets GEMSTACK_API_HOST/PORT to a private loopback port
5
+ # behind the development gateway.
6
+
7
+ max_threads = Integer(ENV.fetch("GEMSTACK_MAX_THREADS", 5))
8
+ min_threads = Integer(ENV.fetch("GEMSTACK_MIN_THREADS", max_threads))
9
+ threads min_threads, max_threads
10
+
11
+ bind "tcp://#{ENV.fetch("GEMSTACK_API_HOST", "0.0.0.0")}:#{ENV.fetch("GEMSTACK_API_PORT", 4000)}"
12
+ environment ENV.fetch("GEMSTACK_ENV", "development")
13
+
14
+ # Cluster mode for multi-core production servers: WEB_CONCURRENCY=4
15
+ worker_count = Integer(ENV.fetch("WEB_CONCURRENCY", 0))
16
+ if worker_count.positive?
17
+ workers worker_count
18
+ preload_app!
19
+ # Connections must not be shared across forked workers; each reconnects lazily.
20
+ before_fork { GemStack::DB.disconnect if defined?(GemStack::DB) }
21
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # API routes, relative to config.http.api_path ("/api"):
4
+ #
5
+ # get "/status", to: "status#show" # GET /api/status → StatusController#show
6
+ # resources :products # /api/products: index, create, show, update, destroy
7
+ #
8
+ # List them with `gemstack routes`. In development this file reloads on save.
9
+ GemStack.routes do
10
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack entry point. Works with Puma (config/puma.rb) or any Rack server.
4
+ require_relative "config/app"
5
+
6
+ run GemStack.boot!
File without changes
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Development data, loaded by `gemstack db:seed` (and db:setup). Make it safe to run twice:
4
+ #
5
+ # Product.find_by(name: "Desk lamp") || Product.create(name: "Desk lamp", price: "39.00")
@@ -0,0 +1,19 @@
1
+ # Copy to .env for local development overrides. Never commit real secrets.
2
+ # Variables already set in your shell always take precedence.
3
+
4
+ # PORT=3000 # public development port (gemstack dev)
5
+ # GEMSTACK_LOG_LEVEL=debug
6
+
7
+ <% if database? -%>
8
+ # Database: config/database.yml. A URL here overrides it (hosting platforms set DATABASE_URL).
9
+ <% if database_adapter == "sqlite3" -%>
10
+ # DATABASE_URL=sqlite3:db/development.sqlite3
11
+ <% elsif database_adapter == "postgresql" -%>
12
+ # DATABASE_URL=postgres://user:password@localhost:5432/<%= database_name %>_development
13
+ # TEST_DATABASE_URL=postgres://user:password@localhost:5432/<%= database_name %>_test
14
+ <% else -%>
15
+ # DATABASE_PASSWORD=
16
+ # DATABASE_URL=<%= database_adapter %>://root:password@127.0.0.1:3306/<%= database_name %>_development
17
+ # TEST_DATABASE_URL=<%= database_adapter %>://root:password@127.0.0.1:3306/<%= database_name %>_test
18
+ <% end -%>
19
+ <% end -%>
@@ -0,0 +1,16 @@
1
+ /.bundle/
2
+ /log/
3
+ /tmp/
4
+ /db/*.sqlite3
5
+ /db/*.sqlite3-*
6
+ .env
7
+ .env.local
8
+ .env.*.local
9
+
10
+ node_modules/
11
+ /frontend/.next/
12
+ /frontend/out/
13
+ /frontend/next-env.d.ts
14
+ *.tsbuildinfo
15
+
16
+ .DS_Store
@@ -0,0 +1 @@
1
+ <%= node_version %>
@@ -0,0 +1 @@
1
+ <%= node_version %>
@@ -0,0 +1 @@
1
+ <%= ruby_version %>
@@ -0,0 +1,4 @@
1
+ ruby <%= ruby_version %>
2
+ <% if frontend? -%>
3
+ nodejs <%= node_version %>
4
+ <% end -%>
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class HealthTest < GemStack::TestCase
6
+ def test_api_boots_and_answers_health_checks
7
+ get_json "/api/health"
8
+
9
+ assert_status 200
10
+ assert_equal({ "status" => "ok" }, json_body)
11
+ end
12
+
13
+ def test_unknown_routes_return_json_errors
14
+ get_json "/api/does-not-exist"
15
+
16
+ assert_error 404, "route_not_found"
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ ENV["GEMSTACK_ENV"] = "test"
4
+
5
+ require_relative "../config/app"
6
+ require "gemstack/testing"
7
+ require "gemstack/jobs/testing"
8
+ <% if database? -%>
9
+ require "gemstack/db/testing"
10
+ <% end -%>
11
+ require "minitest/autorun"
12
+
13
+ <% if database? -%>
14
+ # Creates the test database (TEST_DATABASE_URL) if needed and applies migrations.
15
+ GemStack::DB::Testing.prepare!
16
+ <% end -%>
17
+ GemStack.boot!
18
+ <% if database? -%>
19
+
20
+ # Every test runs in a transaction that is rolled back afterwards.
21
+ GemStack::TestCase.include GemStack::DB::Testing::Transactions
22
+ <% end -%>
23
+
24
+ # Jobs are recorded, not run: assert_enqueued / perform_enqueued_jobs.
25
+ GemStack::TestCase.include GemStack::Jobs::Testing
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Personal API tokens: send `Authorization: Bearer <token>` instead of a cookie.
4
+ # GET /api/auth/tokens · POST /api/auth/tokens · DELETE /api/auth/tokens/:id
5
+ class ApiTokensController < ApplicationController
6
+ before :require_login
7
+ before :require_session, only: %i[create destroy] # a leaked token can't mint or revoke tokens
8
+
9
+ accepts :create do
10
+ required :name, :string, max_length: 100
11
+ end
12
+ returns :index, [ApiTokenSerializer]
13
+ returns :create, NewApiTokenSerializer
14
+
15
+ def index = render(current_user.auth_tokens_dataset.api.order(Sequel.desc(:created_at)), serializer: ApiTokenSerializer)
16
+
17
+ def create
18
+ token, id = GemStack::Auth::Tokens.issue(current_user.id, purpose: "api", name: input[:name])
19
+ render NewApiTokenSerializer::Result.new(token: token, api_token: AuthToken[id]),
20
+ status: :created, serializer: NewApiTokenSerializer
21
+ end
22
+
23
+ def destroy
24
+ revoked = GemStack::Auth::Tokens.revoke(params[:id], user_id: current_user.id)
25
+ raise GemStack::NotFound, "API token not found" unless revoked
26
+ end
27
+
28
+ private
29
+
30
+ def require_session
31
+ return if authenticated_by == :session
32
+
33
+ raise GemStack::Forbidden.new("Manage API tokens from the app.", code: "session_required")
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # POST /api/auth/email/resend · POST /api/auth/email/verify
4
+ class EmailVerificationsController < ApplicationController
5
+ before :require_login, only: :create
6
+ rate_limit to: 5, within: 3600, only: :create
7
+ rate_limit to: 20, within: 3600, only: :update
8
+
9
+ accepts :update do
10
+ required :token, :string, max_length: 200
11
+ end
12
+ returns :create, nil
13
+ returns :update, nil
14
+
15
+ def create
16
+ AuthMailer.email_verification(current_user.id).deliver_later unless current_user.email_verified?
17
+ head :accepted
18
+ end
19
+
20
+ # Works signed in or out: the link may be opened on another device.
21
+ def update
22
+ row = GemStack::Auth::Tokens.consume(input[:token], purpose: "email_verification")
23
+ user = row && User[row[:user_id]]
24
+ # Only valid for the address it was sent to.
25
+ unless user && user.email == row[:email]
26
+ raise GemStack::BadRequest.new("This verification link is invalid or has expired.", code: "invalid_token")
27
+ end
28
+
29
+ user.update(email_verified_at: Time.now) unless user.email_verified?
30
+ end
31
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # POST /api/auth/password/forgot · POST /api/auth/password/reset
4
+ class PasswordResetsController < ApplicationController
5
+ rate_limit to: 10, within: 3600, only: :create
6
+ rate_limit to: 3, within: 3600, only: :create, name: "reset-email", by: -> { params[:email].to_s.downcase }
7
+ rate_limit to: 10, within: 3600, only: :update
8
+
9
+ accepts :create do
10
+ required :email, :string, max_length: 254
11
+ end
12
+ accepts :update do
13
+ required :token, :string, max_length: 200
14
+ required :password, :string, max_length: 1024
15
+ end
16
+ returns :create, nil
17
+ returns :update, UserSerializer
18
+
19
+ # Always 202: whether an account exists is nobody else's business.
20
+ def create
21
+ user = User.first(email: User.normalize_email(input[:email]))
22
+ AuthMailer.password_reset(user.id).deliver_later if user
23
+ head :accepted
24
+ end
25
+
26
+ def update
27
+ errors = GemStack::Auth::Password.errors(input[:password])
28
+ raise GemStack::ValidationError.new(errors: { password: errors }) unless errors.empty?
29
+
30
+ row = GemStack::Auth::Tokens.consume(input[:token], purpose: "password_reset")
31
+ raise GemStack::BadRequest.new("This reset link is invalid or has expired.", code: "invalid_token") unless row
32
+
33
+ user = User[row[:user_id]]
34
+ user.update(password: input[:password])
35
+ # A reset usually means the old password leaked: end every session.
36
+ GemStack::Auth::Sessions.revoke_all(user.id)
37
+ sign_in(user)
38
+ render user
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # POST /api/auth/signup
4
+ class RegistrationsController < ApplicationController
5
+ rate_limit to: 10, within: 3600, only: :create
6
+
7
+ accepts :create do
8
+ required :email, :string, max_length: 254
9
+ required :password, :string, max_length: 1024
10
+ end
11
+ returns :create, UserSerializer
12
+
13
+ def create
14
+ user = User.create(email: input[:email], password: input[:password]) # invalid: 422 with field errors
15
+ AuthMailer.email_verification(user.id).deliver_later
16
+ sign_in(user)
17
+ render user, status: :created
18
+ end
19
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # POST /api/auth/login · GET /api/auth/me · DELETE /api/auth/logout
4
+ class SessionsController < ApplicationController
5
+ before :require_login, only: :show
6
+ # Per IP, and per account so one address can't be guessed at from many IPs.
7
+ rate_limit to: 20, within: 300, only: :create
8
+ rate_limit to: 10, within: 300, only: :create, name: "login-email", by: -> { params[:email].to_s.downcase }
9
+
10
+ accepts :create do
11
+ required :email, :string, max_length: 254
12
+ required :password, :string, max_length: 1024
13
+ end
14
+ returns :create, UserSerializer
15
+ returns :show, UserSerializer
16
+
17
+ def create
18
+ user = User.authenticate_by(email: input[:email], password: input[:password])
19
+ # The same answer for "no such account" and "wrong password".
20
+ raise GemStack::Unauthorized.new("Invalid email or password.", code: "invalid_credentials") unless user
21
+
22
+ sign_in(user)
23
+ render user
24
+ end
25
+
26
+ def show = render(current_user)
27
+
28
+ def destroy = sign_out
29
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Account emails. Templates: app/mailers/templates/auth_mailer/*.erb.
4
+ # Links point at the Next.js pages (config.auth.app_url, APP_URL in production).
5
+ class AuthMailer < ApplicationMailer
6
+ def password_reset(user_id)
7
+ @user = User[user_id] or return
8
+ token, = GemStack::Auth::Tokens.issue(@user.id, purpose: "password_reset")
9
+ @url = GemStack::Auth.url("/reset-password", token: token)
10
+ @minutes = GemStack.config.auth.password_reset_ttl / 60
11
+ mail to: @user.email, subject: "Reset your password"
12
+ end
13
+
14
+ def email_verification(user_id)
15
+ @user = User[user_id] or return
16
+ token, = GemStack::Auth::Tokens.issue(@user.id, purpose: "email_verification", email: @user.email)
17
+ @url = GemStack::Auth.url("/verify-email", token: token)
18
+ mail to: @user.email, subject: "Confirm your email address"
19
+ end
20
+ end
@@ -0,0 +1,2 @@
1
+ <p>Welcome! Please confirm that <%= @user.email %> is your email address.</p>
2
+ <p><a href="<%= @url %>">Confirm my email address</a></p>
@@ -0,0 +1,2 @@
1
+ Welcome! Please confirm that <%= @user.email %> is your email address:
2
+ <%= @url %>
@@ -0,0 +1,3 @@
1
+ <p>Someone (hopefully you) asked to reset the password for <%= @user.email %>.</p>
2
+ <p><a href="<%= @url %>">Choose a new password</a> — the link works once, for <%= @minutes %> minutes.</p>
3
+ <p>If it wasn't you, ignore this email: your password stays the same.</p>
@@ -0,0 +1,6 @@
1
+ Someone (hopefully you) asked to reset the password for <%= @user.email %>.
2
+
3
+ Choose a new password here (the link works once, for <%= @minutes %> minutes):
4
+ <%= @url %>
5
+
6
+ If it wasn't you, ignore this email — your password stays the same.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # API tokens (and single-use reset/verification tokens) — see GemStack::Auth::Tokens.
4
+ class AuthToken < ApplicationModel
5
+ field :purpose, :string, null: false
6
+ field :name, :string, size: 100
7
+ field :last_used_at, :datetime
8
+ field :expires_at, :datetime
9
+
10
+ belongs_to :user
11
+
12
+ dataset_module do
13
+ def api = where(purpose: "api")
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Accounts. GemStack::Auth::User adds password hashing (Argon2id), email
4
+ # normalization, validations and User.authenticate_by(email:, password:).
5
+ class User < ApplicationModel
6
+ include GemStack::Auth::User
7
+
8
+ field :email, :string, null: false, size: 254
9
+ field :email_verified_at, :datetime
10
+
11
+ has_many :auth_tokens
12
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for policies (`gemstack generate policy Order`). Everything is
4
+ # denied unless a policy allows it; see docs/authorization.md.
5
+ class ApplicationPolicy < GemStack::Policy
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApiTokenSerializer < ApplicationSerializer
4
+ model AuthToken
5
+ attributes :id, :name, :last_used_at, :expires_at, :created_at
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The response to creating an API token: the only time the token itself is shown.
4
+ class NewApiTokenSerializer < ApplicationSerializer
5
+ Result = Data.define(:token, :api_token)
6
+
7
+ attribute :token, :string
8
+ attribute :api_token, ApiTokenSerializer
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What the API shows about the signed-in user. Never add password_digest.
4
+ class UserSerializer < ApplicationSerializer
5
+ attributes :id, :email, :email_verified_at, :created_at
6
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Tables used by GemStack::Auth (docs/authentication.md).
4
+ Sequel.migration do
5
+ change do
6
+ create_table(:users) do
7
+ primary_key :id, type: :Bignum
8
+ String :email, null: false, unique: true # stored lowercased
9
+ String :password_digest, null: false # Argon2id
10
+ column :email_verified_at, :timestamptz
11
+ column :created_at, :timestamptz, null: false
12
+ column :updated_at, :timestamptz, null: false
13
+ end
14
+
15
+ # One row per signed-in browser; the cookie holds a random token, the
16
+ # table only its SHA-256 digest.
17
+ create_table(:sessions) do
18
+ primary_key :id, type: :Bignum
19
+ foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
20
+ String :token_digest, size: 64, null: false, unique: true
21
+ column :ip, :inet
22
+ String :user_agent
23
+ column :created_at, :timestamptz, null: false
24
+ column :last_seen_at, :timestamptz, null: false
25
+ column :expires_at, :timestamptz, null: false, index: true
26
+ end
27
+
28
+ # API tokens, password reset and email verification tokens (digests only).
29
+ create_table(:auth_tokens) do
30
+ primary_key :id, type: :Bignum
31
+ foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
32
+ String :purpose, size: 30, null: false
33
+ String :token_digest, size: 64, null: false, unique: true
34
+ String :name
35
+ String :email
36
+ column :created_at, :timestamptz, null: false
37
+ column :last_used_at, :timestamptz
38
+ column :expires_at, :timestamptz
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, type FormEvent } from "react";
6
+ import {
7
+ errorMessage,
8
+ useApiTokens,
9
+ useCreateApiToken,
10
+ useCurrentUser,
11
+ useLogout,
12
+ useResendVerification,
13
+ useRevokeApiToken,
14
+ } from "@/lib/auth";
15
+
16
+ export default function AccountPage() {
17
+ const router = useRouter();
18
+ const { data: user, isPending } = useCurrentUser();
19
+ const logout = useLogout();
20
+ const resend = useResendVerification();
21
+
22
+ if (isPending) return <main className="page muted">Loading…</main>;
23
+ if (!user) {
24
+ return (
25
+ <main className="page">
26
+ <h1>Account</h1>
27
+ <p>
28
+ You are signed out. <Link href="/login">Log in</Link> or <Link href="/signup">sign up</Link>.
29
+ </p>
30
+ </main>
31
+ );
32
+ }
33
+
34
+ return (
35
+ <main className="page">
36
+ <div className="page-header">
37
+ <h1>Account</h1>
38
+ <button className="button" onClick={() => logout.mutate(undefined, { onSuccess: () => router.push("/login") })}>
39
+ Log out
40
+ </button>
41
+ </div>
42
+ <dl className="card">
43
+ <dt>Email</dt>
44
+ <dd>
45
+ {user.email}{" "}
46
+ {user.email_verified_at ? (
47
+ <span className="muted">(confirmed)</span>
48
+ ) : (
49
+ <button className="button" disabled={resend.isPending || resend.isSuccess} onClick={() => resend.mutate()}>
50
+ {resend.isSuccess ? "Email sent" : "Resend confirmation email"}
51
+ </button>
52
+ )}
53
+ </dd>
54
+ <dt>Member since</dt>
55
+ <dd>{new Date(user.created_at).toLocaleDateString()}</dd>
56
+ </dl>
57
+ <ApiTokens />
58
+ </main>
59
+ );
60
+ }
61
+
62
+ function ApiTokens() {
63
+ const tokens = useApiTokens();
64
+ const create = useCreateApiToken();
65
+ const revoke = useRevokeApiToken();
66
+ const [name, setName] = useState("");
67
+
68
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
69
+ event.preventDefault();
70
+ create.mutate(name, { onSuccess: () => setName("") });
71
+ }
72
+
73
+ return (
74
+ <section>
75
+ <h2>API tokens</h2>
76
+ <p className="muted">
77
+ For scripts and other services: send <code>Authorization: Bearer &lt;token&gt;</code>.
78
+ </p>
79
+ {create.data && (
80
+ <p className="status status-ok">
81
+ <span className="dot" />
82
+ Copy your new token now — it won&apos;t be shown again: <code>{create.data.token}</code>
83
+ </p>
84
+ )}
85
+ <form className="form" onSubmit={handleSubmit}>
86
+ <label className="field">
87
+ Name
88
+ <input required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} placeholder="CI" />
89
+ </label>
90
+ {create.error && <p className="form-error">{errorMessage(create.error)}</p>}
91
+ <div className="actions">
92
+ <button className="button" type="submit" disabled={create.isPending}>
93
+ Create token
94
+ </button>
95
+ </div>
96
+ </form>
97
+ <table className="table">
98
+ <tbody>
99
+ {tokens.data?.map((token) => (
100
+ <tr key={token.id}>
101
+ <td>{token.name}</td>
102
+ <td className="muted">
103
+ {token.last_used_at ? `used ${new Date(token.last_used_at).toLocaleString()}` : "never used"}
104
+ </td>
105
+ <td>
106
+ <button className="button button-danger" onClick={() => revoke.mutate(token.id)}>
107
+ Revoke
108
+ </button>
109
+ </td>
110
+ </tr>
111
+ ))}
112
+ </tbody>
113
+ </table>
114
+ </section>
115
+ );
116
+ }