spine-framework-portal 0.2.17 → 0.2.18

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.
@@ -0,0 +1,342 @@
1
+ // Anonymous Session Functions
2
+ // Uses ONLY Spine APIs (ctx.db) - NO direct database access
3
+ // Handles stitch operation: anonymous session → identified account
4
+
5
+ import { createHandler } from './_shared/middleware'
6
+ import { calculateRecency, calculateRawScore } from './custom_funnel-scoring'
7
+
8
+ // Type IDs from migration
9
+ const TYPE_IDS = {
10
+ anonymous_session: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d',
11
+ funnel_signal: '0923f7a2-3ccd-4499-986f-28c6fd0597d9',
12
+ opportunity_queue: '2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e'
13
+ }
14
+
15
+ const LINK_TYPE_IDS = {
16
+ account_signals: '4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a',
17
+ account_opportunities: '5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b'
18
+ }
19
+
20
+ // ============================================
21
+ // STITCH: Anonymous Session → Identified Account
22
+ // ============================================
23
+
24
+ export const stitchAnonymousToAccount = createHandler(async (ctx, body) => {
25
+ const { anonymous_id, person_id, account_id } = body
26
+
27
+ if (!anonymous_id || !person_id || !account_id) {
28
+ return { status: 'error', error: 'Missing required fields: anonymous_id, person_id, account_id' }
29
+ }
30
+
31
+ const now = new Date().toISOString()
32
+
33
+ try {
34
+ // 1. Get anonymous session using ctx.db
35
+ const { data: session, error: sessionError } = await ctx.db
36
+ .from('items')
37
+ .select('id, data')
38
+ .eq('type_id', TYPE_IDS.anonymous_session)
39
+ .eq('data->identity->>anonymous_id', anonymous_id)
40
+ .eq('is_active', true)
41
+ .order('created_at', { ascending: false })
42
+ .limit(1)
43
+ .single()
44
+
45
+ if (sessionError || !session) {
46
+ return { status: 'error', error: 'Anonymous session not found' }
47
+ }
48
+
49
+ const sessionData = session.data || {}
50
+
51
+ // Check if already stitched
52
+ if (sessionData.lifecycle?.stitched_at) {
53
+ return { status: 'error', error: 'Session already stitched' }
54
+ }
55
+
56
+ // 2. Get account using ctx.db
57
+ const { data: account, error: accountError } = await ctx.db
58
+ .from('accounts')
59
+ .select('id, data')
60
+ .eq('id', account_id)
61
+ .single()
62
+
63
+ if (accountError || !account) {
64
+ return { status: 'error', error: 'Account not found' }
65
+ }
66
+
67
+ // 3. Update all signals with account_id and person_id using ctx.db
68
+ const { error: signalsError } = await ctx.db
69
+ .from('items')
70
+ .update({
71
+ account_id: account_id,
72
+ 'data->identity->>person_id': person_id,
73
+ 'data->processing->>stitched_at': now,
74
+ 'data->processing->>stitched_to_account_id': account_id,
75
+ updated_at: now
76
+ })
77
+ .eq('type_id', TYPE_IDS.funnel_signal)
78
+ .eq('data->identity->>anonymous_id', anonymous_id)
79
+ .is('account_id', null)
80
+
81
+ if (signalsError) {
82
+ console.error(`[Stitch] Failed to update signals: ${signalsError.message}`)
83
+ }
84
+
85
+ // 4. Get updated signals for recalculation
86
+ const { data: updatedSignals } = await ctx.db
87
+ .from('items')
88
+ .select('data')
89
+ .eq('type_id', TYPE_IDS.funnel_signal)
90
+ .eq('account_id', account_id)
91
+ .eq('data->classification->>stage', 'identified')
92
+ .eq('is_active', true)
93
+
94
+ // 5. Recalculate identified rating with newly-stitched signals
95
+ let identifiedRating = { rating: 0, raw_score: 0, calculated_at: now, best_signal_id: null as string | null }
96
+
97
+ if (updatedSignals && updatedSignals.length > 0) {
98
+ let bestSignal = updatedSignals[0]
99
+ let bestScore = bestSignal.data?.scoring_components?.raw_score?.calculated || 0
100
+
101
+ for (const signal of updatedSignals) {
102
+ const score = signal.data?.scoring_components?.raw_score?.calculated || 0
103
+ if (score > bestScore) {
104
+ bestScore = score
105
+ bestSignal = signal
106
+ }
107
+ }
108
+
109
+ // Recalculate with current recency
110
+ const signalDate = new Date(bestSignal.data?.processing?.scored_at || now)
111
+ const recency = calculateRecency(signalDate, new Date(), 'identified')
112
+
113
+ if (recency.divisor) {
114
+ const newScore = calculateRawScore(
115
+ bestSignal.data?.action?.action_value || 1,
116
+ bestSignal.data?.scoring_components?.engagement?.type || 1,
117
+ recency.divisor
118
+ )
119
+
120
+ identifiedRating = {
121
+ rating: newScore.rating,
122
+ raw_score: newScore.calculated,
123
+ calculated_at: now,
124
+ best_signal_id: bestSignal.data?.id || null
125
+ }
126
+ }
127
+ }
128
+
129
+ // 6. Update account with stitched data using ctx.db
130
+ const currentFunnel = account.data?.funnel || {}
131
+ const anonymousRating = sessionData.scoring?.ratings?.anonymous
132
+
133
+ const updatedFunnel = {
134
+ ...currentFunnel,
135
+ current_stage: 'identified',
136
+ ratings: {
137
+ ...currentFunnel.ratings,
138
+ anonymous: anonymousRating ? {
139
+ ...anonymousRating,
140
+ stitched_at: now,
141
+ archived: true
142
+ } : currentFunnel.ratings?.anonymous,
143
+ identified: identifiedRating
144
+ },
145
+ attribution: {
146
+ ...currentFunnel.attribution,
147
+ anonymous_first_touch: sessionData.attribution?.first_touch
148
+ },
149
+ stage_history: [
150
+ ...(currentFunnel.stage_history || []),
151
+ { from: 'anonymous', to: 'identified', at: now }
152
+ ]
153
+ }
154
+
155
+ await ctx.db
156
+ .from('accounts')
157
+ .update({
158
+ data: { ...account.data, funnel: updatedFunnel }
159
+ })
160
+ .eq('id', account_id)
161
+
162
+ // 7. Mark session as stitched using ctx.db
163
+ const updatedSessionData = {
164
+ ...sessionData,
165
+ lifecycle: {
166
+ ...sessionData.lifecycle,
167
+ stitched_at: now,
168
+ stitched_to_account_id: account_id,
169
+ stitched_to_person_id: person_id
170
+ }
171
+ }
172
+
173
+ await ctx.db
174
+ .from('items')
175
+ .update({
176
+ data: updatedSessionData,
177
+ updated_at: now
178
+ })
179
+ .eq('id', session.id)
180
+
181
+ // 8. Check for immediate queue entry (strong anonymous activity)
182
+ let queueEntry = null
183
+ if (anonymousRating?.rating >= 4) {
184
+ const inference = { type: 'implementation', confidence: 'high' }
185
+
186
+ const queueData = {
187
+ identity: {
188
+ account_id: account_id,
189
+ person_id: person_id
190
+ },
191
+ trigger: {
192
+ source_signal_id: anonymousRating.best_signal_id,
193
+ trigger_stage: 'anonymous',
194
+ trigger_rating: anonymousRating.rating,
195
+ trigger_raw_score: anonymousRating.raw_score,
196
+ trigger_reason: 'High engagement during anonymous phase'
197
+ },
198
+ recommendation: {
199
+ opportunity_type: inference.type,
200
+ confidence: inference.confidence,
201
+ suggested_priority: anonymousRating.rating
202
+ },
203
+ review: {
204
+ status: 'pending',
205
+ reviewed_by: null,
206
+ reviewed_at: null,
207
+ conversion_opportunity_id: null
208
+ },
209
+ notes: {
210
+ reviewer_notes: null,
211
+ auto_reason: 'Stitched from anonymous session with high engagement'
212
+ }
213
+ }
214
+
215
+ const { data: queueItem } = await ctx.db
216
+ .from('items')
217
+ .insert({
218
+ type_id: TYPE_IDS.opportunity_queue,
219
+ title: `${inference.type} - Stitched Session`,
220
+ account_id: account_id,
221
+ data: queueData
222
+ })
223
+ .select('id')
224
+ .single()
225
+
226
+ if (queueItem) {
227
+ queueEntry = { id: queueItem.id }
228
+
229
+ // Create link to account
230
+ await ctx.db
231
+ .from('links')
232
+ .insert({
233
+ link_type_id: LINK_TYPE_IDS.account_opportunities,
234
+ source_type: 'account',
235
+ source_id: account_id,
236
+ target_type: 'item',
237
+ target_id: queueItem.id
238
+ })
239
+
240
+ // Update account queue reference
241
+ await ctx.db
242
+ .from('accounts')
243
+ .update({
244
+ data: {
245
+ ...account.data,
246
+ funnel: {
247
+ ...updatedFunnel,
248
+ queue: { pending_queue_entry_id: queueItem.id }
249
+ }
250
+ }
251
+ })
252
+ .eq('id', account_id)
253
+ }
254
+ }
255
+
256
+ // 9. Create links between account and all stitched signals
257
+ const { data: stitchedSignals } = await ctx.db
258
+ .from('items')
259
+ .select('id')
260
+ .eq('type_id', TYPE_IDS.funnel_signal)
261
+ .eq('account_id', account_id)
262
+ .eq('data->processing->>stitched_at', now)
263
+
264
+ for (const signal of stitchedSignals || []) {
265
+ await ctx.db
266
+ .from('links')
267
+ .insert({
268
+ link_type_id: LINK_TYPE_IDS.account_signals,
269
+ source_type: 'account',
270
+ source_id: account_id,
271
+ target_type: 'item',
272
+ target_id: signal.id,
273
+ data: { created_at: now, stitched: true }
274
+ })
275
+ }
276
+
277
+ return {
278
+ status: 'success',
279
+ session_id: session.id,
280
+ account_id,
281
+ person_id,
282
+ stitched_signals: stitchedSignals?.length || 0,
283
+ queue_entry: queueEntry,
284
+ anonymous_rating: anonymousRating?.rating || 0,
285
+ identified_rating: identifiedRating.rating
286
+ }
287
+
288
+ } catch (err) {
289
+ console.error('[Stitch] Error:', err)
290
+ return { status: 'error', error: err instanceof Error ? err.message : 'Unknown error' }
291
+ }
292
+ })
293
+
294
+ // ============================================
295
+ // GET ANONYMOUS SESSION DETAILS
296
+ // ============================================
297
+
298
+ export const getAnonymousSession = createHandler(async (ctx, body) => {
299
+ const { anonymous_id } = body
300
+
301
+ if (!anonymous_id) {
302
+ return { status: 'error', error: 'Missing anonymous_id' }
303
+ }
304
+
305
+ // Get session using ctx.db
306
+ const { data: session, error } = await ctx.db
307
+ .from('items')
308
+ .select('id, data, created_at, updated_at')
309
+ .eq('type_id', TYPE_IDS.anonymous_session)
310
+ .eq('data->identity->>anonymous_id', anonymous_id)
311
+ .eq('is_active', true)
312
+ .order('created_at', { ascending: false })
313
+ .limit(1)
314
+ .single()
315
+
316
+ if (error || !session) {
317
+ return { status: 'error', error: 'Session not found' }
318
+ }
319
+
320
+ // Get associated signals using ctx.db
321
+ const { data: signals } = await ctx.db
322
+ .from('items')
323
+ .select('id, data, created_at')
324
+ .eq('type_id', TYPE_IDS.funnel_signal)
325
+ .eq('data->identity->>anonymous_id', anonymous_id)
326
+ .eq('is_active', true)
327
+ .order('created_at', { ascending: false })
328
+
329
+ return {
330
+ status: 'success',
331
+ session: {
332
+ id: session.id,
333
+ anonymous_id,
334
+ attribution: session.data?.attribution,
335
+ scoring: session.data?.scoring,
336
+ lifecycle: session.data?.lifecycle,
337
+ created_at: session.created_at,
338
+ updated_at: session.updated_at
339
+ },
340
+ signals: signals || []
341
+ }
342
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Claim Instance Handler
3
+ * Validates install serial and generates JWT response for CLI claim flow
4
+ *
5
+ * Endpoint: POST /.netlify/functions/custom_claim-instance
6
+ * Body: { serial: string }
7
+ * Response: { response_code: string (JWT), expires_at: string }
8
+ */
9
+
10
+ import { createHandler, CoreContext } from './_shared/middleware'
11
+ import { adminDb } from './_shared/db'
12
+ import { resolveTypeId, resolveLinkTypeId } from './_shared/resolve-ids'
13
+ import { update } from './admin-data'
14
+
15
+ // @ts-ignore - jsonwebtoken types not available
16
+ import jwt from 'jsonwebtoken'
17
+
18
+ // Helper: call admin-data.update as a nested import (entity+id go in ctx.query)
19
+ function adminDataUpdate(ctx: CoreContext, entity: string, id: string, fields: Record<string, any>) {
20
+ return update({ ...ctx, query: { ...((ctx as any).query || {}), entity, id } } as any, fields)
21
+ }
22
+
23
+ // Rate limiting (in-memory, per function instance)
24
+ const rateLimits = new Map<string, { count: number; resetAt: number }>()
25
+
26
+ function checkRateLimit(ip: string): boolean {
27
+ const now = Date.now()
28
+ const windowMs = 60 * 1000 // 1 minute
29
+ const maxRequests = 5
30
+
31
+ const current = rateLimits.get(ip)
32
+
33
+ if (!current || now > current.resetAt) {
34
+ rateLimits.set(ip, { count: 1, resetAt: now + windowMs })
35
+ return true
36
+ }
37
+
38
+ if (current.count >= maxRequests) return false
39
+ current.count++
40
+ return true
41
+ }
42
+
43
+ const signClaimToken = (payload: any): string => {
44
+ const secret = process.env.PORTAL_CLAIM_SECRET
45
+ if (!secret) throw new Error('PORTAL_CLAIM_SECRET not configured')
46
+ return jwt.sign(payload, secret, { algorithm: 'HS256', expiresIn: '10m' })
47
+ }
48
+
49
+ export const handler = createHandler(async (ctx, body) => {
50
+ const { serial } = body || {}
51
+ const personId = ctx.principal?.person_id
52
+ const accountId = ctx.principal?.account_id
53
+ const clientIp = (ctx as any).requestContext?.identity?.sourceIp || 'unknown'
54
+
55
+ if (!serial) {
56
+ const err: any = new Error('serial is required'); err.statusCode = 400; throw err
57
+ }
58
+
59
+ if (!personId || !accountId) {
60
+ const err: any = new Error('Authentication required'); err.statusCode = 401; throw err
61
+ }
62
+
63
+ if (!checkRateLimit(clientIp)) {
64
+ const err: any = new Error('Rate limit exceeded. Please try again in 1 minute.'); err.statusCode = 429; throw err
65
+ }
66
+
67
+ if (!/^sf_inst_[a-z0-9-]+$/.test(serial)) {
68
+ const err: any = new Error('Invalid serial format'); err.statusCode = 400; throw err
69
+ }
70
+
71
+ // Resolve type and link IDs dynamically
72
+ const [spine_instance_type_id, funnel_signal_type_id, account_cli_instances_link_type_id] = await Promise.all([
73
+ resolveTypeId('item', 'spine_instance'),
74
+ resolveTypeId('item', 'funnel_signal'),
75
+ resolveLinkTypeId('account_cli_instances')
76
+ ])
77
+
78
+ const now = new Date().toISOString()
79
+
80
+ // Lookup spine_instance by serial
81
+ const { data: instance, error: instanceError } = await adminDb
82
+ .from('items')
83
+ .select('id, account_id, data')
84
+ .eq('type_id', spine_instance_type_id)
85
+ .eq('data->>serial', serial)
86
+ .single()
87
+
88
+ if (instanceError || !instance) {
89
+ const err: any = new Error('Serial not found. Please install the package first.')
90
+ err.statusCode = 404; throw err
91
+ }
92
+
93
+ if (instance.account_id && instance.account_id !== accountId) {
94
+ const err: any = new Error('This installation is already claimed to another account')
95
+ err.statusCode = 409; throw err
96
+ }
97
+
98
+ // Update instance with claim info
99
+ const result = await adminDataUpdate(ctx, 'items', instance.id, {
100
+ account_id: accountId,
101
+ data: {
102
+ ...instance.data,
103
+ claimed_by_person_id: personId,
104
+ claimed_at: now,
105
+ claimed_to_account_id: accountId
106
+ }
107
+ })
108
+
109
+ if (!result) {
110
+ const err: any = new Error('Failed to claim installation'); err.statusCode = 500; throw err
111
+ }
112
+
113
+ // Create link: account → instance (links has no design_schema — direct is fine)
114
+ const { error: linkError } = await adminDb
115
+ .from('links')
116
+ .upsert({
117
+ link_type_id: account_cli_instances_link_type_id,
118
+ source_type: 'account',
119
+ source_id: accountId,
120
+ target_type: 'item',
121
+ target_id: instance.id,
122
+ data: { created_at: now, claimed_by: personId }
123
+ }, { onConflict: 'link_type_id,source_id,target_id' })
124
+
125
+ if (linkError) {
126
+ console.error('[ClaimInstance] Failed to create link:', linkError)
127
+ }
128
+
129
+ // Stitch existing signals to account (queued, one per record through admin-data)
130
+ const { data: signals } = await adminDb
131
+ .from('items')
132
+ .select('id, data')
133
+ .eq('type_id', funnel_signal_type_id)
134
+ .eq('data->>serial', serial)
135
+ .is('account_id', null)
136
+
137
+ if (signals && signals.length > 0) {
138
+ for (const signal of signals) {
139
+ await adminDataUpdate(
140
+ { ...ctx, accountId },
141
+ 'items',
142
+ signal.id,
143
+ {
144
+ account_id: accountId,
145
+ data: { ...signal.data, stitched_at: now }
146
+ }
147
+ )
148
+ }
149
+ }
150
+
151
+ // Update account data with instance count
152
+ const { data: account } = await adminDb
153
+ .from('accounts')
154
+ .select('data')
155
+ .eq('id', accountId)
156
+ .single()
157
+
158
+ if (account) {
159
+ await adminDataUpdate(ctx, 'accounts', accountId, {
160
+ data: {
161
+ ...account.data,
162
+ cli_instance_count: (account.data?.cli_instance_count || 0) + 1,
163
+ last_cli_claim_at: now
164
+ }
165
+ })
166
+ }
167
+
168
+ // Generate JWT response
169
+ const expiresAt = Math.floor(Date.now() / 1000) + (10 * 60)
170
+
171
+ let responseCode: string
172
+ try {
173
+ responseCode = signClaimToken({
174
+ account_id: accountId,
175
+ person_id: personId,
176
+ serial_suffix: serial.slice(-4),
177
+ instance_id: instance.id,
178
+ iat: Math.floor(Date.now() / 1000),
179
+ exp: expiresAt
180
+ })
181
+ } catch (e) {
182
+ console.error('[ClaimInstance] JWT signing failed:', e)
183
+ const err: any = new Error('Failed to generate claim code'); err.statusCode = 500; throw err
184
+ }
185
+
186
+ return {
187
+ status: 'success',
188
+ response_code: responseCode,
189
+ expires_at: new Date(expiresAt * 1000).toISOString(),
190
+ instance: {
191
+ serial: truncateSerial(serial),
192
+ app_slug: instance.data?.app_slug,
193
+ version: instance.data?.version
194
+ }
195
+ }
196
+ })
197
+
198
+ function truncateSerial(serial: string): string {
199
+ if (!serial || serial.length < 20) return serial
200
+ return `${serial.slice(0, 8)}***${serial.slice(-6)}`
201
+ }