playcademy 0.9.0 → 0.11.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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * HTTP server constants for CLI OAuth callback handling
3
+ */
4
+ /** Port for the local callback server */
5
+ declare const CALLBACK_PORT = 6175;
6
+ /** Path for the OAuth callback endpoint */
7
+ declare const CALLBACK_PATH = "/callback";
8
+ /** Timeout for SSO authentication in milliseconds (30 seconds) */
9
+ declare const SSO_AUTH_TIMEOUT_MS = 30000;
10
+
11
+ /**
12
+ * Default port numbers for local development servers
13
+ */
14
+ declare const DEFAULT_PORTS: {
15
+ /** Sandbox server (mock platform API) */
16
+ readonly SANDBOX: 4321;
17
+ /** Backend dev server (game backend with HMR) */
18
+ readonly BACKEND: 8788;
19
+ };
20
+
21
+ /**
22
+ * Config file names to search for
23
+ */
24
+ declare const CONFIG_FILE_NAMES: string[];
25
+
26
+ /**
27
+ * Base URLs for Playcademy platform environments
28
+ */
29
+ declare const PLAYCADEMY_BASE_URLS: {
30
+ readonly staging: "https://hub.dev.playcademy.net";
31
+ readonly production: "https://hub.playcademy.com";
32
+ };
33
+ /**
34
+ * Game backend domain for deployed Workers
35
+ * Staging games use {slug}-staging.playcademy.gg
36
+ * Production games use {slug}.playcademy.gg
37
+ */
38
+ declare const GAME_BACKEND_DOMAIN: "playcademy.gg";
39
+
40
+ export { CALLBACK_PATH, CALLBACK_PORT, CONFIG_FILE_NAMES, DEFAULT_PORTS, GAME_BACKEND_DOMAIN, PLAYCADEMY_BASE_URLS, SSO_AUTH_TIMEOUT_MS };
@@ -0,0 +1,35 @@
1
+ // src/constants/http-server.ts
2
+ var CALLBACK_PORT = 6175;
3
+ var CALLBACK_PATH = "/callback";
4
+ var SSO_AUTH_TIMEOUT_MS = 3e4;
5
+
6
+ // src/constants/ports.ts
7
+ var DEFAULT_PORTS = {
8
+ /** Sandbox server (mock platform API) */
9
+ SANDBOX: 4321,
10
+ /** Backend dev server (game backend with HMR) */
11
+ BACKEND: 8788
12
+ };
13
+
14
+ // src/constants/timeback.ts
15
+ var CONFIG_FILE_NAMES = [
16
+ "playcademy.config.js",
17
+ "playcademy.config.json",
18
+ "playcademy.config.mjs"
19
+ ];
20
+
21
+ // src/constants/urls.ts
22
+ var PLAYCADEMY_BASE_URLS = {
23
+ staging: "https://hub.dev.playcademy.net",
24
+ production: "https://hub.playcademy.com"
25
+ };
26
+ var GAME_BACKEND_DOMAIN = "playcademy.gg";
27
+ export {
28
+ CALLBACK_PATH,
29
+ CALLBACK_PORT,
30
+ CONFIG_FILE_NAMES,
31
+ DEFAULT_PORTS,
32
+ GAME_BACKEND_DOMAIN,
33
+ PLAYCADEMY_BASE_URLS,
34
+ SSO_AUTH_TIMEOUT_MS
35
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Constants for game backend workers
3
+ */
4
+
5
+ /**
6
+ * Environment variable names required for game backend workers
7
+ */
8
+ export const ENV_VARS = {
9
+ /** Game-specific API key for calling platform API */
10
+ PLAYCADEMY_API_KEY: 'PLAYCADEMY_API_KEY',
11
+ /** Game ID (UUID) */
12
+ GAME_ID: 'GAME_ID',
13
+ /** Platform API base URL */
14
+ PLAYCADEMY_BASE_URL: 'PLAYCADEMY_BASE_URL',
15
+ } as const
16
+
17
+ /**
18
+ * Built-in API routes
19
+ */
20
+ export const ROUTES = {
21
+ /** Health check endpoint */
22
+ HEALTH: '/api/health',
23
+ /** Route index (lists available routes) */
24
+ INDEX: '/api',
25
+
26
+ /** TimeBack integration routes */
27
+ TIMEBACK: {
28
+ PROGRESS: '/api/integrations/timeback/progress',
29
+ SESSION_END: '/api/integrations/timeback/session-end',
30
+ AWARD_XP: '/api/integrations/timeback/award-xp',
31
+ },
32
+ } as const
33
+
34
+ /**
35
+ * Worker naming patterns
36
+ */
37
+ export const WORKER_NAMING = {
38
+ /** Staging workers are prefixed to avoid namespace collisions */
39
+ STAGING_PREFIX: 'staging-',
40
+ /** Staging subdomain suffix */
41
+ STAGING_SUFFIX: '-staging',
42
+ } as const
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Game Backend Entry Point
3
+ *
4
+ * This file is the main entry point for deployed game backends.
5
+ * It creates a Hono app and registers all enabled integration routes.
6
+ *
7
+ * Bundled with esbuild and deployed to Cloudflare Workers (or AWS Lambda).
8
+ * Config is injected at build time via esbuild's `define` option.
9
+ */
10
+
11
+ import { Hono } from 'hono'
12
+ import { cors } from 'hono/cors'
13
+
14
+ import { PlaycademyClient } from '@playcademy/sdk/server'
15
+
16
+ import { ENV_VARS } from './constants'
17
+ import { registerBuiltinRoutes } from './register-routes'
18
+
19
+ import type { PlaycademyConfig } from '@playcademy/sdk/server'
20
+ import type { HonoEnv } from './types'
21
+
22
+ /**
23
+ * Config injected at build time by esbuild
24
+ *
25
+ * The `declare const` tells TypeScript "this exists at runtime, trust me."
26
+ * During bundling, esbuild's `define` option does literal text replacement:
27
+ *
28
+ * Example bundling:
29
+ * Source: if (PLAYCADEMY_CONFIG.integrations.timeback) { ... }
30
+ * Define: { 'PLAYCADEMY_CONFIG': JSON.stringify({ integrations: { timeback: {...} } }) }
31
+ * Output: if ({"integrations":{"timeback":{...}}}.integrations.timeback) { ... }
32
+ *
33
+ * This enables tree-shaking: if timeback is not configured, those code paths are removed.
34
+ * The bundled Worker only includes the routes that are actually enabled.
35
+ */
36
+ declare const PLAYCADEMY_CONFIG: PlaycademyConfig & {
37
+ customRoutes?: Array<{ path: string; file: string }>
38
+ }
39
+
40
+ // XXX: Polyfill process global for SDK compatibility
41
+ // SDK code may reference process.env without importing it
42
+ // @ts-expect-error - Adding global for Worker environment
43
+ globalThis.process = {
44
+ env: {}, // Populated per-request from Worker env bindings
45
+ cwd: () => '/',
46
+ }
47
+
48
+ const app = new Hono<HonoEnv>()
49
+
50
+ // TODO: Harden CORS in production - restrict to trusted origins:
51
+ // - Game's assetBundleBase (for hosted games)
52
+ // - Game's externalUrl (for external games)
53
+ // - Platform frontend domains (hub.playcademy.com, hub.dev.playcademy.net)
54
+ // This would require passing game metadata through env bindings during deployment
55
+ app.use(
56
+ '*',
57
+ cors({
58
+ origin: '*', // Permissive for now
59
+ allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
60
+ allowHeaders: ['Content-Type', 'Authorization'],
61
+ }),
62
+ )
63
+
64
+ let sdkPromise: Promise<PlaycademyClient> | null = null
65
+
66
+ app.use('*', async (c, next) => {
67
+ // Populate process.env from Worker bindings for SDK compatibility
68
+ globalThis.process.env = {
69
+ [ENV_VARS.PLAYCADEMY_API_KEY]: c.env.PLAYCADEMY_API_KEY,
70
+ [ENV_VARS.GAME_ID]: c.env.GAME_ID,
71
+ [ENV_VARS.PLAYCADEMY_BASE_URL]: c.env.PLAYCADEMY_BASE_URL,
72
+ }
73
+
74
+ // Set config for all routes
75
+ c.set('config', PLAYCADEMY_CONFIG)
76
+ c.set('customRoutes', PLAYCADEMY_CONFIG.customRoutes || [])
77
+
78
+ await next()
79
+ })
80
+
81
+ // Initialize SDK lazily on first request
82
+ app.use('*', async (c, next) => {
83
+ if (!sdkPromise) {
84
+ sdkPromise = PlaycademyClient.init({
85
+ apiKey: c.env[ENV_VARS.PLAYCADEMY_API_KEY],
86
+ gameId: c.env[ENV_VARS.GAME_ID],
87
+ baseUrl: c.env[ENV_VARS.PLAYCADEMY_BASE_URL],
88
+ config: PLAYCADEMY_CONFIG,
89
+ })
90
+ }
91
+
92
+ c.set('sdk', await sdkPromise)
93
+ await next()
94
+ })
95
+
96
+ /**
97
+ * Register built-in integration routes based on enabled integrations
98
+ *
99
+ * This function conditionally imports and registers routes like:
100
+ * - POST /api/integrations/timeback/progress (if timeback enabled)
101
+ * - GET /api/health (always included)
102
+ *
103
+ * Uses dynamic imports for tree-shaking: if an integration is not enabled,
104
+ * its route code is completely removed from the bundle.
105
+ */
106
+ registerBuiltinRoutes(app, PLAYCADEMY_CONFIG.integrations)
107
+
108
+ export default app
@@ -0,0 +1 @@
1
+ export { registerBuiltinRoutes } from './register-routes'
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Polyfills for Node.js modules in edge runtimes
3
+ *
4
+ * Provides minimal implementations to allow bundling.
5
+ * These should NOT be called at runtime (config is provided directly).
6
+ */
7
+
8
+ const notAvailable = name => {
9
+ throw new Error(`${name} not available in Worker runtime - config should be provided directly`)
10
+ }
11
+
12
+ // fs exports
13
+ export const existsSync = () => notAvailable('fs.existsSync')
14
+ export const readdirSync = () => notAvailable('fs.readdirSync')
15
+ export const statSync = () => notAvailable('fs.statSync')
16
+ export const readFile = () => notAvailable('fs/promises.readFile')
17
+
18
+ // path exports
19
+ export const resolve = (...args) => args.join('/')
20
+ export const dirname = p => p.split('/').slice(0, -1).join('/')
21
+ export const parse = p => ({
22
+ dir: dirname(p),
23
+ name: p.split('/').pop(),
24
+ base: p.split('/').pop(),
25
+ ext: '',
26
+ root: '/',
27
+ })
28
+ export const join = (...args) => args.join('/')
29
+
30
+ // os exports
31
+ export const tmpdir = () => '/tmp'
32
+ export const homedir = () => notAvailable('os.homedir')
33
+
34
+ // Default export for 'process' module
35
+ export default {
36
+ env: {
37
+ // These will be set by the Worker environment
38
+ // Cloudflare Workers uses env bindings, but we access via process.env
39
+ PLAYCADEMY_API_KEY: globalThis.PLAYCADEMY_API_KEY || '',
40
+ GAME_ID: globalThis.GAME_ID || '',
41
+ PLAYCADEMY_BASE_URL: globalThis.PLAYCADEMY_BASE_URL || '',
42
+ },
43
+ cwd: () => '/',
44
+ }
45
+
46
+ // Named export as well for compatibility
47
+ export const process = {
48
+ env: {
49
+ PLAYCADEMY_API_KEY: globalThis.PLAYCADEMY_API_KEY || '',
50
+ GAME_ID: globalThis.GAME_ID || '',
51
+ PLAYCADEMY_BASE_URL: globalThis.PLAYCADEMY_BASE_URL || '',
52
+ },
53
+ cwd: () => '/',
54
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Route Registration Helper
3
+ *
4
+ * Conditionally registers built-in integration routes based on enabled integrations.
5
+ * Uses dynamic imports to enable tree-shaking (unused integrations are removed from bundle).
6
+ */
7
+
8
+ import { ROUTES } from './constants'
9
+
10
+ import type { Hono } from 'hono'
11
+ import type { HonoEnv, Integrations } from './types'
12
+
13
+ /**
14
+ * Registers all enabled built-in integration routes
15
+ *
16
+ * @param app - Hono application instance
17
+ * @param integrations - Enabled integrations from config
18
+ */
19
+ export async function registerBuiltinRoutes(app: Hono<HonoEnv>, integrations?: Integrations) {
20
+ // Root page (always included)
21
+ const root = await import('./routes/root')
22
+ app.get('/', root.GET)
23
+
24
+ // Route discovery (always included)
25
+ const routesIndex = await import('./routes/index')
26
+ app.get(ROUTES.INDEX, routesIndex.GET)
27
+
28
+ // Health check (always included)
29
+ const health = await import('./routes/health')
30
+ app.get(ROUTES.HEALTH, health.GET)
31
+
32
+ // TimeBack integration
33
+ if (integrations?.timeback) {
34
+ const [progress, sessionEnd, awardXp] = await Promise.all([
35
+ import('./routes/integrations/timeback/progress'),
36
+ import('./routes/integrations/timeback/session-end'),
37
+ import('./routes/integrations/timeback/award-xp'),
38
+ ])
39
+
40
+ app.post(ROUTES.TIMEBACK.PROGRESS, progress.POST)
41
+ app.post(ROUTES.TIMEBACK.SESSION_END, sessionEnd.POST)
42
+ app.post(ROUTES.TIMEBACK.AWARD_XP, awardXp.POST)
43
+ }
44
+
45
+ // TODO: Auth integration
46
+ // TODO: Storage integration
47
+ // TODO: Realtime integration
48
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Health check endpoint
3
+ * Route: GET /api/health
4
+ * Always included in deployed backends
5
+ */
6
+
7
+ import type { Context } from 'hono'
8
+ import type { HonoEnv } from '../types'
9
+
10
+ export async function GET(c: Context<HonoEnv>): Promise<Response> {
11
+ const config = c.get('config')
12
+ const sdk = c.get('sdk')
13
+
14
+ return c.json({
15
+ status: 'ok',
16
+ timestamp: new Date().toISOString(),
17
+
18
+ // Environment check
19
+ env: {
20
+ hasApiKey: !!c.env.PLAYCADEMY_API_KEY,
21
+ hasGameId: !!c.env.GAME_ID,
22
+ hasBaseUrl: !!c.env.PLAYCADEMY_BASE_URL,
23
+ },
24
+
25
+ // Config presence
26
+ config: {
27
+ hasConfig: !!config,
28
+ hasIntegrations: !!config?.integrations,
29
+ },
30
+
31
+ // TimeBack status
32
+ timeback: {
33
+ enabled: !!config?.integrations?.timeback,
34
+ courseIdFetched: !!sdk?.timeback?.courseId,
35
+ },
36
+
37
+ // Custom routes info
38
+ customRoutes:
39
+ c.get('customRoutes')?.map(r => ({
40
+ path: r.path,
41
+ methods: r.methods,
42
+ })) || [],
43
+ })
44
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Route discovery endpoint
3
+ * Route: GET /api
4
+ * Always included - lists all available routes
5
+ */
6
+
7
+ import type { Context } from 'hono'
8
+ import type { HonoEnv } from '../types'
9
+
10
+ export async function GET(c: Context<HonoEnv>): Promise<Response> {
11
+ const config = c.get('config')
12
+ const customRoutes = c.get('customRoutes') || []
13
+ const routes: string[] = ['GET /api', 'GET /api/health']
14
+
15
+ if (config.integrations?.timeback) {
16
+ routes.push(
17
+ 'POST /api/integrations/timeback/progress',
18
+ 'POST /api/integrations/timeback/session-end',
19
+ 'POST /api/integrations/timeback/award-xp',
20
+ )
21
+ }
22
+
23
+ // Add custom routes
24
+ for (const route of customRoutes) {
25
+ const methods = route.methods?.join(', ') || '*'
26
+ routes.push(`${methods} ${route.path}`)
27
+ }
28
+
29
+ return c.json({
30
+ name: config.name,
31
+ routes,
32
+ })
33
+ }
@@ -0,0 +1,87 @@
1
+ import { verifyGameToken } from '@playcademy/sdk/server'
2
+
3
+ import type { Context } from 'hono'
4
+ import type { PlaycademyConfig, XPAwardMetadata } from '@playcademy/sdk/server'
5
+ import type { HonoEnv } from '../../../types'
6
+
7
+ /**
8
+ * TimeBack integration - Award XP to student
9
+ * Route: POST /api/integrations/timeback/award-xp
10
+ * Auto-generated when integrations.timeback is configured
11
+ *
12
+ * Flow:
13
+ * 1. Game frontend calls this route on deployed backend ({slug}.playcademy.gg/api/integrations/timeback/award-xp)
14
+ * 2. This route calls Playcademy platform API via SDK (hub.playcademy.net/api/timeback/award-xp)
15
+ * 3. Platform API sends Caliper events to TimeBack
16
+ *
17
+ * This acts as a secure proxy - game developers don't need TimeBack credentials or
18
+ * their own backend infrastructure. The SDK handles config enrichment and metadata.
19
+ */
20
+
21
+ function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
22
+ const config = c.get('config')
23
+ const timebackConfig = config?.integrations?.timeback
24
+ if (!timebackConfig) throw new Error('TimeBack integration not found')
25
+ return config
26
+ }
27
+
28
+ function enrichMetadata(
29
+ metadata: XPAwardMetadata,
30
+ config: PlaycademyConfig,
31
+ c: Context<HonoEnv>,
32
+ ): XPAwardMetadata {
33
+ const appName = metadata.appName || config?.name
34
+ const subject =
35
+ metadata.subject ||
36
+ config?.integrations?.timeback?.course?.defaultSubject ||
37
+ config?.integrations?.timeback?.course?.subjects?.[0]
38
+ const sensorUrl = metadata.sensorUrl || new URL(c.req.url).origin
39
+
40
+ if (!appName) throw new Error('App name is required')
41
+ if (!subject) throw new Error('Subject is required')
42
+ if (!sensorUrl) throw new Error('Sensor URL is required')
43
+
44
+ return { ...metadata, appName, subject, sensorUrl }
45
+ }
46
+
47
+ export async function POST(c: Context<HonoEnv>): Promise<Response> {
48
+ try {
49
+ // 1. Verify game token from frontend (calls platform API for verification)
50
+ const token = c.req.header('Authorization')?.replace('Bearer ', '')
51
+ if (!token) return c.json({ error: 'Unauthorized' }, 401)
52
+
53
+ const { user } = await verifyGameToken(token)
54
+
55
+ // 2. Ensure user has TimeBack integration
56
+ if (!user.timeback_id) {
57
+ return c.json({ error: 'User does not have TimeBack integration' }, 400)
58
+ }
59
+
60
+ // 3. Parse request body
61
+ const { xpAmount, metadata } = await c.req.json()
62
+
63
+ // 4. Get config and enrich metadata with required Caliper fields
64
+ const config = getConfig(c)
65
+
66
+ // 5. Enrich metadata with required Caliper fields
67
+ const enrichedMetadata = enrichMetadata(metadata, config, c)
68
+
69
+ // 6. Get SDK client from context (initialized once per Worker, reused across requests)
70
+ const sdk = c.get('sdk')
71
+
72
+ // 7. Award XP to student (SDK handles enrichment & Caliper events)
73
+ const result = await sdk.timeback.awardXP(user.timeback_id, xpAmount, enrichedMetadata)
74
+
75
+ return c.json(result)
76
+ } catch (error) {
77
+ console.error('[TimeBack Award XP] Error:', error)
78
+ return c.json(
79
+ {
80
+ error: 'Failed to award XP',
81
+ message: error instanceof Error ? error.message : String(error),
82
+ stack: error instanceof Error ? error.stack : undefined,
83
+ },
84
+ 500,
85
+ )
86
+ }
87
+ }
@@ -0,0 +1,89 @@
1
+ import { verifyGameToken } from '@playcademy/sdk/server'
2
+
3
+ import type { Context } from 'hono'
4
+ import type { PlaycademyConfig, ProgressData } from '@playcademy/sdk/server'
5
+ import type { HonoEnv } from '../../../types'
6
+
7
+ /**
8
+ * TimeBack integration - Record student progress
9
+ * Route: POST /api/integrations/timeback/progress
10
+ * Auto-generated when integrations.timeback is configured
11
+ *
12
+ * Flow:
13
+ * 1. Game frontend calls this route on deployed backend ({slug}.playcademy.gg/api/integrations/timeback/progress)
14
+ * 2. This route calls Playcademy platform API via SDK (hub.playcademy.net/api/timeback/progress)
15
+ * 3. Platform API sends Caliper events to TimeBack
16
+ *
17
+ * This acts as a secure proxy - game developers don't need TimeBack credentials or
18
+ * their own backend infrastructure. The SDK handles config enrichment and metadata.
19
+ */
20
+
21
+ function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
22
+ const config = c.get('config')
23
+ const timebackConfig = config?.integrations?.timeback
24
+ if (!timebackConfig) throw new Error('TimeBack integration not found')
25
+ return config
26
+ }
27
+
28
+ function enrichProgressData(
29
+ progressData: ProgressData,
30
+ config: PlaycademyConfig,
31
+ c: Context<HonoEnv>,
32
+ ): ProgressData {
33
+ const appName = progressData.appName || config?.name
34
+ const subject =
35
+ progressData.subject ||
36
+ config?.integrations?.timeback?.course?.defaultSubject ||
37
+ config?.integrations?.timeback?.course?.subjects?.[0]
38
+ const sensorUrl = progressData.sensorUrl || new URL(c.req.url).origin
39
+
40
+ if (!appName) throw new Error('App name is required')
41
+ if (!subject) throw new Error('Subject is required')
42
+ if (!sensorUrl) throw new Error('Sensor URL is required')
43
+
44
+ return { ...progressData, appName, subject, sensorUrl }
45
+ }
46
+
47
+ export async function POST(c: Context<HonoEnv>): Promise<Response> {
48
+ try {
49
+ // 1. Verify game token from frontend (calls platform API for verification)
50
+ const token = c.req.header('Authorization')?.replace('Bearer ', '')
51
+ if (!token) {
52
+ return c.json({ error: 'Unauthorized' }, 401)
53
+ }
54
+
55
+ const { user } = await verifyGameToken(token)
56
+
57
+ // 2. Ensure user has TimeBack integration
58
+ if (!user.timeback_id) {
59
+ return c.json({ error: 'User does not have TimeBack integration' }, 400)
60
+ }
61
+
62
+ // 3. Parse request body
63
+ const { progressData } = await c.req.json()
64
+
65
+ // 4. Get config and enrich progress data with required Caliper fields
66
+ const config = getConfig(c)
67
+
68
+ // 5. Enrich progress data with required Caliper fields
69
+ const enrichedProgressData = enrichProgressData(progressData, config, c)
70
+
71
+ // 5. Get SDK client from context (initialized once per Worker, reused across requests)
72
+ const sdk = c.get('sdk')
73
+
74
+ // 6. Record progress to TimeBack (SDK handles enrichment & Caliper events)
75
+ const result = await sdk.timeback.recordProgress(user.timeback_id, enrichedProgressData)
76
+
77
+ return c.json(result)
78
+ } catch (error) {
79
+ console.error('[TimeBack Progress] Error:', error)
80
+ return c.json(
81
+ {
82
+ error: 'Failed to record progress',
83
+ message: error instanceof Error ? error.message : String(error),
84
+ stack: error instanceof Error ? error.stack : undefined,
85
+ },
86
+ 500,
87
+ )
88
+ }
89
+ }
@@ -0,0 +1,89 @@
1
+ import { verifyGameToken } from '@playcademy/sdk/server'
2
+
3
+ import type { Context } from 'hono'
4
+ import type { PlaycademyConfig, SessionData } from '@playcademy/sdk/server'
5
+ import type { HonoEnv } from '../../../types'
6
+
7
+ /**
8
+ * TimeBack integration - Record session end
9
+ * Route: POST /api/integrations/timeback/session-end
10
+ * Auto-generated when integrations.timeback is configured
11
+ *
12
+ * Flow:
13
+ * 1. Game frontend calls this route on deployed backend ({slug}.playcademy.gg/api/integrations/timeback/session-end)
14
+ * 2. This route calls Playcademy platform API via SDK (hub.playcademy.net/api/timeback/session-end)
15
+ * 3. Platform API sends Caliper events to TimeBack
16
+ *
17
+ * This acts as a secure proxy - game developers don't need TimeBack credentials or
18
+ * their own backend infrastructure. The SDK handles config enrichment and metadata.
19
+ */
20
+
21
+ function getConfig(c: Context<HonoEnv>): PlaycademyConfig {
22
+ const config = c.get('config')
23
+ const timebackConfig = config?.integrations?.timeback
24
+ if (!timebackConfig) throw new Error('TimeBack integration not found')
25
+ return config
26
+ }
27
+
28
+ function enrichSessionData(
29
+ sessionData: SessionData,
30
+ config: PlaycademyConfig,
31
+ c: Context<HonoEnv>,
32
+ ): SessionData {
33
+ const appName = sessionData.appName || config?.name
34
+ const subject =
35
+ sessionData.subject ||
36
+ config?.integrations?.timeback?.course?.defaultSubject ||
37
+ config?.integrations?.timeback?.course?.subjects?.[0]
38
+ const sensorUrl = sessionData.sensorUrl || new URL(c.req.url).origin
39
+
40
+ if (!appName) throw new Error('App name is required')
41
+ if (!subject) throw new Error('Subject is required')
42
+ if (!sensorUrl) throw new Error('Sensor URL is required')
43
+
44
+ return { ...sessionData, appName, subject, sensorUrl }
45
+ }
46
+
47
+ export async function POST(c: Context<HonoEnv>): Promise<Response> {
48
+ try {
49
+ // 1. Verify game token from frontend (calls platform API for verification)
50
+ const token = c.req.header('Authorization')?.replace('Bearer ', '')
51
+ if (!token) {
52
+ return c.json({ error: 'Unauthorized' }, 401)
53
+ }
54
+
55
+ const { user } = await verifyGameToken(token)
56
+
57
+ // 2. Ensure user has TimeBack integration
58
+ if (!user.timeback_id) {
59
+ return c.json({ error: 'User does not have TimeBack integration' }, 400)
60
+ }
61
+
62
+ // 3. Parse request body
63
+ const { sessionData } = await c.req.json()
64
+
65
+ // 4. Get config and enrich session data with required Caliper fields
66
+ const config = getConfig(c)
67
+
68
+ // 5. Enrich session data with required Caliper fields
69
+ const enrichedSessionData = enrichSessionData(sessionData, config, c)
70
+
71
+ // 6. Get SDK client from context (initialized once per Worker, reused across requests)
72
+ const sdk = c.get('sdk')
73
+
74
+ // 7. Record session timing to TimeBack (SDK handles enrichment & Caliper events)
75
+ const result = await sdk.timeback.recordSessionEnd(user.timeback_id, enrichedSessionData)
76
+
77
+ return c.json(result)
78
+ } catch (error) {
79
+ console.error('[TimeBack Session End] Error:', error)
80
+ return c.json(
81
+ {
82
+ error: 'Failed to record session end',
83
+ message: error instanceof Error ? error.message : String(error),
84
+ stack: error instanceof Error ? error.stack : undefined,
85
+ },
86
+ 500,
87
+ )
88
+ }
89
+ }