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