Add API endpoints

Seto Elkahfi committed Jun 17, 2026 at 01:32 UTC cb0734e8cbde9ce0592b20d7d551381ac1aedf1f
9 files changed +414 -4
.agents/skills/sigit-app/SKILL.md
+95
new file mode 100644 index 0000000..060588b --- /dev/null +++ b/.agents/skills/sigit-app/SKILL.md @@ -0,0 +1,95 @@ +--- +name: sigit-app +description: Use when working on the "siGit Code & Deploy" Tauri desktop app (repo ~/Repositories/sigit-app), especially account management — login, signup, email verification, password reset, me, logout, remove account — and keeping account feature parity with the sigit-si web app. Covers the src-tauri account command set, the AccountStatus/ErrorResponse contract, token storage, the settings/account React views, and the decision about whether the app talks to smbCloud Auth directly or through sigit.si/api/v1. +--- + +# siGit Code & Deploy desktop app (sigit-app) + +`sigit-app` is the **Tauri desktop client** for siGit (repo +`~/Repositories/sigit-app`). It is a **public client** (a shipped binary), so the +public-client security rules apply — see +`smbcloud-cli/.agents/skills/smbcloud-auth/SKILL.md`. This file is the +sigit-app-specific view; keep its account features at **parity with the sigit-si +web account page** (`sigit-si/.agents/skills/smbcloud-auth/SKILL.md`). + +## Account command set + +Rust commands in `src-tauri/src/account/` (registered in `mod.rs`), each a +`#[tauri::command]`: + +- `command_login` → `login(app, env, username, password)` +- `command_signup` → `signup(...)` +- `command_me` → `me(...)` +- `command_logout` → `logout(...)` +- `command_remove_account` → `remove_account(app, env, client, access_token)` +- `command_resend_confirmation_email` +- `command_reset_password` +- `command_resend_reset_password_instructiont` (note: existing misspelled name — + match it exactly when wiring the frontend; don't "fix" it without renaming the + invoke call sites too) +- `command_check_email_oauth` + +### Current data flow + +Today the commands call the **Rust auth SDK directly**, not an HTTP API: + +``` +smbcloud_auth::login::login(env, client(), username, password) +smbcloud_model::{ error_codes::{ErrorCode, ErrorResponse}, login::AccountStatus } +smbcloud_network::environment::Environment // Dev | Production +``` + +- success returns `AccountStatus`; on `AccountStatus::Ready { access_token }` the + token is persisted via `crate::store::store_token` +- preserve the `AccountStatus` contract end to end: + `NotFound` · `Ready { access_token }` · `Incomplete { status }` +- failures return `ErrorResponse::Error { error_code, message }` + (`ErrorCode` from `smbcloud_model`) +- `env: Environment` is passed from the frontend (`useEnvironment().current`) + +## Frontend + +`src/components/sidebar/settings/`: + +- `account.tsx` — signed-in view: email, **Anthropic API key** field (stored + locally on-device via `get_setting`/`update_setting`, used by the Claude ACP + agent — not an auth credential), Logout, Remove Account +- `login-form.tsx`, `signup.tsx`, `verify-email-form.tsx`, + `set-password-form.tsx`, `logged-out-view.tsx`, `debug-setting-view.tsx` + +Frontend calls Rust via `invoke("<command>", { env, accessToken, ... })`. The +env switch is gated through `useEnvironment`; keep production/dev switching behind +debug controls. + +## Integration decision: direct SDK vs sigit.si/api/v1 + +The current architecture is **app → Rust SDK → smbCloud Auth**. The active goal +is to route desktop auth through **`sigit.si/api/v1`** instead (app → sigit.si → +smbCloud Auth). Before implementing, settle this explicitly: + +- **Direct SDK (status quo):** fewer hops, but the desktop binary needs smbCloud + app credentials and bypasses sigit-si entirely. +- **Via sigit.si/api/v1 (goal):** sigit-si becomes the trust boundary, holds the + confidential `app_secret` server-side, and the desktop app only ever sees a + bearer token. Better for the public-client rule; requires building the JSON API + in sigit-si first (see the sigit-si smbcloud-auth skill). + +If switching to the API path, change the command bodies to call +`sigit.si/api/v1/*` and keep the **same** `AccountStatus` / `ErrorResponse` +shapes so the frontend and token storage are unaffected. + +## Validation + +- `cargo check` in `src-tauri` +- `pnpm build` (or the app's package script) when the frontend changed +- exercise login → verify email → me → logout → remove against the dev environment +- confirm token storage and session restore (`me`) still work + +## Common mistakes + +- Breaking the `AccountStatus` contract the frontend depends on +- Renaming a command without updating its `invoke(...)` call sites (esp. the + misspelled `..._instructiont`) +- Treating the Anthropic API key field as an auth credential — it is local-only +- Shipping smbCloud `app_secret` in the binary and assuming it is confidential +- Diverging account features from the sigit-si web account page
.agents/skills/smbcloud-auth/SKILL.md
+114
new file mode 100644 index 0000000..d0a36dc --- /dev/null +++ b/.agents/skills/smbcloud-auth/SKILL.md @@ -0,0 +1,114 @@ +--- +name: smbcloud-auth +description: Use when working on authentication in the sigit-si Rails app — sign in, sign up, profile (me), sign out, or account removal. sigit-si is a platform client ("sigit") that consumes smbCloud Auth as an auth-as-a-service layer through the smbcloud-auth Ruby gem. Covers the SmbcloudAuthService wrapper, the local User upsert, env/credential config, the native-extension build caveat, and the planned /api/v1 JSON surface for the siGit Code & Deploy desktop app. +--- + +# smbCloud Auth in sigit-si + +sigit-si does **not** own user identity. It is a **platform client** (client id +`sigit`) that authenticates against the shared **smbCloud Auth** service. Treat +smbCloud Auth as the source of truth for credentials; sigit-si keeps only a thin +local mirror of the user. + +For the full auth model (platform clients vs tenant auth apps, the Rust/WASM +SDKs, the web console), see the authoritative skill in +`smbcloud-cli/.agents/skills/smbcloud-auth/SKILL.md`. This file is the +**sigit-si-specific** view. + +## Where things live + +- `app/services/smbcloud_auth_service.rb` — the only place that talks to the gem. + Do not call `SmbCloud::Auth` directly from controllers; go through this service. +- `app/controllers/sessions_controller.rb` — HTML sign in / sign out (`/auth`). +- `app/controllers/registrations_controller.rb` — HTML sign up (`/auth/signup`). +- `app/models/user.rb` — local mirror, upserted via + `User.find_or_create_from_smbcloud(profile.merge(access_token:))`. +- `Gemfile` — `gem "smbcloud-auth", "~> 0.3.35"` (native Rust/Magnus extension). + +## SmbcloudAuthService surface + +Class methods, all of which translate gem errors into local error types: + +- `login(email:, password:)` → `access_token` (String) +- `me(access_token:)` → profile hash `{ id:, email:, created_at:, updated_at: }` +- `signup(email:, password:)` +- `logout(access_token:)` +- `remove(access_token:)` — deletes the smbCloud account +- `client` — memoized `SmbCloud::Auth` client built from env config + +### Error contract + +- `SmbcloudAuthService::AuthenticationError` — base; carries optional `error_code` +- `SmbcloudAuthService::AccountNotFoundError` — no account for that email +- `SmbcloudAuthService::AccountIncompleteError` — account exists but not verified; + surface the "check your email" message, do not treat as bad credentials +- Underlying gem failures arrive as `SmbCloud::Auth::Error` (with `error_code`) + and must be caught inside the service, never leaked to controllers. + +Controllers map these to flash + HTTP status (`:unprocessable_entity` for auth +failures, `:internal_server_error` for config/unexpected). Preserve that mapping. + +## Credentials & environment + +Required env vars (raise `KeyError` if missing — handle as a config error, not an +auth failure): + +- `SIGITSI_SMBCLOUD_APP_ID` +- `SIGITSI_SMBCLOUD_APP_SECRET` + +The smbCloud environment (dev vs production) is selected inside the service. +Keep dev and production app credentials separate; never hardcode either. + +Security note: `sigit-si` is a **server-side confidential client**, so holding +`app_secret` here is fine. This is different from the desktop/browser clients — +see the public-client rule in the smbcloud-cli skill before reusing this pattern +in `sigit-app`. + +## Native extension build caveat + +`smbcloud-auth` compiles a Rust extension via `magnus`/`rb_sys`. The pinned +`magnus` version does **not** build against **Ruby 4.0.x**: + +``` +error[E0609]: no field `typed_flag` on type &rb_sys::RTypedData +error: could not compile `magnus` +``` + +This affects every published version of the gem, not a specific one. If a +`bundle install` fails to build the native extension on Ruby 4.0, the fix is in +the **gem** (bump `magnus`/`rb_sys` in its `ext/auth/Cargo.toml` and republish) +or build under Ruby 3.3.x — not in this repo's Gemfile. + +## Planned: /api/v1 JSON auth for the desktop app + +The "siGit Code & Deploy" Tauri app (`sigit-app`) needs a JSON, token-based auth +surface — the current controllers are HTML + session-cookie only. When adding +`sigit.si/api/v1`: + +- add an `Api::V1` controller namespace; reuse `SmbcloudAuthService`, do not + re-implement gem calls +- authenticate requests with the smbCloud `access_token` (bearer), not the Rails + session cookie +- return JSON shapes that match the desktop client's `account/command_*` set: + login, signup, me, logout, remove_account, resend confirmation, reset password, + check email/oauth +- keep the HTML controllers working; the API is additive (migrating the HTML + flows onto the API is optional, not required) +- keep `sigit-app` account features at parity with the sigit-si web account page + +## Validation + +- `bundle exec rails routes | grep -E 'auth|api/v1'` +- `ruby -c` on touched controllers/services +- `bundle exec rails db:migrate` if the local `User` schema changed +- exercise sign in / sign out / sign up against the dev smbCloud environment + +## Common mistakes + +- Calling `SmbCloud::Auth` directly from a controller instead of via the service +- Treating `AccountIncompleteError` as bad credentials instead of "verify email" +- Leaking `SmbCloud::Auth::Error` (or its `error_code`) to the view layer +- Using the Rails session cookie to authenticate desktop-app API requests +- Assuming a gem version bump caused a native build failure that is really the + Ruby 4.0 / magnus incompatibility +- Reusing the server-side `app_secret` pattern in a public client (desktop/browser)
Gemfile
+1 -1
index 66e30f7..5333468 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem "tailwindcss-rails", "~> 3.3.1" gem "jbuilder" # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout -gem "smbcloud-auth", "~> 0.3.34" +gem "smbcloud-auth", "~> 0.3.35" # Markdown rendering for README files gem "redcarpet", "~> 3.6"
app/controllers/api/base_controller.rb
+94
new file mode 100644 index 0000000..f363c5e --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +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: + # + # 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: } + 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 + + private + + # Verifies the bearer token against smbCloud and upserts the local user. + # On success sets @access_token and @current_user; otherwise halts with 401. + def authenticate_token! + token = bearer_token + if token.blank? + return render_error("Missing access token.", status: :unauthorized) + end + + 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 + end + + def bearer_token + request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]&.strip + end + + def current_user + @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 + 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 + end + + def render_record_invalid(error) + render json: { status: "error", error_code: nil, message: 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 + end + end +end
app/controllers/api/v1/me_controller.rb
+25
new file mode 100644 index 0000000..33d269f --- /dev/null +++ b/app/controllers/api/v1/me_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + # Current-user profile and account removal for API clients. + class MeController < Api::BaseController + before_action :authenticate_token! + + # GET /api/v1/me + # Header: Authorization: Bearer <access_token> + def show + render json: { status: "ready", user: user_json(current_user) }, status: :ok + end + + # DELETE /api/v1/me + # 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 + end + end + end +end
app/controllers/api/v1/registrations_controller.rb
+35
new file mode 100644 index 0000000..aacdba0 --- /dev/null +++ b/app/controllers/api/v1/registrations_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Api + module V1 + # Token-based sign up for API clients. + class RegistrationsController < Api::BaseController + # POST /api/v1/auth/sign_up + # 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) + end + + if password.length < 8 + return render_error("Password must be at least 8 characters.", status: :unprocessable_entity) + end + + SmbcloudAuthService.signup(email: email, password: password) + + # 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) + + render json: { status: "ready", access_token: access_token, user: user_json(user) }, + status: :created + end + end + end +end
app/controllers/api/v1/sessions_controller.rb
+37
new file mode 100644 index 0000000..e8939d2 --- /dev/null +++ b/app/controllers/api/v1/sessions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Api + module V1 + # Token-based sign in / sign out for API clients. + class SessionsController < Api::BaseController + before_action :authenticate_token!, only: :destroy + + # POST /api/v1/auth/sign_in + # 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) + 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 + 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 + end + end + end +end
app/views/pages/home.html.erb
-3
index 31d53cf..22f5105 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -4,9 +4,6 @@ <section class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28"> <div class="max-w-3xl"> - <div class="mb-8 inline-flex items-center gap-3 rounded-full border border-surface-500 bg-surface-700 px-3 py-1.5 text-xs font-medium uppercase tracking-[0.18em] text-gray-400"> - <span>Quiet Git hosting</span> - </div> <h1 class="max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl"> Git hosting that stays out of the way.
config/routes.rb
+13
index 1eda314..bb85104 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,19 @@ Rails.application.routes.draw do get "/settings", to: "users#settings", as: :settings patch "/settings", to: "users#update_settings" + # JSON API — token-based auth for the siGit Code & Deploy desktop app. + # Declared before the catch-all "/:username" route so "/api/..." isn't + # swallowed by the username matcher. + namespace :api do + namespace :v1 do + post "auth/sign_in", to: "sessions#create" + delete "auth/sign_out", to: "sessions#destroy" + post "auth/sign_up", to: "registrations#create" + get "me", to: "me#show" + delete "me", to: "me#destroy" + end + end + # User profile (must come before repository routes) get "/:username", to: "users#show", as: :user_profile, constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }