Serve sigit app auth

Seto Elkahfi committed Jun 17, 2026 at 12:25 UTC 06658f2d8c63390d5b1eff061b1a080f681992d4
8 files changed +296 -84
.agents/skills/deployment/SKILL.md
+172
new file mode 100644 index 0000000..20715fb --- /dev/null +++ b/.agents/skills/deployment/SKILL.md @@ -0,0 +1,172 @@ +--- +name: deployment +description: How to deploy, migrate, seed, and restart the sigit.si Rails app in production. Use this whenever shipping sigit-si, debugging a 500 after deploy, running a migration or seed on prod, or restarting Puma. +--- + +# Deploying sigit.si + +The Rails app behind `https://sigit.si` (the code + model hosting platform). Deploys happen by pushing `main` to a bare repo whose `post-receive` hook checks out the work tree and restarts Puma. + +## Server facts + +| Detail | Value | +| ------ | ----- | +| Domain | `sigit.si` (also the landing page `getsiti.5mb.app`) | +| Host | `api.splitfire.ai` = `65.21.240.91` (Hetzner Debian, `debian-4gb-hel1-2`) | +| SSH | `ssh smb1-deploy` (user `deploy`); the app runs as user `git` | +| App dir | `/home/git/apps/sigitsi` (deployed in place, not Capistrano releases) | +| Bare repo | `/home/git/sigitsi.git` (push target; `post-receive` hook deploys) | +| Puma port | 3015 (`SIGITSI_PORT`), single mode | +| Ruby | 3.4.2 via rbenv at `/home/git/.rbenv` | +| User repos | `/home/git/repos/users/<username>/<repo>.git` (`SIGITSI_REPOS_PATH`) | +| nginx vhost | `/etc/nginx/sites-enabled/getsiti.5mb.app` (proxies to 3015) | + +Note: this is a **different box** from `api.smbcloud.xyz` (`deploy-sigitweb`), which only hosts the static landing site. Do not look for the Rails app there. + +### Acting as the app user + +The app, its files, and its Postgres role all belong to `git`. The `deploy` user has passwordless `sudo -u git`. A `git` login shell already has `RAILS_ENV=production`, the DB password (`SIGITSI_DATABASE_PASSWORD`), rbenv, and bundle on PATH, so run app commands like this: + +```bash +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && <command>"' +``` + +There is no `.env` file; env vars come from `git`'s login shell (`~/.profile`). + +## Deploying + +From a local clone with the `main` branch ready: + +```bash +git push <prod-remote> main +``` + +The `post-receive` hook (`/home/git/sigitsi.git/hooks/post-receive`) then: + +1. `git checkout -f main` into `/home/git/apps/sigitsi` +2. `rbenv local`, `nvm use`, `bundle install` +3. `rake assets:precompile` +4. `rake db:prepare` +5. `kill -9 $(lsof -t -i:3015)` then `bundle exec puma -e production > output.log 2>&1 &` +6. `bin/jobs start > output-jobs.log 2>&1 &` + +### The hook does not reliably migrate (read this before trusting a deploy) + +The hook has **no `set -e`**, so a failed DB step does not stop the deploy: Puma restarts anyway, on whatever schema is live. Worse, `rake db:prepare` and `rake db:migrate` operate on **all four configured databases** (primary + solid `cache`/`queue`/`cable`). The cache/queue/cable databases do **not** exist in production and the `sigitsi` role lacks `CREATEDB`, so the task aborts with `permission denied to create database` before it ever migrates the primary DB. Its output goes to the pushing client, not to `output.log` (which Puma immediately truncates), so the failure is invisible afterward. + +Result: a deploy that adds a migration leaves the schema stale and every route touching the new column returns 500, while the home page and auth pages (which do not touch it) stay up. See "Run a migration" below for the fix, and "Known issues". + +## Run a migration + +The deploy hook's `rake db:prepare` now self-migrates (all four databases exist; see "Postgres admin"). To migrate by hand: + +```bash +# check what is pending first +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && bin/rails db:migrate:status | tail"' + +# apply +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && bin/rails db:migrate"' +``` + +If the cache/queue/cable databases are ever missing again, `db:migrate` aborts trying to create them (the `sigitsi` role lacks `CREATEDB`). Scope to the primary to get past it: `bin/rails db:migrate:primary`. + +Then restart Puma (next section) so ActiveRecord regenerates attribute methods for the new columns. A live process with a stale schema raises `NameError: undefined method 'kind'` even after the column exists. + +## Seed production data + +The model library ships with demo content (the `bartowski/Qwen2.5-3B-Instruct-GGUF` model and friends). Production needs this seeded once, e.g. so `https://sigit.si/bartowski/Qwen2.5-3B-Instruct-GGUF` resolves. + +With all four databases present, `bin/rails db:seed` works directly. If the cache/queue/cable DBs are missing, `db:seed`'s `db:abort_if_pending_migrations` prerequisite aborts on them; bypass it by loading the seed straight against the primary connection: + +```bash +# normal +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && bin/rails db:seed"' +# fallback if the solid DBs are missing +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && bin/rails runner \"Rails.application.load_seed\""' +``` + +What it creates (`db/seeds.rb`, idempotent — safe to re-run): + +- Demo owner users (`bartowski`, `meta-llama`, `sentence-transformers`) and one model under the existing `sigit` user (`SiGit-Coder-1.5B-GGUF`). +- Each model's bare git repo under `/home/git/repos/users/<owner>/<name>.git`, holding a YAML model card and Git LFS pointer files for the weights. + +The box has `git-lfs` installed, so the seed deliberately neutralises the local LFS filters (`filter.lfs.clean=cat`, `filter.lfs.process=`) before committing the pointer text. Without that, git-lfs tries to upload real objects and the push fails. This is already handled in `db/seeds.rb`. + +Verify: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0 (Chrome/130)" https://sigit.si/bartowski/Qwen2.5-3B-Instruct-GGUF +``` + +## Restart Puma + +Single-mode Puma on 3015. Two options: + +```bash +# Preferred: hot restart (re-exec, ~zero downtime). Get the master pid: +ssh smb1-deploy 'ss -ltnp | grep 3015' # or: sudo -u git lsof -t -i:3015 +ssh smb1-deploy 'sudo -u git kill -USR2 <master_pid>' + +# Full restart (matches the deploy hook), if a hot restart misbehaves: +ssh smb1-deploy 'sudo -u git bash -lc "cd /home/git/apps/sigitsi && kill -9 \$(lsof -t -i:3015); sleep 1; setsid bundle exec puma -e production > output.log 2>&1 < /dev/null &"' +``` + +Confirm it rebound and serves: + +```bash +ssh smb1-deploy 'ss -ltnp | grep 3015' +ssh smb1-deploy 'curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0 (Chrome/130)" http://127.0.0.1:3015/models' +``` + +There is no systemd unit; Puma is a detached background process owned by `git`. + +## nginx + +The vhost is `/etc/nginx/sites-enabled/sigit.si` (resolve symlinks before editing). It terminates TLS, serves static files from `/home/git/apps/sigitsi/public`, and proxies everything else to the `puma15` upstream (`127.0.0.1:3015`). + +### The static-extension location must fall through to the app + +sigit.si serves **repo file contents** at arbitrary paths (`/:user/:repo/blob/:branch/*path` and `/raw/...`), so URLs like `/sigit/nord/blob/main/public/web-app-manifest-192x192.png` are app routes, not files on disk. The vhost has a catch-all static block: + +```nginx +location ~ ^(?!/rails/).+\.(jpg|jpeg|gif|png|ico|json|txt|xml)$ { + ... + try_files $uri @puma15; # NOT =404 +} +``` + +It **must** end in `try_files $uri @puma15;`. With the stock `try_files $uri =404;`, nginx looks for the file under `public/`, does not find it, and returns 404 without ever reaching Rails. The result: any blob/raw URL whose path ends in `.png/.jpg/.json/.ico/.txt/.xml` (etc.) 404s, while `.md` and other extensions work. The shared `server-nginx-rails` template ships the `=404` form, which is fine for normal apps but wrong here. + +### Editing the vhost safely + +- `nginx` includes every file in `sites-enabled/`. Never leave a backup (`sigit.si.bak.*`) there or `nginx -t` fails with `duplicate upstream "puma15"`. Keep backups in `/root` or `/tmp`. +- Always `sudo nginx -t` before `sudo systemctl reload nginx`. + +## Debugging a 500 after deploy + +1. Read the app log (Puma stdout): `sudo -u git tail -80 /home/git/apps/sigitsi/output.log`. Look for `PG::UndefinedColumn` (pending migration) or `NameError` on a new attribute (stale schema, needs a Puma restart). +2. `bin/rails db:migrate:status` to see if a migration is `down`. +3. Note that `allow_browser versions: :modern` returns **403** to clients with no/old User-Agent, so always pass a modern UA when curling, or a healthy route looks broken. + +## Postgres admin + +PostgreSQL 13, local. The app role `sigitsi` connects over the local socket with a password and has **no** `CREATEDB` or superuser. For any admin task (creating databases, granting roles) go through the `postgres` superuser, which authenticates by `peer`: + +```bash +ssh smb1-deploy 'sudo -u postgres psql -c "<SQL>"' +``` + +The four databases (`sigitsi_production` plus the solid `_cache` / `_queue` / `_cable`) now exist, each owned by `sigitsi`, created with: + +```sql +CREATE DATABASE sigitsi_production_cache OWNER sigitsi; +CREATE DATABASE sigitsi_production_queue OWNER sigitsi; +CREATE DATABASE sigitsi_production_cable OWNER sigitsi; +``` + +Owning the database lets `sigitsi` create its own tables there, so no extra `GRANT` is needed. Creating them (rather than `ALTER ROLE sigitsi CREATEDB`) keeps the app role least-privileged. The `_cache`/`_queue`/`_cable` databases stay empty by design (see below). + +## Known issues + +- **The solid databases are unused.** Production sets Action Cable to the `redis` adapter (`cable.yml`), never sets `config.cache_store = :solid_cache_store`, and does not run solid_queue (no `queue_adapter`, `bin/jobs` is missing so the hook's `bin/jobs start` no-ops). So `_cache`/`_queue`/`_cable` exist only to satisfy the multi-database scaffold in `config/database.yml` and stay empty. They were created so `rake db:prepare` stops aborting and deploys self-migrate. The cleaner long-term fix is to delete the `cache`/`queue`/`cable` blocks from `config/database.yml` (all environments) so Rails manages only the primary; that removes the empty databases and the need for them to exist at all. +- **Deploy hook is not fail-safe.** No `set -e`, and Puma's stdout truncates the same `output.log` that captured the deploy steps, so migration failures ship silently. Hardening it (fail on a bad `db:prepare`, log deploy output to a separate file) is worthwhile.
app/controllers/api/base_controller.rb
+36 -57
index f363c5e..a81ea4d 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -3,42 +3,42 @@ module Api # Base for all JSON API controllers. # - # Unlike the HTML controllers, the API is token-based: clients (e.g. the - # siGit Code & Deploy desktop app) send the smbCloud access token as - # `Authorization: Bearer <token>` instead of relying on the Rails session - # cookie. Response shapes mirror the desktop client's AccountStatus contract: + # The API is token-based: clients (the siGit Code & Deploy desktop app) send + # the smbCloud access token as `Authorization: Bearer <token>`. Response bodies + # match the desktop's shared-model Rust types (smbcloud-model) exactly, so the + # app deserializes them directly: # - # ready → { status: "ready", access_token:, user: {...} } - # not_found → { status: "not_found", error_code:, message: } - # incomplete → { status: "incomplete", error_code:, message: } - # error → { status: "error", error_code:, message: } + # AccountStatus → "NotFound" | {"Ready":{"access_token":…}} | {"Incomplete":{"status":<u32>}} + # User → {"id":,"email":,"created_at":,"updated_at":} + # SignupResult → {"code":,"message":,"data":{…}} + # ErrorResponse → {"error_code":<i32>,"message":} class BaseController < ActionController::API - # Order matters: rescue_from is matched in reverse declaration order, so the - # base AuthenticationError is declared first and its subclasses after, which - # makes the more specific handlers win. - rescue_from SmbcloudAuthService::AuthenticationError, with: :render_auth_error - rescue_from SmbcloudAuthService::AccountNotFoundError, with: :render_not_found - rescue_from SmbcloudAuthService::AccountIncompleteError, with: :render_incomplete - rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid - rescue_from KeyError, with: :render_config_error + # error_codes::ErrorCode (i32) — used in ErrorResponse. + ERR_UNKNOWN = 0 + ERR_UNAUTHORIZED = 100 + ERR_INVALID = 101 + # account::ErrorCode (u32) — used in AccountStatus::Incomplete.status. + ACCOUNT_ERROR_CODES = [ 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007 ].freeze + ACCOUNT_EMAIL_UNVERIFIED = 1001 + + rescue_from SmbcloudAuthService::AuthenticationError, with: :render_auth_error + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + rescue_from KeyError, with: :render_config_error private - # Verifies the bearer token against smbCloud and upserts the local user. - # On success sets @access_token and @current_user; otherwise halts with 401. + # Verifies the bearer token against smbCloud, exposes the profile in + # @me_profile, and upserts the local mirror. On failure halts with an + # ErrorResponse (401). def authenticate_token! token = bearer_token - if token.blank? - return render_error("Missing access token.", status: :unauthorized) - end + return render_error(ERR_UNAUTHORIZED, "Missing access token.", status: :unauthorized) if token.blank? - profile = SmbcloudAuthService.me(access_token: token) + @me_profile = SmbcloudAuthService.me(access_token: token) @access_token = token - @current_user = User.find_or_create_from_smbcloud(profile, access_token: token) - rescue SmbcloudAuthService::AuthenticationError => e - render json: { status: "error", error_code: e.error_code, - message: "Invalid or expired access token." }, - status: :unauthorized + @current_user = User.find_or_create_from_smbcloud(@me_profile, access_token: token) + rescue SmbcloudAuthService::AuthenticationError + render_error(ERR_UNAUTHORIZED, "Invalid or expired access token.", status: :unauthorized) end def bearer_token @@ -49,46 +49,25 @@ module Api @current_user end - # Public profile shape returned to API clients. - def user_json(user) - { - id: user.smbcloud_id, - email: user.email, - username: user.username, - display_name: user.display_name_or_username, - avatar_url: user.avatar_url_or_default - } - end - - def render_error(message, status:, error_code: nil) - render json: { status: "error", error_code: error_code, message: message }, status: status + # ErrorResponse::Error — { error_code: <i32>, message: } + def render_error(error_code, message, status:) + render json: { error_code: error_code, message: message }, status: status end def render_auth_error(error) - render json: { status: "error", error_code: error.error_code, message: error.message }, - status: :unauthorized - end - - def render_not_found(error) - render json: { status: "not_found", error_code: error.error_code, message: error.message }, - status: :not_found - end - - def render_incomplete(error) - render json: { status: "incomplete", error_code: error.error_code, message: error.message }, - status: :unprocessable_entity + # The gem's error_code is already an error_codes::ErrorCode (i32); pass it + # through so e.g. network errors surface as NetworkError, not Unauthorized. + code = error.error_code.is_a?(Integer) ? error.error_code : ERR_UNAUTHORIZED + render_error(code, error.message.presence || "Unauthorized.", status: :unauthorized) end def render_record_invalid(error) - render json: { status: "error", error_code: nil, message: error.record.errors.full_messages.to_sentence }, - status: :unprocessable_entity + render_error(ERR_INVALID, error.record.errors.full_messages.to_sentence, status: :unprocessable_entity) end def render_config_error(error) Rails.logger.error("smbCloud configuration error: #{error.message}") - render json: { status: "error", error_code: nil, - message: "Authentication service is not configured." }, - status: :internal_server_error + render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error) end end end
app/controllers/api/v1/confirmations_controller.rb
+24
new file mode 100644 index 0000000..136340f --- /dev/null +++ b/app/controllers/api/v1/confirmations_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Api + module V1 + # Resend the email-confirmation link for an unconfirmed account. + class ConfirmationsController < Api::BaseController + # POST /api/v1/auth/confirmation/resend + # Params: email + # + # The underlying endpoint always responds the same way regardless of + # whether the account exists or is already confirmed (no enumeration), so + # this returns 204 on success. + def create + email = params[:email].to_s.strip.downcase + if email.blank? + return render_error(ERR_INVALID, "Email is required.", status: :unprocessable_entity) + end + + SmbcloudAuthService.resend_confirmation(email: email) + head :no_content + end + end + end +end
app/controllers/api/v1/me_controller.rb
+18 -5
index 33d269f..d9fc55f 100644 --- a/app/controllers/api/v1/me_controller.rb +++ b/app/controllers/api/v1/me_controller.rb @@ -6,19 +6,32 @@ module Api class MeController < Api::BaseController before_action :authenticate_token! - # GET /api/v1/me + # GET /api/v1/me — returns User { id, email, created_at, updated_at }. # Header: Authorization: Bearer <access_token> def show - render json: { status: "ready", user: user_json(current_user) }, status: :ok + render json: user_profile_json, status: :ok end - # DELETE /api/v1/me + # DELETE /api/v1/me — permanently removes the smbCloud account and the + # local mirror. Returns 204 on success. # Header: Authorization: Bearer <access_token> - # Permanently removes the smbCloud account and the local mirror. def destroy SmbcloudAuthService.remove(access_token: @access_token) current_user.destroy - render json: { status: "ok" }, status: :ok + head :no_content + end + + private + + # The bare smbCloud profile, matching the desktop's `User` type. + def user_profile_json + p = @me_profile.respond_to?(:symbolize_keys) ? @me_profile.symbolize_keys : @me_profile + { + id: p[:id], + email: p[:email], + created_at: p[:created_at], + updated_at: p[:updated_at] + } end end end
app/controllers/api/v1/registrations_controller.rb
+17 -12
index aacdba0..001bdea 100644 --- a/app/controllers/api/v1/registrations_controller.rb +++ b/app/controllers/api/v1/registrations_controller.rb @@ -4,31 +4,36 @@ module Api module V1 # Token-based sign up for API clients. class RegistrationsController < Api::BaseController - # POST /api/v1/auth/sign_up + # POST /api/v1/auth/sign_up — returns SignupResult. # Params: email, password def create email = params[:email].to_s.strip.downcase password = params[:password].to_s if email.blank? || password.blank? - return render_error("Email and password are required.", status: :unprocessable_entity) + return render_error(ERR_INVALID, "Email and password are required.", status: :unprocessable_entity) end if password.length < 8 - return render_error("Password must be at least 8 characters.", status: :unprocessable_entity) + return render_error(ERR_INVALID, "Password must be at least 8 characters.", status: :unprocessable_entity) end - SmbcloudAuthService.signup(email: email, password: password) + result = SmbcloudAuthService.signup(email: email, password: password) + render json: signup_result_json(result), status: :ok + end - # Try to log straight in. If the account needs email verification first, - # login raises AccountIncompleteError → rendered as "incomplete" so the - # client can route the user to the verify-email step. - access_token = SmbcloudAuthService.login(email: email, password: password) - profile = SmbcloudAuthService.me(access_token: access_token) - user = User.find_or_create_from_smbcloud(profile, access_token: access_token) + private - render json: { status: "ready", access_token: access_token, user: user_json(user) }, - status: :created + # SignupResult — { code: Option<i32>, message: String, data: Option<Data> }. + # The gem already returns this shape; normalise keys and guarantee a message. + def signup_result_json(result) + result = result.respond_to?(:symbolize_keys) ? result.symbolize_keys : result.to_h + { + code: result[:code], + message: result[:message].presence || + "Signed up successfully. Please check your email to confirm your account.", + data: result[:data] + } end end end
app/controllers/api/v1/sessions_controller.rb
+24 -9
index e8939d2..0db164a 100644 --- a/app/controllers/api/v1/sessions_controller.rb +++ b/app/controllers/api/v1/sessions_controller.rb @@ -6,31 +6,46 @@ module Api class SessionsController < Api::BaseController before_action :authenticate_token!, only: :destroy - # POST /api/v1/auth/sign_in + # POST /api/v1/auth/sign_in — returns AccountStatus. # Params: email, password def create email = params[:email].to_s.strip.downcase password = params[:password].to_s if email.blank? || password.blank? - return render_error("Email and password are required.", status: :unprocessable_entity) + return render_error(ERR_INVALID, "Email and password are required.", status: :unprocessable_entity) end - # Raises AccountNotFoundError / AccountIncompleteError / AuthenticationError, - # each mapped to the matching JSON shape by Api::BaseController. access_token = SmbcloudAuthService.login(email: email, password: password) - profile = SmbcloudAuthService.me(access_token: access_token) - user = User.find_or_create_from_smbcloud(profile, access_token: access_token) - render json: { status: "ready", access_token: access_token, user: user_json(user) }, - status: :ok + # Keep the web account page in sync by upserting the local mirror. + profile = SmbcloudAuthService.me(access_token: access_token) + User.find_or_create_from_smbcloud(profile, access_token: access_token) + + # AccountStatus::Ready + render json: { Ready: { access_token: access_token } }, status: :ok + rescue SmbcloudAuthService::AccountNotFoundError + # AccountStatus::NotFound (serialised as the bare JSON string "NotFound") + render json: "NotFound".to_json, status: :ok + rescue SmbcloudAuthService::AccountIncompleteError => e + # AccountStatus::Incomplete { status: <account::ErrorCode u32> } + render json: { Incomplete: { status: incomplete_status_code(e) } }, status: :ok end # DELETE /api/v1/auth/sign_out # Header: Authorization: Bearer <access_token> def destroy SmbcloudAuthService.logout(access_token: @access_token) - render json: { status: "ok" }, status: :ok + head :no_content + end + + private + + # Map the gem's error_code to a valid account::ErrorCode (u32); default to + # EmailUnverified, the canonical "incomplete / verify your email" case. + def incomplete_status_code(error) + code = error.error_code.to_i + ACCOUNT_ERROR_CODES.include?(code) ? code : ACCOUNT_EMAIL_UNVERIFIED end end end
app/services/smbcloud_auth_service.rb
+4 -1
index 611fd84..8c3f009 100644 --- a/app/services/smbcloud_auth_service.rb +++ b/app/services/smbcloud_auth_service.rb @@ -191,7 +191,10 @@ class SmbcloudAuthService rescue AuthenticationError, KeyError raise rescue StandardError => e - raise AuthenticationError.new(e.message) + # Log the detail server-side; return a clean message so internal infra + # (hosts, ports) never leaks to API clients or the flash. + Rails.logger.warn("smbCloud request to #{path} failed: #{e.class}: #{e.message}") + raise AuthenticationError.new("Network error contacting the authentication service.") end def self.smbcloud_app_id
config/routes.rb
+1
index e0d304a..c73c107 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,7 @@ Rails.application.routes.draw do post "auth/sign_in", to: "sessions#create" delete "auth/sign_out", to: "sessions#destroy" post "auth/sign_up", to: "registrations#create" + post "auth/confirmation/resend", to: "confirmations#create" get "me", to: "me#show" delete "me", to: "me#destroy" end