@checkstack/auth-backend 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,20 @@
15
15
  "when": 1767621359670,
16
16
  "tag": "0001_certain_madame_hydra",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1768325932660,
23
+ "tag": "0002_lowly_squirrel_girl",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1768332454744,
30
+ "tag": "0003_tranquil_sally_floyd",
31
+ "breakpoints": true
18
32
  }
19
33
  ]
20
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -402,6 +402,15 @@ export default createBackendPlugin({
402
402
  ];
403
403
  }
404
404
 
405
+ // Get team memberships for this application
406
+ const appTeams = await db
407
+ .select({ teamId: schema.applicationTeam.teamId })
408
+ .from(schema.applicationTeam)
409
+ .where(
410
+ eq(schema.applicationTeam.applicationId, applicationId)
411
+ );
412
+ const teamIds = appTeams.map((t) => t.teamId);
413
+
405
414
  // Return ApplicationUser
406
415
  return {
407
416
  type: "application" as const,
@@ -409,6 +418,7 @@ export default createBackendPlugin({
409
418
  name: app.name,
410
419
  roles: roleIds,
411
420
  permissions,
421
+ teamIds,
412
422
  };
413
423
  }
414
424
  }
@@ -547,7 +557,7 @@ export default createBackendPlugin({
547
557
  // Using void to prevent timing attacks revealing email existence
548
558
  const notificationClient = rpcClient.forPlugin(NotificationApi);
549
559
  const frontendUrl =
550
- process.env.VITE_FRONTEND_URL || "http://localhost:5173";
560
+ process.env.BASE_URL || "http://localhost:5173";
551
561
  const resetUrl = `${frontendUrl}/auth/reset-password?token=${
552
562
  url.split("token=")[1] ?? ""
553
563
  }`;
@@ -572,10 +582,8 @@ export default createBackendPlugin({
572
582
  },
573
583
  socialProviders,
574
584
  basePath: "/api/auth",
575
- baseURL: process.env.VITE_API_BASE_URL || "http://localhost:3000",
576
- trustedOrigins: [
577
- process.env.VITE_FRONTEND_URL || "http://localhost:5173",
578
- ],
585
+ baseURL: process.env.BASE_URL || "http://localhost:5173",
586
+ trustedOrigins: [process.env.BASE_URL || "http://localhost:5173"],
579
587
  databaseHooks: {
580
588
  user: {
581
589
  create: {
package/src/router.ts CHANGED
@@ -1016,6 +1016,423 @@ export const createAuthRouter = (
1016
1016
  }
1017
1017
  );
1018
1018
 
1019
+ // ==========================================================================
1020
+ // TEAM MANAGEMENT HANDLERS
1021
+ // ==========================================================================
1022
+
1023
+ const getTeams = os.getTeams.handler(async ({ context }) => {
1024
+ const teams = await internalDb.select().from(schema.team);
1025
+ const memberCounts = await internalDb
1026
+ .select({ teamId: schema.userTeam.teamId })
1027
+ .from(schema.userTeam);
1028
+
1029
+ const userId = isRealUser(context.user) ? context.user.id : undefined;
1030
+ const managerRows = userId
1031
+ ? await internalDb
1032
+ .select()
1033
+ .from(schema.teamManager)
1034
+ .where(eq(schema.teamManager.userId, userId))
1035
+ : [];
1036
+ const managedTeamIds = new Set(managerRows.map((m) => m.teamId));
1037
+
1038
+ return teams.map((t) => ({
1039
+ id: t.id,
1040
+ name: t.name,
1041
+ description: t.description,
1042
+ memberCount: memberCounts.filter((m) => m.teamId === t.id).length,
1043
+ isManager: managedTeamIds.has(t.id),
1044
+ }));
1045
+ });
1046
+
1047
+ const getTeam = os.getTeam.handler(async ({ input }) => {
1048
+ const teams = await internalDb
1049
+ .select()
1050
+ .from(schema.team)
1051
+ .where(eq(schema.team.id, input.teamId))
1052
+ .limit(1);
1053
+ if (teams.length === 0) return;
1054
+
1055
+ const team = teams[0];
1056
+ const memberRows = await internalDb
1057
+ .select({ userId: schema.userTeam.userId })
1058
+ .from(schema.userTeam)
1059
+ .where(eq(schema.userTeam.teamId, team.id));
1060
+ const managerRows = await internalDb
1061
+ .select({ userId: schema.teamManager.userId })
1062
+ .from(schema.teamManager)
1063
+ .where(eq(schema.teamManager.teamId, team.id));
1064
+
1065
+ const userIds = [
1066
+ ...new Set([
1067
+ ...memberRows.map((m) => m.userId),
1068
+ ...managerRows.map((m) => m.userId),
1069
+ ]),
1070
+ ];
1071
+ const users =
1072
+ userIds.length > 0
1073
+ ? await internalDb
1074
+ .select({
1075
+ id: schema.user.id,
1076
+ name: schema.user.name,
1077
+ email: schema.user.email,
1078
+ })
1079
+ .from(schema.user)
1080
+ .where(inArray(schema.user.id, userIds))
1081
+ : [];
1082
+ const userMap = new Map(users.map((u) => [u.id, u]));
1083
+
1084
+ return {
1085
+ id: team.id,
1086
+ name: team.name,
1087
+ description: team.description,
1088
+ members: memberRows
1089
+ .map((m) => userMap.get(m.userId))
1090
+ .filter((u): u is NonNullable<typeof u> => u !== undefined),
1091
+ managers: managerRows
1092
+ .map((m) => userMap.get(m.userId))
1093
+ .filter((u): u is NonNullable<typeof u> => u !== undefined),
1094
+ };
1095
+ });
1096
+
1097
+ const createTeam = os.createTeam.handler(async ({ input, context }) => {
1098
+ const id = crypto.randomUUID();
1099
+ const now = new Date();
1100
+ await internalDb.insert(schema.team).values({
1101
+ id,
1102
+ name: input.name,
1103
+ description: input.description,
1104
+ createdAt: now,
1105
+ updatedAt: now,
1106
+ });
1107
+ context.logger.info(`[auth-backend] Created team: ${input.name}`);
1108
+ return { id };
1109
+ });
1110
+
1111
+ const updateTeam = os.updateTeam.handler(async ({ input, context }) => {
1112
+ const { id, name, description } = input;
1113
+ // TODO: Check if user is manager or has teamsManage permission
1114
+ const updates: {
1115
+ name?: string;
1116
+ description?: string | null;
1117
+ updatedAt: Date;
1118
+ } = {
1119
+ updatedAt: new Date(),
1120
+ };
1121
+ if (name !== undefined) updates.name = name;
1122
+ if (description !== undefined) updates.description = description;
1123
+ await internalDb
1124
+ .update(schema.team)
1125
+ .set(updates)
1126
+ .where(eq(schema.team.id, id));
1127
+ context.logger.info(`[auth-backend] Updated team: ${id}`);
1128
+ });
1129
+
1130
+ const deleteTeam = os.deleteTeam.handler(async ({ input: id, context }) => {
1131
+ await internalDb.transaction(async (tx) => {
1132
+ await tx.delete(schema.userTeam).where(eq(schema.userTeam.teamId, id));
1133
+ await tx
1134
+ .delete(schema.teamManager)
1135
+ .where(eq(schema.teamManager.teamId, id));
1136
+ await tx
1137
+ .delete(schema.applicationTeam)
1138
+ .where(eq(schema.applicationTeam.teamId, id));
1139
+ await tx
1140
+ .delete(schema.resourceTeamAccess)
1141
+ .where(eq(schema.resourceTeamAccess.teamId, id));
1142
+ await tx.delete(schema.team).where(eq(schema.team.id, id));
1143
+ });
1144
+ context.logger.info(`[auth-backend] Deleted team: ${id}`);
1145
+ });
1146
+
1147
+ const addUserToTeam = os.addUserToTeam.handler(async ({ input }) => {
1148
+ await internalDb
1149
+ .insert(schema.userTeam)
1150
+ .values({ userId: input.userId, teamId: input.teamId })
1151
+ .onConflictDoNothing();
1152
+ });
1153
+
1154
+ const removeUserFromTeam = os.removeUserFromTeam.handler(
1155
+ async ({ input }) => {
1156
+ await internalDb
1157
+ .delete(schema.userTeam)
1158
+ .where(
1159
+ and(
1160
+ eq(schema.userTeam.userId, input.userId),
1161
+ eq(schema.userTeam.teamId, input.teamId)
1162
+ )
1163
+ );
1164
+ }
1165
+ );
1166
+
1167
+ const addTeamManager = os.addTeamManager.handler(async ({ input }) => {
1168
+ await internalDb
1169
+ .insert(schema.teamManager)
1170
+ .values({ userId: input.userId, teamId: input.teamId })
1171
+ .onConflictDoNothing();
1172
+ });
1173
+
1174
+ const removeTeamManager = os.removeTeamManager.handler(async ({ input }) => {
1175
+ await internalDb
1176
+ .delete(schema.teamManager)
1177
+ .where(
1178
+ and(
1179
+ eq(schema.teamManager.userId, input.userId),
1180
+ eq(schema.teamManager.teamId, input.teamId)
1181
+ )
1182
+ );
1183
+ });
1184
+
1185
+ const getResourceTeamAccess = os.getResourceTeamAccess.handler(
1186
+ async ({ input }) => {
1187
+ const rows = await internalDb
1188
+ .select()
1189
+ .from(schema.resourceTeamAccess)
1190
+ .innerJoin(
1191
+ schema.team,
1192
+ eq(schema.resourceTeamAccess.teamId, schema.team.id)
1193
+ )
1194
+ .where(
1195
+ and(
1196
+ eq(schema.resourceTeamAccess.resourceType, input.resourceType),
1197
+ eq(schema.resourceTeamAccess.resourceId, input.resourceId)
1198
+ )
1199
+ );
1200
+ return rows.map((r) => ({
1201
+ teamId: r.resource_team_access.teamId,
1202
+ teamName: r.team.name,
1203
+ canRead: r.resource_team_access.canRead,
1204
+ canManage: r.resource_team_access.canManage,
1205
+ }));
1206
+ }
1207
+ );
1208
+
1209
+ const setResourceTeamAccess = os.setResourceTeamAccess.handler(
1210
+ async ({ input }) => {
1211
+ const { resourceType, resourceId, teamId, canRead, canManage } = input;
1212
+ await internalDb
1213
+ .insert(schema.resourceTeamAccess)
1214
+ .values({
1215
+ resourceType,
1216
+ resourceId,
1217
+ teamId,
1218
+ canRead: canRead ?? true,
1219
+ canManage: canManage ?? false,
1220
+ })
1221
+ .onConflictDoUpdate({
1222
+ target: [
1223
+ schema.resourceTeamAccess.resourceType,
1224
+ schema.resourceTeamAccess.resourceId,
1225
+ schema.resourceTeamAccess.teamId,
1226
+ ],
1227
+ set: {
1228
+ canRead: canRead ?? true,
1229
+ canManage: canManage ?? false,
1230
+ },
1231
+ });
1232
+ }
1233
+ );
1234
+
1235
+ const removeResourceTeamAccess = os.removeResourceTeamAccess.handler(
1236
+ async ({ input }) => {
1237
+ await internalDb
1238
+ .delete(schema.resourceTeamAccess)
1239
+ .where(
1240
+ and(
1241
+ eq(schema.resourceTeamAccess.resourceType, input.resourceType),
1242
+ eq(schema.resourceTeamAccess.resourceId, input.resourceId),
1243
+ eq(schema.resourceTeamAccess.teamId, input.teamId)
1244
+ )
1245
+ );
1246
+ }
1247
+ );
1248
+
1249
+ // Resource-level access settings
1250
+ const getResourceAccessSettings = os.getResourceAccessSettings.handler(
1251
+ async ({ input }) => {
1252
+ const rows = await internalDb
1253
+ .select()
1254
+ .from(schema.resourceAccessSettings)
1255
+ .where(
1256
+ and(
1257
+ eq(schema.resourceAccessSettings.resourceType, input.resourceType),
1258
+ eq(schema.resourceAccessSettings.resourceId, input.resourceId)
1259
+ )
1260
+ )
1261
+ .limit(1);
1262
+ return { teamOnly: rows[0]?.teamOnly ?? false };
1263
+ }
1264
+ );
1265
+
1266
+ const setResourceAccessSettings = os.setResourceAccessSettings.handler(
1267
+ async ({ input }) => {
1268
+ const { resourceType, resourceId, teamOnly } = input;
1269
+ await internalDb
1270
+ .insert(schema.resourceAccessSettings)
1271
+ .values({ resourceType, resourceId, teamOnly })
1272
+ .onConflictDoUpdate({
1273
+ target: [
1274
+ schema.resourceAccessSettings.resourceType,
1275
+ schema.resourceAccessSettings.resourceId,
1276
+ ],
1277
+ set: { teamOnly },
1278
+ });
1279
+ }
1280
+ );
1281
+
1282
+ // S2S Endpoints for middleware
1283
+ const checkResourceTeamAccess = os.checkResourceTeamAccess.handler(
1284
+ async ({ input }) => {
1285
+ const {
1286
+ userId,
1287
+ userType,
1288
+ resourceType,
1289
+ resourceId,
1290
+ action,
1291
+ hasGlobalPermission,
1292
+ } = input;
1293
+
1294
+ const grants = await internalDb
1295
+ .select()
1296
+ .from(schema.resourceTeamAccess)
1297
+ .where(
1298
+ and(
1299
+ eq(schema.resourceTeamAccess.resourceType, resourceType),
1300
+ eq(schema.resourceTeamAccess.resourceId, resourceId)
1301
+ )
1302
+ );
1303
+
1304
+ // No grants = global permission applies
1305
+ if (grants.length === 0) return { hasAccess: hasGlobalPermission };
1306
+
1307
+ // Check resource-level settings for teamOnly
1308
+ const settingsRows = await internalDb
1309
+ .select()
1310
+ .from(schema.resourceAccessSettings)
1311
+ .where(
1312
+ and(
1313
+ eq(schema.resourceAccessSettings.resourceType, resourceType),
1314
+ eq(schema.resourceAccessSettings.resourceId, resourceId)
1315
+ )
1316
+ )
1317
+ .limit(1);
1318
+ const isTeamOnly = settingsRows[0]?.teamOnly ?? false;
1319
+
1320
+ if (!isTeamOnly && hasGlobalPermission) return { hasAccess: true };
1321
+
1322
+ // Get user's teams
1323
+ const teamTable =
1324
+ userType === "user" ? schema.userTeam : schema.applicationTeam;
1325
+ const userIdCol =
1326
+ userType === "user"
1327
+ ? schema.userTeam.userId
1328
+ : schema.applicationTeam.applicationId;
1329
+ const userTeams = await internalDb
1330
+ .select({
1331
+ teamId:
1332
+ userType === "user"
1333
+ ? schema.userTeam.teamId
1334
+ : schema.applicationTeam.teamId,
1335
+ })
1336
+ .from(teamTable)
1337
+ .where(eq(userIdCol, userId));
1338
+ const userTeamIds = new Set(userTeams.map((t) => t.teamId));
1339
+
1340
+ const field = action === "manage" ? "canManage" : "canRead";
1341
+ const hasAccess = grants.some(
1342
+ (g) => userTeamIds.has(g.teamId) && g[field]
1343
+ );
1344
+ return { hasAccess };
1345
+ }
1346
+ );
1347
+
1348
+ const getAccessibleResourceIds = os.getAccessibleResourceIds.handler(
1349
+ async ({ input }) => {
1350
+ const {
1351
+ userId,
1352
+ userType,
1353
+ resourceType,
1354
+ resourceIds,
1355
+ action,
1356
+ hasGlobalPermission,
1357
+ } = input;
1358
+ if (resourceIds.length === 0) return [];
1359
+
1360
+ // Get all grants for these resources
1361
+ const grants = await internalDb
1362
+ .select()
1363
+ .from(schema.resourceTeamAccess)
1364
+ .where(
1365
+ and(
1366
+ eq(schema.resourceTeamAccess.resourceType, resourceType),
1367
+ inArray(schema.resourceTeamAccess.resourceId, resourceIds)
1368
+ )
1369
+ );
1370
+
1371
+ // Get resource-level settings for teamOnly
1372
+ const settingsRows = await internalDb
1373
+ .select()
1374
+ .from(schema.resourceAccessSettings)
1375
+ .where(
1376
+ and(
1377
+ eq(schema.resourceAccessSettings.resourceType, resourceType),
1378
+ inArray(schema.resourceAccessSettings.resourceId, resourceIds)
1379
+ )
1380
+ );
1381
+ const teamOnlyByResource = new Map(
1382
+ settingsRows.map((s) => [s.resourceId, s.teamOnly])
1383
+ );
1384
+
1385
+ // Get user's teams
1386
+ const teamTable =
1387
+ userType === "user" ? schema.userTeam : schema.applicationTeam;
1388
+ const userIdCol =
1389
+ userType === "user"
1390
+ ? schema.userTeam.userId
1391
+ : schema.applicationTeam.applicationId;
1392
+ const userTeams = await internalDb
1393
+ .select({
1394
+ teamId:
1395
+ userType === "user"
1396
+ ? schema.userTeam.teamId
1397
+ : schema.applicationTeam.teamId,
1398
+ })
1399
+ .from(teamTable)
1400
+ .where(eq(userIdCol, userId));
1401
+ const userTeamIds = new Set(userTeams.map((t) => t.teamId));
1402
+
1403
+ const field = action === "manage" ? "canManage" : "canRead";
1404
+ const grantsByResource = new Map<string, typeof grants>();
1405
+ for (const g of grants) {
1406
+ const existing = grantsByResource.get(g.resourceId) || [];
1407
+ existing.push(g);
1408
+ grantsByResource.set(g.resourceId, existing);
1409
+ }
1410
+
1411
+ return resourceIds.filter((id) => {
1412
+ const resourceGrants = grantsByResource.get(id) || [];
1413
+ if (resourceGrants.length === 0) return hasGlobalPermission;
1414
+ const isTeamOnly = teamOnlyByResource.get(id) ?? false;
1415
+ if (!isTeamOnly && hasGlobalPermission) return true;
1416
+ return resourceGrants.some(
1417
+ (g) => userTeamIds.has(g.teamId) && g[field]
1418
+ );
1419
+ });
1420
+ }
1421
+ );
1422
+
1423
+ const deleteResourceGrants = os.deleteResourceGrants.handler(
1424
+ async ({ input }) => {
1425
+ await internalDb
1426
+ .delete(schema.resourceTeamAccess)
1427
+ .where(
1428
+ and(
1429
+ eq(schema.resourceTeamAccess.resourceType, input.resourceType),
1430
+ eq(schema.resourceTeamAccess.resourceId, input.resourceId)
1431
+ )
1432
+ );
1433
+ }
1434
+ );
1435
+
1019
1436
  return os.router({
1020
1437
  getEnabledStrategies,
1021
1438
  permissions,
@@ -1045,6 +1462,24 @@ export const createAuthRouter = (
1045
1462
  updateApplication,
1046
1463
  deleteApplication,
1047
1464
  regenerateApplicationSecret,
1465
+ // Teams
1466
+ getTeams,
1467
+ getTeam,
1468
+ createTeam,
1469
+ updateTeam,
1470
+ deleteTeam,
1471
+ addUserToTeam,
1472
+ removeUserFromTeam,
1473
+ addTeamManager,
1474
+ removeTeamManager,
1475
+ getResourceTeamAccess,
1476
+ setResourceTeamAccess,
1477
+ removeResourceTeamAccess,
1478
+ getResourceAccessSettings,
1479
+ setResourceAccessSettings,
1480
+ checkResourceTeamAccess,
1481
+ getAccessibleResourceIds,
1482
+ deleteResourceGrants,
1048
1483
  });
1049
1484
  };
1050
1485
 
package/src/schema.ts CHANGED
@@ -171,3 +171,110 @@ export const applicationRole = pgTable(
171
171
  pk: primaryKey({ columns: [t.applicationId, t.roleId] }),
172
172
  })
173
173
  );
174
+
175
+ // --- Teams Schema ---
176
+
177
+ /**
178
+ * Teams for resource-level access control.
179
+ * Users can be members of multiple teams, and resources can be scoped to teams.
180
+ */
181
+ export const team = pgTable("team", {
182
+ id: text("id").primaryKey(),
183
+ name: text("name").notNull(),
184
+ description: text("description"),
185
+ createdAt: timestamp("created_at").notNull().defaultNow(),
186
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
187
+ });
188
+
189
+ /**
190
+ * User-to-Team membership (M:N).
191
+ * Users can belong to multiple teams.
192
+ */
193
+ export const userTeam = pgTable(
194
+ "user_team",
195
+ {
196
+ userId: text("user_id")
197
+ .notNull()
198
+ .references(() => user.id, { onDelete: "cascade" }),
199
+ teamId: text("team_id")
200
+ .notNull()
201
+ .references(() => team.id, { onDelete: "cascade" }),
202
+ },
203
+ (t) => ({
204
+ pk: primaryKey({ columns: [t.userId, t.teamId] }),
205
+ })
206
+ );
207
+
208
+ /**
209
+ * Application-to-Team membership (M:N).
210
+ * API keys can belong to teams for resource access.
211
+ */
212
+ export const applicationTeam = pgTable(
213
+ "application_team",
214
+ {
215
+ applicationId: text("application_id")
216
+ .notNull()
217
+ .references(() => application.id, { onDelete: "cascade" }),
218
+ teamId: text("team_id")
219
+ .notNull()
220
+ .references(() => team.id, { onDelete: "cascade" }),
221
+ },
222
+ (t) => ({
223
+ pk: primaryKey({ columns: [t.applicationId, t.teamId] }),
224
+ })
225
+ );
226
+
227
+ /**
228
+ * Team managers - users who can manage a specific team's membership and resource access.
229
+ * Team managers cannot delete the team or manage other teams.
230
+ */
231
+ export const teamManager = pgTable(
232
+ "team_manager",
233
+ {
234
+ teamId: text("team_id")
235
+ .notNull()
236
+ .references(() => team.id, { onDelete: "cascade" }),
237
+ userId: text("user_id")
238
+ .notNull()
239
+ .references(() => user.id, { onDelete: "cascade" }),
240
+ },
241
+ (t) => ({
242
+ pk: primaryKey({ columns: [t.teamId, t.userId] }),
243
+ })
244
+ );
245
+
246
+ /**
247
+ * Resource-level access settings.
248
+ * Controls whether a resource requires team membership (teamOnly) vs allowing global permissions.
249
+ */
250
+ export const resourceAccessSettings = pgTable(
251
+ "resource_access_settings",
252
+ {
253
+ resourceType: text("resource_type").notNull(), // e.g., "catalog.system"
254
+ resourceId: text("resource_id").notNull(),
255
+ teamOnly: boolean("team_only").notNull().default(false), // If true, global permissions don't apply
256
+ },
257
+ (t) => ({
258
+ pk: primaryKey({ columns: [t.resourceType, t.resourceId] }),
259
+ })
260
+ );
261
+
262
+ /**
263
+ * Centralized resource-level access control.
264
+ * Stores team grants for all resource types across the platform.
265
+ */
266
+ export const resourceTeamAccess = pgTable(
267
+ "resource_team_access",
268
+ {
269
+ resourceType: text("resource_type").notNull(), // e.g., "catalog.system"
270
+ resourceId: text("resource_id").notNull(),
271
+ teamId: text("team_id")
272
+ .notNull()
273
+ .references(() => team.id, { onDelete: "cascade" }),
274
+ canRead: boolean("can_read").notNull().default(true),
275
+ canManage: boolean("can_manage").notNull().default(false),
276
+ },
277
+ (t) => ({
278
+ pk: primaryKey({ columns: [t.resourceType, t.resourceId, t.teamId] }),
279
+ })
280
+ );