@firedrill-tools/resend 0.1.1

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +198 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +1687 -0
  5. package/firedrill/conformance.suite.json +19 -0
  6. package/firedrill/contacts-outage.scenario.json +11 -0
  7. package/firedrill/quota-exhausted.scenario.json +17 -0
  8. package/firedrill/rate-limited.scenario.json +11 -0
  9. package/firedrill/resend-audience.drill.json +407 -0
  10. package/firedrill/resend-contacts-outage.drill.json +63 -0
  11. package/firedrill/resend-denied.drill.json +68 -0
  12. package/firedrill/resend-domains-and-keys.drill.json +307 -0
  13. package/firedrill/resend-email-flow.drill.json +286 -0
  14. package/firedrill/resend-fresh-install.drill.json +73 -0
  15. package/firedrill/resend-invalid-key.drill.json +803 -0
  16. package/firedrill/resend-large-page.drill.json +53 -0
  17. package/firedrill/resend-quota-exhausted.drill.json +82 -0
  18. package/firedrill/resend-rate-limited.drill.json +81 -0
  19. package/firedrill/resend-restricted-key.drill.json +441 -0
  20. package/firedrill/resend-tight-limits.drill.json +163 -0
  21. package/firedrill/tight-limits.scenario.json +16 -0
  22. package/firedrill/tools/resend/app/assets/ATTRIBUTION.md +30 -0
  23. package/firedrill/tools/resend/app/assets/fonts/Inter-OFL.txt +93 -0
  24. package/firedrill/tools/resend/app/assets/fonts/JetBrainsMono-OFL.txt +93 -0
  25. package/firedrill/tools/resend/app/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  26. package/firedrill/tools/resend/app/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  27. package/firedrill/tools/resend/app/assets/resend-icon-white.svg +3 -0
  28. package/firedrill/tools/resend/app/assets/resend-wordmark-white.svg +3 -0
  29. package/firedrill/tools/resend/app/assets/resend.svg +3 -0
  30. package/firedrill/tools/resend/app/site/app.js +90 -0
  31. package/firedrill/tools/resend/app/site/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  32. package/firedrill/tools/resend/app/site/assets/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  33. package/firedrill/tools/resend/app/site/assets/resend-icon-white.svg +3 -0
  34. package/firedrill/tools/resend/app/site/assets/resend-wordmark-white.svg +3 -0
  35. package/firedrill/tools/resend/app/site/assets/resend.svg +3 -0
  36. package/firedrill/tools/resend/app/site/contact-panel.js +86 -0
  37. package/firedrill/tools/resend/app/site/icons.js +70 -0
  38. package/firedrill/tools/resend/app/site/index.html +56 -0
  39. package/firedrill/tools/resend/app/site/list.js +41 -0
  40. package/firedrill/tools/resend/app/site/styles.css +67 -0
  41. package/firedrill/tools/resend/app/site/ui.js +259 -0
  42. package/firedrill/tools/resend/app/site/view-audience.js +160 -0
  43. package/firedrill/tools/resend/app/site/view-domains.js +106 -0
  44. package/firedrill/tools/resend/app/site/view-email-detail.js +126 -0
  45. package/firedrill/tools/resend/app/site/view-emails.js +83 -0
  46. package/firedrill/tools/resend/app/site/view-keys.js +91 -0
  47. package/firedrill/tools/resend/app/site/views.css +141 -0
  48. package/firedrill/tools/resend/behavior.mjs +114 -0
  49. package/firedrill/tools/resend/lib/check.mjs +75 -0
  50. package/firedrill/tools/resend/lib/core.mjs +112 -0
  51. package/firedrill/tools/resend/lib/json-depth.mjs +45 -0
  52. package/firedrill/tools/resend/lib/page.mjs +66 -0
  53. package/firedrill/tools/resend/lib/time.mjs +60 -0
  54. package/firedrill/tools/resend/lib/wire.mjs +98 -0
  55. package/firedrill/tools/resend/ops/contacts.mjs +199 -0
  56. package/firedrill/tools/resend/ops/domains.mjs +125 -0
  57. package/firedrill/tools/resend/ops/email-model.mjs +100 -0
  58. package/firedrill/tools/resend/ops/emails.mjs +139 -0
  59. package/firedrill/tools/resend/ops/keys-segments.mjs +97 -0
  60. package/firedrill/tools/resend/resend.tool.json +4804 -0
  61. package/firedrill/world.json +2148 -0
  62. package/firedrill.json +5 -0
  63. package/package.json +62 -0
  64. package/starter.json +1686 -0
  65. package/test/conformance.mjs +480 -0
@@ -0,0 +1,97 @@
1
+ // API keys (token revealed once, never stored), segments, and the app's workspace context.
2
+ import { checkIdempotencyKey, fail, newId, normalizeUuid, randomChars, requireUuid, resolveKey, scanAll } from "../lib/core.mjs";
3
+ import { present } from "../lib/check.mjs";
4
+ import { paginate } from "../lib/page.mjs";
5
+ import { isoTime, utcDay, wireTime } from "../lib/time.mjs";
6
+
7
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
8
+
9
+ export function apiKeysCreate(input, context) {
10
+ resolveKey(context);
11
+ checkIdempotencyKey(context, input);
12
+ if (!present(input.name)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `name` field.");
13
+ if (input.name.length > 50) fail(context, "VALIDATION_ERROR", "The `name` field must be at most 50 characters.");
14
+ const permission = input.permission ?? "full_access";
15
+ if (permission !== "full_access" && permission !== "sending_access") {
16
+ fail(context, "VALIDATION_ERROR", "The `permission` field must be `full_access` or `sending_access`.");
17
+ }
18
+ let domainId = null;
19
+ if (present(input.domainId)) {
20
+ if (permission !== "sending_access") fail(context, "VALIDATION_ERROR", "The `domain_id` field requires `sending_access` permission.");
21
+ domainId = normalizeUuid(input.domainId);
22
+ if (domainId === null || context.state.get("domains", domainId) === null) fail(context, "VALIDATION_ERROR", "The `domain_id` field must name an existing domain.");
23
+ }
24
+ const id = newId(context, "apiKeys");
25
+ const secret = randomChars(context, BASE62, 32);
26
+ context.state.put("api-keys", id, {
27
+ id, name: input.name, permission, domainId, tokenPreview: `re_${secret.slice(0, 6)}`, createdAtUs: context.clock.nowUs(), lastUsedAtUs: null,
28
+ });
29
+ return { id, token: `re_${secret}` };
30
+ }
31
+
32
+ export function apiKeysList(input, context) {
33
+ resolveKey(context);
34
+ const rows = scanAll(context, "api-keys").map((record) => record.value);
35
+ return paginate(context, input, rows, (key) => ({ id: key.id, name: key.name, created_at: wireTime(key.createdAtUs), last_used_at: wireTime(key.lastUsedAtUs) }));
36
+ }
37
+
38
+ export function apiKeysRemove(input, context) {
39
+ const { team } = resolveKey(context);
40
+ checkIdempotencyKey(context, input);
41
+ const id = requireUuid(context, input.id);
42
+ if (context.state.get("api-keys", id) === null) fail(context, "NOT_FOUND", "API key not found");
43
+ // The team's default key is the identity of actors without `resendApiKeyId`; removing it would lock them out.
44
+ if (id === team.defaultApiKeyId) fail(context, "VALIDATION_ERROR", "The team's default API key cannot be removed in this simulated service.");
45
+ context.state.delete("api-keys", id);
46
+ return { object: "api_key", id, deleted: true };
47
+ }
48
+
49
+ export function segmentsCreate(input, context) {
50
+ resolveKey(context);
51
+ checkIdempotencyKey(context, input);
52
+ if (!present(input.name)) fail(context, "MISSING_REQUIRED_FIELD", "Missing `name` field.");
53
+ if (input.name.length > 100) fail(context, "VALIDATION_ERROR", "The `name` field must be at most 100 characters.");
54
+ for (const field of ["filter", "audienceId"]) {
55
+ if (present(input[field])) fail(context, "VALIDATION_ERROR", `The \`${field === "filter" ? "filter" : "audience_id"}\` field is not supported by this simulated service.`);
56
+ }
57
+ const id = newId(context, "segments");
58
+ context.state.put("segments", id, { id, name: input.name, createdAtUs: context.clock.nowUs() });
59
+ return { object: "segment", id, name: input.name };
60
+ }
61
+
62
+ export function renderSegment(segment) {
63
+ return { id: segment.id, name: segment.name, created_at: wireTime(segment.createdAtUs) };
64
+ }
65
+
66
+ export function segmentsList(input, context) {
67
+ resolveKey(context);
68
+ const rows = scanAll(context, "segments").map((record) => record.value);
69
+ return paginate(context, input, rows, renderSegment);
70
+ }
71
+
72
+ export function segmentsRemove(input, context) {
73
+ resolveKey(context);
74
+ checkIdempotencyKey(context, input);
75
+ const id = requireUuid(context, input.id);
76
+ if (context.state.get("segments", id) === null) fail(context, "NOT_FOUND", "Segment not found");
77
+ for (const member of scanAll(context, "segment-members", `${id}/`)) {
78
+ context.state.delete("segment-members", member.rowId);
79
+ context.state.delete("contact-segments", `${member.value.contactId}/${id}`);
80
+ }
81
+ context.state.delete("segments", id);
82
+ return { object: "segment", id, deleted: true };
83
+ }
84
+
85
+ export function workspaceContext(input, context) {
86
+ const { key, team } = resolveKey(context, { send: true });
87
+ const now = context.clock.nowUs();
88
+ const usage = context.state.get("meta", "usage");
89
+ const today = utcDay(now);
90
+ const sentToday = usage !== null && usage.day === today ? usage.sent : 0;
91
+ return {
92
+ now: isoTime(now),
93
+ today,
94
+ team: { name: team.name, plan: team.plan, dailyQuota: team.dailyQuota, sentToday },
95
+ apiKey: { id: key.id, name: key.name, permission: key.permission, domainId: key.domainId },
96
+ };
97
+ }