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
|
|
242
|
-
const
|
|
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
|
-
.
|
|
246
|
-
.order('slug') // member_admin sorts before member
|
|
246
|
+
.eq('slug', targetRoleSlug)
|
|
247
247
|
.limit(1)
|
|
248
|
-
|
|
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
|
|
288
|
-
const
|
|
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',
|
|
292
|
-
.
|
|
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 =
|
|
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) {
|