@playcademy/sandbox 0.3.17-beta.36 → 0.3.17-beta.37

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.
Files changed (3) hide show
  1. package/dist/cli.js +146 -92
  2. package/dist/server.js +146 -92
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1330,7 +1330,7 @@ var package_default;
1330
1330
  var init_package = __esm(() => {
1331
1331
  package_default = {
1332
1332
  name: "@playcademy/sandbox",
1333
- version: "0.3.17-beta.36",
1333
+ version: "0.3.17-beta.37",
1334
1334
  description: "Local development server for Playcademy game development",
1335
1335
  type: "module",
1336
1336
  exports: {
@@ -11297,8 +11297,9 @@ var init_pg_core = __esm(() => {
11297
11297
  });
11298
11298
 
11299
11299
  // ../data/src/domains/game/table.ts
11300
- var gamePlatformEnum, gameTypeEnum, gameVisibilityEnum, games, gameSessions, gameStates, deploymentProviderEnum, deployJobStatusEnum, gameDeployments, gameDeployJobs, customHostnameStatusEnum, customHostnameSslStatusEnum, customHostnameEnvironmentEnum, gameCustomHostnames;
11300
+ var gamePlatformEnum, gameTypeEnum, gameVisibilityEnum, games, gameMemberRoleEnum, gameMembers, gameMembersRelations, gameSessions, gameStates, deploymentProviderEnum, deployJobStatusEnum, gameDeployments, gameDeployJobs, customHostnameStatusEnum, customHostnameSslStatusEnum, customHostnameEnvironmentEnum, gameCustomHostnames;
11301
11301
  var init_table3 = __esm(() => {
11302
+ init_drizzle_orm();
11302
11303
  init_pg_core();
11303
11304
  init_table5();
11304
11305
  init_table6();
@@ -11307,9 +11308,6 @@ var init_table3 = __esm(() => {
11307
11308
  gameVisibilityEnum = pgEnum("game_visibility", ["visible", "unlisted", "internal"]);
11308
11309
  games = pgTable("games", {
11309
11310
  id: uuid("id").primaryKey().defaultRandom(),
11310
- developerId: text("developer_id").references(() => users.id, {
11311
- onDelete: "set null"
11312
- }),
11313
11311
  slug: varchar("slug", { length: 255 }).notNull().unique(),
11314
11312
  displayName: varchar("display_name", { length: 255 }).notNull(),
11315
11313
  version: varchar("version", { length: 50 }).notNull(),
@@ -11325,6 +11323,24 @@ var init_table3 = __esm(() => {
11325
11323
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
11326
11324
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow()
11327
11325
  });
11326
+ gameMemberRoleEnum = pgEnum("game_member_role", ["owner", "collaborator"]);
11327
+ gameMembers = pgTable("game_members", {
11328
+ id: uuid("id").primaryKey().defaultRandom(),
11329
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11330
+ userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
11331
+ role: gameMemberRoleEnum("role").notNull().default("collaborator"),
11332
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11333
+ }, (table3) => [uniqueIndex("game_members_game_user_idx").on(table3.gameId, table3.userId)]);
11334
+ gameMembersRelations = relations(gameMembers, ({ one }) => ({
11335
+ game: one(games, {
11336
+ fields: [gameMembers.gameId],
11337
+ references: [games.id]
11338
+ }),
11339
+ user: one(users, {
11340
+ fields: [gameMembers.userId],
11341
+ references: [users.id]
11342
+ })
11343
+ }));
11328
11344
  gameSessions = pgTable("game_sessions", {
11329
11345
  id: uuid("id").primaryKey().defaultRandom(),
11330
11346
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -12142,6 +12158,9 @@ __export(exports_tables_index, {
12142
12158
  gameScoresRelations: () => gameScoresRelations,
12143
12159
  gameScores: () => gameScores,
12144
12160
  gamePlatformEnum: () => gamePlatformEnum,
12161
+ gameMembersRelations: () => gameMembersRelations,
12162
+ gameMembers: () => gameMembers,
12163
+ gameMemberRoleEnum: () => gameMemberRoleEnum,
12145
12164
  gameDeployments: () => gameDeployments,
12146
12165
  gameDeployJobs: () => gameDeployJobs,
12147
12166
  gameCustomHostnames: () => gameCustomHostnames,
@@ -26776,29 +26795,33 @@ var init_game_service = __esm(() => {
26776
26795
  const db2 = this.deps.db;
26777
26796
  const isAdmin = caller?.role === "admin";
26778
26797
  const isDeveloper = caller?.role === "developer";
26779
- let whereClause;
26780
26798
  if (isAdmin) {
26781
- whereClause = undefined;
26782
- } else if (isDeveloper && caller?.id) {
26783
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
26784
- } else {
26785
- whereClause = ne(games.visibility, "internal");
26799
+ return db2.query.games.findMany({
26800
+ orderBy: [desc(games.createdAt)]
26801
+ });
26802
+ }
26803
+ if (isDeveloper && caller?.id) {
26804
+ const rows = await db2.select().from(games).where(or(ne(games.visibility, "internal"), exists(db2.select({ one: sql`1` }).from(gameMembers).where(and(eq(gameMembers.gameId, games.id), eq(gameMembers.userId, caller.id)))))).orderBy(desc(games.createdAt));
26805
+ return rows;
26786
26806
  }
26787
26807
  return db2.query.games.findMany({
26788
- where: whereClause,
26808
+ where: ne(games.visibility, "internal"),
26789
26809
  orderBy: [desc(games.createdAt)]
26790
26810
  });
26791
26811
  }
26792
- async listManageable(user) {
26812
+ async listAccessible(user) {
26793
26813
  const seesAllGames = user.role === "admin" || user.role === "teacher";
26794
26814
  if (!seesAllGames) {
26795
26815
  this.validateDeveloperStatus(user);
26796
26816
  }
26797
26817
  const db2 = this.deps.db;
26798
- return db2.query.games.findMany({
26799
- where: seesAllGames ? undefined : eq(games.developerId, user.id),
26800
- orderBy: [desc(games.createdAt)]
26801
- });
26818
+ if (seesAllGames) {
26819
+ return db2.query.games.findMany({
26820
+ orderBy: [desc(games.createdAt)]
26821
+ });
26822
+ }
26823
+ const rows = await db2.select({ games }).from(games).innerJoin(gameMembers, eq(gameMembers.gameId, games.id)).where(eq(gameMembers.userId, user.id)).orderBy(desc(games.createdAt));
26824
+ return rows.map((r) => r.games);
26802
26825
  }
26803
26826
  async getSubjects() {
26804
26827
  const db2 = this.deps.db;
@@ -26822,7 +26845,7 @@ var init_game_service = __esm(() => {
26822
26845
  if (!game) {
26823
26846
  throw new NotFoundError("Game", gameId);
26824
26847
  }
26825
- this.enforceVisibility(game, caller, gameId);
26848
+ await this.enforceVisibility(game, caller, gameId);
26826
26849
  return game;
26827
26850
  }
26828
26851
  async getBySlug(slug, caller) {
@@ -26833,7 +26856,7 @@ var init_game_service = __esm(() => {
26833
26856
  if (!game) {
26834
26857
  throw new NotFoundError("Game", slug);
26835
26858
  }
26836
- this.enforceVisibility(game, caller, slug);
26859
+ await this.enforceVisibility(game, caller, slug);
26837
26860
  return game;
26838
26861
  }
26839
26862
  async getManifest(gameId, caller) {
@@ -26999,15 +27022,20 @@ var init_game_service = __esm(() => {
26999
27022
  };
27000
27023
  }
27001
27024
  }
27002
- enforceVisibility(game, caller, lookupIdentifier) {
27025
+ async enforceVisibility(game, caller, lookupIdentifier) {
27003
27026
  if (game.visibility !== "internal") {
27004
27027
  return;
27005
27028
  }
27006
- const isAdmin = caller?.role === "admin";
27007
- const isOwner = caller?.id != null && caller.id === game.developerId;
27008
- if (!isAdmin && !isOwner) {
27029
+ if (!caller) {
27009
27030
  throw new NotFoundError("Game", lookupIdentifier);
27010
27031
  }
27032
+ if (caller.role === "admin") {
27033
+ return;
27034
+ }
27035
+ if (await this.hasGameMembership(game.id, caller.id)) {
27036
+ return;
27037
+ }
27038
+ throw new NotFoundError("Game", lookupIdentifier);
27011
27039
  }
27012
27040
  async upsertBySlug(slug, data, user) {
27013
27041
  const db2 = this.deps.db;
@@ -27044,17 +27072,24 @@ var init_game_service = __esm(() => {
27044
27072
  ...gameDataForDb,
27045
27073
  id: gameId,
27046
27074
  slug,
27047
- developerId: user.id,
27048
27075
  metadata: data.metadata || {},
27049
27076
  version: data.gameType === "external" ? "external" : "",
27050
27077
  deploymentUrl: null,
27051
27078
  createdAt: new Date
27052
27079
  };
27053
- const [createdGame] = await db2.insert(games).values(insertData).returning();
27054
- if (!createdGame) {
27055
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
27056
- throw new InternalError("DB insert failed to return result for new game");
27057
- }
27080
+ const createdGame = await db2.transaction(async (tx) => {
27081
+ const [game] = await tx.insert(games).values(insertData).returning();
27082
+ if (!game) {
27083
+ logger5.error("Game insert returned no rows", { slug, userId: user.id });
27084
+ throw new InternalError("DB insert failed to return result for new game");
27085
+ }
27086
+ await tx.insert(gameMembers).values({
27087
+ gameId: game.id,
27088
+ userId: user.id,
27089
+ role: "owner"
27090
+ });
27091
+ return game;
27092
+ });
27058
27093
  gameResponse = createdGame;
27059
27094
  }
27060
27095
  if (data.mapElementId) {
@@ -27152,51 +27187,43 @@ var init_game_service = __esm(() => {
27152
27187
  displayName: gameToDelete.displayName
27153
27188
  };
27154
27189
  }
27190
+ async hasGameMembership(gameId, userId) {
27191
+ const membership = await this.deps.db.query.gameMembers.findFirst({
27192
+ where: and(eq(gameMembers.gameId, gameId), eq(gameMembers.userId, userId)),
27193
+ columns: { id: true }
27194
+ });
27195
+ return Boolean(membership);
27196
+ }
27155
27197
  async validateOwnership(user, gameId) {
27156
- if (user.role === "admin") {
27157
- const gameExists = await this.deps.db.query.games.findFirst({
27158
- where: eq(games.id, gameId),
27159
- columns: { id: true }
27160
- });
27161
- if (!gameExists) {
27162
- throw new NotFoundError("Game", gameId);
27163
- }
27164
- return;
27165
- }
27166
27198
  const db2 = this.deps.db;
27167
- const gameOwnership = await db2.query.games.findFirst({
27168
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27199
+ const gameExists = await db2.query.games.findFirst({
27200
+ where: eq(games.id, gameId),
27169
27201
  columns: { id: true }
27170
27202
  });
27171
- if (!gameOwnership) {
27172
- const gameExists = await db2.query.games.findFirst({
27173
- where: eq(games.id, gameId),
27174
- columns: { id: true }
27175
- });
27176
- if (!gameExists) {
27177
- throw new NotFoundError("Game", gameId);
27178
- }
27203
+ if (!gameExists) {
27204
+ throw new NotFoundError("Game", gameId);
27205
+ }
27206
+ if (user.role === "admin") {
27207
+ return;
27208
+ }
27209
+ if (!await this.hasGameMembership(gameId, user.id)) {
27179
27210
  throw new AccessDeniedError("You do not own this game");
27180
27211
  }
27181
27212
  }
27182
27213
  async validateDeveloperAccess(user, gameId) {
27183
27214
  this.validateDeveloperStatus(user);
27184
- if (user.role === "admin") {
27185
- const gameExists = await this.deps.db.query.games.findFirst({
27186
- where: eq(games.id, gameId),
27187
- columns: { id: true }
27188
- });
27189
- if (!gameExists) {
27190
- throw new NotFoundError("Game", gameId);
27191
- }
27192
- return;
27193
- }
27194
27215
  const db2 = this.deps.db;
27195
- const existingGame = await db2.query.games.findFirst({
27196
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27216
+ const gameExists = await db2.query.games.findFirst({
27217
+ where: eq(games.id, gameId),
27197
27218
  columns: { id: true }
27198
27219
  });
27199
- if (!existingGame) {
27220
+ if (!gameExists) {
27221
+ throw new NotFoundError("Game", gameId);
27222
+ }
27223
+ if (user.role === "admin") {
27224
+ return;
27225
+ }
27226
+ if (!await this.hasGameMembership(gameId, user.id)) {
27200
27227
  throw new NotFoundError("Game", gameId);
27201
27228
  }
27202
27229
  }
@@ -27216,21 +27243,18 @@ var init_game_service = __esm(() => {
27216
27243
  async validateDeveloperAccessBySlug(user, slug) {
27217
27244
  this.validateDeveloperStatus(user);
27218
27245
  const db2 = this.deps.db;
27219
- if (user.role === "admin") {
27220
- const game2 = await db2.query.games.findFirst({
27221
- where: eq(games.slug, slug)
27222
- });
27223
- if (!game2) {
27224
- throw new NotFoundError("Game", slug);
27225
- }
27226
- return game2;
27227
- }
27228
27246
  const game = await db2.query.games.findFirst({
27229
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
27247
+ where: eq(games.slug, slug)
27230
27248
  });
27231
27249
  if (!game) {
27232
27250
  throw new NotFoundError("Game", slug);
27233
27251
  }
27252
+ if (user.role === "admin") {
27253
+ return game;
27254
+ }
27255
+ if (!await this.hasGameMembership(game.id, user.id)) {
27256
+ throw new NotFoundError("Game", slug);
27257
+ }
27234
27258
  return game;
27235
27259
  }
27236
27260
  validateDeveloperStatus(user) {
@@ -34879,10 +34903,17 @@ class LogsService {
34879
34903
  throw new AccessDeniedError("Must be an approved developer");
34880
34904
  }
34881
34905
  const game = await db2.query.games.findFirst({
34882
- where: and(eq(games.slug, slug2), eq(games.developerId, user.id)),
34906
+ where: eq(games.slug, slug2),
34883
34907
  columns: { id: true }
34884
34908
  });
34885
34909
  if (!game) {
34910
+ throw new NotFoundError("Game", slug2);
34911
+ }
34912
+ const membership = await db2.query.gameMembers.findFirst({
34913
+ where: and(eq(gameMembers.gameId, game.id), eq(gameMembers.userId, user.id)),
34914
+ columns: { id: true }
34915
+ });
34916
+ if (!membership) {
34886
34917
  logger28.warn("Developer attempted access to unowned game logs", {
34887
34918
  userId: user.id,
34888
34919
  slug: slug2
@@ -93763,7 +93794,6 @@ async function seedCoreGames(db2) {
93763
93794
  const coreGames = [
93764
93795
  {
93765
93796
  id: CORE_GAME_UUIDS.PLAYGROUND,
93766
- developerId: DEMO_USERS.developer.id,
93767
93797
  slug: "playground",
93768
93798
  displayName: "Playground",
93769
93799
  version: "local",
@@ -93780,6 +93810,11 @@ async function seedCoreGames(db2) {
93780
93810
  for (const gameData of coreGames) {
93781
93811
  try {
93782
93812
  await db2.insert(games).values(gameData).onConflictDoNothing();
93813
+ await db2.insert(gameMembers).values({
93814
+ gameId: gameData.id,
93815
+ userId: DEMO_USERS.developer.id,
93816
+ role: "owner"
93817
+ }).onConflictDoNothing();
93783
93818
  } catch (error2) {
93784
93819
  logger37.error(`Error seeding core game '${gameData.slug}': ${error2}`);
93785
93820
  }
@@ -93804,7 +93839,6 @@ async function seedCurrentProjectGame(db2, project) {
93804
93839
  }
93805
93840
  const gameRecord = {
93806
93841
  id: desiredGameId ?? crypto.randomUUID(),
93807
- developerId: DEMO_USERS.developer.id,
93808
93842
  slug: project.slug,
93809
93843
  displayName: project.displayName,
93810
93844
  version: project.version,
@@ -93822,6 +93856,11 @@ async function seedCurrentProjectGame(db2, project) {
93822
93856
  if (!newGame) {
93823
93857
  throw new Error("Failed to create game record");
93824
93858
  }
93859
+ await db2.insert(gameMembers).values({
93860
+ gameId: newGame.id,
93861
+ userId: DEMO_USERS.developer.id,
93862
+ role: "owner"
93863
+ }).onConflictDoNothing();
93825
93864
  if (project.timebackCourses && project.timebackCourses.length > 0) {
93826
93865
  await seedTimebackIntegrations(db2, newGame.id, project.timebackCourses);
93827
93866
  }
@@ -94810,7 +94849,6 @@ var init_schemas2 = __esm(() => {
94810
94849
  id: true,
94811
94850
  slug: true,
94812
94851
  createdAt: true,
94813
- developerId: true,
94814
94852
  version: true
94815
94853
  }).refine((data) => {
94816
94854
  if (data.gameType === "hosted" && data.deploymentUrl === null) {
@@ -96212,7 +96250,7 @@ var init_domain_controller = __esm(() => {
96212
96250
  });
96213
96251
 
96214
96252
  // ../api-core/src/controllers/game.controller.ts
96215
- var logger48, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
96253
+ var logger48, list3, listAccessible, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
96216
96254
  var init_game_controller = __esm(() => {
96217
96255
  init_esm();
96218
96256
  init_schemas_index();
@@ -96225,9 +96263,9 @@ var init_game_controller = __esm(() => {
96225
96263
  logger48.debug("Listing games", { userId: ctx.user.id });
96226
96264
  return ctx.services.game.list(ctx.user);
96227
96265
  });
96228
- listManageable = requireNonAnonymous(async (ctx) => {
96229
- logger48.debug("Listing manageable games", { userId: ctx.user.id });
96230
- return ctx.services.game.listManageable(ctx.user);
96266
+ listAccessible = requireNonAnonymous(async (ctx) => {
96267
+ logger48.debug("Listing accessible games", { userId: ctx.user.id });
96268
+ return ctx.services.game.listAccessible(ctx.user);
96231
96269
  });
96232
96270
  getSubjects = requireNonAnonymous(async (ctx) => {
96233
96271
  logger48.debug("Getting game subjects", { userId: ctx.user.id });
@@ -96300,7 +96338,7 @@ var init_game_controller = __esm(() => {
96300
96338
  });
96301
96339
  games2 = {
96302
96340
  list: list3,
96303
- listManageable,
96341
+ listAccessible,
96304
96342
  getSubjects,
96305
96343
  getById: getById2,
96306
96344
  getManifest,
@@ -98421,6 +98459,7 @@ var init_crud = __esm(() => {
98421
98459
  init_api();
98422
98460
  gameCrudRouter = new Hono2;
98423
98461
  gameCrudRouter.get("/", handle2(games2.list));
98462
+ gameCrudRouter.get("/accessible", handle2(games2.listAccessible));
98424
98463
  gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
98425
98464
  gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
98426
98465
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
@@ -98563,10 +98602,12 @@ var init_deploy = __esm(() => {
98563
98602
  const db2 = ctx.db;
98564
98603
  const existingGames = await db2.select().from(games).where(eq(games.slug, slug2));
98565
98604
  const existingGame = existingGames[0];
98566
- if (existingGame) {
98567
- const isAdmin = user.role === "admin";
98568
- const isOwner = existingGame.developerId === user.id;
98569
- if (!isAdmin && !isOwner) {
98605
+ if (existingGame && user.role !== "admin") {
98606
+ const membership = await db2.query.gameMembers.findFirst({
98607
+ where: and(eq(gameMembers.gameId, existingGame.id), eq(gameMembers.userId, user.id)),
98608
+ columns: { id: true }
98609
+ });
98610
+ if (!membership) {
98570
98611
  return c2.json({
98571
98612
  error: {
98572
98613
  code: "FORBIDDEN",
@@ -98590,17 +98631,24 @@ var init_deploy = __esm(() => {
98590
98631
  if (!body2.metadata?.displayName) {
98591
98632
  return c2.json({ error: { code: "BAD_REQUEST", message: "Display name required for new game" } }, 400);
98592
98633
  }
98593
- const inserted = await db2.insert(games).values({
98634
+ const gameValues = {
98594
98635
  slug: slug2,
98595
98636
  displayName: body2.metadata.displayName,
98596
98637
  version: "1.0.0",
98597
98638
  platform: body2.metadata.platform ?? "web",
98598
98639
  gameType: "hosted",
98599
- developerId: user.id,
98600
98640
  deploymentUrl: `http://localhost:4321`,
98601
98641
  metadata: body2.metadata.metadata ?? {}
98602
- }).returning();
98603
- game = inserted[0];
98642
+ };
98643
+ game = await db2.transaction(async (tx) => {
98644
+ const [inserted] = await tx.insert(games).values(gameValues).returning();
98645
+ await tx.insert(gameMembers).values({
98646
+ gameId: inserted.id,
98647
+ userId: user.id,
98648
+ role: "owner"
98649
+ });
98650
+ return inserted;
98651
+ });
98604
98652
  }
98605
98653
  let backendCode = body2.code;
98606
98654
  if (!backendCode && body2.codeUploadToken) {
@@ -98679,13 +98727,19 @@ var init_deploy = __esm(() => {
98679
98727
  const ctx = getSandboxContext();
98680
98728
  const game = await ctx.db.query.games.findFirst({
98681
98729
  where: eq(games.slug, slug2 ?? ""),
98682
- columns: { id: true, developerId: true }
98730
+ columns: { id: true }
98683
98731
  });
98684
98732
  if (!game) {
98685
98733
  return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98686
98734
  }
98687
- if (game.developerId !== user.id && user.role !== "admin") {
98688
- return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98735
+ if (user.role !== "admin") {
98736
+ const membership = await ctx.db.query.gameMembers.findFirst({
98737
+ where: and(eq(gameMembers.gameId, game.id), eq(gameMembers.userId, user.id)),
98738
+ columns: { id: true }
98739
+ });
98740
+ if (!membership) {
98741
+ return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98742
+ }
98689
98743
  }
98690
98744
  const job = await ctx.db.query.gameDeployJobs.findFirst({
98691
98745
  where: and(eq(gameDeployJobs.id, jobId), eq(gameDeployJobs.gameId, game.id))
package/dist/server.js CHANGED
@@ -1329,7 +1329,7 @@ var package_default;
1329
1329
  var init_package = __esm(() => {
1330
1330
  package_default = {
1331
1331
  name: "@playcademy/sandbox",
1332
- version: "0.3.17-beta.36",
1332
+ version: "0.3.17-beta.37",
1333
1333
  description: "Local development server for Playcademy game development",
1334
1334
  type: "module",
1335
1335
  exports: {
@@ -11296,8 +11296,9 @@ var init_pg_core = __esm(() => {
11296
11296
  });
11297
11297
 
11298
11298
  // ../data/src/domains/game/table.ts
11299
- var gamePlatformEnum, gameTypeEnum, gameVisibilityEnum, games, gameSessions, gameStates, deploymentProviderEnum, deployJobStatusEnum, gameDeployments, gameDeployJobs, customHostnameStatusEnum, customHostnameSslStatusEnum, customHostnameEnvironmentEnum, gameCustomHostnames;
11299
+ var gamePlatformEnum, gameTypeEnum, gameVisibilityEnum, games, gameMemberRoleEnum, gameMembers, gameMembersRelations, gameSessions, gameStates, deploymentProviderEnum, deployJobStatusEnum, gameDeployments, gameDeployJobs, customHostnameStatusEnum, customHostnameSslStatusEnum, customHostnameEnvironmentEnum, gameCustomHostnames;
11300
11300
  var init_table3 = __esm(() => {
11301
+ init_drizzle_orm();
11301
11302
  init_pg_core();
11302
11303
  init_table5();
11303
11304
  init_table6();
@@ -11306,9 +11307,6 @@ var init_table3 = __esm(() => {
11306
11307
  gameVisibilityEnum = pgEnum("game_visibility", ["visible", "unlisted", "internal"]);
11307
11308
  games = pgTable("games", {
11308
11309
  id: uuid("id").primaryKey().defaultRandom(),
11309
- developerId: text("developer_id").references(() => users.id, {
11310
- onDelete: "set null"
11311
- }),
11312
11310
  slug: varchar("slug", { length: 255 }).notNull().unique(),
11313
11311
  displayName: varchar("display_name", { length: 255 }).notNull(),
11314
11312
  version: varchar("version", { length: 50 }).notNull(),
@@ -11324,6 +11322,24 @@ var init_table3 = __esm(() => {
11324
11322
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
11325
11323
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow()
11326
11324
  });
11325
+ gameMemberRoleEnum = pgEnum("game_member_role", ["owner", "collaborator"]);
11326
+ gameMembers = pgTable("game_members", {
11327
+ id: uuid("id").primaryKey().defaultRandom(),
11328
+ gameId: uuid("game_id").notNull().references(() => games.id, { onDelete: "cascade" }),
11329
+ userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
11330
+ role: gameMemberRoleEnum("role").notNull().default("collaborator"),
11331
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
11332
+ }, (table3) => [uniqueIndex("game_members_game_user_idx").on(table3.gameId, table3.userId)]);
11333
+ gameMembersRelations = relations(gameMembers, ({ one }) => ({
11334
+ game: one(games, {
11335
+ fields: [gameMembers.gameId],
11336
+ references: [games.id]
11337
+ }),
11338
+ user: one(users, {
11339
+ fields: [gameMembers.userId],
11340
+ references: [users.id]
11341
+ })
11342
+ }));
11327
11343
  gameSessions = pgTable("game_sessions", {
11328
11344
  id: uuid("id").primaryKey().defaultRandom(),
11329
11345
  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -12141,6 +12157,9 @@ __export(exports_tables_index, {
12141
12157
  gameScoresRelations: () => gameScoresRelations,
12142
12158
  gameScores: () => gameScores,
12143
12159
  gamePlatformEnum: () => gamePlatformEnum,
12160
+ gameMembersRelations: () => gameMembersRelations,
12161
+ gameMembers: () => gameMembers,
12162
+ gameMemberRoleEnum: () => gameMemberRoleEnum,
12144
12163
  gameDeployments: () => gameDeployments,
12145
12164
  gameDeployJobs: () => gameDeployJobs,
12146
12165
  gameCustomHostnames: () => gameCustomHostnames,
@@ -26775,29 +26794,33 @@ var init_game_service = __esm(() => {
26775
26794
  const db2 = this.deps.db;
26776
26795
  const isAdmin = caller?.role === "admin";
26777
26796
  const isDeveloper = caller?.role === "developer";
26778
- let whereClause;
26779
26797
  if (isAdmin) {
26780
- whereClause = undefined;
26781
- } else if (isDeveloper && caller?.id) {
26782
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
26783
- } else {
26784
- whereClause = ne(games.visibility, "internal");
26798
+ return db2.query.games.findMany({
26799
+ orderBy: [desc(games.createdAt)]
26800
+ });
26801
+ }
26802
+ if (isDeveloper && caller?.id) {
26803
+ const rows = await db2.select().from(games).where(or(ne(games.visibility, "internal"), exists(db2.select({ one: sql`1` }).from(gameMembers).where(and(eq(gameMembers.gameId, games.id), eq(gameMembers.userId, caller.id)))))).orderBy(desc(games.createdAt));
26804
+ return rows;
26785
26805
  }
26786
26806
  return db2.query.games.findMany({
26787
- where: whereClause,
26807
+ where: ne(games.visibility, "internal"),
26788
26808
  orderBy: [desc(games.createdAt)]
26789
26809
  });
26790
26810
  }
26791
- async listManageable(user) {
26811
+ async listAccessible(user) {
26792
26812
  const seesAllGames = user.role === "admin" || user.role === "teacher";
26793
26813
  if (!seesAllGames) {
26794
26814
  this.validateDeveloperStatus(user);
26795
26815
  }
26796
26816
  const db2 = this.deps.db;
26797
- return db2.query.games.findMany({
26798
- where: seesAllGames ? undefined : eq(games.developerId, user.id),
26799
- orderBy: [desc(games.createdAt)]
26800
- });
26817
+ if (seesAllGames) {
26818
+ return db2.query.games.findMany({
26819
+ orderBy: [desc(games.createdAt)]
26820
+ });
26821
+ }
26822
+ const rows = await db2.select({ games }).from(games).innerJoin(gameMembers, eq(gameMembers.gameId, games.id)).where(eq(gameMembers.userId, user.id)).orderBy(desc(games.createdAt));
26823
+ return rows.map((r) => r.games);
26801
26824
  }
26802
26825
  async getSubjects() {
26803
26826
  const db2 = this.deps.db;
@@ -26821,7 +26844,7 @@ var init_game_service = __esm(() => {
26821
26844
  if (!game) {
26822
26845
  throw new NotFoundError("Game", gameId);
26823
26846
  }
26824
- this.enforceVisibility(game, caller, gameId);
26847
+ await this.enforceVisibility(game, caller, gameId);
26825
26848
  return game;
26826
26849
  }
26827
26850
  async getBySlug(slug, caller) {
@@ -26832,7 +26855,7 @@ var init_game_service = __esm(() => {
26832
26855
  if (!game) {
26833
26856
  throw new NotFoundError("Game", slug);
26834
26857
  }
26835
- this.enforceVisibility(game, caller, slug);
26858
+ await this.enforceVisibility(game, caller, slug);
26836
26859
  return game;
26837
26860
  }
26838
26861
  async getManifest(gameId, caller) {
@@ -26998,15 +27021,20 @@ var init_game_service = __esm(() => {
26998
27021
  };
26999
27022
  }
27000
27023
  }
27001
- enforceVisibility(game, caller, lookupIdentifier) {
27024
+ async enforceVisibility(game, caller, lookupIdentifier) {
27002
27025
  if (game.visibility !== "internal") {
27003
27026
  return;
27004
27027
  }
27005
- const isAdmin = caller?.role === "admin";
27006
- const isOwner = caller?.id != null && caller.id === game.developerId;
27007
- if (!isAdmin && !isOwner) {
27028
+ if (!caller) {
27008
27029
  throw new NotFoundError("Game", lookupIdentifier);
27009
27030
  }
27031
+ if (caller.role === "admin") {
27032
+ return;
27033
+ }
27034
+ if (await this.hasGameMembership(game.id, caller.id)) {
27035
+ return;
27036
+ }
27037
+ throw new NotFoundError("Game", lookupIdentifier);
27010
27038
  }
27011
27039
  async upsertBySlug(slug, data, user) {
27012
27040
  const db2 = this.deps.db;
@@ -27043,17 +27071,24 @@ var init_game_service = __esm(() => {
27043
27071
  ...gameDataForDb,
27044
27072
  id: gameId,
27045
27073
  slug,
27046
- developerId: user.id,
27047
27074
  metadata: data.metadata || {},
27048
27075
  version: data.gameType === "external" ? "external" : "",
27049
27076
  deploymentUrl: null,
27050
27077
  createdAt: new Date
27051
27078
  };
27052
- const [createdGame] = await db2.insert(games).values(insertData).returning();
27053
- if (!createdGame) {
27054
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
27055
- throw new InternalError("DB insert failed to return result for new game");
27056
- }
27079
+ const createdGame = await db2.transaction(async (tx) => {
27080
+ const [game] = await tx.insert(games).values(insertData).returning();
27081
+ if (!game) {
27082
+ logger5.error("Game insert returned no rows", { slug, userId: user.id });
27083
+ throw new InternalError("DB insert failed to return result for new game");
27084
+ }
27085
+ await tx.insert(gameMembers).values({
27086
+ gameId: game.id,
27087
+ userId: user.id,
27088
+ role: "owner"
27089
+ });
27090
+ return game;
27091
+ });
27057
27092
  gameResponse = createdGame;
27058
27093
  }
27059
27094
  if (data.mapElementId) {
@@ -27151,51 +27186,43 @@ var init_game_service = __esm(() => {
27151
27186
  displayName: gameToDelete.displayName
27152
27187
  };
27153
27188
  }
27189
+ async hasGameMembership(gameId, userId) {
27190
+ const membership = await this.deps.db.query.gameMembers.findFirst({
27191
+ where: and(eq(gameMembers.gameId, gameId), eq(gameMembers.userId, userId)),
27192
+ columns: { id: true }
27193
+ });
27194
+ return Boolean(membership);
27195
+ }
27154
27196
  async validateOwnership(user, gameId) {
27155
- if (user.role === "admin") {
27156
- const gameExists = await this.deps.db.query.games.findFirst({
27157
- where: eq(games.id, gameId),
27158
- columns: { id: true }
27159
- });
27160
- if (!gameExists) {
27161
- throw new NotFoundError("Game", gameId);
27162
- }
27163
- return;
27164
- }
27165
27197
  const db2 = this.deps.db;
27166
- const gameOwnership = await db2.query.games.findFirst({
27167
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27198
+ const gameExists = await db2.query.games.findFirst({
27199
+ where: eq(games.id, gameId),
27168
27200
  columns: { id: true }
27169
27201
  });
27170
- if (!gameOwnership) {
27171
- const gameExists = await db2.query.games.findFirst({
27172
- where: eq(games.id, gameId),
27173
- columns: { id: true }
27174
- });
27175
- if (!gameExists) {
27176
- throw new NotFoundError("Game", gameId);
27177
- }
27202
+ if (!gameExists) {
27203
+ throw new NotFoundError("Game", gameId);
27204
+ }
27205
+ if (user.role === "admin") {
27206
+ return;
27207
+ }
27208
+ if (!await this.hasGameMembership(gameId, user.id)) {
27178
27209
  throw new AccessDeniedError("You do not own this game");
27179
27210
  }
27180
27211
  }
27181
27212
  async validateDeveloperAccess(user, gameId) {
27182
27213
  this.validateDeveloperStatus(user);
27183
- if (user.role === "admin") {
27184
- const gameExists = await this.deps.db.query.games.findFirst({
27185
- where: eq(games.id, gameId),
27186
- columns: { id: true }
27187
- });
27188
- if (!gameExists) {
27189
- throw new NotFoundError("Game", gameId);
27190
- }
27191
- return;
27192
- }
27193
27214
  const db2 = this.deps.db;
27194
- const existingGame = await db2.query.games.findFirst({
27195
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27215
+ const gameExists = await db2.query.games.findFirst({
27216
+ where: eq(games.id, gameId),
27196
27217
  columns: { id: true }
27197
27218
  });
27198
- if (!existingGame) {
27219
+ if (!gameExists) {
27220
+ throw new NotFoundError("Game", gameId);
27221
+ }
27222
+ if (user.role === "admin") {
27223
+ return;
27224
+ }
27225
+ if (!await this.hasGameMembership(gameId, user.id)) {
27199
27226
  throw new NotFoundError("Game", gameId);
27200
27227
  }
27201
27228
  }
@@ -27215,21 +27242,18 @@ var init_game_service = __esm(() => {
27215
27242
  async validateDeveloperAccessBySlug(user, slug) {
27216
27243
  this.validateDeveloperStatus(user);
27217
27244
  const db2 = this.deps.db;
27218
- if (user.role === "admin") {
27219
- const game2 = await db2.query.games.findFirst({
27220
- where: eq(games.slug, slug)
27221
- });
27222
- if (!game2) {
27223
- throw new NotFoundError("Game", slug);
27224
- }
27225
- return game2;
27226
- }
27227
27245
  const game = await db2.query.games.findFirst({
27228
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
27246
+ where: eq(games.slug, slug)
27229
27247
  });
27230
27248
  if (!game) {
27231
27249
  throw new NotFoundError("Game", slug);
27232
27250
  }
27251
+ if (user.role === "admin") {
27252
+ return game;
27253
+ }
27254
+ if (!await this.hasGameMembership(game.id, user.id)) {
27255
+ throw new NotFoundError("Game", slug);
27256
+ }
27233
27257
  return game;
27234
27258
  }
27235
27259
  validateDeveloperStatus(user) {
@@ -34878,10 +34902,17 @@ class LogsService {
34878
34902
  throw new AccessDeniedError("Must be an approved developer");
34879
34903
  }
34880
34904
  const game = await db2.query.games.findFirst({
34881
- where: and(eq(games.slug, slug2), eq(games.developerId, user.id)),
34905
+ where: eq(games.slug, slug2),
34882
34906
  columns: { id: true }
34883
34907
  });
34884
34908
  if (!game) {
34909
+ throw new NotFoundError("Game", slug2);
34910
+ }
34911
+ const membership = await db2.query.gameMembers.findFirst({
34912
+ where: and(eq(gameMembers.gameId, game.id), eq(gameMembers.userId, user.id)),
34913
+ columns: { id: true }
34914
+ });
34915
+ if (!membership) {
34885
34916
  logger28.warn("Developer attempted access to unowned game logs", {
34886
34917
  userId: user.id,
34887
34918
  slug: slug2
@@ -93762,7 +93793,6 @@ async function seedCoreGames(db2) {
93762
93793
  const coreGames = [
93763
93794
  {
93764
93795
  id: CORE_GAME_UUIDS.PLAYGROUND,
93765
- developerId: DEMO_USERS.developer.id,
93766
93796
  slug: "playground",
93767
93797
  displayName: "Playground",
93768
93798
  version: "local",
@@ -93779,6 +93809,11 @@ async function seedCoreGames(db2) {
93779
93809
  for (const gameData of coreGames) {
93780
93810
  try {
93781
93811
  await db2.insert(games).values(gameData).onConflictDoNothing();
93812
+ await db2.insert(gameMembers).values({
93813
+ gameId: gameData.id,
93814
+ userId: DEMO_USERS.developer.id,
93815
+ role: "owner"
93816
+ }).onConflictDoNothing();
93782
93817
  } catch (error2) {
93783
93818
  logger37.error(`Error seeding core game '${gameData.slug}': ${error2}`);
93784
93819
  }
@@ -93803,7 +93838,6 @@ async function seedCurrentProjectGame(db2, project) {
93803
93838
  }
93804
93839
  const gameRecord = {
93805
93840
  id: desiredGameId ?? crypto.randomUUID(),
93806
- developerId: DEMO_USERS.developer.id,
93807
93841
  slug: project.slug,
93808
93842
  displayName: project.displayName,
93809
93843
  version: project.version,
@@ -93821,6 +93855,11 @@ async function seedCurrentProjectGame(db2, project) {
93821
93855
  if (!newGame) {
93822
93856
  throw new Error("Failed to create game record");
93823
93857
  }
93858
+ await db2.insert(gameMembers).values({
93859
+ gameId: newGame.id,
93860
+ userId: DEMO_USERS.developer.id,
93861
+ role: "owner"
93862
+ }).onConflictDoNothing();
93824
93863
  if (project.timebackCourses && project.timebackCourses.length > 0) {
93825
93864
  await seedTimebackIntegrations(db2, newGame.id, project.timebackCourses);
93826
93865
  }
@@ -94809,7 +94848,6 @@ var init_schemas2 = __esm(() => {
94809
94848
  id: true,
94810
94849
  slug: true,
94811
94850
  createdAt: true,
94812
- developerId: true,
94813
94851
  version: true
94814
94852
  }).refine((data) => {
94815
94853
  if (data.gameType === "hosted" && data.deploymentUrl === null) {
@@ -96211,7 +96249,7 @@ var init_domain_controller = __esm(() => {
96211
96249
  });
96212
96250
 
96213
96251
  // ../api-core/src/controllers/game.controller.ts
96214
- var logger48, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
96252
+ var logger48, list3, listAccessible, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
96215
96253
  var init_game_controller = __esm(() => {
96216
96254
  init_esm();
96217
96255
  init_schemas_index();
@@ -96224,9 +96262,9 @@ var init_game_controller = __esm(() => {
96224
96262
  logger48.debug("Listing games", { userId: ctx.user.id });
96225
96263
  return ctx.services.game.list(ctx.user);
96226
96264
  });
96227
- listManageable = requireNonAnonymous(async (ctx) => {
96228
- logger48.debug("Listing manageable games", { userId: ctx.user.id });
96229
- return ctx.services.game.listManageable(ctx.user);
96265
+ listAccessible = requireNonAnonymous(async (ctx) => {
96266
+ logger48.debug("Listing accessible games", { userId: ctx.user.id });
96267
+ return ctx.services.game.listAccessible(ctx.user);
96230
96268
  });
96231
96269
  getSubjects = requireNonAnonymous(async (ctx) => {
96232
96270
  logger48.debug("Getting game subjects", { userId: ctx.user.id });
@@ -96299,7 +96337,7 @@ var init_game_controller = __esm(() => {
96299
96337
  });
96300
96338
  games2 = {
96301
96339
  list: list3,
96302
- listManageable,
96340
+ listAccessible,
96303
96341
  getSubjects,
96304
96342
  getById: getById2,
96305
96343
  getManifest,
@@ -98420,6 +98458,7 @@ var init_crud = __esm(() => {
98420
98458
  init_api();
98421
98459
  gameCrudRouter = new Hono2;
98422
98460
  gameCrudRouter.get("/", handle2(games2.list));
98461
+ gameCrudRouter.get("/accessible", handle2(games2.listAccessible));
98423
98462
  gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
98424
98463
  gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
98425
98464
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
@@ -98562,10 +98601,12 @@ var init_deploy = __esm(() => {
98562
98601
  const db2 = ctx.db;
98563
98602
  const existingGames = await db2.select().from(games).where(eq(games.slug, slug2));
98564
98603
  const existingGame = existingGames[0];
98565
- if (existingGame) {
98566
- const isAdmin = user.role === "admin";
98567
- const isOwner = existingGame.developerId === user.id;
98568
- if (!isAdmin && !isOwner) {
98604
+ if (existingGame && user.role !== "admin") {
98605
+ const membership = await db2.query.gameMembers.findFirst({
98606
+ where: and(eq(gameMembers.gameId, existingGame.id), eq(gameMembers.userId, user.id)),
98607
+ columns: { id: true }
98608
+ });
98609
+ if (!membership) {
98569
98610
  return c2.json({
98570
98611
  error: {
98571
98612
  code: "FORBIDDEN",
@@ -98589,17 +98630,24 @@ var init_deploy = __esm(() => {
98589
98630
  if (!body2.metadata?.displayName) {
98590
98631
  return c2.json({ error: { code: "BAD_REQUEST", message: "Display name required for new game" } }, 400);
98591
98632
  }
98592
- const inserted = await db2.insert(games).values({
98633
+ const gameValues = {
98593
98634
  slug: slug2,
98594
98635
  displayName: body2.metadata.displayName,
98595
98636
  version: "1.0.0",
98596
98637
  platform: body2.metadata.platform ?? "web",
98597
98638
  gameType: "hosted",
98598
- developerId: user.id,
98599
98639
  deploymentUrl: `http://localhost:4321`,
98600
98640
  metadata: body2.metadata.metadata ?? {}
98601
- }).returning();
98602
- game = inserted[0];
98641
+ };
98642
+ game = await db2.transaction(async (tx) => {
98643
+ const [inserted] = await tx.insert(games).values(gameValues).returning();
98644
+ await tx.insert(gameMembers).values({
98645
+ gameId: inserted.id,
98646
+ userId: user.id,
98647
+ role: "owner"
98648
+ });
98649
+ return inserted;
98650
+ });
98603
98651
  }
98604
98652
  let backendCode = body2.code;
98605
98653
  if (!backendCode && body2.codeUploadToken) {
@@ -98678,13 +98726,19 @@ var init_deploy = __esm(() => {
98678
98726
  const ctx = getSandboxContext();
98679
98727
  const game = await ctx.db.query.games.findFirst({
98680
98728
  where: eq(games.slug, slug2 ?? ""),
98681
- columns: { id: true, developerId: true }
98729
+ columns: { id: true }
98682
98730
  });
98683
98731
  if (!game) {
98684
98732
  return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98685
98733
  }
98686
- if (game.developerId !== user.id && user.role !== "admin") {
98687
- return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98734
+ if (user.role !== "admin") {
98735
+ const membership = await ctx.db.query.gameMembers.findFirst({
98736
+ where: and(eq(gameMembers.gameId, game.id), eq(gameMembers.userId, user.id)),
98737
+ columns: { id: true }
98738
+ });
98739
+ if (!membership) {
98740
+ return c2.json({ error: { code: "NOT_FOUND", message: "Game not found" } }, 404);
98741
+ }
98688
98742
  }
98689
98743
  const job = await ctx.db.query.gameDeployJobs.findFirst({
98690
98744
  where: and(eq(gameDeployJobs.id, jobId), eq(gameDeployJobs.gameId, game.id))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.17-beta.36",
3
+ "version": "0.3.17-beta.37",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {