sporades 0.8.1 → 0.8.5

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/CHANGES.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changes
2
2
 
3
+ ## Unreleased - 2026-08-15
4
+
5
+ Changes since v0.8.1.
6
+
7
+ ### 🚀 Features
8
+
9
+ - Add reactive query arguments (5b882f5).
10
+ - Add exact pagination and join admission (63f68a0).
11
+
12
+ ### 🐛 Bug Fixes
13
+
14
+ - Resolve npm audit vulnerabilities (a6a4b51).
15
+
16
+ ### 📝 Documentation
17
+
18
+ - Describe parameterized queries (287ef63).
19
+ - Plan reactive query arguments (4876db8).
20
+
21
+ ### 📦 Packaging
22
+
23
+ - Release v0.8.3 (c17bb1b).
24
+
3
25
  ## 0.8.1 - 2026-08-14
4
26
 
5
27
  Corrects the incomplete `0.8.0` package release with the merged `main` runtime,
@@ -16,3 +38,4 @@ generated artifacts, and documentation.
16
38
  explicit Team decisions in table and File ACLs. Teams are built in but do
17
39
  not select a current Team or automatically partition Capsule data; Sporades
18
40
  never sends Join-link email. See the [Built-in Teams reference](https://mgscox.github.io/sporades/reference/teams).
41
+
@@ -418,7 +418,7 @@ function writeEnvelope(result, failed = false) {
418
418
  }
419
419
 
420
420
  // src/cli/cli-version.ts
421
- var CLI_VERSION = "0.8.1";
421
+ var CLI_VERSION = "0.8.5";
422
422
 
423
423
  // src/cli/schedule-inspection-envelope.ts
424
424
  function sanitizeScheduleInspectionEnvelope(envelope, invalid) {
package/bin/sporades.js CHANGED
@@ -38,11 +38,69 @@ export function onMessage(listener) {
38
38
  }
39
39
 
40
40
  export const queries = {
41
- subscribe(name, listener) {
42
- return connect().subscribeQuery(name, listener);
41
+ subscribe(name, listener, ...args) {
42
+ const normalizedArgs = args.length === 1 && args[0]?.[normalizedQueryArgumentsMarker]
43
+ ? args[0]
44
+ : normalizeQueryArguments(args);
45
+ return connect().subscribeNormalizedQuery(name, listener, normalizedArgs);
43
46
  },
44
47
  };
45
48
 
49
+ const queryArgumentLimitBytes = 65536;
50
+ const normalizedQueryArgumentsMarker = Symbol("sporades.normalizedQueryArguments");
51
+
52
+ function normalizeQueryArguments(args) {
53
+ if (!Array.isArray(args)) throw new TypeError("Query arguments must be an array.");
54
+ const ancestors = new Set();
55
+ const snapshot = normalizeQueryArgumentValue(args, ancestors);
56
+ const identity = JSON.stringify(snapshot);
57
+ const byteLength = new TextEncoder().encode(identity).byteLength;
58
+ if (byteLength > queryArgumentLimitBytes) throw new RangeError("Query arguments exceed the 65536-byte limit.");
59
+ return Object.freeze({ snapshot, identity, byteLength, [normalizedQueryArgumentsMarker]: true });
60
+ }
61
+
62
+ function normalizeQueryArgumentValue(value, ancestors) {
63
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
64
+ if (typeof value === "number") {
65
+ if (!Number.isFinite(value)) throw new TypeError("Query arguments must contain JSON values.");
66
+ return value;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError("Query arguments must not contain symbol keys.");
70
+ if (ancestors.has(value)) throw new TypeError("Query arguments must not contain cycles.");
71
+ if (Object.getOwnPropertyNames(value).some((key) => key !== "length" && (!/^(0|[1-9]\\d*)$/.test(key) || Number(key) >= value.length))) {
72
+ throw new TypeError("Query arguments must not contain non-index array properties.");
73
+ }
74
+ ancestors.add(value);
75
+ const snapshot = [];
76
+ for (let index = 0; index < value.length; index += 1) {
77
+ if (!Object.hasOwn(value, index)) throw new TypeError("Query arguments must not contain sparse arrays.");
78
+ snapshot.push(normalizeQueryArgumentValue(value[index], ancestors));
79
+ }
80
+ ancestors.delete(value);
81
+ return Object.freeze(snapshot);
82
+ }
83
+ if (!value || typeof value !== "object") throw new TypeError("Query arguments must contain JSON values.");
84
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError("Query arguments must not contain symbol keys.");
85
+ const prototype = Object.getPrototypeOf(value);
86
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("Query arguments must contain plain JSON objects.");
87
+ if (ancestors.has(value)) throw new TypeError("Query arguments must not contain cycles.");
88
+ ancestors.add(value);
89
+ const snapshot = Object.create(null);
90
+ for (const key of Object.getOwnPropertyNames(value).sort()) {
91
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
92
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) throw new TypeError("Query arguments must contain plain JSON objects.");
93
+ Object.defineProperty(snapshot, key, {
94
+ value: normalizeQueryArgumentValue(descriptor.value, ancestors),
95
+ enumerable: true,
96
+ configurable: false,
97
+ writable: false,
98
+ });
99
+ }
100
+ ancestors.delete(value);
101
+ return Object.freeze(snapshot);
102
+ }
103
+
46
104
  export const mutations = {
47
105
  run(name, ...args) {
48
106
  return connect().mutate(name, args);
@@ -68,8 +126,8 @@ export const teams = {
68
126
  rename(teamId, name) {
69
127
  return connect().teamsRename(teamId, name);
70
128
  },
71
- listMembers(teamId) {
72
- return connect().teamsListMembers(teamId);
129
+ listMembers(teamId, options = {}) {
130
+ return connect().teamsListMembers(teamId, options);
73
131
  },
74
132
  updateApplicationRoles(teamId, userId, changes) {
75
133
  return connect().teamsUpdateApplicationRoles(teamId, userId, changes);
@@ -171,13 +229,14 @@ export const files = {
171
229
  export function createHooks(primitives) {
172
230
  const { useEffect, useState } = primitives;
173
231
 
174
- function useQuery(name) {
232
+ function useQuery(name, ...args) {
233
+ const normalizedArgs = normalizeQueryArguments(args);
175
234
  const [state, setState] = useState({ data: null, error: null, loading: true });
176
235
 
177
236
  useEffect(() => {
178
- const subscription = queries.subscribe(name, setState);
237
+ const subscription = queries.subscribe(name, setState, normalizedArgs);
179
238
  return () => subscription.unsubscribe();
180
- }, [name]);
239
+ }, [name, normalizedArgs.identity]);
181
240
 
182
241
  return state;
183
242
  }
@@ -237,9 +296,10 @@ export function createHooks(primitives) {
237
296
  export function createVueComposables(primitives) {
238
297
  const { reactive, onScopeDispose } = primitives;
239
298
 
240
- function useQuery(name) {
299
+ function useQuery(name, ...args) {
300
+ const normalizedArgs = normalizeQueryArguments(args);
241
301
  const state = reactive({ data: null, error: null, loading: true });
242
- const subscription = queries.subscribe(name, (nextState) => Object.assign(state, nextState));
302
+ const subscription = queries.subscribe(name, (nextState) => Object.assign(state, nextState), normalizedArgs);
243
303
  onScopeDispose(() => subscription.unsubscribe());
244
304
  return state;
245
305
  }
@@ -293,9 +353,10 @@ export function createVueComposables(primitives) {
293
353
  export function createSolidPrimitives(primitives) {
294
354
  const { createSignal, onCleanup } = primitives;
295
355
 
296
- function createQuery(name) {
356
+ function createQuery(name, ...args) {
357
+ const normalizedArgs = normalizeQueryArguments(args);
297
358
  const [state, setState] = createSignal({ data: null, error: null, loading: true });
298
- const subscription = queries.subscribe(name, setState);
359
+ const subscription = queries.subscribe(name, setState, normalizedArgs);
299
360
  onCleanup(() => subscription.unsubscribe());
300
361
  return state;
301
362
  }
@@ -389,8 +450,9 @@ export function createLitControllers() {
389
450
  return controller;
390
451
  }
391
452
 
392
- function queryController(host, name) {
393
- return observedController(host, { data: null, error: null, loading: true }, (publish) => queries.subscribe(name, publish));
453
+ function queryController(host, name, ...args) {
454
+ const normalizedArgs = normalizeQueryArguments(args);
455
+ return observedController(host, { data: null, error: null, loading: true }, (publish) => queries.subscribe(name, publish, normalizedArgs));
394
456
  }
395
457
 
396
458
  function mutationController(host, name) {
@@ -477,7 +539,10 @@ export function createInfernoAdapters() {
477
539
  };
478
540
  return adapter;
479
541
  }
480
- const queryAdapter = (host, name) => observedAdapter(host, { data: null, error: null, loading: true }, (publish) => queries.subscribe(name, publish));
542
+ const queryAdapter = (host, name, ...args) => {
543
+ const normalizedArgs = normalizeQueryArguments(args);
544
+ return observedAdapter(host, { data: null, error: null, loading: true }, (publish) => queries.subscribe(name, publish, normalizedArgs));
545
+ };
481
546
  function mutationAdapter(host, name) {
482
547
  let pending = 0, latestInvocation = 0;
483
548
  const adapter = { state: { data: null, error: null, loading: false }, async run(...args) {
@@ -513,10 +578,11 @@ export function createInfernoAdapters() {
513
578
  }
514
579
 
515
580
  export function createSvelteStores() {
516
- function queryStore(name) {
581
+ function queryStore(name, ...args) {
582
+ const normalizedArgs = normalizeQueryArguments(args);
517
583
  return createLazyStore(
518
584
  { data: null, error: null, loading: true },
519
- (publish) => queries.subscribe(name, publish).unsubscribe,
585
+ (publish) => queries.subscribe(name, publish, normalizedArgs).unsubscribe,
520
586
  true,
521
587
  );
522
588
  }
@@ -688,6 +754,7 @@ function createConnection() {
688
754
  id: subscription.id,
689
755
  type: "query.subscribe",
690
756
  query: subscription.name,
757
+ args: subscription.args.snapshot,
691
758
  });
692
759
  }
693
760
  });
@@ -1073,17 +1140,19 @@ function createConnection() {
1073
1140
  confirmPasswordReset(code, newPassword) {
1074
1141
  return request("auth.confirmPasswordReset", { code, newPassword });
1075
1142
  },
1076
- subscribeQuery(name, listener) {
1143
+ subscribeNormalizedQuery(name, listener, args) {
1077
1144
  if (typeof name !== "string" || !name) throw new TypeError("queries.subscribe requires a query name.");
1078
1145
  if (typeof listener !== "function") throw new TypeError("queries.subscribe requires a listener function.");
1079
- let subscription = queryChannels.get(name);
1146
+ const channelsForName = queryChannels.get(name) ?? new Map();
1147
+ if (!queryChannels.has(name)) queryChannels.set(name, channelsForName);
1148
+ let subscription = channelsForName.get(args.identity);
1080
1149
  if (!subscription) {
1081
1150
  const id = nextId++;
1082
- subscription = { id, name, listeners: new Set(), latest: null };
1083
- queryChannels.set(name, subscription);
1151
+ subscription = { id, name, args, listeners: new Set(), latest: null };
1152
+ channelsForName.set(args.identity, subscription);
1084
1153
  subscriptions.set(id, subscription);
1085
1154
  const activeSocket = open();
1086
- if (activeSocket.readyState === WebSocket.OPEN) send({ id, type: "query.subscribe", query: name });
1155
+ if (activeSocket.readyState === WebSocket.OPEN) send({ id, type: "query.subscribe", query: name, args: args.snapshot });
1087
1156
  }
1088
1157
  subscription.listeners.add(listener);
1089
1158
  listener(subscription.latest ?? { data: null, error: null, loading: true });
@@ -1093,7 +1162,8 @@ function createConnection() {
1093
1162
  active = false;
1094
1163
  subscription.listeners.delete(listener);
1095
1164
  if (subscription.listeners.size === 0) {
1096
- queryChannels.delete(name);
1165
+ channelsForName.delete(args.identity);
1166
+ if (channelsForName.size === 0) queryChannels.delete(name);
1097
1167
  subscriptions.delete(subscription.id);
1098
1168
  sendIfOpen({ id: nextId++, type: "query.unsubscribe", subscriptionId: subscription.id });
1099
1169
  }
@@ -1117,7 +1187,7 @@ function createConnection() {
1117
1187
  teamsList() { return request("teams.list"); },
1118
1188
  teamsCreate(name) { return request("teams.create", { name }); },
1119
1189
  teamsRename(teamId, name) { return request("teams.rename", { teamId, name }); },
1120
- teamsListMembers(teamId) { return request("teams.listMembers", { teamId }); },
1190
+ teamsListMembers(teamId, options = {}) { return request("teams.listMembers", { teamId, cursor: options.cursor, limit: options.limit }); },
1121
1191
  teamsUpdateApplicationRoles(teamId, userId, changes) { return request("teams.updateApplicationRoles", { teamId, userId, add: changes?.add, remove: changes?.remove }); },
1122
1192
  teamsCreateJoinLink(teamId, email, options = {}) { return request("teams.createJoinLink", { teamId, email, ttlSeconds: options.ttlSeconds }); },
1123
1193
  teamsListJoinLinks(teamId) { return request("teams.listJoinLinks", { teamId }); },
@@ -8477,6 +8547,7 @@ var INITIAL_TEAM_NAME = "My Team";
8477
8547
  var TEAM_NAME_MAX_BYTES = 80;
8478
8548
  var TEAM_MEMBER_COUNT_MAX = 99;
8479
8549
  var TEAM_MEMBER_LIST_MAX = 100;
8550
+ var TEAM_MEMBER_LIST_DEFAULT = TEAM_MEMBER_LIST_MAX;
8480
8551
  var TEAM_MEMBERSHIP_MAX = 25;
8481
8552
  var TEAM_BOOTSTRAP_RETRY_LIMIT = 5;
8482
8553
  var TEAM_JOIN_LINK_DEFAULT_TTL_SECONDS = 60 * 60 * 24;
@@ -8538,9 +8609,9 @@ function createCurrentUserTeamsApi(database, auth, contextGetter) {
8538
8609
  requireAuth({ auth }, { linked: true });
8539
8610
  return renameCurrentUserTeam(database, auth, teamId, name, contextGetter?.());
8540
8611
  },
8541
- async listMembers(teamId) {
8612
+ async listMembers(teamId, options = {}) {
8542
8613
  requireAuth({ auth }, { linked: true });
8543
- return listTeamMembers(database, auth, teamId);
8614
+ return listTeamMembers(database, auth, teamId, options);
8544
8615
  },
8545
8616
  async updateApplicationRoles(teamId, userId, changes) {
8546
8617
  requireAuth({ auth }, { linked: true });
@@ -8806,6 +8877,7 @@ async function joinCurrentUserTeam(database, auth, code, eventContext) {
8806
8877
  "SELECT [role] FROM [sporades_team_memberships] WHERE [teamId] = ? AND [userId] = ?"
8807
8878
  )).get(row.teamId, auth.userId);
8808
8879
  if (!membership) {
8880
+ await enforceTeamJoinAdmission(database, tx, auth, String(row.teamId));
8809
8881
  await ensureMembershipCounterOnAdapter(tx, auth.userId);
8810
8882
  const claim = await tx.prepare(sql(
8811
8883
  "UPDATE [sporades_team_membership_counters] SET [membershipCount] = [membershipCount] + 1 WHERE [userId] = ? AND [membershipCount] < ?"
@@ -8829,6 +8901,23 @@ async function joinCurrentUserTeam(database, auth, code, eventContext) {
8829
8901
  emitTeamSecurityEvent(database, eventContext, "teams.joined", auth.userId, joined.id, "succeeded", "TEAM_JOINED");
8830
8902
  return { team: joined };
8831
8903
  }
8904
+ async function enforceTeamJoinAdmission(database, tx, auth, teamId) {
8905
+ if (typeof database.teamJoinAdmission !== "function") return;
8906
+ const count = await tx.prepare(tx.dialect.sql(
8907
+ "SELECT COUNT(*) AS [count] FROM [sporades_team_memberships] WHERE [teamId] = ?"
8908
+ )).get(teamId);
8909
+ try {
8910
+ const context = database.createTeamJoinAdmissionContext(tx, auth);
8911
+ const decision = await database.teamJoinAdmission(context, {
8912
+ teamId,
8913
+ userId: auth.userId,
8914
+ currentMemberCount: Number(count?.count ?? 0)
8915
+ });
8916
+ if (decision?.allow !== true) throw teamJoinDenied();
8917
+ } catch {
8918
+ throw teamJoinDenied();
8919
+ }
8920
+ }
8832
8921
  function normalizeTeamJoinEmail(email) {
8833
8922
  const normalized = String(email ?? "").trim().toLowerCase();
8834
8923
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
@@ -8949,6 +9038,9 @@ function teamJoinLinkLimitError() {
8949
9038
  function invalidTeamJoinLink() {
8950
9039
  return commandError2("Join link is invalid.", "Use a current Join link for this linked account.", "INVALID_JOIN_LINK");
8951
9040
  }
9041
+ function teamJoinDenied() {
9042
+ return commandError2("Could not join this Team.", "Ask a Team administrator for access.", "TEAM_JOIN_DENIED");
9043
+ }
8952
9044
  async function listCurrentUserTeams(database, auth) {
8953
9045
  requireAuth({ auth }, { linked: true });
8954
9046
  await ensureInitialTeam(database, auth);
@@ -9159,7 +9251,7 @@ async function deleteCurrentUserTeam(database, auth, teamId, eventContext) {
9159
9251
  emitTeamSecurityEvent(database, eventContext, "teams.deleted", auth.userId, teamId, "succeeded", "TEAM_DELETED");
9160
9252
  return { deleted: true };
9161
9253
  }
9162
- async function listTeamMembers(database, auth, teamId) {
9254
+ async function listTeamMembers(database, auth, teamId, options = {}) {
9163
9255
  requireAuth({ auth }, { linked: true });
9164
9256
  if (!isOpaqueTeamId(teamId)) throw teamDenied();
9165
9257
  return withTeamTransaction(database, async (tx) => {
@@ -9168,20 +9260,57 @@ async function listTeamMembers(database, auth, teamId) {
9168
9260
  "SELECT [role] FROM [sporades_team_memberships] WHERE [teamId] = ? AND [userId] = ?"
9169
9261
  )).get(teamId, auth.userId);
9170
9262
  if (callerMembership?.role !== "admin") throw teamDenied();
9263
+ const page = normalizeTeamMemberPage(options);
9264
+ const total = await tx.prepare(sql(
9265
+ "SELECT COUNT(*) AS [count] FROM [sporades_team_memberships] WHERE [teamId] = ?"
9266
+ )).get(teamId);
9267
+ const cursorClause = page.cursor ? "AND ([m].[createdAt] > ? OR ([m].[createdAt] = ? AND [m].[userId] > ?)) " : "";
9268
+ const params = page.cursor ? [teamId, page.cursor.createdAt, page.cursor.createdAt, page.cursor.userId, page.limit + 1] : [teamId, page.limit + 1];
9171
9269
  const rows = await tx.prepare(sql(
9172
- "SELECT [m].[userId], [u].[displayName], [u].[picture], [m].[role] FROM [sporades_team_memberships] [m] JOIN [sporades_auth_users] [u] ON [u].[id] = [m].[userId] WHERE [m].[teamId] = ? ORDER BY [m].[createdAt] ASC, [m].[userId] ASC LIMIT ?"
9173
- )).all(teamId, TEAM_MEMBER_LIST_MAX);
9270
+ "SELECT [m].[userId], [u].[displayName], [u].[picture], [m].[role] , [m].[createdAt] FROM [sporades_team_memberships] [m] JOIN [sporades_auth_users] [u] ON [u].[id] = [m].[userId] WHERE [m].[teamId] = ? " + cursorClause + "ORDER BY [m].[createdAt] ASC, [m].[userId] ASC LIMIT ?"
9271
+ )).all(...params);
9272
+ const hasMore = rows.length > page.limit;
9273
+ const pageRows = hasMore ? rows.slice(0, page.limit) : rows;
9274
+ const last = pageRows.at(-1);
9174
9275
  return {
9175
- members: await Promise.all(rows.map(async (row) => ({
9276
+ members: await Promise.all(pageRows.map(async (row) => ({
9176
9277
  userId: String(row.userId),
9177
9278
  displayName: String(row.displayName),
9178
9279
  picture: typeof row.picture === "string" && row.picture.length > 0 ? row.picture : null,
9179
9280
  role: row.role === "admin" ? "admin" : "member",
9180
9281
  applicationRoles: await activeTeamApplicationRoles(tx, database.teamApplicationRoles, teamId, row.userId)
9181
- })))
9282
+ }))),
9283
+ ...hasMore && last ? { nextCursor: encodeTeamMemberCursor(String(last.createdAt), String(last.userId)) } : {},
9284
+ totalCount: Number(total?.count ?? 0)
9182
9285
  };
9183
9286
  });
9184
9287
  }
9288
+ function normalizeTeamMemberPage(options) {
9289
+ if (!options || typeof options !== "object" || Array.isArray(options)) throw invalidTeamMemberPage();
9290
+ const limit = options.limit === void 0 ? TEAM_MEMBER_LIST_DEFAULT : options.limit;
9291
+ if (!Number.isInteger(limit) || limit < 1 || limit > TEAM_MEMBER_LIST_MAX) throw invalidTeamMemberPage();
9292
+ if (options.cursor === void 0) return { cursor: null, limit };
9293
+ if (typeof options.cursor !== "string" || options.cursor.length < 1 || options.cursor.length > 512) throw invalidTeamMemberPage();
9294
+ try {
9295
+ if (!/^[A-Za-z0-9_-]+$/.test(options.cursor)) throw invalidTeamMemberPage();
9296
+ const cursorBytes = Buffer.from(options.cursor, "base64url");
9297
+ if (cursorBytes.toString("base64url") !== options.cursor) throw invalidTeamMemberPage();
9298
+ const decoded = JSON.parse(cursorBytes.toString("utf8"));
9299
+ if (decoded?.v !== 1 || typeof decoded.createdAt !== "string" || !Number.isFinite(Date.parse(decoded.createdAt)) || typeof decoded.userId !== "string" || decoded.userId.length < 1 || decoded.userId.length > 256) {
9300
+ throw invalidTeamMemberPage();
9301
+ }
9302
+ return { cursor: { createdAt: decoded.createdAt, userId: decoded.userId }, limit };
9303
+ } catch (error) {
9304
+ if (error?.code === "INVALID_TEAM_MEMBER_PAGE") throw error;
9305
+ throw invalidTeamMemberPage();
9306
+ }
9307
+ }
9308
+ function encodeTeamMemberCursor(createdAt, userId) {
9309
+ return Buffer.from(JSON.stringify({ v: 1, createdAt, userId }), "utf8").toString("base64url");
9310
+ }
9311
+ function invalidTeamMemberPage() {
9312
+ return commandError2("Team member page is invalid.", `Use a limit from 1 through ${TEAM_MEMBER_LIST_MAX} and a cursor returned by listMembers().`, "INVALID_TEAM_MEMBER_PAGE");
9313
+ }
9185
9314
  async function ensureInitialTeam(database, auth) {
9186
9315
  if (database.__transactionActive) {
9187
9316
  return ensureInitialTeamOnAdapter(database.adapter, auth.userId);
@@ -14445,7 +14574,10 @@ function quoteIdentifier(identifier) {
14445
14574
  // src/server-runtime-source.ts
14446
14575
  async function openDevDatabase(databasePath, serverSource, serverEnv = {}, config = {}, capsuleDefinition = null, options = {}) {
14447
14576
  if (capsuleDefinition?.teams !== void 0 && (!capsuleDefinition.teams || typeof capsuleDefinition.teams !== "object" || Array.isArray(capsuleDefinition.teams))) {
14448
- throw commandError2("Invalid Capsule Teams declaration.", "Declare teams as { appRoles?: string[] }.", "INVALID_TEAM_APPLICATION_ROLES");
14577
+ throw commandError2("Invalid Capsule Teams declaration.", "Declare teams as { appRoles?: string[], admitJoin?: function }.", "INVALID_TEAM_APPLICATION_ROLES");
14578
+ }
14579
+ if (capsuleDefinition?.teams?.admitJoin !== void 0 && typeof capsuleDefinition.teams.admitJoin !== "function") {
14580
+ throw commandError2("Invalid Capsule Team admission policy.", "Declare teams.admitJoin as a server function.", "INVALID_TEAM_JOIN_ADMISSION");
14449
14581
  }
14450
14582
  const teamApplicationRoles = normalizeTeamApplicationRoles(capsuleDefinition?.teams?.appRoles);
14451
14583
  if (capsuleDefinition?.files !== void 0 && (!capsuleDefinition.files || typeof capsuleDefinition.files !== "object" || Array.isArray(capsuleDefinition.files))) {
@@ -14532,6 +14664,10 @@ async function openDevDatabase(databasePath, serverSource, serverEnv = {}, confi
14532
14664
  passwordResetConfig: resolvePasswordResetConfig(config),
14533
14665
  teamJoinLinkConfig: resolveTeamJoinLinkConfig(config),
14534
14666
  teamApplicationRoles,
14667
+ teamJoinAdmission: capsuleDefinition?.teams?.admitJoin,
14668
+ createTeamJoinAdmissionContext(transactionAdapter, auth) {
14669
+ return createTeamJoinAdmissionContext(createTransactionDatabase(database, transactionAdapter), auth);
14670
+ },
14535
14671
  fileAcl,
14536
14672
  securityPolicy: resolveRuntimeSecurityPolicy(config),
14537
14673
  fileStorage,
@@ -15823,6 +15959,33 @@ function createEndpointDatabaseApi(database, contextGetter = null) {
15823
15959
  database.schema.tables.map((table) => [table.name, createEndpointTableApi(database, table, {}, contextGetter)])
15824
15960
  );
15825
15961
  }
15962
+ function createEndpointReadOnlyDatabaseApi(database, contextGetter = null) {
15963
+ return Object.fromEntries(
15964
+ database.schema.tables.map((table) => [
15965
+ table.name,
15966
+ readOnlyEndpointTableApi(createEndpointTableApi(database, table, {}, contextGetter))
15967
+ ])
15968
+ );
15969
+ }
15970
+ function readOnlyEndpointTableApi(tableApi) {
15971
+ return {
15972
+ where(fieldName, value) {
15973
+ return readOnlyEndpointTableApi(tableApi.where(fieldName, value));
15974
+ },
15975
+ orderBy(fieldName, direction = "asc") {
15976
+ return readOnlyEndpointTableApi(tableApi.orderBy(fieldName, direction));
15977
+ },
15978
+ limit(count) {
15979
+ return readOnlyEndpointTableApi(tableApi.limit(count));
15980
+ },
15981
+ get() {
15982
+ return tableApi.get();
15983
+ },
15984
+ all() {
15985
+ return tableApi.all();
15986
+ }
15987
+ };
15988
+ }
15826
15989
  function createEndpointTableApi(database, table, query = {}, contextGetter = null) {
15827
15990
  return {
15828
15991
  insert(values) {
@@ -16528,7 +16691,30 @@ function createWebSocketHub(getDatabase, trustedRefresh = null) {
16528
16691
  });
16529
16692
  return;
16530
16693
  }
16531
- const subscription = { id: message.id, name: queryName, style: message.query ? "direct" : "rows", generation: 0 };
16694
+ let args;
16695
+ try {
16696
+ args = normalizeQueryArguments(message.args ?? []);
16697
+ } catch {
16698
+ sendJson(client, {
16699
+ id: message.id,
16700
+ type: "query.result",
16701
+ query: queryName,
16702
+ data: null,
16703
+ error: invalidQueryArgumentsError()
16704
+ });
16705
+ return;
16706
+ }
16707
+ if (!message.query && args.length > 0) {
16708
+ sendJson(client, {
16709
+ id: message.id,
16710
+ type: "query.result",
16711
+ query: queryName,
16712
+ data: null,
16713
+ error: invalidQueryArgumentsError()
16714
+ });
16715
+ return;
16716
+ }
16717
+ const subscription = { id: message.id, name: queryName, args, style: message.query ? "direct" : "rows", generation: 0 };
16532
16718
  client.subscriptions.set(message.id, subscription);
16533
16719
  void sendQueryResult(client, subscription, (error) => sendUnhandledMessageError(client, rawMessage, error));
16534
16720
  return;
@@ -16626,7 +16812,7 @@ function createWebSocketHub(getDatabase, trustedRefresh = null) {
16626
16812
  }
16627
16813
  if (message.type === "teams.listMembers") {
16628
16814
  try {
16629
- const data = await listTeamMembers(database, client.session.auth, message.teamId);
16815
+ const data = await listTeamMembers(database, client.session.auth, message.teamId, { cursor: message.cursor, limit: message.limit });
16630
16816
  sendJson(client, { id: message.id ?? null, type: "teams.listMembers.result", data, error: null });
16631
16817
  } catch (error) {
16632
16818
  if (error?.sporadesAuthDenialLogData) emitAuthDeniedLog(database, { data: error.sporadesAuthDenialLogData });
@@ -16913,7 +17099,7 @@ function createWebSocketHub(getDatabase, trustedRefresh = null) {
16913
17099
  subscription.generation = generation;
16914
17100
  try {
16915
17101
  const database = getDatabase();
16916
- const result = await runQuery(database, client.session.auth, subscription.name);
17102
+ const result = await runQuery(database, client.session.auth, subscription.name, subscription.args);
16917
17103
  const data = subscription.style === "direct" ? result.data ?? result.rows : { rows: result.data ?? result.rows };
16918
17104
  if (client.subscriptions.get(subscription.id) !== subscription || subscription.generation !== generation) return;
16919
17105
  sendJson(client, {
@@ -17135,7 +17321,13 @@ function sendJsonWithCompletion(client, message, timeoutMs = 250) {
17135
17321
  }
17136
17322
  });
17137
17323
  }
17138
- async function runQuery(database, auth, queryName) {
17324
+ async function runQuery(database, auth, queryName, rawArgs = []) {
17325
+ let args;
17326
+ try {
17327
+ args = normalizeQueryArguments(rawArgs);
17328
+ } catch {
17329
+ return { rows: null, data: null, error: invalidQueryArgumentsError() };
17330
+ }
17139
17331
  let context;
17140
17332
  try {
17141
17333
  context = await applyContextMiddleware(database, createMutationContext(database, auth), "query");
@@ -17149,9 +17341,10 @@ async function runQuery(database, auth, queryName) {
17149
17341
  };
17150
17342
  }
17151
17343
  if (queryName === "ctx.env") {
17344
+ if (args.length > 0) return { rows: null, data: null, error: invalidQueryArgumentsError() };
17152
17345
  return { data: context.env, error: null };
17153
17346
  }
17154
- const customResult = await runCustomQuery(database, context, queryName);
17347
+ const customResult = await runCustomQuery(database, context, queryName, args);
17155
17348
  if (customResult) {
17156
17349
  return customResult;
17157
17350
  }
@@ -17165,6 +17358,7 @@ async function runQuery(database, auth, queryName) {
17165
17358
  }
17166
17359
  };
17167
17360
  }
17361
+ if (args.length > 0) return { rows: null, data: null, error: invalidQueryArgumentsError() };
17168
17362
  const cacheKey = `${table.name}:${context.auth.userId}`;
17169
17363
  if (!database.rowCache.has(cacheKey)) {
17170
17364
  const columns = ["id", "createdAt", "updatedAt", ...table.fields.map((field) => field.name)];
@@ -17179,14 +17373,14 @@ async function runQuery(database, auth, queryName) {
17179
17373
  const rows = await filterRowsByReadAcl(database, table, database.rowCache.get(cacheKey), context);
17180
17374
  return { rows, error: null };
17181
17375
  }
17182
- async function runCustomQuery(database, context, queryName) {
17376
+ async function runCustomQuery(database, context, queryName, args) {
17183
17377
  const handler = database.queries.find((candidate) => candidate.name === queryName);
17184
17378
  if (!handler) {
17185
17379
  return null;
17186
17380
  }
17187
17381
  try {
17188
17382
  const queryHandler = typeof handler.handler === "function" ? handler.handler : new Function(`return (${handler.handlerSource});`)();
17189
- const data = await queryHandler(context);
17383
+ const data = await queryHandler(context, ...args);
17190
17384
  assertJsonCompatible(data);
17191
17385
  return { data, error: null };
17192
17386
  } catch (error) {
@@ -17203,6 +17397,64 @@ async function runCustomQuery(database, context, queryName) {
17203
17397
  };
17204
17398
  }
17205
17399
  }
17400
+ var QUERY_ARGUMENT_LIMIT_BYTES = 65536;
17401
+ function invalidQueryArgumentsError() {
17402
+ return {
17403
+ message: "Invalid query arguments.",
17404
+ hint: "Use a JSON-compatible argument array no larger than 65536 UTF-8 bytes."
17405
+ };
17406
+ }
17407
+ function normalizeQueryArguments(value) {
17408
+ if (!Array.isArray(value)) throw new Error("Query arguments must be an array.");
17409
+ const snapshot = normalizeQueryArgumentValue(value, /* @__PURE__ */ new Set());
17410
+ const canonicalJson = JSON.stringify(snapshot);
17411
+ if (Buffer.byteLength(canonicalJson, "utf8") > QUERY_ARGUMENT_LIMIT_BYTES) {
17412
+ throw new Error("Query arguments are too large.");
17413
+ }
17414
+ return snapshot;
17415
+ }
17416
+ function normalizeQueryArgumentValue(value, ancestors) {
17417
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
17418
+ if (typeof value === "number") {
17419
+ if (!Number.isFinite(value)) throw new Error("Query arguments must contain finite numbers.");
17420
+ return value;
17421
+ }
17422
+ if (typeof value !== "object") throw new Error("Query arguments must be JSON-compatible.");
17423
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error("Query arguments must not contain symbol keys.");
17424
+ if (ancestors.has(value)) throw new Error("Query arguments must not contain cycles.");
17425
+ ancestors.add(value);
17426
+ try {
17427
+ if (Array.isArray(value)) {
17428
+ if (Object.getOwnPropertyNames(value).some((key) => key !== "length" && (!/^(0|[1-9]\\d*)$/.test(key) || Number(key) >= value.length))) {
17429
+ throw new Error("Query arguments must not contain non-index array properties.");
17430
+ }
17431
+ const copy2 = [];
17432
+ for (let index = 0; index < value.length; index += 1) {
17433
+ if (!Object.hasOwn(value, index)) throw new Error("Query arguments must not contain sparse arrays.");
17434
+ copy2.push(normalizeQueryArgumentValue(value[index], ancestors));
17435
+ }
17436
+ return Object.freeze(copy2);
17437
+ }
17438
+ const prototype = Object.getPrototypeOf(value);
17439
+ if (prototype !== Object.prototype && prototype !== null) {
17440
+ throw new Error("Query arguments must contain plain objects only.");
17441
+ }
17442
+ const copy = /* @__PURE__ */ Object.create(null);
17443
+ for (const key of Object.getOwnPropertyNames(value).sort()) {
17444
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
17445
+ if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) throw new Error("Query arguments must not contain accessors.");
17446
+ Object.defineProperty(copy, key, {
17447
+ value: normalizeQueryArgumentValue(descriptor.value, ancestors),
17448
+ enumerable: true,
17449
+ writable: false,
17450
+ configurable: false
17451
+ });
17452
+ }
17453
+ return Object.freeze(copy);
17454
+ } finally {
17455
+ ancestors.delete(value);
17456
+ }
17457
+ }
17206
17458
  async function runMutation(database, auth, mutationName, args) {
17207
17459
  let context;
17208
17460
  let result;
@@ -17426,6 +17678,16 @@ function createMutationContext(database, auth) {
17426
17678
  };
17427
17679
  return context;
17428
17680
  }
17681
+ function createTeamJoinAdmissionContext(database, auth) {
17682
+ const context = {
17683
+ auth,
17684
+ env: database.serverEnv,
17685
+ log: createEndpointLogger(database)
17686
+ };
17687
+ const holder = createContextHolder(context);
17688
+ context.db = createEndpointReadOnlyDatabaseApi(database, () => holder.current);
17689
+ return context;
17690
+ }
17429
17691
  function createCurrentUserJobApi(database, contextGetter) {
17430
17692
  return {
17431
17693
  async enqueue(handlerName, payload, options = {}) {
@@ -23542,7 +23804,7 @@ jobs:
23542
23804
  }
23543
23805
 
23544
23806
  // src/cli/cli-version.ts
23545
- var CLI_VERSION = "0.8.1";
23807
+ var CLI_VERSION = "0.8.5";
23546
23808
 
23547
23809
  // src/cli/sporades.ts
23548
23810
  var SUPPORTED_TEMPLATES = new Set(CLIENT_TEMPLATES);
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.8.1";
1
+ export declare const CLI_VERSION = "0.8.5";
2
2
  //# sourceMappingURL=cli-version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Generated by `npm run build` from package.json. Do not edit by hand.
2
- export const CLI_VERSION = "0.8.1";
2
+ export const CLI_VERSION = "0.8.5";
3
3
  //# sourceMappingURL=cli-version.js.map
@@ -144,7 +144,7 @@ export declare function sendEmailPasswordResetLink(database: LooseRecord, sessio
144
144
  ok: boolean;
145
145
  error?: undefined;
146
146
  }>;
147
- export declare function runQuery(database: LooseRecord, auth: any, queryName: string): Promise<any>;
147
+ export declare function runQuery(database: LooseRecord, auth: any, queryName: string, rawArgs?: unknown): Promise<any>;
148
148
  export declare function runMutation(database: LooseRecord, auth: any, mutationName: string, args: any): Promise<any>;
149
149
  export declare function runAppMessage(database: LooseRecord, auth: any, messageName: any, data: any, options?: LooseRecord): Promise<any>;
150
150
  export declare function runCurrentUserJobWorker(database: LooseRecord): Promise<void>;