@playcademy/sandbox 0.10.2-beta.1 → 0.10.2-beta.2

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/cli.js CHANGED
@@ -282,7 +282,17 @@ var init_platform = __esm(() => {
282
282
  });
283
283
 
284
284
  // ../constants/src/skills.ts
285
- var init_skills = () => {};
285
+ var SKILL_STATUS_ROUTE = "/__playcademy/skills/status", SKILL_STATUS_TOKEN_AUDIENCE = "playcademy-game-skill-status", SKILL_STATUS_MAX_SKILLS = 50, SKILL_STATUSES, SKILL_MASTERY_SOURCES;
286
+ var init_skills = __esm(() => {
287
+ SKILL_STATUSES = [
288
+ "locked",
289
+ "unlocked",
290
+ "in_progress",
291
+ "mastered",
292
+ "unknown_skill"
293
+ ];
294
+ SKILL_MASTERY_SOURCES = ["assessment", "placement", "test_out", "manual"];
295
+ });
286
296
 
287
297
  // ../constants/src/system.ts
288
298
  var PLATFORM_TIMEZONE = "America/New_York";
@@ -1132,7 +1142,7 @@ var package_default;
1132
1142
  var init_package = __esm(() => {
1133
1143
  package_default = {
1134
1144
  name: "@playcademy/sandbox",
1135
- version: "0.10.2-beta.1",
1145
+ version: "0.10.2-beta.2",
1136
1146
  description: "Local development server for Playcademy game development",
1137
1147
  type: "module",
1138
1148
  exports: {
@@ -30009,6 +30019,23 @@ function generateDeploymentHash(code) {
30009
30019
  function computeDeployPayloadFingerprint(payload) {
30010
30020
  return sha256Hex(canonicalJson2(payload));
30011
30021
  }
30022
+ function toActiveDeploymentRow(record) {
30023
+ return {
30024
+ gameId: record.gameId,
30025
+ deploymentId: record.deploymentId,
30026
+ provider: "cloudflare",
30027
+ target: record.target,
30028
+ url: record.url,
30029
+ codeHash: record.codeHash,
30030
+ schemaHash: record.schemaHash ?? null,
30031
+ schemaFingerprint: record.schemaFingerprint ?? null,
30032
+ timeTravelBookmark: record.timeTravelBookmark ?? null,
30033
+ bookmarkCapturedAt: record.bookmarkCapturedAt ?? null,
30034
+ resources: record.resources,
30035
+ isActive: true,
30036
+ deployJobId: record.deployJobId ?? null
30037
+ };
30038
+ }
30012
30039
  var GAME_WORKER_KEY_PREFIX = "game-worker-", DASHBOARD_WORKER_KEY_PREFIX = "dash-worker-";
30013
30040
  var init_deployment_util = __esm(() => {
30014
30041
  init_drizzle_orm();
@@ -37032,24 +37059,9 @@ class DeployService {
37032
37059
  }
37033
37060
  }
37034
37061
  async saveDeployment(record) {
37035
- const db2 = this.deps.db;
37036
- await db2.transaction(async (tx) => {
37062
+ await this.deps.db.transaction(async (tx) => {
37037
37063
  await tx.update(gameDeployments).set({ isActive: false }).where(and(eq(gameDeployments.gameId, record.gameId), eq(gameDeployments.target, record.target)));
37038
- await tx.insert(gameDeployments).values({
37039
- gameId: record.gameId,
37040
- deploymentId: record.deploymentId,
37041
- provider: "cloudflare",
37042
- target: record.target,
37043
- url: record.url,
37044
- codeHash: record.codeHash,
37045
- schemaHash: record.schemaHash ?? null,
37046
- schemaFingerprint: record.schemaFingerprint ?? null,
37047
- timeTravelBookmark: record.timeTravelBookmark ?? null,
37048
- bookmarkCapturedAt: record.bookmarkCapturedAt ?? null,
37049
- resources: record.resources,
37050
- isActive: true,
37051
- deployJobId: record.deployJobId ?? null
37052
- });
37064
+ await tx.insert(gameDeployments).values(toActiveDeploymentRow(record));
37053
37065
  });
37054
37066
  }
37055
37067
  async notifyDeploymentFailure(failure) {
@@ -46881,6 +46893,39 @@ function publishableRepositoryUrl(value) {
46881
46893
  return value;
46882
46894
  }
46883
46895
 
46896
+ // ../api-core/src/utils/releases.util.ts
46897
+ function toRelease(row) {
46898
+ return {
46899
+ slug: row.slug,
46900
+ url: row.url,
46901
+ source: normalizeDeploySource(row.source),
46902
+ deployedAt: row.deployedAt.toISOString()
46903
+ };
46904
+ }
46905
+ function toGameReleaseResponse(rows, history) {
46906
+ const releases = rows.map(toRelease);
46907
+ const current = releases[0] ?? null;
46908
+ return history === undefined ? { current } : { current, history: releases };
46909
+ }
46910
+ function normalizeDeploySource(value) {
46911
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
46912
+ return null;
46913
+ }
46914
+ const raw = value;
46915
+ const repository = nullableString(raw.repository);
46916
+ return {
46917
+ repository: repository === null ? null : publishableRepositoryUrl(repository),
46918
+ commit: nullableString(raw.commit),
46919
+ branch: nullableString(raw.branch),
46920
+ dirty: raw.dirty === true,
46921
+ externalBuild: raw.externalBuild === true
46922
+ };
46923
+ }
46924
+ function nullableString(value) {
46925
+ return typeof value === "string" && value.length > 0 ? value : null;
46926
+ }
46927
+ var init_releases_util = () => {};
46928
+
46884
46929
  // ../api-core/src/services/releases.service.ts
46885
46930
  class ReleasesService {
46886
46931
  deps;
@@ -46905,9 +46950,7 @@ class ReleasesService {
46905
46950
  "app.releases.history_requested": history ?? 0,
46906
46951
  "app.releases.history_rows": rows.length
46907
46952
  });
46908
- const releases = rows.map(toRelease);
46909
- const current = releases[0] ?? null;
46910
- return history === undefined ? { current } : { current, history: releases };
46953
+ return toGameReleaseResponse(rows, history);
46911
46954
  }
46912
46955
  }
46913
46956
  function releaseColumns() {
@@ -46924,36 +46967,12 @@ function listedGame() {
46924
46967
  function activeWorkerDeployment() {
46925
46968
  return and(eq(gameDeployments.isActive, true), eq(gameDeployments.target, "game"));
46926
46969
  }
46927
- function toRelease(row) {
46928
- return {
46929
- slug: row.slug,
46930
- url: row.url,
46931
- source: normalizeSource(row.source),
46932
- deployedAt: row.deployedAt.toISOString()
46933
- };
46934
- }
46935
- function normalizeSource(value) {
46936
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
46937
- return null;
46938
- }
46939
- const raw = value;
46940
- const repository = nullableString(raw.repository);
46941
- return {
46942
- repository: repository === null ? null : publishableRepositoryUrl(repository),
46943
- commit: nullableString(raw.commit),
46944
- branch: nullableString(raw.branch),
46945
- dirty: raw.dirty === true,
46946
- externalBuild: raw.externalBuild === true
46947
- };
46948
- }
46949
- function nullableString(value) {
46950
- return typeof value === "string" && value.length > 0 ? value : null;
46951
- }
46952
46970
  var init_releases_service = __esm(() => {
46953
46971
  init_drizzle_orm();
46954
46972
  init_tables_index();
46955
46973
  init_spans();
46956
46974
  init_errors2();
46975
+ init_releases_util();
46957
46976
  });
46958
46977
 
46959
46978
  // ../api-core/src/services/secrets.service.ts
@@ -47409,6 +47428,226 @@ var init_seed_service = __esm(() => {
47409
47428
  init_deployment_util();
47410
47429
  });
47411
47430
 
47431
+ // ../api-core/src/utils/game-worker-call.util.ts
47432
+ function resolveGameWorkerBaseUrl(localGameUrls, slug, deployedUrl) {
47433
+ return (slug ? localGameUrls[slug] : undefined) ?? deployedUrl;
47434
+ }
47435
+ async function callGameWorker(url, token, options) {
47436
+ const controller = new AbortController;
47437
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
47438
+ try {
47439
+ const response = await fetch(url, {
47440
+ method: "GET",
47441
+ headers: {
47442
+ Accept: "application/json",
47443
+ Authorization: `Bearer ${token}`
47444
+ },
47445
+ signal: controller.signal
47446
+ });
47447
+ const body2 = await response.text();
47448
+ return {
47449
+ ok: true,
47450
+ response: new Response(NULL_BODY_STATUSES.has(response.status) ? null : body2, {
47451
+ status: response.status,
47452
+ headers: response.headers
47453
+ })
47454
+ };
47455
+ } catch (error) {
47456
+ if (error instanceof Error && error.name === "AbortError") {
47457
+ return { ok: false, reason: "timeout", details: options.timeoutMessage };
47458
+ }
47459
+ return { ok: false, reason: "fetch_failed", details: errorMessage(error) };
47460
+ } finally {
47461
+ clearTimeout(timeout);
47462
+ }
47463
+ }
47464
+ var NULL_BODY_STATUSES;
47465
+ var init_game_worker_call_util = __esm(() => {
47466
+ NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
47467
+ });
47468
+
47469
+ // ../utils/src/skills.ts
47470
+ function normalizeSkillId(skill) {
47471
+ return skill.trim().toLowerCase();
47472
+ }
47473
+ function normalizeSkillIds(skills2, max) {
47474
+ const normalized = [...new Set(skills2.map(normalizeSkillId).filter((skill) => skill.length > 0))];
47475
+ if (normalized.length === 0) {
47476
+ return { ok: false, error: "At least one skill is required" };
47477
+ }
47478
+ if (normalized.length > max) {
47479
+ return { ok: false, error: `At most ${max} skills can be requested at once` };
47480
+ }
47481
+ return { ok: true, skills: normalized };
47482
+ }
47483
+
47484
+ // ../edge-play/src/game/lib/skills.ts
47485
+ function isValidIsoDate(value) {
47486
+ if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) {
47487
+ return false;
47488
+ }
47489
+ const [year, month, day] = value.slice(0, 10).split("-").map(Number);
47490
+ const date3 = new Date(Date.UTC(year, month - 1, day));
47491
+ return !isNaN(Date.parse(value)) && date3.getUTCFullYear() === year && date3.getUTCMonth() === month - 1 && date3.getUTCDate() === day;
47492
+ }
47493
+ function normalizeResult(result) {
47494
+ if (!isRecord(result)) {
47495
+ return null;
47496
+ }
47497
+ if (typeof result.skill !== "string" || !STATUSES.has(result.status)) {
47498
+ return null;
47499
+ }
47500
+ const status = result.status;
47501
+ if (result.source !== undefined) {
47502
+ if (status !== "mastered" || !MASTERY_SOURCES.has(result.source)) {
47503
+ return null;
47504
+ }
47505
+ }
47506
+ if (result.asOf !== undefined && !isValidIsoDate(result.asOf)) {
47507
+ return null;
47508
+ }
47509
+ return {
47510
+ skill: normalizeSkillId(result.skill),
47511
+ status,
47512
+ ...result.source === undefined ? {} : { source: result.source },
47513
+ ...result.asOf === undefined ? {} : { asOf: result.asOf }
47514
+ };
47515
+ }
47516
+ function validateSkillStatusResponse(value, studentId, askedSkills) {
47517
+ if (!isRecord(value)) {
47518
+ return null;
47519
+ }
47520
+ const response = value;
47521
+ if (response.studentId !== studentId || !Array.isArray(response.results)) {
47522
+ return null;
47523
+ }
47524
+ const asked = new Set(askedSkills);
47525
+ const results = new Map;
47526
+ for (const raw of response.results) {
47527
+ const result = normalizeResult(raw);
47528
+ if (!result || !asked.has(result.skill) || results.has(result.skill)) {
47529
+ return null;
47530
+ }
47531
+ results.set(result.skill, result);
47532
+ }
47533
+ return results.size === asked.size ? results : null;
47534
+ }
47535
+ var STATUSES, MASTERY_SOURCES, ISO_DATE_PATTERN;
47536
+ var init_skills2 = __esm(() => {
47537
+ init_src();
47538
+ STATUSES = new Set(SKILL_STATUSES);
47539
+ MASTERY_SOURCES = new Set(SKILL_MASTERY_SOURCES);
47540
+ ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2}))?$/;
47541
+ });
47542
+
47543
+ // ../api-core/src/utils/skill-status.util.ts
47544
+ function normalizeSkillStatusRequest(skills2) {
47545
+ const normalized = normalizeSkillIds(skills2, SKILL_STATUS_MAX_SKILLS);
47546
+ if (!normalized.ok) {
47547
+ throw new ValidationError(normalized.error);
47548
+ }
47549
+ return normalized.skills;
47550
+ }
47551
+ function buildSkillStatusUrl(baseUrl, skills2) {
47552
+ const url = new URL(SKILL_STATUS_ROUTE, baseUrl);
47553
+ for (const skill of skills2) {
47554
+ url.searchParams.append("skill", skill);
47555
+ }
47556
+ return url;
47557
+ }
47558
+ function pickSkillStatusGames(rows, requestingSlug, answeringSlug) {
47559
+ const requestingGame = rows.find((row) => row.slug === requestingSlug);
47560
+ const answeringGame = rows.find((row) => row.slug === answeringSlug);
47561
+ if (!requestingGame) {
47562
+ throw new NotFoundError("Game", requestingSlug);
47563
+ }
47564
+ if (!answeringGame) {
47565
+ throw new NotFoundError("Game", answeringSlug);
47566
+ }
47567
+ return { requestingGame, answeringGame };
47568
+ }
47569
+ async function classifySkillStatusResponse(response, studentId, skills2) {
47570
+ if (response.status === 404) {
47571
+ return { supported: false, reason: "not_implemented" };
47572
+ }
47573
+ if (!response.ok) {
47574
+ return { supported: false, reason: "fetch_failed" };
47575
+ }
47576
+ let json3;
47577
+ try {
47578
+ json3 = JSON.parse(await response.text());
47579
+ } catch {
47580
+ return { supported: false, reason: "not_implemented" };
47581
+ }
47582
+ const answered = validateSkillStatusResponse(json3, studentId, skills2);
47583
+ if (!answered) {
47584
+ return { supported: false, reason: "invalid_response" };
47585
+ }
47586
+ return { supported: true, results: skills2.map((skill) => answered.get(skill)) };
47587
+ }
47588
+ var init_skill_status_util = __esm(() => {
47589
+ init_src();
47590
+ init_skills2();
47591
+ init_errors2();
47592
+ });
47593
+
47594
+ // ../api-core/src/services/skill-status.service.ts
47595
+ class SkillStatusService {
47596
+ deps;
47597
+ constructor(deps) {
47598
+ this.deps = deps;
47599
+ }
47600
+ async getStatus(input) {
47601
+ const skills2 = normalizeSkillStatusRequest(input.skills);
47602
+ setAttributes({
47603
+ "app.skill_status.requesting_game": input.requestingGameSlug,
47604
+ "app.skill_status.answering_game": input.game,
47605
+ "app.skill_status.skill_count": skills2.length
47606
+ });
47607
+ const rows = await this.deps.db.select({ id: games.id, slug: games.slug }).from(games).where(inArray(games.slug, [input.requestingGameSlug, input.game]));
47608
+ const { requestingGame, answeringGame } = pickSkillStatusGames(rows, input.requestingGameSlug, input.game);
47609
+ const deployment = await this.deps.db.query.gameDeployments.findFirst({
47610
+ where: activeDeploymentWhere(answeringGame.id),
47611
+ columns: { url: true }
47612
+ });
47613
+ const baseUrl = resolveGameWorkerBaseUrl(this.deps.localGameUrls, answeringGame.slug, deployment?.url);
47614
+ if (!baseUrl) {
47615
+ return recordResult({ supported: false, reason: "no_active_deployment" });
47616
+ }
47617
+ const token = await this.deps.mintSkillStatusToken({
47618
+ gameId: answeringGame.id,
47619
+ studentId: input.studentId,
47620
+ requestingGame
47621
+ });
47622
+ const call = await callGameWorker(buildSkillStatusUrl(baseUrl, skills2), token, {
47623
+ timeoutMs: SKILL_STATUS_TIMEOUT_MS,
47624
+ timeoutMessage: "Skill status request timed out"
47625
+ });
47626
+ if (!call.ok) {
47627
+ setAttributes({ "app.skill_status.failure_details": call.details });
47628
+ return recordResult({ supported: false, reason: call.reason });
47629
+ }
47630
+ setAttributes({ "app.skill_status.http_status": call.response.status });
47631
+ return recordResult(await classifySkillStatusResponse(call.response, input.studentId, skills2));
47632
+ }
47633
+ }
47634
+ function recordResult(result) {
47635
+ setAttributes(result.supported ? {
47636
+ "app.skill_status.supported": true,
47637
+ "app.skill_status.unknown_skill_count": result.results.filter((entry) => entry.status === "unknown_skill").length
47638
+ } : { "app.skill_status.supported": false, "app.skill_status.reason": result.reason });
47639
+ return result;
47640
+ }
47641
+ var SKILL_STATUS_TIMEOUT_MS = 3000;
47642
+ var init_skill_status_service = __esm(() => {
47643
+ init_drizzle_orm();
47644
+ init_helpers_index();
47645
+ init_tables_index();
47646
+ init_spans();
47647
+ init_game_worker_call_util();
47648
+ init_skill_status_util();
47649
+ });
47650
+
47412
47651
  // ../../node_modules/.bun/drizzle-zod@0.7.1+3ad0422b77d1a05b/node_modules/drizzle-zod/index.mjs
47413
47652
  function isColumnType(column2, columnTypes) {
47414
47653
  return columnTypes.includes(column2.columnType);
@@ -98258,12 +98497,6 @@ class TimebackAdminService {
98258
98497
  grade: integration.grade
98259
98498
  };
98260
98499
  }
98261
- resolveGameMetricsBaseUrl(slug2, deployedUrl) {
98262
- if (!slug2) {
98263
- return deployedUrl;
98264
- }
98265
- return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
98266
- }
98267
98500
  static resolveGameMetricsUrl(baseUrl, runIds) {
98268
98501
  try {
98269
98502
  const url4 = new URL("/__playcademy/metrics", baseUrl);
@@ -98297,9 +98530,6 @@ class TimebackAdminService {
98297
98530
  }
98298
98531
  return chunks;
98299
98532
  }
98300
- static isAbortError(error88) {
98301
- return error88 instanceof Error && error88.name === "AbortError";
98302
- }
98303
98533
  static roundXpToTenths(value) {
98304
98534
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
98305
98535
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -98937,7 +99167,7 @@ class TimebackAdminService {
98937
99167
  if (enrollmentsByCourseId.size === 0) {
98938
99168
  throw new NotFoundError("Student enrollment", `${timebackId}:${gameId}`);
98939
99169
  }
98940
- const metricsBaseUrl = this.resolveGameMetricsBaseUrl(game2?.slug, deployment?.url);
99170
+ const metricsBaseUrl = resolveGameWorkerBaseUrl(this.deps.config.localGameUrls, game2?.slug, deployment?.url);
98941
99171
  if (!metricsBaseUrl) {
98942
99172
  setAttributes({
98943
99173
  "app.timeback.metrics.supported": false,
@@ -98960,40 +99190,18 @@ class TimebackAdminService {
98960
99190
  };
98961
99191
  }
98962
99192
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
98963
- const controller = new AbortController;
98964
- const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
98965
- let response;
98966
- try {
98967
- response = await fetch(metricsUrl, {
98968
- method: "GET",
98969
- headers: {
98970
- Accept: "application/json",
98971
- Authorization: `Bearer ${token}`
98972
- },
98973
- signal: controller.signal
98974
- });
98975
- } catch (error88) {
98976
- const timedOut = TimebackAdminService.isAbortError(error88);
98977
- let details;
98978
- if (timedOut) {
98979
- details = "Game metrics request timed out";
98980
- } else if (error88 instanceof Error) {
98981
- details = error88.message;
98982
- } else {
98983
- details = String(error88);
98984
- }
99193
+ const call = await callGameWorker(metricsUrl, token, {
99194
+ timeoutMs: options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS,
99195
+ timeoutMessage: "Game metrics request timed out"
99196
+ });
99197
+ if (!call.ok) {
98985
99198
  setAttributes({
98986
99199
  "app.timeback.metrics.supported": false,
98987
- "app.timeback.metrics.reason": timedOut ? "timeout" : "fetch_failed"
99200
+ "app.timeback.metrics.reason": call.reason
98988
99201
  });
98989
- return {
98990
- supported: false,
98991
- reason: timedOut ? "timeout" : "fetch_failed",
98992
- details
98993
- };
98994
- } finally {
98995
- clearTimeout(timeout);
99202
+ return { supported: false, reason: call.reason, details: call.details };
98996
99203
  }
99204
+ const { response } = call;
98997
99205
  const result = await classifyGameMetricsResponse(response, { gameId, timebackId });
98998
99206
  setAttributes({
98999
99207
  "app.timeback.metrics.supported": result.supported,
@@ -99991,6 +100199,7 @@ var init_timeback_admin_service = __esm(async () => {
99991
100199
  init_src9();
99992
100200
  init_timeback3();
99993
100201
  init_errors2();
100202
+ init_game_worker_call_util();
99994
100203
  init_timeback_admin_metrics_util();
99995
100204
  init_timeback_admin_util();
99996
100205
  init_timeback_discrepancy_queue_util();
@@ -109902,6 +110111,7 @@ function createPlatformServices(deps) {
109902
110111
  timebackClient,
109903
110112
  alerts,
109904
110113
  mintPlatformServiceToken,
110114
+ mintSkillStatusToken,
109905
110115
  mintAssessmentPreparationReceipt,
109906
110116
  verifyAssessmentPreparationReceipt,
109907
110117
  mintAssessmentToken,
@@ -109985,6 +110195,11 @@ function createPlatformServices(deps) {
109985
110195
  mintAssessmentToken,
109986
110196
  verifyAssessmentToken
109987
110197
  });
110198
+ const skillStatus = new SkillStatusService({
110199
+ db: db2,
110200
+ localGameUrls: config5.localGameUrls,
110201
+ mintSkillStatusToken
110202
+ });
109988
110203
  return {
109989
110204
  assessmentAssets,
109990
110205
  assessmentArtifacts,
@@ -109997,6 +110212,7 @@ function createPlatformServices(deps) {
109997
110212
  deploymentState,
109998
110213
  database,
109999
110214
  seed,
110215
+ skillStatus,
110000
110216
  timeback: timeback2,
110001
110217
  timebackAdmin,
110002
110218
  timebackAssessmentRuntime,
@@ -110015,6 +110231,7 @@ var init_platform2 = __esm(async () => {
110015
110231
  init_releases_service();
110016
110232
  init_secrets_service();
110017
110233
  init_seed_service();
110234
+ init_skill_status_service();
110018
110235
  init_upload_service();
110019
110236
  await __promiseAll([
110020
110237
  init_timeback_admin_service(),
@@ -110733,6 +110950,7 @@ function createServices(ctx) {
110733
110950
  timebackClient: timeback2,
110734
110951
  alerts: infra2.alerts,
110735
110952
  mintPlatformServiceToken: auth2.mintPlatformServiceToken.bind(auth2),
110953
+ mintSkillStatusToken: auth2.mintSkillStatusToken.bind(auth2),
110736
110954
  mintAssessmentPreparationReceipt: auth2.mintAssessmentPreparationReceipt.bind(auth2),
110737
110955
  verifyAssessmentPreparationReceipt: auth2.verifyAssessmentPreparationReceipt.bind(auth2),
110738
110956
  mintAssessmentToken: auth2.mintAssessmentToken.bind(auth2),
@@ -110781,6 +110999,17 @@ function workerKeyGrantsGameRead(key, slug2) {
110781
110999
  function isGameWorkerKey(key) {
110782
111000
  return Boolean(key?.name?.startsWith(GAME_WORKER_KEY_PREFIX));
110783
111001
  }
111002
+ function gameWorkerKeySlug(key) {
111003
+ if (!isGameWorkerKey(key)) {
111004
+ return null;
111005
+ }
111006
+ const writes = (key?.permissions?.games ?? []).filter((scope) => scope.startsWith("write:"));
111007
+ if (writes.length !== 1) {
111008
+ return null;
111009
+ }
111010
+ const slug2 = writes[0].slice("write:".length);
111011
+ return slug2.length > 0 ? slug2 : null;
111012
+ }
110784
111013
  function deniedWorkerKeyRequest(key, method, pathname) {
110785
111014
  if (isDashboardWorkerKey(key) && !isAllowedDashboardWorkerRequest(method, pathname, key.permissions)) {
110786
111015
  return DASHBOARD_WORKER_KEY_RESTRICTED;
@@ -111157,6 +111386,19 @@ var init_clients = __esm(async () => {
111157
111386
  });
111158
111387
 
111159
111388
  // src/infrastructure/api/providers/auth.provider.ts
111389
+ function mintSandboxServiceToken(audience, claims) {
111390
+ const now2 = Math.floor(Date.now() / 1000);
111391
+ const header = btoa(JSON.stringify({ alg: "none", kid: "sandbox", typ: "sandbox" }));
111392
+ const payload = btoa(JSON.stringify({
111393
+ iss: "playcademy-platform",
111394
+ aud: audience,
111395
+ ...claims,
111396
+ iat: now2,
111397
+ exp: now2 + 60,
111398
+ jti: crypto.randomUUID()
111399
+ }));
111400
+ return `${header}.${payload}.sandbox`;
111401
+ }
111160
111402
  function createSandboxAuthProvider() {
111161
111403
  return {
111162
111404
  async createApiKey(params) {
@@ -111228,18 +111470,10 @@ function createSandboxAuthProvider() {
111228
111470
  }
111229
111471
  },
111230
111472
  async mintPlatformServiceToken(gameId, studentId) {
111231
- const now2 = Math.floor(Date.now() / 1000);
111232
- const header = btoa(JSON.stringify({ alg: "none", kid: "sandbox", typ: "sandbox" }));
111233
- const payload = btoa(JSON.stringify({
111234
- iss: "playcademy-platform",
111235
- aud: "playcademy-game-metrics",
111236
- gameId,
111237
- studentId,
111238
- iat: now2,
111239
- exp: now2 + 60,
111240
- jti: crypto.randomUUID()
111241
- }));
111242
- return `${header}.${payload}.sandbox`;
111473
+ return mintSandboxServiceToken("playcademy-game-metrics", { gameId, studentId });
111474
+ },
111475
+ async mintSkillStatusToken(claims) {
111476
+ return mintSandboxServiceToken(SKILL_STATUS_TOKEN_AUDIENCE, { ...claims });
111243
111477
  },
111244
111478
  async mintAssessmentPreparationReceipt(plan) {
111245
111479
  const now2 = Date.now();
@@ -111319,6 +111553,7 @@ function createSandboxAuthProvider() {
111319
111553
  }
111320
111554
  var sandboxApiKeys, sandboxAssessmentPreparationReceipts, ASSESSMENT_PREPARATION_RECEIPT_LIFETIME_MS, MAX_TOKEN_LIFETIME_SECONDS, sandboxAssessmentTokens;
111321
111555
  var init_auth_provider = __esm(async () => {
111556
+ init_src();
111322
111557
  init_src6();
111323
111558
  init_constants();
111324
111559
  await init_dist5();
@@ -167914,6 +168149,36 @@ var init_session_controller = __esm(() => {
167914
168149
  });
167915
168150
  });
167916
168151
 
168152
+ // ../api-core/src/controllers/skills.controller.ts
168153
+ async function getStatus4(ctx) {
168154
+ const requestingGameSlug = gameWorkerKeySlug(ctx.apiKey);
168155
+ if (!requestingGameSlug) {
168156
+ throw ApiError.forbidden("Skill status can only be requested by a deployed game worker");
168157
+ }
168158
+ const body2 = await parseRequestBody(ctx.request, SkillStatusRequestSchema);
168159
+ return ctx.services.skillStatus.getStatus({ ...body2, requestingGameSlug });
168160
+ }
168161
+ async function getStatusLocally(ctx) {
168162
+ await parseRequestBody(ctx.request, SkillStatusRequestSchema);
168163
+ return { supported: false, reason: "no_active_deployment" };
168164
+ }
168165
+ var SkillStatusRequestSchema, skills2;
168166
+ var init_skills_controller = __esm(() => {
168167
+ init_esm();
168168
+ init_src();
168169
+ init_errors2();
168170
+ init_utils7();
168171
+ SkillStatusRequestSchema = exports_external.object({
168172
+ game: exports_external.string().min(1),
168173
+ studentId: exports_external.string().min(1),
168174
+ skills: exports_external.array(exports_external.string()).min(1).max(SKILL_STATUS_MAX_SKILLS * 2)
168175
+ });
168176
+ skills2 = defineControllerNames("skills", {
168177
+ getStatus: requireAuth(getStatus4),
168178
+ getStatusLocally: requireAuth(getStatusLocally)
168179
+ });
168180
+ });
168181
+
167917
168182
  // ../api-core/src/controllers/timeback.controller.ts
167918
168183
  function parseQtiLibraryParams(searchParams) {
167919
168184
  const requestedPage = Number(searchParams.get("page"));
@@ -168951,6 +169216,7 @@ var init_controllers = __esm(() => {
168951
169216
  init_secrets_controller();
168952
169217
  init_seed_controller();
168953
169218
  init_session_controller();
169219
+ init_skills_controller();
168954
169220
  init_timeback_controller();
168955
169221
  init_upload_controller();
168956
169222
  init_user_controller();
@@ -169764,6 +170030,16 @@ var init_leaderboard = __esm(async () => {
169764
170030
  leaderboardRouter.get("/", handle2(leaderboard.getGlobalLeaderboard));
169765
170031
  });
169766
170032
 
170033
+ // src/routes/platform/skills.ts
170034
+ var skillsRouter;
170035
+ var init_skills3 = __esm(async () => {
170036
+ init_dist7();
170037
+ init_controllers();
170038
+ await init_api3();
170039
+ skillsRouter = new Hono2;
170040
+ skillsRouter.post("/status", handle2(skills2.getStatusLocally));
170041
+ });
170042
+
169767
170043
  // src/routes/integrations/timeback.ts
169768
170044
  function hashCode(str) {
169769
170045
  let hash2 = 0;
@@ -170112,6 +170388,7 @@ var exports_routes = {};
170112
170388
  __export(exports_routes, {
170113
170389
  usersRouter: () => usersRouter,
170114
170390
  timebackRouter: () => timebackRouter,
170391
+ skillsRouter: () => skillsRouter,
170115
170392
  manifestRouter: () => manifestRouter,
170116
170393
  ltiRouter: () => ltiRouter,
170117
170394
  leaderboardRouter: () => leaderboardRouter,
@@ -170128,6 +170405,7 @@ var init_routes = __esm(async () => {
170128
170405
  init_users(),
170129
170406
  init_games2(),
170130
170407
  init_leaderboard(),
170408
+ init_skills3(),
170131
170409
  init_timeback8(),
170132
170410
  init_lti()
170133
170411
  ]);
@@ -170143,6 +170421,7 @@ async function registerRoutes(app) {
170143
170421
  leaderboardRouter: leaderboardRouter2,
170144
170422
  ltiRouter: ltiRouter2,
170145
170423
  manifestRouter: manifestRouter2,
170424
+ skillsRouter: skillsRouter2,
170146
170425
  timebackRouter: timebackRouter2,
170147
170426
  usersRouter: usersRouter2
170148
170427
  } = await init_routes().then(() => exports_routes);
@@ -170151,6 +170430,7 @@ async function registerRoutes(app) {
170151
170430
  app.route("/api/users", usersRouter2);
170152
170431
  app.route("/api/games", gamesRouter2);
170153
170432
  app.route("/api/leaderboard", leaderboardRouter2);
170433
+ app.route("/api/skills", skillsRouter2);
170154
170434
  app.route("/api/dev", devRouter2);
170155
170435
  app.route("/api/timeback", timebackRouter2);
170156
170436
  app.route("/api/lti", ltiRouter2);
package/dist/constants.js CHANGED
@@ -97,7 +97,17 @@ var init_platform = __esm(() => {
97
97
  });
98
98
 
99
99
  // ../constants/src/skills.ts
100
- var init_skills = () => {};
100
+ var SKILL_STATUS_ROUTE = "/__playcademy/skills/status", SKILL_STATUS_TOKEN_AUDIENCE = "playcademy-game-skill-status", SKILL_STATUS_MAX_SKILLS = 50, SKILL_STATUSES, SKILL_MASTERY_SOURCES;
101
+ var init_skills = __esm(() => {
102
+ SKILL_STATUSES = [
103
+ "locked",
104
+ "unlocked",
105
+ "in_progress",
106
+ "mastered",
107
+ "unknown_skill"
108
+ ];
109
+ SKILL_MASTERY_SOURCES = ["assessment", "placement", "test_out", "manual"];
110
+ });
101
111
 
102
112
  // ../constants/src/system.ts
103
113
  var PLATFORM_TIMEZONE = "America/New_York";
package/dist/server.js CHANGED
@@ -281,7 +281,17 @@ var init_platform = __esm(() => {
281
281
  });
282
282
 
283
283
  // ../constants/src/skills.ts
284
- var init_skills = () => {};
284
+ var SKILL_STATUS_ROUTE = "/__playcademy/skills/status", SKILL_STATUS_TOKEN_AUDIENCE = "playcademy-game-skill-status", SKILL_STATUS_MAX_SKILLS = 50, SKILL_STATUSES, SKILL_MASTERY_SOURCES;
285
+ var init_skills = __esm(() => {
286
+ SKILL_STATUSES = [
287
+ "locked",
288
+ "unlocked",
289
+ "in_progress",
290
+ "mastered",
291
+ "unknown_skill"
292
+ ];
293
+ SKILL_MASTERY_SOURCES = ["assessment", "placement", "test_out", "manual"];
294
+ });
285
295
 
286
296
  // ../constants/src/system.ts
287
297
  var PLATFORM_TIMEZONE = "America/New_York";
@@ -1131,7 +1141,7 @@ var package_default;
1131
1141
  var init_package = __esm(() => {
1132
1142
  package_default = {
1133
1143
  name: "@playcademy/sandbox",
1134
- version: "0.10.2-beta.1",
1144
+ version: "0.10.2-beta.2",
1135
1145
  description: "Local development server for Playcademy game development",
1136
1146
  type: "module",
1137
1147
  exports: {
@@ -30008,6 +30018,23 @@ function generateDeploymentHash(code) {
30008
30018
  function computeDeployPayloadFingerprint(payload) {
30009
30019
  return sha256Hex(canonicalJson2(payload));
30010
30020
  }
30021
+ function toActiveDeploymentRow(record) {
30022
+ return {
30023
+ gameId: record.gameId,
30024
+ deploymentId: record.deploymentId,
30025
+ provider: "cloudflare",
30026
+ target: record.target,
30027
+ url: record.url,
30028
+ codeHash: record.codeHash,
30029
+ schemaHash: record.schemaHash ?? null,
30030
+ schemaFingerprint: record.schemaFingerprint ?? null,
30031
+ timeTravelBookmark: record.timeTravelBookmark ?? null,
30032
+ bookmarkCapturedAt: record.bookmarkCapturedAt ?? null,
30033
+ resources: record.resources,
30034
+ isActive: true,
30035
+ deployJobId: record.deployJobId ?? null
30036
+ };
30037
+ }
30011
30038
  var GAME_WORKER_KEY_PREFIX = "game-worker-", DASHBOARD_WORKER_KEY_PREFIX = "dash-worker-";
30012
30039
  var init_deployment_util = __esm(() => {
30013
30040
  init_drizzle_orm();
@@ -37031,24 +37058,9 @@ class DeployService {
37031
37058
  }
37032
37059
  }
37033
37060
  async saveDeployment(record) {
37034
- const db2 = this.deps.db;
37035
- await db2.transaction(async (tx) => {
37061
+ await this.deps.db.transaction(async (tx) => {
37036
37062
  await tx.update(gameDeployments).set({ isActive: false }).where(and(eq(gameDeployments.gameId, record.gameId), eq(gameDeployments.target, record.target)));
37037
- await tx.insert(gameDeployments).values({
37038
- gameId: record.gameId,
37039
- deploymentId: record.deploymentId,
37040
- provider: "cloudflare",
37041
- target: record.target,
37042
- url: record.url,
37043
- codeHash: record.codeHash,
37044
- schemaHash: record.schemaHash ?? null,
37045
- schemaFingerprint: record.schemaFingerprint ?? null,
37046
- timeTravelBookmark: record.timeTravelBookmark ?? null,
37047
- bookmarkCapturedAt: record.bookmarkCapturedAt ?? null,
37048
- resources: record.resources,
37049
- isActive: true,
37050
- deployJobId: record.deployJobId ?? null
37051
- });
37063
+ await tx.insert(gameDeployments).values(toActiveDeploymentRow(record));
37052
37064
  });
37053
37065
  }
37054
37066
  async notifyDeploymentFailure(failure) {
@@ -46880,6 +46892,39 @@ function publishableRepositoryUrl(value) {
46880
46892
  return value;
46881
46893
  }
46882
46894
 
46895
+ // ../api-core/src/utils/releases.util.ts
46896
+ function toRelease(row) {
46897
+ return {
46898
+ slug: row.slug,
46899
+ url: row.url,
46900
+ source: normalizeDeploySource(row.source),
46901
+ deployedAt: row.deployedAt.toISOString()
46902
+ };
46903
+ }
46904
+ function toGameReleaseResponse(rows, history) {
46905
+ const releases = rows.map(toRelease);
46906
+ const current = releases[0] ?? null;
46907
+ return history === undefined ? { current } : { current, history: releases };
46908
+ }
46909
+ function normalizeDeploySource(value) {
46910
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
46911
+ return null;
46912
+ }
46913
+ const raw = value;
46914
+ const repository = nullableString(raw.repository);
46915
+ return {
46916
+ repository: repository === null ? null : publishableRepositoryUrl(repository),
46917
+ commit: nullableString(raw.commit),
46918
+ branch: nullableString(raw.branch),
46919
+ dirty: raw.dirty === true,
46920
+ externalBuild: raw.externalBuild === true
46921
+ };
46922
+ }
46923
+ function nullableString(value) {
46924
+ return typeof value === "string" && value.length > 0 ? value : null;
46925
+ }
46926
+ var init_releases_util = () => {};
46927
+
46883
46928
  // ../api-core/src/services/releases.service.ts
46884
46929
  class ReleasesService {
46885
46930
  deps;
@@ -46904,9 +46949,7 @@ class ReleasesService {
46904
46949
  "app.releases.history_requested": history ?? 0,
46905
46950
  "app.releases.history_rows": rows.length
46906
46951
  });
46907
- const releases = rows.map(toRelease);
46908
- const current = releases[0] ?? null;
46909
- return history === undefined ? { current } : { current, history: releases };
46952
+ return toGameReleaseResponse(rows, history);
46910
46953
  }
46911
46954
  }
46912
46955
  function releaseColumns() {
@@ -46923,36 +46966,12 @@ function listedGame() {
46923
46966
  function activeWorkerDeployment() {
46924
46967
  return and(eq(gameDeployments.isActive, true), eq(gameDeployments.target, "game"));
46925
46968
  }
46926
- function toRelease(row) {
46927
- return {
46928
- slug: row.slug,
46929
- url: row.url,
46930
- source: normalizeSource(row.source),
46931
- deployedAt: row.deployedAt.toISOString()
46932
- };
46933
- }
46934
- function normalizeSource(value) {
46935
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
46936
- return null;
46937
- }
46938
- const raw = value;
46939
- const repository = nullableString(raw.repository);
46940
- return {
46941
- repository: repository === null ? null : publishableRepositoryUrl(repository),
46942
- commit: nullableString(raw.commit),
46943
- branch: nullableString(raw.branch),
46944
- dirty: raw.dirty === true,
46945
- externalBuild: raw.externalBuild === true
46946
- };
46947
- }
46948
- function nullableString(value) {
46949
- return typeof value === "string" && value.length > 0 ? value : null;
46950
- }
46951
46969
  var init_releases_service = __esm(() => {
46952
46970
  init_drizzle_orm();
46953
46971
  init_tables_index();
46954
46972
  init_spans();
46955
46973
  init_errors2();
46974
+ init_releases_util();
46956
46975
  });
46957
46976
 
46958
46977
  // ../api-core/src/services/secrets.service.ts
@@ -47408,6 +47427,226 @@ var init_seed_service = __esm(() => {
47408
47427
  init_deployment_util();
47409
47428
  });
47410
47429
 
47430
+ // ../api-core/src/utils/game-worker-call.util.ts
47431
+ function resolveGameWorkerBaseUrl(localGameUrls, slug, deployedUrl) {
47432
+ return (slug ? localGameUrls[slug] : undefined) ?? deployedUrl;
47433
+ }
47434
+ async function callGameWorker(url, token, options) {
47435
+ const controller = new AbortController;
47436
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
47437
+ try {
47438
+ const response = await fetch(url, {
47439
+ method: "GET",
47440
+ headers: {
47441
+ Accept: "application/json",
47442
+ Authorization: `Bearer ${token}`
47443
+ },
47444
+ signal: controller.signal
47445
+ });
47446
+ const body2 = await response.text();
47447
+ return {
47448
+ ok: true,
47449
+ response: new Response(NULL_BODY_STATUSES.has(response.status) ? null : body2, {
47450
+ status: response.status,
47451
+ headers: response.headers
47452
+ })
47453
+ };
47454
+ } catch (error) {
47455
+ if (error instanceof Error && error.name === "AbortError") {
47456
+ return { ok: false, reason: "timeout", details: options.timeoutMessage };
47457
+ }
47458
+ return { ok: false, reason: "fetch_failed", details: errorMessage(error) };
47459
+ } finally {
47460
+ clearTimeout(timeout);
47461
+ }
47462
+ }
47463
+ var NULL_BODY_STATUSES;
47464
+ var init_game_worker_call_util = __esm(() => {
47465
+ NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
47466
+ });
47467
+
47468
+ // ../utils/src/skills.ts
47469
+ function normalizeSkillId(skill) {
47470
+ return skill.trim().toLowerCase();
47471
+ }
47472
+ function normalizeSkillIds(skills2, max) {
47473
+ const normalized = [...new Set(skills2.map(normalizeSkillId).filter((skill) => skill.length > 0))];
47474
+ if (normalized.length === 0) {
47475
+ return { ok: false, error: "At least one skill is required" };
47476
+ }
47477
+ if (normalized.length > max) {
47478
+ return { ok: false, error: `At most ${max} skills can be requested at once` };
47479
+ }
47480
+ return { ok: true, skills: normalized };
47481
+ }
47482
+
47483
+ // ../edge-play/src/game/lib/skills.ts
47484
+ function isValidIsoDate(value) {
47485
+ if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) {
47486
+ return false;
47487
+ }
47488
+ const [year, month, day] = value.slice(0, 10).split("-").map(Number);
47489
+ const date3 = new Date(Date.UTC(year, month - 1, day));
47490
+ return !isNaN(Date.parse(value)) && date3.getUTCFullYear() === year && date3.getUTCMonth() === month - 1 && date3.getUTCDate() === day;
47491
+ }
47492
+ function normalizeResult(result) {
47493
+ if (!isRecord(result)) {
47494
+ return null;
47495
+ }
47496
+ if (typeof result.skill !== "string" || !STATUSES.has(result.status)) {
47497
+ return null;
47498
+ }
47499
+ const status = result.status;
47500
+ if (result.source !== undefined) {
47501
+ if (status !== "mastered" || !MASTERY_SOURCES.has(result.source)) {
47502
+ return null;
47503
+ }
47504
+ }
47505
+ if (result.asOf !== undefined && !isValidIsoDate(result.asOf)) {
47506
+ return null;
47507
+ }
47508
+ return {
47509
+ skill: normalizeSkillId(result.skill),
47510
+ status,
47511
+ ...result.source === undefined ? {} : { source: result.source },
47512
+ ...result.asOf === undefined ? {} : { asOf: result.asOf }
47513
+ };
47514
+ }
47515
+ function validateSkillStatusResponse(value, studentId, askedSkills) {
47516
+ if (!isRecord(value)) {
47517
+ return null;
47518
+ }
47519
+ const response = value;
47520
+ if (response.studentId !== studentId || !Array.isArray(response.results)) {
47521
+ return null;
47522
+ }
47523
+ const asked = new Set(askedSkills);
47524
+ const results = new Map;
47525
+ for (const raw of response.results) {
47526
+ const result = normalizeResult(raw);
47527
+ if (!result || !asked.has(result.skill) || results.has(result.skill)) {
47528
+ return null;
47529
+ }
47530
+ results.set(result.skill, result);
47531
+ }
47532
+ return results.size === asked.size ? results : null;
47533
+ }
47534
+ var STATUSES, MASTERY_SOURCES, ISO_DATE_PATTERN;
47535
+ var init_skills2 = __esm(() => {
47536
+ init_src();
47537
+ STATUSES = new Set(SKILL_STATUSES);
47538
+ MASTERY_SOURCES = new Set(SKILL_MASTERY_SOURCES);
47539
+ ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2}))?$/;
47540
+ });
47541
+
47542
+ // ../api-core/src/utils/skill-status.util.ts
47543
+ function normalizeSkillStatusRequest(skills2) {
47544
+ const normalized = normalizeSkillIds(skills2, SKILL_STATUS_MAX_SKILLS);
47545
+ if (!normalized.ok) {
47546
+ throw new ValidationError(normalized.error);
47547
+ }
47548
+ return normalized.skills;
47549
+ }
47550
+ function buildSkillStatusUrl(baseUrl, skills2) {
47551
+ const url = new URL(SKILL_STATUS_ROUTE, baseUrl);
47552
+ for (const skill of skills2) {
47553
+ url.searchParams.append("skill", skill);
47554
+ }
47555
+ return url;
47556
+ }
47557
+ function pickSkillStatusGames(rows, requestingSlug, answeringSlug) {
47558
+ const requestingGame = rows.find((row) => row.slug === requestingSlug);
47559
+ const answeringGame = rows.find((row) => row.slug === answeringSlug);
47560
+ if (!requestingGame) {
47561
+ throw new NotFoundError("Game", requestingSlug);
47562
+ }
47563
+ if (!answeringGame) {
47564
+ throw new NotFoundError("Game", answeringSlug);
47565
+ }
47566
+ return { requestingGame, answeringGame };
47567
+ }
47568
+ async function classifySkillStatusResponse(response, studentId, skills2) {
47569
+ if (response.status === 404) {
47570
+ return { supported: false, reason: "not_implemented" };
47571
+ }
47572
+ if (!response.ok) {
47573
+ return { supported: false, reason: "fetch_failed" };
47574
+ }
47575
+ let json3;
47576
+ try {
47577
+ json3 = JSON.parse(await response.text());
47578
+ } catch {
47579
+ return { supported: false, reason: "not_implemented" };
47580
+ }
47581
+ const answered = validateSkillStatusResponse(json3, studentId, skills2);
47582
+ if (!answered) {
47583
+ return { supported: false, reason: "invalid_response" };
47584
+ }
47585
+ return { supported: true, results: skills2.map((skill) => answered.get(skill)) };
47586
+ }
47587
+ var init_skill_status_util = __esm(() => {
47588
+ init_src();
47589
+ init_skills2();
47590
+ init_errors2();
47591
+ });
47592
+
47593
+ // ../api-core/src/services/skill-status.service.ts
47594
+ class SkillStatusService {
47595
+ deps;
47596
+ constructor(deps) {
47597
+ this.deps = deps;
47598
+ }
47599
+ async getStatus(input) {
47600
+ const skills2 = normalizeSkillStatusRequest(input.skills);
47601
+ setAttributes({
47602
+ "app.skill_status.requesting_game": input.requestingGameSlug,
47603
+ "app.skill_status.answering_game": input.game,
47604
+ "app.skill_status.skill_count": skills2.length
47605
+ });
47606
+ const rows = await this.deps.db.select({ id: games.id, slug: games.slug }).from(games).where(inArray(games.slug, [input.requestingGameSlug, input.game]));
47607
+ const { requestingGame, answeringGame } = pickSkillStatusGames(rows, input.requestingGameSlug, input.game);
47608
+ const deployment = await this.deps.db.query.gameDeployments.findFirst({
47609
+ where: activeDeploymentWhere(answeringGame.id),
47610
+ columns: { url: true }
47611
+ });
47612
+ const baseUrl = resolveGameWorkerBaseUrl(this.deps.localGameUrls, answeringGame.slug, deployment?.url);
47613
+ if (!baseUrl) {
47614
+ return recordResult({ supported: false, reason: "no_active_deployment" });
47615
+ }
47616
+ const token = await this.deps.mintSkillStatusToken({
47617
+ gameId: answeringGame.id,
47618
+ studentId: input.studentId,
47619
+ requestingGame
47620
+ });
47621
+ const call = await callGameWorker(buildSkillStatusUrl(baseUrl, skills2), token, {
47622
+ timeoutMs: SKILL_STATUS_TIMEOUT_MS,
47623
+ timeoutMessage: "Skill status request timed out"
47624
+ });
47625
+ if (!call.ok) {
47626
+ setAttributes({ "app.skill_status.failure_details": call.details });
47627
+ return recordResult({ supported: false, reason: call.reason });
47628
+ }
47629
+ setAttributes({ "app.skill_status.http_status": call.response.status });
47630
+ return recordResult(await classifySkillStatusResponse(call.response, input.studentId, skills2));
47631
+ }
47632
+ }
47633
+ function recordResult(result) {
47634
+ setAttributes(result.supported ? {
47635
+ "app.skill_status.supported": true,
47636
+ "app.skill_status.unknown_skill_count": result.results.filter((entry) => entry.status === "unknown_skill").length
47637
+ } : { "app.skill_status.supported": false, "app.skill_status.reason": result.reason });
47638
+ return result;
47639
+ }
47640
+ var SKILL_STATUS_TIMEOUT_MS = 3000;
47641
+ var init_skill_status_service = __esm(() => {
47642
+ init_drizzle_orm();
47643
+ init_helpers_index();
47644
+ init_tables_index();
47645
+ init_spans();
47646
+ init_game_worker_call_util();
47647
+ init_skill_status_util();
47648
+ });
47649
+
47411
47650
  // ../../node_modules/.bun/drizzle-zod@0.7.1+3ad0422b77d1a05b/node_modules/drizzle-zod/index.mjs
47412
47651
  function isColumnType(column2, columnTypes) {
47413
47652
  return columnTypes.includes(column2.columnType);
@@ -98257,12 +98496,6 @@ class TimebackAdminService {
98257
98496
  grade: integration.grade
98258
98497
  };
98259
98498
  }
98260
- resolveGameMetricsBaseUrl(slug2, deployedUrl) {
98261
- if (!slug2) {
98262
- return deployedUrl;
98263
- }
98264
- return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
98265
- }
98266
98499
  static resolveGameMetricsUrl(baseUrl, runIds) {
98267
98500
  try {
98268
98501
  const url4 = new URL("/__playcademy/metrics", baseUrl);
@@ -98296,9 +98529,6 @@ class TimebackAdminService {
98296
98529
  }
98297
98530
  return chunks;
98298
98531
  }
98299
- static isAbortError(error88) {
98300
- return error88 instanceof Error && error88.name === "AbortError";
98301
- }
98302
98532
  static roundXpToTenths(value) {
98303
98533
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
98304
98534
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -98936,7 +99166,7 @@ class TimebackAdminService {
98936
99166
  if (enrollmentsByCourseId.size === 0) {
98937
99167
  throw new NotFoundError("Student enrollment", `${timebackId}:${gameId}`);
98938
99168
  }
98939
- const metricsBaseUrl = this.resolveGameMetricsBaseUrl(game2?.slug, deployment?.url);
99169
+ const metricsBaseUrl = resolveGameWorkerBaseUrl(this.deps.config.localGameUrls, game2?.slug, deployment?.url);
98940
99170
  if (!metricsBaseUrl) {
98941
99171
  setAttributes({
98942
99172
  "app.timeback.metrics.supported": false,
@@ -98959,40 +99189,18 @@ class TimebackAdminService {
98959
99189
  };
98960
99190
  }
98961
99191
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
98962
- const controller = new AbortController;
98963
- const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
98964
- let response;
98965
- try {
98966
- response = await fetch(metricsUrl, {
98967
- method: "GET",
98968
- headers: {
98969
- Accept: "application/json",
98970
- Authorization: `Bearer ${token}`
98971
- },
98972
- signal: controller.signal
98973
- });
98974
- } catch (error88) {
98975
- const timedOut = TimebackAdminService.isAbortError(error88);
98976
- let details;
98977
- if (timedOut) {
98978
- details = "Game metrics request timed out";
98979
- } else if (error88 instanceof Error) {
98980
- details = error88.message;
98981
- } else {
98982
- details = String(error88);
98983
- }
99192
+ const call = await callGameWorker(metricsUrl, token, {
99193
+ timeoutMs: options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS,
99194
+ timeoutMessage: "Game metrics request timed out"
99195
+ });
99196
+ if (!call.ok) {
98984
99197
  setAttributes({
98985
99198
  "app.timeback.metrics.supported": false,
98986
- "app.timeback.metrics.reason": timedOut ? "timeout" : "fetch_failed"
99199
+ "app.timeback.metrics.reason": call.reason
98987
99200
  });
98988
- return {
98989
- supported: false,
98990
- reason: timedOut ? "timeout" : "fetch_failed",
98991
- details
98992
- };
98993
- } finally {
98994
- clearTimeout(timeout);
99201
+ return { supported: false, reason: call.reason, details: call.details };
98995
99202
  }
99203
+ const { response } = call;
98996
99204
  const result = await classifyGameMetricsResponse(response, { gameId, timebackId });
98997
99205
  setAttributes({
98998
99206
  "app.timeback.metrics.supported": result.supported,
@@ -99990,6 +100198,7 @@ var init_timeback_admin_service = __esm(async () => {
99990
100198
  init_src9();
99991
100199
  init_timeback3();
99992
100200
  init_errors2();
100201
+ init_game_worker_call_util();
99993
100202
  init_timeback_admin_metrics_util();
99994
100203
  init_timeback_admin_util();
99995
100204
  init_timeback_discrepancy_queue_util();
@@ -109901,6 +110110,7 @@ function createPlatformServices(deps) {
109901
110110
  timebackClient,
109902
110111
  alerts,
109903
110112
  mintPlatformServiceToken,
110113
+ mintSkillStatusToken,
109904
110114
  mintAssessmentPreparationReceipt,
109905
110115
  verifyAssessmentPreparationReceipt,
109906
110116
  mintAssessmentToken,
@@ -109984,6 +110194,11 @@ function createPlatformServices(deps) {
109984
110194
  mintAssessmentToken,
109985
110195
  verifyAssessmentToken
109986
110196
  });
110197
+ const skillStatus = new SkillStatusService({
110198
+ db: db2,
110199
+ localGameUrls: config5.localGameUrls,
110200
+ mintSkillStatusToken
110201
+ });
109987
110202
  return {
109988
110203
  assessmentAssets,
109989
110204
  assessmentArtifacts,
@@ -109996,6 +110211,7 @@ function createPlatformServices(deps) {
109996
110211
  deploymentState,
109997
110212
  database,
109998
110213
  seed,
110214
+ skillStatus,
109999
110215
  timeback: timeback2,
110000
110216
  timebackAdmin,
110001
110217
  timebackAssessmentRuntime,
@@ -110014,6 +110230,7 @@ var init_platform2 = __esm(async () => {
110014
110230
  init_releases_service();
110015
110231
  init_secrets_service();
110016
110232
  init_seed_service();
110233
+ init_skill_status_service();
110017
110234
  init_upload_service();
110018
110235
  await __promiseAll([
110019
110236
  init_timeback_admin_service(),
@@ -110732,6 +110949,7 @@ function createServices(ctx) {
110732
110949
  timebackClient: timeback2,
110733
110950
  alerts: infra2.alerts,
110734
110951
  mintPlatformServiceToken: auth2.mintPlatformServiceToken.bind(auth2),
110952
+ mintSkillStatusToken: auth2.mintSkillStatusToken.bind(auth2),
110735
110953
  mintAssessmentPreparationReceipt: auth2.mintAssessmentPreparationReceipt.bind(auth2),
110736
110954
  verifyAssessmentPreparationReceipt: auth2.verifyAssessmentPreparationReceipt.bind(auth2),
110737
110955
  mintAssessmentToken: auth2.mintAssessmentToken.bind(auth2),
@@ -110780,6 +110998,17 @@ function workerKeyGrantsGameRead(key, slug2) {
110780
110998
  function isGameWorkerKey(key) {
110781
110999
  return Boolean(key?.name?.startsWith(GAME_WORKER_KEY_PREFIX));
110782
111000
  }
111001
+ function gameWorkerKeySlug(key) {
111002
+ if (!isGameWorkerKey(key)) {
111003
+ return null;
111004
+ }
111005
+ const writes = (key?.permissions?.games ?? []).filter((scope) => scope.startsWith("write:"));
111006
+ if (writes.length !== 1) {
111007
+ return null;
111008
+ }
111009
+ const slug2 = writes[0].slice("write:".length);
111010
+ return slug2.length > 0 ? slug2 : null;
111011
+ }
110783
111012
  function deniedWorkerKeyRequest(key, method, pathname) {
110784
111013
  if (isDashboardWorkerKey(key) && !isAllowedDashboardWorkerRequest(method, pathname, key.permissions)) {
110785
111014
  return DASHBOARD_WORKER_KEY_RESTRICTED;
@@ -111156,6 +111385,19 @@ var init_clients = __esm(async () => {
111156
111385
  });
111157
111386
 
111158
111387
  // src/infrastructure/api/providers/auth.provider.ts
111388
+ function mintSandboxServiceToken(audience, claims) {
111389
+ const now2 = Math.floor(Date.now() / 1000);
111390
+ const header = btoa(JSON.stringify({ alg: "none", kid: "sandbox", typ: "sandbox" }));
111391
+ const payload = btoa(JSON.stringify({
111392
+ iss: "playcademy-platform",
111393
+ aud: audience,
111394
+ ...claims,
111395
+ iat: now2,
111396
+ exp: now2 + 60,
111397
+ jti: crypto.randomUUID()
111398
+ }));
111399
+ return `${header}.${payload}.sandbox`;
111400
+ }
111159
111401
  function createSandboxAuthProvider() {
111160
111402
  return {
111161
111403
  async createApiKey(params) {
@@ -111227,18 +111469,10 @@ function createSandboxAuthProvider() {
111227
111469
  }
111228
111470
  },
111229
111471
  async mintPlatformServiceToken(gameId, studentId) {
111230
- const now2 = Math.floor(Date.now() / 1000);
111231
- const header = btoa(JSON.stringify({ alg: "none", kid: "sandbox", typ: "sandbox" }));
111232
- const payload = btoa(JSON.stringify({
111233
- iss: "playcademy-platform",
111234
- aud: "playcademy-game-metrics",
111235
- gameId,
111236
- studentId,
111237
- iat: now2,
111238
- exp: now2 + 60,
111239
- jti: crypto.randomUUID()
111240
- }));
111241
- return `${header}.${payload}.sandbox`;
111472
+ return mintSandboxServiceToken("playcademy-game-metrics", { gameId, studentId });
111473
+ },
111474
+ async mintSkillStatusToken(claims) {
111475
+ return mintSandboxServiceToken(SKILL_STATUS_TOKEN_AUDIENCE, { ...claims });
111242
111476
  },
111243
111477
  async mintAssessmentPreparationReceipt(plan) {
111244
111478
  const now2 = Date.now();
@@ -111318,6 +111552,7 @@ function createSandboxAuthProvider() {
111318
111552
  }
111319
111553
  var sandboxApiKeys, sandboxAssessmentPreparationReceipts, ASSESSMENT_PREPARATION_RECEIPT_LIFETIME_MS, MAX_TOKEN_LIFETIME_SECONDS, sandboxAssessmentTokens;
111320
111554
  var init_auth_provider = __esm(async () => {
111555
+ init_src();
111321
111556
  init_src6();
111322
111557
  init_constants();
111323
111558
  await init_dist5();
@@ -167913,6 +168148,36 @@ var init_session_controller = __esm(() => {
167913
168148
  });
167914
168149
  });
167915
168150
 
168151
+ // ../api-core/src/controllers/skills.controller.ts
168152
+ async function getStatus4(ctx) {
168153
+ const requestingGameSlug = gameWorkerKeySlug(ctx.apiKey);
168154
+ if (!requestingGameSlug) {
168155
+ throw ApiError.forbidden("Skill status can only be requested by a deployed game worker");
168156
+ }
168157
+ const body2 = await parseRequestBody(ctx.request, SkillStatusRequestSchema);
168158
+ return ctx.services.skillStatus.getStatus({ ...body2, requestingGameSlug });
168159
+ }
168160
+ async function getStatusLocally(ctx) {
168161
+ await parseRequestBody(ctx.request, SkillStatusRequestSchema);
168162
+ return { supported: false, reason: "no_active_deployment" };
168163
+ }
168164
+ var SkillStatusRequestSchema, skills2;
168165
+ var init_skills_controller = __esm(() => {
168166
+ init_esm();
168167
+ init_src();
168168
+ init_errors2();
168169
+ init_utils7();
168170
+ SkillStatusRequestSchema = exports_external.object({
168171
+ game: exports_external.string().min(1),
168172
+ studentId: exports_external.string().min(1),
168173
+ skills: exports_external.array(exports_external.string()).min(1).max(SKILL_STATUS_MAX_SKILLS * 2)
168174
+ });
168175
+ skills2 = defineControllerNames("skills", {
168176
+ getStatus: requireAuth(getStatus4),
168177
+ getStatusLocally: requireAuth(getStatusLocally)
168178
+ });
168179
+ });
168180
+
167916
168181
  // ../api-core/src/controllers/timeback.controller.ts
167917
168182
  function parseQtiLibraryParams(searchParams) {
167918
168183
  const requestedPage = Number(searchParams.get("page"));
@@ -168950,6 +169215,7 @@ var init_controllers = __esm(() => {
168950
169215
  init_secrets_controller();
168951
169216
  init_seed_controller();
168952
169217
  init_session_controller();
169218
+ init_skills_controller();
168953
169219
  init_timeback_controller();
168954
169220
  init_upload_controller();
168955
169221
  init_user_controller();
@@ -169763,6 +170029,16 @@ var init_leaderboard = __esm(async () => {
169763
170029
  leaderboardRouter.get("/", handle2(leaderboard.getGlobalLeaderboard));
169764
170030
  });
169765
170031
 
170032
+ // src/routes/platform/skills.ts
170033
+ var skillsRouter;
170034
+ var init_skills3 = __esm(async () => {
170035
+ init_dist7();
170036
+ init_controllers();
170037
+ await init_api3();
170038
+ skillsRouter = new Hono2;
170039
+ skillsRouter.post("/status", handle2(skills2.getStatusLocally));
170040
+ });
170041
+
169766
170042
  // src/routes/integrations/timeback.ts
169767
170043
  function hashCode(str) {
169768
170044
  let hash2 = 0;
@@ -170111,6 +170387,7 @@ var exports_routes = {};
170111
170387
  __export(exports_routes, {
170112
170388
  usersRouter: () => usersRouter,
170113
170389
  timebackRouter: () => timebackRouter,
170390
+ skillsRouter: () => skillsRouter,
170114
170391
  manifestRouter: () => manifestRouter,
170115
170392
  ltiRouter: () => ltiRouter,
170116
170393
  leaderboardRouter: () => leaderboardRouter,
@@ -170127,6 +170404,7 @@ var init_routes = __esm(async () => {
170127
170404
  init_users(),
170128
170405
  init_games2(),
170129
170406
  init_leaderboard(),
170407
+ init_skills3(),
170130
170408
  init_timeback8(),
170131
170409
  init_lti()
170132
170410
  ]);
@@ -170142,6 +170420,7 @@ async function registerRoutes(app) {
170142
170420
  leaderboardRouter: leaderboardRouter2,
170143
170421
  ltiRouter: ltiRouter2,
170144
170422
  manifestRouter: manifestRouter2,
170423
+ skillsRouter: skillsRouter2,
170145
170424
  timebackRouter: timebackRouter2,
170146
170425
  usersRouter: usersRouter2
170147
170426
  } = await init_routes().then(() => exports_routes);
@@ -170150,6 +170429,7 @@ async function registerRoutes(app) {
170150
170429
  app.route("/api/users", usersRouter2);
170151
170430
  app.route("/api/games", gamesRouter2);
170152
170431
  app.route("/api/leaderboard", leaderboardRouter2);
170432
+ app.route("/api/skills", skillsRouter2);
170153
170433
  app.route("/api/dev", devRouter2);
170154
170434
  app.route("/api/timeback", timebackRouter2);
170155
170435
  app.route("/api/lti", ltiRouter2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.10.2-beta.1",
3
+ "version": "0.10.2-beta.2",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {