@superbuilders/primer-tives 0.9.0 → 1.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.
Files changed (41) hide show
  1. package/README.md +298 -63
  2. package/dist/client/choice-state.d.ts.map +1 -1
  3. package/dist/client/extended-text-state.d.ts.map +1 -1
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/index.js +338 -179
  6. package/dist/client/index.js.map +13 -11
  7. package/dist/client/match-state.d.ts.map +1 -1
  8. package/dist/client/order-state.d.ts.map +1 -1
  9. package/dist/client/session.d.ts.map +1 -1
  10. package/dist/client/text-entry-state.d.ts.map +1 -1
  11. package/dist/client/transport.d.ts +1 -1
  12. package/dist/client/transport.d.ts.map +1 -1
  13. package/dist/client/types.d.ts +2 -115
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/contracts/index.d.ts +4 -0
  16. package/dist/contracts/index.d.ts.map +1 -0
  17. package/dist/contracts/index.js +305 -0
  18. package/dist/contracts/index.js.map +11 -0
  19. package/dist/contracts/pci-schemas.d.ts +25 -0
  20. package/dist/contracts/pci-schemas.d.ts.map +1 -0
  21. package/dist/contracts/types.d.ts +118 -0
  22. package/dist/contracts/types.d.ts.map +1 -0
  23. package/dist/contracts/validation.d.ts +132 -0
  24. package/dist/contracts/validation.d.ts.map +1 -0
  25. package/dist/errors.d.ts +2 -1
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/errors.js +48 -0
  28. package/dist/errors.js.map +10 -0
  29. package/dist/server/create-server.d.ts +21 -19
  30. package/dist/server/create-server.d.ts.map +1 -1
  31. package/dist/server/exchange.d.ts +10 -5
  32. package/dist/server/exchange.d.ts.map +1 -1
  33. package/dist/server/hints.d.ts +25 -0
  34. package/dist/server/hints.d.ts.map +1 -0
  35. package/dist/server/index.d.ts +4 -3
  36. package/dist/server/index.d.ts.map +1 -1
  37. package/dist/server/index.js +355 -83
  38. package/dist/server/index.js.map +9 -8
  39. package/dist/server/students.d.ts +3 -5
  40. package/dist/server/students.d.ts.map +1 -1
  41. package/package.json +17 -4
@@ -1,6 +1,3 @@
1
- // src/client/create.ts
2
- import * as errors10 from "@superbuilders/errors";
3
-
4
1
  // src/errors.ts
5
2
  import * as errors from "@superbuilders/errors";
6
3
  var ErrNetwork = errors.new("network");
@@ -15,6 +12,7 @@ var ErrTimeout = errors.new("timeout");
15
12
  var ErrForbidden = errors.new("forbidden");
16
13
  var ErrNotFound = errors.new("not found");
17
14
  var ErrConflict = errors.new("conflict");
15
+ var ErrExternalAuthorityRequired = errors.new("external authority required");
18
16
  var ErrRateLimited = errors.new("rate limited");
19
17
  var ErrServiceUnavailable = errors.new("service unavailable");
20
18
  var ErrNotSerializable = errors.new("PrimerState is live in-memory state and must not be serialized or stored");
@@ -23,15 +21,323 @@ var ErrInvalidSecretKey = errors.new("invalid secret key");
23
21
  var ErrStudentNotFound = errors.new("student not found");
24
22
  var ErrUnsupportedGrade = errors.new("unsupported grade");
25
23
  var ErrTimebackUnavailable = errors.new("timeback unavailable");
24
+ // src/contracts/validation.ts
25
+ import { z as z2 } from "zod";
26
+
27
+ // src/contracts/pci-schemas.ts
28
+ import { z } from "zod";
29
+ var DivisionRemainderPropsSchema = z.object({
30
+ dividend: z.number(),
31
+ divisor: z.number()
32
+ });
33
+ var DivisionRemainderSubmissionSchema = z.object({
34
+ quotient: z.string(),
35
+ remainder: z.string()
36
+ });
37
+ var FractionOperandSchema = z.object({
38
+ numerator: z.number(),
39
+ denominator: z.number()
40
+ });
41
+ var FractionAdditionPropsSchema = z.object({
42
+ left: FractionOperandSchema,
43
+ right: FractionOperandSchema
44
+ });
45
+ var FractionAdditionSubmissionSchema = z.object({
46
+ numerator: z.string(),
47
+ denominator: z.string()
48
+ });
49
+
50
+ // src/contracts/validation.ts
51
+ var MatchPairSchema = z2.object({
52
+ source: z2.string(),
53
+ target: z2.string()
54
+ });
55
+ var ChoiceSubmissionSchema = z2.object({
56
+ type: z2.literal("choice"),
57
+ selectedKeys: z2.array(z2.string())
58
+ });
59
+ var TextEntrySubmissionSchema = z2.object({
60
+ type: z2.literal("text-entry"),
61
+ value: z2.string()
62
+ });
63
+ var ExtendedTextSubmissionSchema = z2.object({
64
+ type: z2.literal("extended-text"),
65
+ values: z2.array(z2.string()).min(1)
66
+ });
67
+ var OrderSubmissionSchema = z2.object({
68
+ type: z2.literal("order"),
69
+ orderedKeys: z2.array(z2.string())
70
+ });
71
+ var MatchSubmissionSchema = z2.object({
72
+ type: z2.literal("match"),
73
+ pairs: z2.array(MatchPairSchema)
74
+ });
75
+ var DivisionRemainderPciSubmissionSchema = z2.object({
76
+ type: z2.literal("portable-custom"),
77
+ pciId: z2.literal("urn:primer:pci:division-remainder"),
78
+ value: DivisionRemainderSubmissionSchema
79
+ });
80
+ var FractionAdditionPciSubmissionSchema = z2.object({
81
+ type: z2.literal("portable-custom"),
82
+ pciId: z2.literal("urn:primer:pci:fraction-addition"),
83
+ value: FractionAdditionSubmissionSchema
84
+ });
85
+ var RendererSubmissionSchema = z2.union([
86
+ ChoiceSubmissionSchema,
87
+ TextEntrySubmissionSchema,
88
+ ExtendedTextSubmissionSchema,
89
+ OrderSubmissionSchema,
90
+ MatchSubmissionSchema,
91
+ DivisionRemainderPciSubmissionSchema,
92
+ FractionAdditionPciSubmissionSchema
93
+ ]);
94
+ function invalid(issues) {
95
+ return { ok: false, issues: Array.isArray(issues) ? issues : [issues] };
96
+ }
97
+ function valid(submission) {
98
+ return {
99
+ ok: true,
100
+ value: submission
101
+ };
102
+ }
103
+ function duplicates(values, keyOf) {
104
+ const seen = new Set;
105
+ const duplicated = new Set;
106
+ for (const value of values) {
107
+ const key = keyOf(value);
108
+ if (seen.has(key)) {
109
+ duplicated.add(key);
110
+ continue;
111
+ }
112
+ seen.add(key);
113
+ }
114
+ return [...duplicated];
115
+ }
116
+ function findUnknownIds(values, choices) {
117
+ const ids = new Set(choices.map(function getId(choice) {
118
+ return choice.identifier;
119
+ }));
120
+ return values.filter(function isUnknown(value) {
121
+ return !ids.has(value);
122
+ });
123
+ }
124
+ function countByIdentifier(pairs, side) {
125
+ const counts = new Map;
126
+ for (const pair of pairs) {
127
+ const key = pair[side];
128
+ counts.set(key, (counts.get(key) ?? 0) + 1);
129
+ }
130
+ return counts;
131
+ }
132
+ function validateUsageBounds(choices, counts, side) {
133
+ const issues = [];
134
+ for (const choice of choices) {
135
+ const count = counts.get(choice.identifier) ?? 0;
136
+ if (choice.matchMax !== 0 && count > choice.matchMax) {
137
+ issues.push(`${side} '${choice.identifier}' used ${count} times, max ${choice.matchMax}`);
138
+ }
139
+ if (count < choice.matchMin) {
140
+ issues.push(`${side} '${choice.identifier}' used ${count} times, min ${choice.matchMin}`);
141
+ }
142
+ }
143
+ return issues;
144
+ }
145
+ function pciSubmissionSchema(pciId) {
146
+ switch (pciId) {
147
+ case "urn:primer:pci:division-remainder":
148
+ return DivisionRemainderSubmissionSchema;
149
+ case "urn:primer:pci:fraction-addition":
150
+ return FractionAdditionSubmissionSchema;
151
+ }
152
+ }
153
+ function validateChoiceSubmission(interaction, submission) {
154
+ if (submission.type !== "choice") {
155
+ return invalid(`submission type '${submission.type}' does not match interaction type 'choice'`);
156
+ }
157
+ const issues = [];
158
+ if (submission.selectedKeys.length < interaction.minChoices) {
159
+ issues.push(`need at least ${interaction.minChoices} selections`);
160
+ }
161
+ if (submission.selectedKeys.length > interaction.maxChoices) {
162
+ issues.push(`at most ${interaction.maxChoices} selections`);
163
+ }
164
+ const duplicateKeys = duplicates(submission.selectedKeys, function keyOf(value) {
165
+ return value;
166
+ });
167
+ if (duplicateKeys.length > 0) {
168
+ issues.push("duplicate selections");
169
+ }
170
+ const unknownKeys = findUnknownIds(submission.selectedKeys, interaction.options);
171
+ if (unknownKeys.length > 0) {
172
+ issues.push(`unknown options: ${unknownKeys.join(", ")}`);
173
+ }
174
+ if (issues.length > 0) {
175
+ return invalid(issues);
176
+ }
177
+ return valid(submission);
178
+ }
179
+ function validateTextEntrySubmission(interaction, submission) {
180
+ if (submission.type !== "text-entry") {
181
+ return invalid(`submission type '${submission.type}' does not match interaction type 'text-entry'`);
182
+ }
183
+ return valid(submission);
184
+ }
185
+ function validateExtendedTextSubmission(interaction, submission) {
186
+ if (submission.type !== "extended-text") {
187
+ return invalid(`submission type '${submission.type}' does not match interaction type 'extended-text'`);
188
+ }
189
+ const issues = [];
190
+ if (interaction.cardinality === "single") {
191
+ if (submission.values.length !== 1) {
192
+ issues.push("single-cardinality extended-text requires exactly one value");
193
+ }
194
+ } else {
195
+ if (submission.values.length < interaction.minStrings) {
196
+ issues.push(`need at least ${interaction.minStrings} values`);
197
+ }
198
+ if (submission.values.length > interaction.maxStrings) {
199
+ issues.push(`at most ${interaction.maxStrings} values`);
200
+ }
201
+ const duplicateValues = duplicates(submission.values, function keyOf(value) {
202
+ return value;
203
+ });
204
+ if (duplicateValues.length > 0) {
205
+ issues.push("duplicate values are not allowed for multiple-cardinality extended-text");
206
+ }
207
+ }
208
+ if (issues.length > 0) {
209
+ return invalid(issues);
210
+ }
211
+ return valid(submission);
212
+ }
213
+ function validateOrderSubmission(interaction, submission) {
214
+ if (submission.type !== "order") {
215
+ return invalid(`submission type '${submission.type}' does not match interaction type 'order'`);
216
+ }
217
+ const issues = [];
218
+ if (submission.orderedKeys.length < interaction.minChoices) {
219
+ issues.push(`need at least ${interaction.minChoices} selections`);
220
+ }
221
+ if (submission.orderedKeys.length > interaction.maxChoices) {
222
+ issues.push(`at most ${interaction.maxChoices} selections`);
223
+ }
224
+ const duplicateKeys = duplicates(submission.orderedKeys, function keyOf(value) {
225
+ return value;
226
+ });
227
+ if (duplicateKeys.length > 0) {
228
+ issues.push("duplicate selections");
229
+ }
230
+ const unknownKeys = findUnknownIds(submission.orderedKeys, interaction.choices);
231
+ if (unknownKeys.length > 0) {
232
+ issues.push(`unknown choices: ${unknownKeys.join(", ")}`);
233
+ }
234
+ if (issues.length > 0) {
235
+ return invalid(issues);
236
+ }
237
+ return valid(submission);
238
+ }
239
+ function validateMatchSubmission(interaction, submission) {
240
+ if (submission.type !== "match") {
241
+ return invalid(`submission type '${submission.type}' does not match interaction type 'match'`);
242
+ }
243
+ const issues = [];
244
+ if (submission.pairs.length < interaction.minAssociations) {
245
+ issues.push(`need at least ${interaction.minAssociations} associations`);
246
+ }
247
+ if (submission.pairs.length > interaction.maxAssociations) {
248
+ issues.push(`at most ${interaction.maxAssociations} associations`);
249
+ }
250
+ const duplicatePairs = duplicates(submission.pairs, function keyOf(pair) {
251
+ return `${pair.source}->${pair.target}`;
252
+ });
253
+ if (duplicatePairs.length > 0) {
254
+ issues.push("duplicate associations are not allowed");
255
+ }
256
+ const sourceIds = new Set(interaction.sourceChoices.map(function getId(choice) {
257
+ return choice.identifier;
258
+ }));
259
+ const targetIds = new Set(interaction.targetChoices.map(function getId(choice) {
260
+ return choice.identifier;
261
+ }));
262
+ for (const pair of submission.pairs) {
263
+ if (!sourceIds.has(pair.source)) {
264
+ issues.push(`unknown source '${pair.source}'`);
265
+ }
266
+ if (!targetIds.has(pair.target)) {
267
+ issues.push(`unknown target '${pair.target}'`);
268
+ }
269
+ }
270
+ const sourceCounts = countByIdentifier(submission.pairs, "source");
271
+ const targetCounts = countByIdentifier(submission.pairs, "target");
272
+ issues.push(...validateUsageBounds(interaction.sourceChoices, sourceCounts, "source"));
273
+ issues.push(...validateUsageBounds(interaction.targetChoices, targetCounts, "target"));
274
+ if (issues.length > 0) {
275
+ return invalid(issues);
276
+ }
277
+ return valid(submission);
278
+ }
279
+ function validatePortableCustomSubmission(interaction, submission) {
280
+ if (submission.type !== "portable-custom") {
281
+ return invalid(`submission type '${submission.type}' does not match interaction type 'portable-custom'`);
282
+ }
283
+ if (submission.pciId !== interaction.pciId) {
284
+ return invalid(`submission PCI '${submission.pciId}' does not match interaction PCI '${interaction.pciId}'`);
285
+ }
286
+ const schema = pciSubmissionSchema(interaction.pciId);
287
+ const result = schema.safeParse(submission.value);
288
+ if (!result.success) {
289
+ return invalid(result.error.issues.map(function toIssue(issue) {
290
+ return issue.message;
291
+ }));
292
+ }
293
+ return valid(submission);
294
+ }
295
+ function validateSubmissionForInteraction(interaction, submission) {
296
+ switch (interaction.type) {
297
+ case "choice":
298
+ return validateChoiceSubmission(interaction, submission);
299
+ case "text-entry":
300
+ return validateTextEntrySubmission(interaction, submission);
301
+ case "extended-text":
302
+ return validateExtendedTextSubmission(interaction, submission);
303
+ case "order":
304
+ return validateOrderSubmission(interaction, submission);
305
+ case "match":
306
+ return validateMatchSubmission(interaction, submission);
307
+ case "portable-custom":
308
+ return validatePortableCustomSubmission(interaction, submission);
309
+ }
310
+ }
311
+ function submissionValidationMessage(result) {
312
+ return result.issues.join("; ");
313
+ }
314
+ // src/client/create.ts
315
+ import * as errors10 from "@superbuilders/errors";
26
316
 
27
317
  // src/client/transport.ts
28
318
  import * as errors2 from "@superbuilders/errors";
29
319
  var ADVANCE_PATH = "/api/v0/advance";
30
- function httpSentinel(status) {
320
+ function parseAdvanceErrorBody(body) {
321
+ if (body.length === 0) {
322
+ return null;
323
+ }
324
+ const parsed = errors2.trySync(function parseJson() {
325
+ return JSON.parse(body);
326
+ });
327
+ if (parsed.error) {
328
+ return null;
329
+ }
330
+ return parsed.data;
331
+ }
332
+ function httpSentinel(status, body) {
31
333
  if (status === 400) {
32
334
  return ErrBadRequest;
33
335
  }
34
336
  if (status === 401) {
337
+ const parsed = parseAdvanceErrorBody(body);
338
+ if (parsed?.detail === "token_expired") {
339
+ return ErrTokenExpired;
340
+ }
35
341
  return ErrInvalidAccessToken;
36
342
  }
37
343
  if (status === 403) {
@@ -105,7 +411,7 @@ function createTransport(tc) {
105
411
  const text = await res.text().catch(function fallback() {
106
412
  return "";
107
413
  });
108
- const sentinel = res.status === 422 ? ErrUnsupportedPci : httpSentinel(res.status);
414
+ const sentinel = res.status === 422 ? ErrUnsupportedPci : httpSentinel(res.status, text);
109
415
  log?.error("transport http error", {
110
416
  status: res.status
111
417
  });
@@ -140,38 +446,6 @@ function poisonToJSON() {
140
446
 
141
447
  // src/client/choice-state.ts
142
448
  import * as errors3 from "@superbuilders/errors";
143
- function findUnknownKey(selectedKeys, options) {
144
- const optionIds = new Set(options.map(function getId(o) {
145
- return o.identifier;
146
- }));
147
- for (const key of selectedKeys) {
148
- if (!optionIds.has(key)) {
149
- return key;
150
- }
151
- }
152
- return null;
153
- }
154
- function validateChoiceSubmission(log, selectedKeys, options, maxChoices, minChoices) {
155
- if (selectedKeys.length < minChoices) {
156
- log?.error("choice submit below minChoices", { selectedKeys, minChoices });
157
- return errors3.wrap(ErrInvalidSubmission, `need at least ${minChoices} selections`);
158
- }
159
- if (selectedKeys.length > maxChoices) {
160
- log?.error("choice submit above maxChoices", { selectedKeys, maxChoices });
161
- return errors3.wrap(ErrInvalidSubmission, `at most ${maxChoices} selections`);
162
- }
163
- const uniqueKeys = new Set(selectedKeys);
164
- if (uniqueKeys.size !== selectedKeys.length) {
165
- log?.error("choice submit has duplicates", { selectedKeys });
166
- return errors3.wrap(ErrInvalidSubmission, "duplicate selections");
167
- }
168
- const unknownKey = findUnknownKey(selectedKeys, options);
169
- if (unknownKey) {
170
- log?.error("choice submit has unknown key", { key: unknownKey });
171
- return errors3.wrap(ErrInvalidSubmission, `unknown option '${unknownKey}'`);
172
- }
173
- return null;
174
- }
175
449
  function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices) {
176
450
  let submitPending;
177
451
  let submitKey;
@@ -188,9 +462,10 @@ function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices
188
462
  }
189
463
  return Promise.resolve(ctx.errored(errors3.wrap(ErrConflict, "cannot submit a different choice payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
190
464
  }
191
- const validationError = validateChoiceSubmission(ctx.log, selectedKeys, options, maxChoices, minChoices);
192
- if (validationError) {
193
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
465
+ const validation = validateSubmissionForInteraction(interaction, submission);
466
+ if (!validation.ok) {
467
+ ctx.log?.error("choice submit invalid", { selectedKeys, issues: validation.issues });
468
+ return Promise.resolve(ctx.errored(errors3.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
194
469
  }
195
470
  submitKey = key;
196
471
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -228,17 +503,6 @@ function choiceState(ctx, stimulus, interaction, options, maxChoices, minChoices
228
503
 
229
504
  // src/client/extended-text-state.ts
230
505
  import * as errors4 from "@superbuilders/errors";
231
- function validateExtendedTextSubmission(log, values, minStrings, maxStrings) {
232
- if (values.length < minStrings) {
233
- log?.error("extended-text submit below minStrings", { count: values.length, minStrings });
234
- return errors4.wrap(ErrInvalidSubmission, `need at least ${minStrings} values`);
235
- }
236
- if (values.length > maxStrings) {
237
- log?.error("extended-text submit above maxStrings", { count: values.length, maxStrings });
238
- return errors4.wrap(ErrInvalidSubmission, `at most ${maxStrings} values`);
239
- }
240
- return null;
241
- }
242
506
  function extendedTextState(ctx, stimulus, interaction) {
243
507
  if (interaction.cardinality === "single") {
244
508
  let submitText = function(value) {
@@ -253,6 +517,11 @@ function extendedTextState(ctx, stimulus, interaction) {
253
517
  }
254
518
  return Promise.resolve(ctx.errored(errors4.wrap(ErrConflict, "cannot submit a different extended-text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
255
519
  }
520
+ const validation = validateSubmissionForInteraction(interaction, submission);
521
+ if (!validation.ok) {
522
+ ctx.log?.error("extended-text submit invalid", { value, issues: validation.issues });
523
+ return Promise.resolve(ctx.errored(errors4.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
524
+ }
256
525
  submitKey2 = key;
257
526
  submitPending2 = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
258
527
  submitPending2 = undefined;
@@ -302,9 +571,10 @@ function extendedTextState(ctx, stimulus, interaction) {
302
571
  }
303
572
  return Promise.resolve(ctx.errored(errors4.wrap(ErrConflict, "cannot submit a different extended-text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
304
573
  }
305
- const validationError = validateExtendedTextSubmission(ctx.log, values, multi.minStrings, multi.maxStrings);
306
- if (validationError) {
307
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
574
+ const validation = validateSubmissionForInteraction(multi, submission);
575
+ if (!validation.ok) {
576
+ ctx.log?.error("extended-text submit invalid", { values, issues: validation.issues });
577
+ return Promise.resolve(ctx.errored(errors4.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
308
578
  }
309
579
  submitKey = key;
310
580
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -364,92 +634,6 @@ function feedbackState(ctx, stimulus, interaction, submission, isCorrect, feedba
364
634
 
365
635
  // src/client/match-state.ts
366
636
  import * as errors5 from "@superbuilders/errors";
367
- function bumpCount(counts, key) {
368
- const prev = counts.get(key);
369
- const next = prev === undefined ? 1 : prev + 1;
370
- counts.set(key, next);
371
- }
372
- function getCount(counts, key) {
373
- const recorded = counts.get(key);
374
- return recorded === undefined ? 0 : recorded;
375
- }
376
- function validateAssociationBounds(log, pairs, minAssociations, maxAssociations) {
377
- if (pairs.length < minAssociations) {
378
- log?.error("match submit below minAssociations", { pairs, minAssociations });
379
- return errors5.wrap(ErrInvalidSubmission, `need at least ${minAssociations} associations`);
380
- }
381
- if (pairs.length > maxAssociations) {
382
- log?.error("match submit above maxAssociations", { pairs, maxAssociations });
383
- return errors5.wrap(ErrInvalidSubmission, `at most ${maxAssociations} associations`);
384
- }
385
- return null;
386
- }
387
- function validatePairsReferToKnownChoices(log, pairs, sourceIds, targetIds) {
388
- for (const pair of pairs) {
389
- if (!sourceIds.has(pair.source)) {
390
- log?.error("match submit has unknown source", { source: pair.source });
391
- return errors5.wrap(ErrInvalidSubmission, `unknown source '${pair.source}'`);
392
- }
393
- if (!targetIds.has(pair.target)) {
394
- log?.error("match submit has unknown target", { target: pair.target });
395
- return errors5.wrap(ErrInvalidSubmission, `unknown target '${pair.target}'`);
396
- }
397
- }
398
- return null;
399
- }
400
- function validateChoiceCaps(log, side, choices, counts) {
401
- for (const choice of choices) {
402
- const count = getCount(counts, choice.identifier);
403
- if (choice.matchMax !== 0 && count > choice.matchMax) {
404
- log?.error(`match submit exceeds ${side} matchMax`, {
405
- identifier: choice.identifier,
406
- count,
407
- matchMax: choice.matchMax
408
- });
409
- return errors5.wrap(ErrInvalidSubmission, `${side} '${choice.identifier}' used ${count} times, max ${choice.matchMax}`);
410
- }
411
- if (count < choice.matchMin) {
412
- log?.error(`match submit below ${side} matchMin`, {
413
- identifier: choice.identifier,
414
- count,
415
- matchMin: choice.matchMin
416
- });
417
- return errors5.wrap(ErrInvalidSubmission, `${side} '${choice.identifier}' used ${count} times, min ${choice.matchMin}`);
418
- }
419
- }
420
- return null;
421
- }
422
- function validateMatchSubmission(log, pairs, sourceChoices, targetChoices, minAssociations, maxAssociations) {
423
- const boundsError = validateAssociationBounds(log, pairs, minAssociations, maxAssociations);
424
- if (boundsError) {
425
- return boundsError;
426
- }
427
- const sourceIds = new Set(sourceChoices.map(function getId(c) {
428
- return c.identifier;
429
- }));
430
- const targetIds = new Set(targetChoices.map(function getId(c) {
431
- return c.identifier;
432
- }));
433
- const referenceError = validatePairsReferToKnownChoices(log, pairs, sourceIds, targetIds);
434
- if (referenceError) {
435
- return referenceError;
436
- }
437
- const sourceCounts = new Map;
438
- const targetCounts = new Map;
439
- for (const pair of pairs) {
440
- bumpCount(sourceCounts, pair.source);
441
- bumpCount(targetCounts, pair.target);
442
- }
443
- const sourceCapError = validateChoiceCaps(log, "source", sourceChoices, sourceCounts);
444
- if (sourceCapError) {
445
- return sourceCapError;
446
- }
447
- const targetCapError = validateChoiceCaps(log, "target", targetChoices, targetCounts);
448
- if (targetCapError) {
449
- return targetCapError;
450
- }
451
- return null;
452
- }
453
637
  function matchState(ctx, stimulus, interaction) {
454
638
  let submitPending;
455
639
  let submitKey;
@@ -466,9 +650,10 @@ function matchState(ctx, stimulus, interaction) {
466
650
  }
467
651
  return Promise.resolve(ctx.errored(errors5.wrap(ErrConflict, "cannot submit a different match payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
468
652
  }
469
- const validationError = validateMatchSubmission(ctx.log, pairs, interaction.sourceChoices, interaction.targetChoices, interaction.minAssociations, interaction.maxAssociations);
470
- if (validationError) {
471
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
653
+ const validation = validateSubmissionForInteraction(interaction, submission);
654
+ if (!validation.ok) {
655
+ ctx.log?.error("match submit invalid", { pairs, issues: validation.issues });
656
+ return Promise.resolve(ctx.errored(errors5.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
472
657
  }
473
658
  submitKey = key;
474
659
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -524,38 +709,6 @@ function observationState(ctx, stimulus) {
524
709
 
525
710
  // src/client/order-state.ts
526
711
  import * as errors6 from "@superbuilders/errors";
527
- function findUnknownKey2(orderedKeys, choices) {
528
- const choiceIds = new Set(choices.map(function getId(c) {
529
- return c.identifier;
530
- }));
531
- for (const key of orderedKeys) {
532
- if (!choiceIds.has(key)) {
533
- return key;
534
- }
535
- }
536
- return null;
537
- }
538
- function validateOrderSubmission(log, orderedKeys, choices, minChoices, maxChoices) {
539
- if (orderedKeys.length < minChoices) {
540
- log?.error("order submit below minChoices", { orderedKeys, minChoices });
541
- return errors6.wrap(ErrInvalidSubmission, `need at least ${minChoices} selections`);
542
- }
543
- if (orderedKeys.length > maxChoices) {
544
- log?.error("order submit above maxChoices", { orderedKeys, maxChoices });
545
- return errors6.wrap(ErrInvalidSubmission, `at most ${maxChoices} selections`);
546
- }
547
- const uniqueKeys = new Set(orderedKeys);
548
- if (uniqueKeys.size !== orderedKeys.length) {
549
- log?.error("order submit has duplicates", { orderedKeys });
550
- return errors6.wrap(ErrInvalidSubmission, "duplicate selections");
551
- }
552
- const unknownKey = findUnknownKey2(orderedKeys, choices);
553
- if (unknownKey) {
554
- log?.error("order submit has unknown key", { key: unknownKey });
555
- return errors6.wrap(ErrInvalidSubmission, `unknown choice '${unknownKey}'`);
556
- }
557
- return null;
558
- }
559
712
  function orderState(ctx, stimulus, interaction) {
560
713
  let submitPending;
561
714
  let submitKey;
@@ -572,9 +725,10 @@ function orderState(ctx, stimulus, interaction) {
572
725
  }
573
726
  return Promise.resolve(ctx.errored(errors6.wrap(ErrConflict, "cannot submit a different order payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
574
727
  }
575
- const validationError = validateOrderSubmission(ctx.log, orderedKeys, interaction.choices, interaction.minChoices, interaction.maxChoices);
576
- if (validationError) {
577
- return Promise.resolve(ctx.errored(validationError, "interaction", { kind: "interaction", submission }));
728
+ const validation = validateSubmissionForInteraction(interaction, submission);
729
+ if (!validation.ok) {
730
+ ctx.log?.error("order submit invalid", { orderedKeys, issues: validation.issues });
731
+ return Promise.resolve(ctx.errored(errors6.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
578
732
  }
579
733
  submitKey = key;
580
734
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
@@ -682,6 +836,11 @@ function textEntryState(ctx, stimulus, interaction) {
682
836
  }
683
837
  return Promise.resolve(ctx.errored(errors8.wrap(ErrConflict, "cannot submit a different text payload while submit is in flight"), "interaction", { kind: "interaction", submission }));
684
838
  }
839
+ const validation = validateSubmissionForInteraction(interaction, submission);
840
+ if (!validation.ok) {
841
+ ctx.log?.error("text-entry submit invalid", { value, issues: validation.issues });
842
+ return Promise.resolve(ctx.errored(errors8.wrap(ErrInvalidSubmission, submissionValidationMessage(validation)), "interaction", { kind: "interaction", submission }));
843
+ }
685
844
  submitKey = key;
686
845
  submitPending = ctx.execute({ kind: "interaction", submission }, "interaction").finally(function clearPending() {
687
846
  submitPending = undefined;
@@ -923,4 +1082,4 @@ export {
923
1082
  ErrBadRequest
924
1083
  };
925
1084
 
926
- //# debugId=66FB6CF9D880A12964756E2164756E21
1085
+ //# debugId=BFF38D60446A9E1864756E2164756E21