@cat-factory/app 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,6 +32,9 @@ const configuredProviders = computed<PatProvider[]>(
32
32
  )
33
33
  const isLocalMode = computed(() => auth.localMode?.enabled === true)
34
34
  const hasConfiguredPat = computed(() => configuredProviders.value.length > 0)
35
+ // Mothership mode: identity + org data live on a hosted mothership, so the primary sign-in is a
36
+ // round-trip to the mothership's OAuth (the node then exchanges the session for a machine token).
37
+ const isMothership = computed(() => auth.localMode?.mothership === true)
35
38
 
36
39
  const patBusy = ref(false)
37
40
  const patError = ref<string | null>(null)
@@ -186,6 +189,29 @@ const noSignInMethod = computed(
186
189
  </p>
187
190
  </div>
188
191
 
192
+ <!-- Mothership mode: sign in through the hosted mothership (it owns identity + the
193
+ allowlist). The node exchanges the returned session for a machine token. -->
194
+ <div v-if="isMothership && mode !== 'forgot'" class="mb-4 space-y-2">
195
+ <UButton
196
+ block
197
+ size="lg"
198
+ color="primary"
199
+ icon="i-lucide-cloud"
200
+ data-testid="mothership-signin"
201
+ @click="auth.signInViaMothership()"
202
+ >
203
+ {{ t('auth.mothership.signIn') }}
204
+ </UButton>
205
+ <p class="px-1 text-xs text-slate-400">{{ t('auth.mothership.hint') }}</p>
206
+ <p
207
+ v-if="auth.mothershipError"
208
+ class="px-1 text-xs text-rose-400"
209
+ data-testid="mothership-error"
210
+ >
211
+ {{ t('auth.mothership.error') }}
212
+ </p>
213
+ </div>
214
+
189
215
  <!-- Local mode: sign in with the env-configured source-control PAT. The token lives
190
216
  server-side (GITHUB_PAT / GITLAB_PAT); we only pick a provider here. -->
191
217
  <div v-if="isLocalMode && mode !== 'forgot'" class="space-y-3">
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  acceptInvitationContract,
3
3
  authConfigContract,
4
+ connectMothershipContract,
4
5
  forgotPasswordContract,
5
6
  logoutContract,
6
7
  meContract,
@@ -51,6 +52,11 @@ export function authApi({ http, send, ws }: ApiContext) {
51
52
 
52
53
  logout: () => send(logoutContract, { pathPrefix: '/auth' }),
53
54
 
55
+ // Mothership mode (local facade): hand the local node a mothership SESSION token (captured
56
+ // from the mothership OAuth redirect fragment). The node exchanges it for a cached machine
57
+ // token and returns a LOCAL session for the same user. Mounted at the app root (no prefix).
58
+ connectMothership: (session: string) => send(connectMothershipContract, { body: { session } }),
59
+
54
60
  // Mint a short-lived, workspace-scoped ticket for the events WebSocket. A
55
61
  // browser can't set Authorization on a WS handshake, so the socket auths from
56
62
  // this `?ticket=` instead of the long-lived session token. Empty string when
@@ -60,6 +60,12 @@ export const useAuthStore = defineStore(
60
60
  const autoLoginProvider = ref<'github' | 'gitlab' | null>(null)
61
61
  /** True once the initial auth handshake has settled. */
62
62
  const ready = ref(false)
63
+ /**
64
+ * Mothership mode: the last mothership sign-in failure (node unreachable / rejected session),
65
+ * or null. Set when the post-OAuth connect exchange fails, so the login screen can tell the
66
+ * user the click didn't take instead of silently returning them to the sign-in button.
67
+ */
68
+ const mothershipError = ref<string | null>(null)
63
69
  /**
64
70
  * True only once `getAuthConfig()` has resolved successfully. Distinguishes "the backend
65
71
  * told us auth is off" from "we never reached the backend" (the bootstrap catch path),
@@ -109,9 +115,59 @@ export const useAuthStore = defineStore(
109
115
  history.replaceState(null, '', window.location.pathname + window.location.search)
110
116
  }
111
117
 
118
+ /**
119
+ * Mothership mode: when the mothership OAuth redirect returns here (flagged
120
+ * `?mothership_connect=1`), the URL fragment carries a MOTHERSHIP session — not a local one.
121
+ * Hand it to our OWN node, which exchanges it for a cached machine token and returns a LOCAL
122
+ * session for the same user. Returns true when it handled the redirect (so the caller skips
123
+ * the normal `consumeRedirectToken`, which would wrongly store the mothership session locally).
124
+ */
125
+ async function maybeConnectMothership(): Promise<boolean> {
126
+ if (typeof window === 'undefined') return false
127
+ const params = new URLSearchParams(window.location.search)
128
+ if (params.get('mothership_connect') !== '1') return false
129
+ const match = /(?:^#|[#&])token=([^&]+)/.exec(window.location.hash)
130
+ const session = match ? decodeURIComponent(match[1]!) : null
131
+ // Clean the flag + fragment from the URL regardless of outcome, so it isn't left in history.
132
+ params.delete('mothership_connect')
133
+ const qs = params.toString()
134
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : ''))
135
+ if (!session) return true
136
+ try {
137
+ const result = await api.connectMothership(session)
138
+ applySession({ token: result.session, user: result.user })
139
+ mothershipError.value = null
140
+ } catch (err) {
141
+ // Surface the failure so the login screen shows it, rather than silently dropping the
142
+ // user back on the sign-in button as if the click did nothing. The captured session is
143
+ // already stripped from the URL, so recovery is a fresh "Sign in via mothership".
144
+ mothershipError.value =
145
+ err instanceof Error ? err.message : 'Could not connect to the mothership'
146
+ }
147
+ return true
148
+ }
149
+
150
+ /**
151
+ * Mothership mode: sign in through the hosted mothership. The mothership owns identity + the
152
+ * allowlist, so we send the browser to ITS OAuth and return here flagged for the connect
153
+ * exchange (`maybeConnectMothership`). No-op if the mothership URL isn't known.
154
+ */
155
+ function signInViaMothership() {
156
+ if (typeof window === 'undefined') return
157
+ const base = localMode.value?.mothershipUrl
158
+ if (!base) return
159
+ mothershipError.value = null
160
+ const here = new URL(window.location.origin + window.location.pathname)
161
+ here.searchParams.set('mothership_connect', '1')
162
+ const redirect = new URLSearchParams({ redirect: here.toString() })
163
+ window.location.href = `${base.replace(/\/$/, '')}/auth/login?${redirect}`
164
+ }
165
+
112
166
  /** Resolve auth state: capture any redirect token, then check the backend. */
113
167
  async function bootstrap() {
114
- consumeRedirectToken()
168
+ // A returning mothership-connect redirect is handled first (it carries a mothership session,
169
+ // which must be exchanged — not stored as a local token by `consumeRedirectToken`).
170
+ if (!(await maybeConnectMothership())) consumeRedirectToken()
115
171
  try {
116
172
  const config = await api.getAuthConfig()
117
173
  required.value = config.enabled
@@ -277,6 +333,7 @@ export const useAuthStore = defineStore(
277
333
  infrastructure,
278
334
  autoLoginProvider,
279
335
  ready,
336
+ mothershipError,
280
337
  configLoaded,
281
338
  isLocalFacade,
282
339
  isAuthenticated,
@@ -284,6 +341,7 @@ export const useAuthStore = defineStore(
284
341
  bootstrap,
285
342
  login,
286
343
  loginWithGoogle,
344
+ signInViaMothership,
287
345
  signup,
288
346
  passwordLogin,
289
347
  patLogin,
@@ -872,6 +872,11 @@
872
872
  "userMenu": {
873
873
  "mySetup": "My setup",
874
874
  "signOut": "Sign out"
875
+ },
876
+ "mothership": {
877
+ "signIn": "Sign in via mothership",
878
+ "hint": "Your projects and identity live on the hosted mothership. Sign in there to connect this node.",
879
+ "error": "Could not sign in via the mothership. Try again."
875
880
  }
876
881
  },
877
882
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "Mi configuración",
834
834
  "signOut": "Cerrar sesión"
835
+ },
836
+ "mothership": {
837
+ "signIn": "Iniciar sesión con la central",
838
+ "hint": "Tus proyectos e identidad están en la central alojada: inicia sesión allí para conectar este nodo.",
839
+ "error": "No se pudo iniciar sesión con la central. Inténtalo de nuevo."
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "Ma configuration",
834
834
  "signOut": "Se déconnecter"
835
+ },
836
+ "mothership": {
837
+ "signIn": "Se connecter via le serveur central",
838
+ "hint": "Vos projets et votre identité sont sur le serveur central hébergé. Connectez-vous là-bas pour relier ce nœud.",
839
+ "error": "Impossible de se connecter via le serveur central. Réessayez."
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "ההגדרות שלי",
834
834
  "signOut": "התנתק"
835
+ },
836
+ "mothership": {
837
+ "signIn": "התחברות דרך שרת המרכז",
838
+ "hint": "הפרויקטים והזהות שלך נמצאים בשרת המרכז המתארח. התחבר שם כדי לחבר את הצומת הזה.",
839
+ "error": "לא ניתן להתחבר דרך שרת המרכז. נסה שוב."
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "マイセットアップ",
834
834
  "signOut": "サインアウト"
835
+ },
836
+ "mothership": {
837
+ "signIn": "マザーシップ経由でサインイン",
838
+ "hint": "プロジェクトと認証情報はホスト型のマザーシップにあります。そこでサインインしてこのノードを接続してください。",
839
+ "error": "マザーシップ経由でサインインできませんでした。もう一度お試しください。"
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "Moja konfiguracja",
834
834
  "signOut": "Wyloguj się"
835
+ },
836
+ "mothership": {
837
+ "signIn": "Zaloguj się przez serwer centralny",
838
+ "hint": "Twoje projekty i tożsamość znajdują się na hostowanym serwerze centralnym. Zaloguj się tam, aby połączyć ten węzeł.",
839
+ "error": "Nie udało się zalogować przez serwer centralny. Spróbuj ponownie."
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "Kurulumum",
834
834
  "signOut": "Oturumu kapat"
835
+ },
836
+ "mothership": {
837
+ "signIn": "Ana sunucu üzerinden oturum aç",
838
+ "hint": "Projeleriniz ve kimliğiniz barındırılan ana sunucuda tutulur. Bu düğümü bağlamak için orada oturum açın.",
839
+ "error": "Ana sunucu üzerinden oturum açılamadı. Tekrar deneyin."
835
840
  }
836
841
  },
837
842
  "layout": {
@@ -832,6 +832,11 @@
832
832
  "userMenu": {
833
833
  "mySetup": "Моє налаштування",
834
834
  "signOut": "Вийти"
835
+ },
836
+ "mothership": {
837
+ "signIn": "Увійти через центральний сервер",
838
+ "hint": "Ваші проєкти та обліковий запис зберігаються на центральному сервері. Увійдіть там, щоб приєднати цей вузол.",
839
+ "error": "Не вдалося увійти через центральний сервер. Спробуйте ще раз."
835
840
  }
836
841
  },
837
842
  "layout": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "^3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.71.0"
37
+ "@cat-factory/contracts": "0.72.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",