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}/ }