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 +23 -0
- package/bin/sporades-host-helper.js +1 -1
- package/bin/sporades.js +301 -39
- package/dist/cli/cli-version.d.ts +1 -1
- package/dist/cli/cli-version.js +1 -1
- package/dist/server-runtime-source.d.ts +1 -1
- package/dist/server-runtime-source.d.ts.map +1 -1
- package/dist/server-runtime-source.js +152 -8
- package/dist/server-runtime-source.js.map +1 -1
- package/dist/teams-runtime.d.ts +3 -2
- package/dist/teams-runtime.d.ts.map +1 -1
- package/dist/teams-runtime.js +75 -5
- package/dist/teams-runtime.js.map +1 -1
- package/dist/templates/client-runtime-template.d.ts.map +1 -1
- package/dist/templates/client-runtime-template.js +93 -23
- package/dist/templates/client-runtime-template.js.map +1 -1
- package/package.json +7 -2
- package/src/types/client.d.ts +10 -9
- package/src/types/server.d.ts +36 -5
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
+
export declare const CLI_VERSION = "0.8.5";
|
|
2
2
|
//# sourceMappingURL=cli-version.d.ts.map
|
package/dist/cli/cli-version.js
CHANGED
|
@@ -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>;
|