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 new
+95
@@ -0,0 +1,95 @@
1 +---
2 +name: sigit-app
3 +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.
4 +---
5 +
6 +# siGit Code & Deploy desktop app (sigit-app)
7 +
8 +`sigit-app` is the **Tauri desktop client** for siGit (repo
9 +`~/Repositories/sigit-app`). It is a **public client** (a shipped binary), so the
10 +public-client security rules apply — see
11 +`smbcloud-cli/.agents/skills/smbcloud-auth/SKILL.md`. This file is the
12 +sigit-app-specific view; keep its account features at **parity with the sigit-si
13 +web account page** (`sigit-si/.agents/skills/smbcloud-auth/SKILL.md`).
14 +
15 +## Account command set
16 +
17 +Rust commands in `src-tauri/src/account/` (registered in `mod.rs`), each a
18 +`#[tauri::command]`:
19 +
20 +- `command_login` → `login(app, env, username, password)`
21 +- `command_signup` → `signup(...)`
22 +- `command_me` → `me(...)`
23 +- `command_logout` → `logout(...)`
24 +- `command_remove_account` → `remove_account(app, env, client, access_token)`
25 +- `command_resend_confirmation_email`
26 +- `command_reset_password`
27 +- `command_resend_reset_password_instructiont` (note: existing misspelled name —
28 + match it exactly when wiring the frontend; don't "fix" it without renaming the
29 + invoke call sites too)
30 +- `command_check_email_oauth`
31 +
32 +### Current data flow
33 +
34 +Today the commands call the **Rust auth SDK directly**, not an HTTP API:
35 +
36 +```
37 +smbcloud_auth::login::login(env, client(), username, password)
38 +smbcloud_model::{ error_codes::{ErrorCode, ErrorResponse}, login::AccountStatus }
39 +smbcloud_network::environment::Environment // Dev | Production
40 +```
41 +
42 +- success returns `AccountStatus`; on `AccountStatus::Ready { access_token }` the
43 + token is persisted via `crate::store::store_token`
44 +- preserve the `AccountStatus` contract end to end:
45 + `NotFound` · `Ready { access_token }` · `Incomplete { status }`
46 +- failures return `ErrorResponse::Error { error_code, message }`
47 + (`ErrorCode` from `smbcloud_model`)
48 +- `env: Environment` is passed from the frontend (`useEnvironment().current`)
49 +
50 +## Frontend
51 +
52 +`src/components/sidebar/settings/`:
53 +
54 +- `account.tsx` — signed-in view: email, **Anthropic API key** field (stored
55 + locally on-device via `get_setting`/`update_setting`, used by the Claude ACP
56 + agent — not an auth credential), Logout, Remove Account
57 +- `login-form.tsx`, `signup.tsx`, `verify-email-form.tsx`,
58 + `set-password-form.tsx`, `logged-out-view.tsx`, `debug-setting-view.tsx`
59 +
60 +Frontend calls Rust via `invoke("<command>", { env, accessToken, ... })`. The
61 +env switch is gated through `useEnvironment`; keep production/dev switching behind
62 +debug controls.
63 +
64 +## Integration decision: direct SDK vs sigit.si/api/v1
65 +
66 +The current architecture is **app → Rust SDK → smbCloud Auth**. The active goal
67 +is to route desktop auth through **`sigit.si/api/v1`** instead (app → sigit.si →
68 +smbCloud Auth). Before implementing, settle this explicitly:
69 +
70 +- **Direct SDK (status quo):** fewer hops, but the desktop binary needs smbCloud
71 + app credentials and bypasses sigit-si entirely.
72 +- **Via sigit.si/api/v1 (goal):** sigit-si becomes the trust boundary, holds the
73 + confidential `app_secret` server-side, and the desktop app only ever sees a
74 + bearer token. Better for the public-client rule; requires building the JSON API
75 + in sigit-si first (see the sigit-si smbcloud-auth skill).
76 +
77 +If switching to the API path, change the command bodies to call
78 +`sigit.si/api/v1/*` and keep the **same** `AccountStatus` / `ErrorResponse`
79 +shapes so the frontend and token storage are unaffected.
80 +
81 +## Validation
82 +
83 +- `cargo check` in `src-tauri`
84 +- `pnpm build` (or the app's package script) when the frontend changed
85 +- exercise login → verify email → me → logout → remove against the dev environment
86 +- confirm token storage and session restore (`me`) still work
87 +
88 +## Common mistakes
89 +
90 +- Breaking the `AccountStatus` contract the frontend depends on
91 +- Renaming a command without updating its `invoke(...)` call sites (esp. the
92 + misspelled `..._instructiont`)
93 +- Treating the Anthropic API key field as an auth credential — it is local-only
94 +- Shipping smbCloud `app_secret` in the binary and assuming it is confidential
95 +- Diverging account features from the sigit-si web account page
.agents/skills/smbcloud-auth/SKILL.md new
+114
@@ -0,0 +1,114 @@
1 +---
2 +name: smbcloud-auth
3 +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.
4 +---
5 +
6 +# smbCloud Auth in sigit-si
7 +
8 +sigit-si does **not** own user identity. It is a **platform client** (client id
9 +`sigit`) that authenticates against the shared **smbCloud Auth** service. Treat
10 +smbCloud Auth as the source of truth for credentials; sigit-si keeps only a thin
11 +local mirror of the user.
12 +
13 +For the full auth model (platform clients vs tenant auth apps, the Rust/WASM
14 +SDKs, the web console), see the authoritative skill in
15 +`smbcloud-cli/.agents/skills/smbcloud-auth/SKILL.md`. This file is the
16 +**sigit-si-specific** view.
17 +
18 +## Where things live
19 +
20 +- `app/services/smbcloud_auth_service.rb` — the only place that talks to the gem.
21 + Do not call `SmbCloud::Auth` directly from controllers; go through this service.
22 +- `app/controllers/sessions_controller.rb` — HTML sign in / sign out (`/auth`).
23 +- `app/controllers/registrations_controller.rb` — HTML sign up (`/auth/signup`).
24 +- `app/models/user.rb` — local mirror, upserted via
25 + `User.find_or_create_from_smbcloud(profile.merge(access_token:))`.
26 +- `Gemfile` — `gem "smbcloud-auth", "~> 0.3.35"` (native Rust/Magnus extension).
27 +
28 +## SmbcloudAuthService surface
29 +
30 +Class methods, all of which translate gem errors into local error types:
31 +
32 +- `login(email:, password:)` → `access_token` (String)
33 +- `me(access_token:)` → profile hash `{ id:, email:, created_at:, updated_at: }`
34 +- `signup(email:, password:)`
35 +- `logout(access_token:)`
36 +- `remove(access_token:)` — deletes the smbCloud account
37 +- `client` — memoized `SmbCloud::Auth` client built from env config
38 +
39 +### Error contract
40 +
41 +- `SmbcloudAuthService::AuthenticationError` — base; carries optional `error_code`
42 +- `SmbcloudAuthService::AccountNotFoundError` — no account for that email
43 +- `SmbcloudAuthService::AccountIncompleteError` — account exists but not verified;
44 + surface the "check your email" message, do not treat as bad credentials
45 +- Underlying gem failures arrive as `SmbCloud::Auth::Error` (with `error_code`)
46 + and must be caught inside the service, never leaked to controllers.
47 +
48 +Controllers map these to flash + HTTP status (`:unprocessable_entity` for auth
49 +failures, `:internal_server_error` for config/unexpected). Preserve that mapping.
50 +
51 +## Credentials & environment
52 +
53 +Required env vars (raise `KeyError` if missing — handle as a config error, not an
54 +auth failure):
55 +
56 +- `SIGITSI_SMBCLOUD_APP_ID`
57 +- `SIGITSI_SMBCLOUD_APP_SECRET`
58 +
59 +The smbCloud environment (dev vs production) is selected inside the service.
60 +Keep dev and production app credentials separate; never hardcode either.
61 +
62 +Security note: `sigit-si` is a **server-side confidential client**, so holding
63 +`app_secret` here is fine. This is different from the desktop/browser clients —
64 +see the public-client rule in the smbcloud-cli skill before reusing this pattern
65 +in `sigit-app`.
66 +
67 +## Native extension build caveat
68 +
69 +`smbcloud-auth` compiles a Rust extension via `magnus`/`rb_sys`. The pinned
70 +`magnus` version does **not** build against **Ruby 4.0.x**:
71 +
72 +```
73 +error[E0609]: no field `typed_flag` on type &rb_sys::RTypedData
74 +error: could not compile `magnus`
75 +```
76 +
77 +This affects every published version of the gem, not a specific one. If a
78 +`bundle install` fails to build the native extension on Ruby 4.0, the fix is in
79 +the **gem** (bump `magnus`/`rb_sys` in its `ext/auth/Cargo.toml` and republish)
80 +or build under Ruby 3.3.x — not in this repo's Gemfile.
81 +
82 +## Planned: /api/v1 JSON auth for the desktop app
83 +
84 +The "siGit Code & Deploy" Tauri app (`sigit-app`) needs a JSON, token-based auth
85 +surface — the current controllers are HTML + session-cookie only. When adding
86 +`sigit.si/api/v1`:
87 +
88 +- add an `Api::V1` controller namespace; reuse `SmbcloudAuthService`, do not
89 + re-implement gem calls
90 +- authenticate requests with the smbCloud `access_token` (bearer), not the Rails
91 + session cookie
92 +- return JSON shapes that match the desktop client's `account/command_*` set:
93 + login, signup, me, logout, remove_account, resend confirmation, reset password,
94 + check email/oauth
95 +- keep the HTML controllers working; the API is additive (migrating the HTML
96 + flows onto the API is optional, not required)
97 +- keep `sigit-app` account features at parity with the sigit-si web account page
98 +
99 +## Validation
100 +
101 +- `bundle exec rails routes | grep -E 'auth|api/v1'`
102 +- `ruby -c` on touched controllers/services
103 +- `bundle exec rails db:migrate` if the local `User` schema changed
104 +- exercise sign in / sign out / sign up against the dev smbCloud environment
105 +
106 +## Common mistakes
107 +
108 +- Calling `SmbCloud::Auth` directly from a controller instead of via the service
109 +- Treating `AccountIncompleteError` as bad credentials instead of "verify email"
110 +- Leaking `SmbCloud::Auth::Error` (or its `error_code`) to the view layer
111 +- Using the Rails session cookie to authenticate desktop-app API requests
112 +- Assuming a gem version bump caused a native build failure that is really the
113 + Ruby 4.0 / magnus incompatibility
114 +- Reusing the server-side `app_secret` pattern in a public client (desktop/browser)
Gemfile
+1 -1
@@ -20,7 +20,7 @@ gem "tailwindcss-rails", "~> 3.3.1"
20 gem "jbuilder"
21
22 # smbCloud Auth — native Rust/Magnus extension for login, signup, me, logout
23 -gem "smbcloud-auth", "~> 0.3.34"
23 +gem "smbcloud-auth", "~> 0.3.35"
24
25 # Markdown rendering for README files
26 gem "redcarpet", "~> 3.6"
app/controllers/api/base_controller.rb new
+94
@@ -0,0 +1,94 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + # Base for all JSON API controllers.
5 + #
6 + # Unlike the HTML controllers, the API is token-based: clients (e.g. the
7 + # siGit Code & Deploy desktop app) send the smbCloud access token as
8 + # `Authorization: Bearer <token>` instead of relying on the Rails session
9 + # cookie. Response shapes mirror the desktop client's AccountStatus contract:
10 + #
11 + # ready → { status: "ready", access_token:, user: {...} }
12 + # not_found → { status: "not_found", error_code:, message: }
13 + # incomplete → { status: "incomplete", error_code:, message: }
14 + # error → { status: "error", error_code:, message: }
15 + class BaseController < ActionController::API
16 + # Order matters: rescue_from is matched in reverse declaration order, so the
17 + # base AuthenticationError is declared first and its subclasses after, which
18 + # makes the more specific handlers win.
19 + rescue_from SmbcloudAuthService::AuthenticationError, with: :render_auth_error
20 + rescue_from SmbcloudAuthService::AccountNotFoundError, with: :render_not_found
21 + rescue_from SmbcloudAuthService::AccountIncompleteError, with: :render_incomplete
22 + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
23 + rescue_from KeyError, with: :render_config_error
24 +
25 + private
26 +
27 + # Verifies the bearer token against smbCloud and upserts the local user.
28 + # On success sets @access_token and @current_user; otherwise halts with 401.
29 + def authenticate_token!
30 + token = bearer_token
31 + if token.blank?
32 + return render_error("Missing access token.", status: :unauthorized)
33 + end
34 +
35 + profile = SmbcloudAuthService.me(access_token: token)
36 + @access_token = token
37 + @current_user = User.find_or_create_from_smbcloud(profile, access_token: token)
38 + rescue SmbcloudAuthService::AuthenticationError => e
39 + render json: { status: "error", error_code: e.error_code,
40 + message: "Invalid or expired access token." },
41 + status: :unauthorized
42 + end
43 +
44 + def bearer_token
45 + request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]&.strip
46 + end
47 +
48 + def current_user
49 + @current_user
50 + end
51 +
52 + # Public profile shape returned to API clients.
53 + def user_json(user)
54 + {
55 + id: user.smbcloud_id,
56 + email: user.email,
57 + username: user.username,
58 + display_name: user.display_name_or_username,
59 + avatar_url: user.avatar_url_or_default
60 + }
61 + end
62 +
63 + def render_error(message, status:, error_code: nil)
64 + render json: { status: "error", error_code: error_code, message: message }, status: status
65 + end
66 +
67 + def render_auth_error(error)
68 + render json: { status: "error", error_code: error.error_code, message: error.message },
69 + status: :unauthorized
70 + end
71 +
72 + def render_not_found(error)
73 + render json: { status: "not_found", error_code: error.error_code, message: error.message },
74 + status: :not_found
75 + end
76 +
77 + def render_incomplete(error)
78 + render json: { status: "incomplete", error_code: error.error_code, message: error.message },
79 + status: :unprocessable_entity
80 + end
81 +
82 + def render_record_invalid(error)
83 + render json: { status: "error", error_code: nil, message: error.record.errors.full_messages.to_sentence },
84 + status: :unprocessable_entity
85 + end
86 +
87 + def render_config_error(error)
88 + Rails.logger.error("smbCloud configuration error: #{error.message}")
89 + render json: { status: "error", error_code: nil,
90 + message: "Authentication service is not configured." },
91 + status: :internal_server_error
92 + end
93 + end
94 +end
app/controllers/api/v1/me_controller.rb new
+25
@@ -0,0 +1,25 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # Current-user profile and account removal for API clients.
6 + class MeController < Api::BaseController
7 + before_action :authenticate_token!
8 +
9 + # GET /api/v1/me
10 + # Header: Authorization: Bearer <access_token>
11 + def show
12 + render json: { status: "ready", user: user_json(current_user) }, status: :ok
13 + end
14 +
15 + # DELETE /api/v1/me
16 + # Header: Authorization: Bearer <access_token>
17 + # Permanently removes the smbCloud account and the local mirror.
18 + def destroy
19 + SmbcloudAuthService.remove(access_token: @access_token)
20 + current_user.destroy
21 + render json: { status: "ok" }, status: :ok
22 + end
23 + end
24 + end
25 +end
app/controllers/api/v1/registrations_controller.rb new
+35
@@ -0,0 +1,35 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # Token-based sign up for API clients.
6 + class RegistrationsController < Api::BaseController
7 + # POST /api/v1/auth/sign_up
8 + # Params: email, password
9 + def create
10 + email = params[:email].to_s.strip.downcase
11 + password = params[:password].to_s
12 +
13 + if email.blank? || password.blank?
14 + return render_error("Email and password are required.", status: :unprocessable_entity)
15 + end
16 +
17 + if password.length < 8
18 + return render_error("Password must be at least 8 characters.", status: :unprocessable_entity)
19 + end
20 +
21 + SmbcloudAuthService.signup(email: email, password: password)
22 +
23 + # Try to log straight in. If the account needs email verification first,
24 + # login raises AccountIncompleteError → rendered as "incomplete" so the
25 + # client can route the user to the verify-email step.
26 + access_token = SmbcloudAuthService.login(email: email, password: password)
27 + profile = SmbcloudAuthService.me(access_token: access_token)
28 + user = User.find_or_create_from_smbcloud(profile, access_token: access_token)
29 +
30 + render json: { status: "ready", access_token: access_token, user: user_json(user) },
31 + status: :created
32 + end
33 + end
34 + end
35 +end
app/controllers/api/v1/sessions_controller.rb new
+37
@@ -0,0 +1,37 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # Token-based sign in / sign out for API clients.
6 + class SessionsController < Api::BaseController
7 + before_action :authenticate_token!, only: :destroy
8 +
9 + # POST /api/v1/auth/sign_in
10 + # Params: email, password
11 + def create
12 + email = params[:email].to_s.strip.downcase
13 + password = params[:password].to_s
14 +
15 + if email.blank? || password.blank?
16 + return render_error("Email and password are required.", status: :unprocessable_entity)
17 + end
18 +
19 + # Raises AccountNotFoundError / AccountIncompleteError / AuthenticationError,
20 + # each mapped to the matching JSON shape by Api::BaseController.
21 + access_token = SmbcloudAuthService.login(email: email, password: password)
22 + profile = SmbcloudAuthService.me(access_token: access_token)
23 + user = User.find_or_create_from_smbcloud(profile, access_token: access_token)
24 +
25 + render json: { status: "ready", access_token: access_token, user: user_json(user) },
26 + status: :ok
27 + end
28 +
29 + # DELETE /api/v1/auth/sign_out
30 + # Header: Authorization: Bearer <access_token>
31 + def destroy
32 + SmbcloudAuthService.logout(access_token: @access_token)
33 + render json: { status: "ok" }, status: :ok
34 + end
35 + end
36 + end
37 +end
app/views/pages/home.html.erb
-3
@@ -4,9 +4,6 @@
4 <section class="border-b border-surface-600 bg-surface-800">
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 sm:py-28">
6 <div class="max-w-3xl">
7 - <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">
8 - <span>Quiet Git hosting</span>
9 - </div>
7
8 <h1 class="max-w-2xl text-4xl font-semibold tracking-tight text-gray-100 sm:text-6xl">
9 Git hosting that stays out of the way.
config/routes.rb
+13
@@ -26,6 +26,19 @@ Rails.application.routes.draw do
26 get "/settings", to: "users#settings", as: :settings
27 patch "/settings", to: "users#update_settings"
28
29 + # JSON API — token-based auth for the siGit Code & Deploy desktop app.
30 + # Declared before the catch-all "/:username" route so "/api/..." isn't
31 + # swallowed by the username matcher.
32 + namespace :api do
33 + namespace :v1 do
34 + post "auth/sign_in", to: "sessions#create"
35 + delete "auth/sign_out", to: "sessions#destroy"
36 + post "auth/sign_up", to: "registrations#create"
37 + get "me", to: "me#show"
38 + delete "me", to: "me#destroy"
39 + end
40 + end
41 +
42 # User profile (must come before repository routes)
43 get "/:username", to: "users#show", as: :user_profile,
44 constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }