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.
- package/dist/constants.d.ts +40 -0
- package/dist/constants.js +35 -0
- package/dist/edge-play/src/constants.ts +42 -0
- package/dist/edge-play/src/entry.ts +108 -0
- package/dist/edge-play/src/index.ts +1 -0
- package/dist/edge-play/src/polyfills.js +54 -0
- package/dist/edge-play/src/register-routes.ts +48 -0
- package/dist/edge-play/src/routes/health.ts +44 -0
- package/dist/edge-play/src/routes/index.ts +33 -0
- package/dist/edge-play/src/routes/integrations/timeback/award-xp.ts +87 -0
- package/dist/edge-play/src/routes/integrations/timeback/progress.ts +89 -0
- package/dist/edge-play/src/routes/integrations/timeback/session-end.ts +89 -0
- package/dist/edge-play/src/routes/root.html +169 -0
- package/dist/edge-play/src/routes/root.ts +21 -0
- package/dist/edge-play/src/types.ts +70 -0
- package/dist/index.js +948 -6140
- package/dist/templates/sample-route.ts +31 -0
- package/dist/templates/timeback-config.js.template +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/utils.d.ts +107 -0
- package/dist/utils.js +561 -6029
- package/package.json +8 -4
|
@@ -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
|
+
}
|