spine-framework 0.3.55 → 0.3.56

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.
@@ -255,6 +255,25 @@ async function fastReassemble(projectRoot: string): Promise<void> {
255
255
  copyChangedFiles(customFuncsDir, funcsDest)
256
256
  }
257
257
  }
258
+
259
+ // Reassemble spine.config.json to public directory
260
+ const spineConfigSrc = resolve(projectRoot, 'spine.config.json')
261
+ const publicDest = resolve(srcDest, 'public')
262
+ if (existsSync(spineConfigSrc)) {
263
+ if (!existsSync(publicDest)) {
264
+ mkdirSync(publicDest, { recursive: true })
265
+ }
266
+ const spineConfigDest = resolve(publicDest, 'spine.config.json')
267
+ const srcStat = statSync(spineConfigSrc)
268
+ let needsCopy = true
269
+ if (existsSync(spineConfigDest)) {
270
+ const destStat = statSync(spineConfigDest)
271
+ needsCopy = srcStat.mtimeMs > destStat.mtimeMs
272
+ }
273
+ if (needsCopy) {
274
+ copyFileSync(spineConfigSrc, spineConfigDest)
275
+ }
276
+ }
258
277
  }
259
278
 
260
279
  /**
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { Command } from 'commander'
21
- import { existsSync, readFileSync, mkdirSync, cpSync } from 'fs'
21
+ import { existsSync, readFileSync, mkdirSync, cpSync, writeFileSync } from 'fs'
22
22
  import { resolve, dirname, join, basename } from 'path'
23
23
  import { fileURLToPath } from 'url'
24
24
  import { execSync } from 'child_process'
@@ -81,6 +81,14 @@ export function registerInstallAppCommands(program: Command) {
81
81
  await seedApp(appDest, appSlug)
82
82
  }
83
83
 
84
+ // 6. Aggregate registration config from manifest into spine.config.json
85
+ const manifestPath = join(appDest, 'manifest.json')
86
+ let manifest: Record<string, any> = {}
87
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {}
88
+ if (manifest.registration?.enabled) {
89
+ updateRegistrationConfig(appSlug, manifest.registration)
90
+ }
91
+
84
92
  console.log(`\n✅ ${appSlug} installed successfully!\n`)
85
93
  console.log('Next: npm run assemble && netlify dev\n')
86
94
 
@@ -153,6 +161,14 @@ export function registerInstallAppCommands(program: Command) {
153
161
  }
154
162
  }
155
163
 
164
+ // Update registration config from manifest
165
+ const manifestPath = join(appDest, 'manifest.json')
166
+ let manifest: Record<string, any> = {}
167
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {}
168
+ if (manifest.registration?.enabled) {
169
+ updateRegistrationConfig(appSlug, manifest.registration)
170
+ }
171
+
156
172
  console.log(`\n✅ ${appSlug} updated.\n`)
157
173
  } catch (err: any) {
158
174
  console.error(`\n❌ Update failed: ${err.message}`)
@@ -278,3 +294,75 @@ async function seedApp(appDir: string, appSlug: string) {
278
294
  }
279
295
  }
280
296
  }
297
+
298
+ // ─── SPINE CONFIG HELPERS ───────────────────────────────────────────────────
299
+
300
+ interface RegistrationConfig {
301
+ enabled: boolean
302
+ default_role: string
303
+ redirect_path: string
304
+ }
305
+
306
+ interface SpineConfig {
307
+ registration?: {
308
+ default_app?: string
309
+ default_role?: string
310
+ allow_url_override?: boolean
311
+ apps?: Record<string, {
312
+ role: string
313
+ path: string
314
+ enabled: boolean
315
+ }>
316
+ }
317
+ }
318
+
319
+ const SPINE_CONFIG_PATH = join(PROJECT_ROOT, 'spine.config.json')
320
+
321
+ function loadSpineConfig(): SpineConfig {
322
+ if (!existsSync(SPINE_CONFIG_PATH)) {
323
+ return { registration: { apps: {} } }
324
+ }
325
+ try {
326
+ return JSON.parse(readFileSync(SPINE_CONFIG_PATH, 'utf8'))
327
+ } catch {
328
+ return { registration: { apps: {} } }
329
+ }
330
+ }
331
+
332
+ function saveSpineConfig(config: SpineConfig): void {
333
+ writeFileSync(SPINE_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
334
+ }
335
+
336
+ function updateRegistrationConfig(appSlug: string, regConfig: RegistrationConfig): void {
337
+ const config = loadSpineConfig()
338
+
339
+ // Ensure registration section exists
340
+ if (!config.registration) {
341
+ config.registration = { apps: {} }
342
+ }
343
+ if (!config.registration.apps) {
344
+ config.registration.apps = {}
345
+ }
346
+
347
+ // Add/update this app's registration entry
348
+ config.registration.apps[appSlug] = {
349
+ role: regConfig.default_role,
350
+ path: regConfig.redirect_path,
351
+ enabled: regConfig.enabled,
352
+ }
353
+
354
+ // If this is the first registration-enabled app, make it the default
355
+ const enabledApps = Object.entries(config.registration.apps).filter(([, cfg]) => cfg.enabled)
356
+ if (enabledApps.length === 1 || !config.registration.default_app) {
357
+ config.registration.default_app = appSlug
358
+ config.registration.default_role = regConfig.default_role
359
+ }
360
+
361
+ // Default allow_url_override to true if not set
362
+ if (config.registration.allow_url_override === undefined) {
363
+ config.registration.allow_url_override = true
364
+ }
365
+
366
+ saveSpineConfig(config)
367
+ console.log(` ✓ Updated spine.config.json registration for ${appSlug}`)
368
+ }
@@ -13,6 +13,7 @@
13
13
  * **Public actions (no auth required):**
14
14
  * | action | method | description |
15
15
  * |-------------------|--------|-------------|
16
+ * | check-first-user | GET | Boolean: is this the first user registration? |
16
17
  * | check-domain | GET | Boolean: does this email domain have existing accounts? |
17
18
  * | validate-token | GET | Validate invite token, return preview |
18
19
  * | complete-registration | POST | Finalise walk-up registration after Supabase auth |
@@ -93,6 +94,22 @@ function isAdminRole(roleSlug: string | undefined): boolean {
93
94
  )
94
95
  }
95
96
 
97
+ // ─── PUBLIC: check-first-user ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Returns { is_first_user: boolean } — true if there are zero people in the system.
101
+ * Used by RegisterPage to show simplified first-user flow.
102
+ */
103
+ export const checkFirstUser = createHandler(async (_ctx, _body) => {
104
+ const { count, error } = await adminDb
105
+ .from('people')
106
+ .select('*', { count: 'exact', head: true })
107
+
108
+ if (error) throw new Error('Failed to check first user: ' + error.message)
109
+
110
+ return { is_first_user: (count ?? 0) === 0 }
111
+ })
112
+
96
113
  // ─── PUBLIC: check-domain ─────────────────────────────────────────────────────
97
114
 
98
115
  /**
@@ -621,6 +638,7 @@ export const handler = createHandler(async (ctx, body) => {
621
638
 
622
639
  switch (action) {
623
640
  // Public
641
+ case 'check-first-user': return await checkFirstUser(ctx, body)
624
642
  case 'check-domain': return await checkDomain(ctx, body)
625
643
  case 'validate-token': return await validateToken(ctx, body)
626
644
  case 'complete-registration': return await completeRegistration(ctx, body)
@@ -0,0 +1,27 @@
1
+ -- Migration 016: Invite system indexes
2
+ -- Invite and access_request stubs are stored as people rows with status
3
+ -- 'invited' or 'pending' and invite metadata in the data jsonb column.
4
+ --
5
+ -- people.status values:
6
+ -- active — normal active person
7
+ -- inactive — deactivated
8
+ -- invited — admin-generated invite stub, unclaimed (pre-approval by definition)
9
+ -- pending — walk-up access request stub, awaiting member_admin approval
10
+ --
11
+ -- Invite metadata stored in people.data:
12
+ -- invite_token text — 16-char random lookup key (URL: /register?t=TOKEN)
13
+ -- invite_kind text — 'invite' | 'access_request'
14
+ -- invite_expires_at text — ISO timestamp
15
+ -- invite_attempts integer — failed validation attempts
16
+ -- invite_blocked_at text — ISO timestamp, set after too many attempts
17
+ -- invite_invited_by uuid — person UUID of the inviter
18
+
19
+ -- Fast token lookup
20
+ CREATE INDEX IF NOT EXISTS people_invite_token_idx
21
+ ON public.people ((data->>'invite_token'))
22
+ WHERE data->>'invite_token' IS NOT NULL;
23
+
24
+ -- Efficient scoped invite list queries
25
+ CREATE INDEX IF NOT EXISTS people_invite_kind_account_idx
26
+ ON public.people (account_id, status)
27
+ WHERE data->>'invite_kind' IS NOT NULL;
@@ -4,21 +4,28 @@
4
4
  * @layer frontend-page
5
5
  * @stability stable
6
6
  *
7
- * Registration page. Handles four paths:
7
+ * Registration page. Handles multiple paths:
8
8
  *
9
- * 1. Invite link (?t=TOKEN) — email + account pre-filled and locked.
9
+ * 1. First user (no people in system) — simplified form, becomes system_admin.
10
+ * No personal/company toggle shown.
11
+ *
12
+ * 2. Invite link (?t=TOKEN) — email + account pre-filled and locked.
10
13
  * On submit: Supabase signUp → auth trigger resolves stub → active.
11
14
  *
12
- * 2. Walk-up personal — new account named after the person.
15
+ * 3. Walk-up personal — new account named after the person.
13
16
  * On submit: Supabase signUp → complete-registration(new_account).
14
17
  *
15
- * 3. Walk-up company, no domain match — new account with company name.
18
+ * 4. Walk-up company, no domain match — new account with company name.
16
19
  * On submit: Supabase signUp → complete-registration(new_account).
17
20
  *
18
- * 4. Walk-up company, domain match — user chooses join or create new.
21
+ * 5. Walk-up company, domain match — user chooses join or create new.
19
22
  * Join: Supabase signUp → complete-registration(join_request) → pending.
20
23
  * Create new: same as path 3.
21
24
  *
25
+ * URL params:
26
+ * ?app=portal|cortex — override default registration app
27
+ * ?role=member|support — override default role
28
+ *
22
29
  * @seeAlso functions/invites.ts (check-domain, validate-token, complete-registration)
23
30
  * @seeAlso migrations/005_auth_user_trigger.sql (invite stub resolution)
24
31
  * @seeAlso src/pages/auth/PendingApprovalPage.tsx
@@ -41,6 +48,17 @@ interface InvitePreview {
41
48
  kind: string
42
49
  }
43
50
 
51
+ interface RegistrationConfig {
52
+ default_app: string
53
+ default_role: string
54
+ allow_url_override: boolean
55
+ apps: Record<string, {
56
+ role: string
57
+ path: string
58
+ enabled: boolean
59
+ }>
60
+ }
61
+
44
62
  // ─── HELPERS ──────────────────────────────────────────────────────────────────
45
63
 
46
64
  async function callInvitesApi(action: string, params: Record<string, any> = {}, method: 'GET' | 'POST' = 'GET') {
@@ -58,6 +76,54 @@ async function callInvitesApi(action: string, params: Record<string, any> = {},
58
76
  return res.json()
59
77
  }
60
78
 
79
+ async function loadRegistrationConfig(): Promise<RegistrationConfig | null> {
80
+ try {
81
+ const res = await fetch('/spine.config.json')
82
+ if (!res.ok) return null
83
+ const config = await res.json()
84
+ return config.registration ?? null
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ async function checkFirstUser(): Promise<boolean> {
91
+ try {
92
+ const result = await callInvitesApi('check-first-user')
93
+ return result.data?.is_first_user ?? false
94
+ } catch {
95
+ return false
96
+ }
97
+ }
98
+
99
+ function getPostRegistrationRedirect(
100
+ config: RegistrationConfig | null,
101
+ searchParams: URLSearchParams,
102
+ isInvite: boolean,
103
+ inviteAccountId?: string
104
+ ): string {
105
+ // Invites: redirect to the account's primary app or default
106
+ if (isInvite) {
107
+ return '/'
108
+ }
109
+
110
+ // Check for URL override if allowed
111
+ if (config?.allow_url_override) {
112
+ const appOverride = searchParams.get('app')
113
+ if (appOverride && config.apps[appOverride]?.enabled) {
114
+ return config.apps[appOverride].path
115
+ }
116
+ }
117
+
118
+ // Default app from config
119
+ if (config?.default_app && config.apps[config.default_app]?.enabled) {
120
+ return config.apps[config.default_app].path
121
+ }
122
+
123
+ // Fallback
124
+ return '/'
125
+ }
126
+
61
127
  // ─── COMPONENT ────────────────────────────────────────────────────────────────
62
128
 
63
129
  export function RegisterPage() {
@@ -65,6 +131,10 @@ export function RegisterPage() {
65
131
  const navigate = useNavigate()
66
132
  const inviteToken = searchParams.get('t')
67
133
 
134
+ // Registration config
135
+ const [regConfig, setRegConfig] = useState<RegistrationConfig | null>(null)
136
+ const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
137
+
68
138
  // Invite preview (when token present)
69
139
  const [invitePreview, setInvitePreview] = useState<InvitePreview | null>(null)
70
140
  const [tokenError, setTokenError] = useState('')
@@ -85,7 +155,19 @@ export function RegisterPage() {
85
155
  const [error, setError] = useState('')
86
156
  const [isLoading, setIsLoading] = useState(false)
87
157
 
88
- // ── Resolve invite token on mount ──────────────────────────────────────────
158
+ // ── Load config and check first user on mount ──────────────────────────────
159
+ useEffect(() => {
160
+ ;(async () => {
161
+ const [config, firstUserCheck] = await Promise.all([
162
+ loadRegistrationConfig(),
163
+ checkFirstUser(),
164
+ ])
165
+ setRegConfig(config)
166
+ setIsFirstUser(firstUserCheck)
167
+ })()
168
+ }, [])
169
+
170
+ // ── Resolve invite token ───────────────────────────────────────────────────
89
171
  useEffect(() => {
90
172
  if (!inviteToken) return
91
173
  ;(async () => {
@@ -103,7 +185,7 @@ export function RegisterPage() {
103
185
 
104
186
  // ── Domain check on email blur ─────────────────────────────────────────────
105
187
  const handleEmailBlur = useCallback(async () => {
106
- if (!email || !email.includes('@') || inviteToken || useType === 'personal') return
188
+ if (!email || !email.includes('@') || inviteToken || useType === 'personal' || isFirstUser) return
107
189
 
108
190
  setDomainCheckLoading(true)
109
191
  setDomainMatchAccountId(null)
@@ -113,10 +195,9 @@ export function RegisterPage() {
113
195
  setDomainCheckLoading(false)
114
196
 
115
197
  if (result.data?.match) {
116
- // We signal a match but don't expose the account id here (fetched server-side on join)
117
198
  setDomainMatchAccountId('__matched__')
118
199
  }
119
- }, [email, inviteToken, useType])
200
+ }, [email, inviteToken, useType, isFirstUser])
120
201
 
121
202
  // ── Submit ─────────────────────────────────────────────────────────────────
122
203
  const handleSubmit = async (e: React.FormEvent) => {
@@ -126,7 +207,7 @@ export function RegisterPage() {
126
207
 
127
208
  try {
128
209
  // 1. Supabase auth signup
129
- const { error: signUpError } = await supabase.auth.signUp({
210
+ const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
130
211
  email: invitePreview?.email ?? email,
131
212
  password,
132
213
  options: { data: { full_name: fullName } },
@@ -137,13 +218,20 @@ export function RegisterPage() {
137
218
  return
138
219
  }
139
220
 
140
- // 2. Invite path — trigger handles everything, navigate to login
221
+ // 2. First user path — trigger handles everything, navigate to root
222
+ if (isFirstUser) {
223
+ // First user becomes system_admin immediately, go to admin
224
+ navigate('/spine-framework/admin', { replace: true })
225
+ return
226
+ }
227
+
228
+ // 3. Invite path — trigger handles everything, navigate to account
141
229
  if (inviteToken) {
142
- navigate('/login?registered=1')
230
+ navigate('/', { replace: true })
143
231
  return
144
232
  }
145
233
 
146
- // 3. Walk-up paths — call complete-registration
234
+ // 4. Walk-up paths — call complete-registration
147
235
  const resolvedEmail = email
148
236
 
149
237
  if (domainMatchChoice === 'join') {
@@ -157,7 +245,7 @@ export function RegisterPage() {
157
245
  setError(joinResult.error)
158
246
  return
159
247
  }
160
- navigate('/pending-approval')
248
+ navigate('/pending-approval', { replace: true })
161
249
  return
162
250
  }
163
251
 
@@ -174,7 +262,9 @@ export function RegisterPage() {
174
262
  return
175
263
  }
176
264
 
177
- navigate('/login?registered=1')
265
+ // Navigate to configured post-registration destination
266
+ const redirectPath = getPostRegistrationRedirect(regConfig, searchParams, false)
267
+ navigate(redirectPath, { replace: true })
178
268
  } catch (err: any) {
179
269
  setError(err.message || 'Registration failed')
180
270
  } finally {
@@ -182,8 +272,8 @@ export function RegisterPage() {
182
272
  }
183
273
  }
184
274
 
185
- // ── Token loading / error states ───────────────────────────────────────────
186
- if (tokenLoading) {
275
+ // ── Loading states ───────────────────────────────────────────────────────────
276
+ if (tokenLoading || isFirstUser === null) {
187
277
  return (
188
278
  <div className="min-h-screen flex items-center justify-center bg-slate-50">
189
279
  <LoadingSpinner className="w-8 h-8" />
@@ -208,8 +298,31 @@ export function RegisterPage() {
208
298
  }
209
299
 
210
300
  const isInvite = !!inviteToken && !!invitePreview
211
- const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company'
301
+ const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company' && !isFirstUser
212
302
  const effectiveEmail = isInvite ? invitePreview.email : email
303
+ const showUseTypeToggle = !isInvite && !isFirstUser
304
+
305
+ // Header text based on scenario
306
+ let headerTitle = 'Create your account'
307
+ let headerSubtitle: React.ReactNode = null
308
+
309
+ if (isFirstUser) {
310
+ headerTitle = 'Create system administrator account'
311
+ headerSubtitle = (
312
+ <p className="mt-2 text-center text-sm text-slate-500">
313
+ You're the first user. This account will have full system access.
314
+ </p>
315
+ )
316
+ } else if (isInvite) {
317
+ headerTitle = `Join ${invitePreview.account_name ?? 'the team'}`
318
+ if (invitePreview.role_name) {
319
+ headerSubtitle = (
320
+ <p className="mt-2 text-center text-sm text-slate-500">
321
+ You've been invited as <span className="font-medium">{invitePreview.role_name}</span>
322
+ </p>
323
+ )
324
+ }
325
+ }
213
326
 
214
327
  return (
215
328
  <div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
@@ -221,19 +334,17 @@ export function RegisterPage() {
221
334
  <span className="text-white font-bold text-xl">S</span>
222
335
  </div>
223
336
  <h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
224
- {isInvite ? `Join ${invitePreview.account_name ?? 'the team'}` : 'Create your account'}
337
+ {headerTitle}
225
338
  </h2>
226
- {isInvite && invitePreview.role_name && (
227
- <p className="mt-2 text-center text-sm text-slate-500">
228
- You've been invited as <span className="font-medium">{invitePreview.role_name}</span>
339
+ {headerSubtitle}
340
+ {!isFirstUser && (
341
+ <p className="mt-2 text-center text-sm text-slate-600">
342
+ Already have an account?{' '}
343
+ <Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">
344
+ Sign in
345
+ </Link>
229
346
  </p>
230
347
  )}
231
- <p className="mt-2 text-center text-sm text-slate-600">
232
- Already have an account?{' '}
233
- <Link to="/login" className="font-medium text-blue-600 hover:text-blue-500">
234
- Sign in
235
- </Link>
236
- </p>
237
348
  </div>
238
349
 
239
350
  <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
@@ -244,8 +355,8 @@ export function RegisterPage() {
244
355
  )}
245
356
 
246
357
  <div className="space-y-4">
247
- {/* Personal / Company toggle — only for walk-up */}
248
- {!isInvite && (
358
+ {/* Personal / Company toggle — only for walk-up, not first user */}
359
+ {showUseTypeToggle && (
249
360
  <div>
250
361
  <label className="block text-sm font-medium text-slate-700 mb-2">
251
362
  Account type
@@ -343,7 +454,7 @@ export function RegisterPage() {
343
454
  )}
344
455
 
345
456
  {/* Company name — shown for company type when not joining existing */}
346
- {!isInvite && useType === 'company' && domainMatchChoice !== 'join' && (
457
+ {!isInvite && useType === 'company' && domainMatchChoice !== 'join' && !isFirstUser && (
347
458
  <div>
348
459
  <label htmlFor="companyName" className="block text-sm font-medium text-slate-700">
349
460
  Company name
@@ -402,18 +513,19 @@ export function RegisterPage() {
402
513
 
403
514
  <button
404
515
  type="submit"
405
- disabled={isLoading || (showDomainMatchPrompt ? false : false)}
516
+ disabled={isLoading}
406
517
  className="group relative w-full flex justify-center py-2 px-4 border border-transparent rounded-[5px] shadow-sm text-sm font-medium text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
407
518
  >
408
519
  {isLoading ? (
409
- <>
410
- <LoadingSpinner size="sm" />
411
- <span className="ml-2">Creating account...</span>
412
- </>
520
+ <LoadingSpinner className="w-5 h-5" />
521
+ ) : isFirstUser ? (
522
+ 'Create System Admin Account'
523
+ ) : isInvite ? (
524
+ 'Accept Invite'
413
525
  ) : domainMatchChoice === 'join' ? (
414
- 'Request access'
526
+ 'Request to Join'
415
527
  ) : (
416
- 'Create account'
528
+ 'Create Account'
417
529
  )}
418
530
  </button>
419
531
  </form>
@@ -10,6 +10,10 @@ export default defineConfig({
10
10
 
11
11
  // Root directory for the assembled app
12
12
  root: path.resolve(__dirname, '../.assembled/src'),
13
+
14
+ // Public directory for static files copied to dist
15
+ // Only spine.config.json is exposed publicly from project root
16
+ publicDir: path.resolve(__dirname, '../public'),
13
17
 
14
18
  // Build configuration
15
19
  build: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "scripts/",
12
12
  "config/",
13
13
  "netlify.toml",
14
+ "spine.config.json",
14
15
  ".framework/cli/bin.js",
15
16
  ".framework/cli/",
16
17
  ".framework/docs/",
@@ -21,6 +22,7 @@
21
22
  ".framework/migrations/004_enhancements.sql",
22
23
  ".framework/migrations/005_auth_user_trigger.sql",
23
24
  ".framework/migrations/006_fix_current_actor_id.sql",
25
+ ".framework/migrations/016_invites.sql",
24
26
  ".framework/public/",
25
27
  ".framework/scripts/",
26
28
  ".framework/seeds/",
@@ -61,6 +61,13 @@ if [ -f "$PROJECT_ROOT/.framework/public/_redirects" ]; then
61
61
  echo " ✓ Core: _redirects copied to Vite-served directory for Netlify dev"
62
62
  fi
63
63
 
64
+ # 3.6. Copy spine.config.json to public directory for runtime access
65
+ if [ -f "$PROJECT_ROOT/spine.config.json" ]; then
66
+ mkdir -p "$TARGET_SRC_DIR/public"
67
+ cp "$PROJECT_ROOT/spine.config.json" "$TARGET_SRC_DIR/public/spine.config.json"
68
+ echo " ✓ Config: spine.config.json copied to public/"
69
+ fi
70
+
64
71
  # 6. Overlay custom src (overrides + additions)
65
72
  if [ -d "$CUSTOM_SRC_DIR" ]; then
66
73
  # Copy files and subdirectories
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "./.framework/schemas/spine.config.schema.json",
3
+ "registration": {
4
+ "default_app": "portal",
5
+ "default_role": "member",
6
+ "allow_url_override": true,
7
+ "apps": {
8
+ "portal": {
9
+ "role": "member",
10
+ "path": "/portal",
11
+ "enabled": true
12
+ },
13
+ "cortex": {
14
+ "role": "support",
15
+ "path": "/cortex",
16
+ "enabled": true
17
+ }
18
+ }
19
+ }
20
+ }