spine-framework 0.3.56 → 0.3.58

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.
@@ -76,7 +76,7 @@ export type { RequestContext, HandlerFunction, HandlerResult } from './middlewar
76
76
  * createHandler — wraps a handler function with auth, principal resolution, and audit.
77
77
  * Use this when writing Netlify function handlers.
78
78
  */
79
- export { createHandler, requireUserContext, requireSystemContextWithAudit, json, error as errorResponse, cors } from './middleware'
79
+ export { createHandler, createPublicHandler, requireUserContext, requireSystemContextWithAudit, json, error as errorResponse, cors } from './middleware'
80
80
 
81
81
  // ============================================
82
82
  // Identity — Principal model
@@ -274,6 +274,96 @@ export function createHandler(handler: HandlerFunction) {
274
274
  }
275
275
  }
276
276
 
277
+ // ─── PUBLIC HANDLER FACTORY ────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Wraps a handler function without requiring authentication.
281
+ * Used for public endpoints like check-first-user, check-domain, validate-token.
282
+ *
283
+ * Similar to createHandler but allows anonymous principals.
284
+ */
285
+ export function createPublicHandler(handler: HandlerFunction) {
286
+ return async (event: any, context: any) => {
287
+ // Detect nested call: if event is already a RequestContext with a principal,
288
+ // skip event parsing and call the raw handler directly
289
+ if (event && event.requestId && event.principal) {
290
+ return handler(event, context)
291
+ }
292
+
293
+ const requestId = crypto.randomUUID()
294
+ const startTime = Date.now()
295
+
296
+ try {
297
+ // Parse headers
298
+ const appId = event.headers?.['x-app-id'] || event.headers?.['X-App-Id']
299
+
300
+ // Parse query string parameters
301
+ const queryParams: Record<string, string> = {}
302
+ if (event.queryStringParameters) {
303
+ Object.assign(queryParams, event.queryStringParameters)
304
+ }
305
+ if (!queryParams.method && event.httpMethod) {
306
+ queryParams.method = event.httpMethod
307
+ }
308
+
309
+ // Resolve principal - may be anonymous for public endpoints
310
+ const principal = await resolvePrincipal(event)
311
+
312
+ // Get RLS-scoped database client based on principal type
313
+ // For anonymous users, this returns adminDb (service role)
314
+ const ctxDb = getPrincipalDb(principal)
315
+
316
+ // Build request context
317
+ const ctx: RequestContext = {
318
+ requestId,
319
+ principal,
320
+ db: ctxDb,
321
+ accountId: principal?.accountId || null,
322
+ appId: appId || null,
323
+ query: queryParams,
324
+ requestPath: event.path || '/',
325
+ }
326
+
327
+ // Parse body
328
+ let body = null
329
+ if (event.body) {
330
+ try {
331
+ body = JSON.parse(event.body)
332
+ } catch (e) {
333
+ return error('Invalid JSON in request body', 400)
334
+ }
335
+ }
336
+ if (ctx.query.id) {
337
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
338
+ body = {}
339
+ }
340
+ if (!('id' in body)) {
341
+ body.id = ctx.query.id
342
+ }
343
+ }
344
+
345
+ // Execute handler
346
+ const result = await handler(ctx, body)
347
+ const durationMs = Date.now() - startTime
348
+
349
+ // Return success response
350
+ return json({
351
+ data: result,
352
+ error: null,
353
+ meta: {
354
+ requestId,
355
+ duration: durationMs
356
+ }
357
+ })
358
+
359
+ } catch (err: any) {
360
+ console.error(`[${requestId}] Public handler error:`, err)
361
+ const status = typeof err.statusCode === 'number' ? err.statusCode : 500
362
+ return error(err.message || 'Internal server error', status)
363
+ }
364
+ }
365
+ }
366
+
277
367
  // ─── RESPONSE HELPERS ────────────────────────────────────────────────────────
278
368
 
279
369
  /**
@@ -52,7 +52,7 @@
52
52
  * @seeAlso src/components/shared/InviteManager.tsx (UI)
53
53
  */
54
54
 
55
- import { createHandler } from './_shared/middleware'
55
+ import { createHandler, createPublicHandler } from './_shared/middleware'
56
56
  import { adminDb } from './_shared/db'
57
57
  import { emitLog } from './_shared/audit'
58
58
 
@@ -213,7 +213,7 @@ export const validateToken = createHandler(async (_ctx, _body) => {
213
213
  * (invite path is handled entirely in the DB trigger)
214
214
  */
215
215
  export const completeRegistration = createHandler(async (ctx, body) => {
216
- const { path, account_name, email } = body
216
+ const { path, account_name, email, role_slug } = body
217
217
 
218
218
  if (!path || !email) {
219
219
  throw new Error('path and email are required')
@@ -238,14 +238,45 @@ export const completeRegistration = createHandler(async (ctx, body) => {
238
238
  .eq('slug', 'account')
239
239
  .single()
240
240
 
241
- // Resolve member_admin role (fall back to member if not seeded yet)
242
- const { data: role } = await adminDb
241
+ // Resolve role from role_slug param, or fall back to member_admin/member lookup
242
+ const targetRoleSlug = role_slug || 'member'
243
+ let { data: roles } = await adminDb
243
244
  .from('roles')
244
245
  .select('id')
245
- .in('slug', ['member_admin', 'member'])
246
- .order('slug') // member_admin sorts before member
246
+ .eq('slug', targetRoleSlug)
247
247
  .limit(1)
248
- .single()
248
+
249
+ let role = roles?.[0]
250
+
251
+ // If specific role not found, try member_admin or member as fallback
252
+ if (!role) {
253
+ const { data: fallbackRoles } = await adminDb
254
+ .from('roles')
255
+ .select('id')
256
+ .in('slug', ['member_admin', 'member'])
257
+ .order('slug')
258
+ .limit(1)
259
+ role = fallbackRoles?.[0]
260
+ }
261
+
262
+ // If no roles exist yet (apps not installed), create a default member role
263
+ if (!role) {
264
+ const { data: newRole, error: roleErr } = await adminDb
265
+ .from('roles')
266
+ .insert({
267
+ slug: 'member',
268
+ name: 'Member',
269
+ description: 'Default member role with basic permissions',
270
+ permissions: { items: ['read', 'create'], accounts: ['read'] },
271
+ is_active: true,
272
+ })
273
+ .select('id')
274
+ .single()
275
+
276
+ if (!roleErr && newRole) {
277
+ role = newRole
278
+ }
279
+ }
249
280
 
250
281
  // Create account
251
282
  const slug = generateAccountSlug(account_name)
@@ -281,15 +312,28 @@ export const completeRegistration = createHandler(async (ctx, body) => {
281
312
  }
282
313
 
283
314
  if (path === 'join_request') {
284
- const { target_account_id } = body
315
+ const { target_account_id, role_slug } = body
285
316
  if (!target_account_id) throw new Error('target_account_id is required for join_request')
286
317
 
287
- // Resolve member role
288
- const { data: role } = await adminDb
318
+ // Resolve role from role_slug param, or fall back to member
319
+ const targetRoleSlug = role_slug || 'member'
320
+ let { data: roles } = await adminDb
289
321
  .from('roles')
290
322
  .select('id')
291
- .eq('slug', 'member')
292
- .single()
323
+ .eq('slug', targetRoleSlug)
324
+ .limit(1)
325
+
326
+ let role = roles?.[0]
327
+
328
+ // Fallback to member if specific role not found
329
+ if (!role) {
330
+ const { data: fallbackRoles } = await adminDb
331
+ .from('roles')
332
+ .select('id')
333
+ .eq('slug', 'member')
334
+ .limit(1)
335
+ role = fallbackRoles?.[0]
336
+ }
293
337
 
294
338
  // Resolve type_id
295
339
  const { data: personType } = await adminDb
@@ -633,16 +677,16 @@ export const deny = createHandler(async (ctx, body) => {
633
677
 
634
678
  // ─── MAIN HANDLER ────────────────────────────────────────────────────────────
635
679
 
636
- export const handler = createHandler(async (ctx, body) => {
680
+ export const handler = createPublicHandler(async (ctx, body) => {
637
681
  const action = ctx.query?.action
638
682
 
639
683
  switch (action) {
640
- // Public
684
+ // Public endpoints (no auth required)
641
685
  case 'check-first-user': return await checkFirstUser(ctx, body)
642
686
  case 'check-domain': return await checkDomain(ctx, body)
643
687
  case 'validate-token': return await validateToken(ctx, body)
644
688
  case 'complete-registration': return await completeRegistration(ctx, body)
645
- // Protected
689
+ // Protected endpoints (require auth)
646
690
  case 'create-invite': return await createInvite(ctx, body)
647
691
  case 'list-invites': return await listInvites(ctx, body)
648
692
  case 'revoke-invite': return await revokeInvite(ctx, body)
@@ -234,11 +234,17 @@ export function RegisterPage() {
234
234
  // 4. Walk-up paths — call complete-registration
235
235
  const resolvedEmail = email
236
236
 
237
+ // Determine role from URL params or config
238
+ const urlRole = searchParams.get('role')
239
+ const urlApp = searchParams.get('app')
240
+ const effectiveRole = urlRole || (urlApp && regConfig?.apps[urlApp]?.role) || regConfig?.default_role || 'member'
241
+
237
242
  if (domainMatchChoice === 'join') {
238
243
  // Join existing account as access_request
239
244
  const joinResult = await callInvitesApi('complete-registration', {
240
245
  path: 'join_request',
241
246
  email: resolvedEmail,
247
+ role_slug: effectiveRole,
242
248
  }, 'POST')
243
249
 
244
250
  if (joinResult.error) {
@@ -255,6 +261,7 @@ export function RegisterPage() {
255
261
  path: 'new_account',
256
262
  email: resolvedEmail,
257
263
  account_name: accountName,
264
+ role_slug: effectiveRole,
258
265
  }, 'POST')
259
266
 
260
267
  if (newResult.error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.56",
3
+ "version": "0.3.58",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {