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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- 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
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
<%= node_version %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= node_version %>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= ruby_version %>
|
|
@@ -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,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,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,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 <token></code>.
|
|
78
|
+
</p>
|
|
79
|
+
{create.data && (
|
|
80
|
+
<p className="status status-ok">
|
|
81
|
+
<span className="dot" />
|
|
82
|
+
Copy your new token now — it won'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
|
+
}
|