@reventlessdev/reventless-seed 1.0.0-alpha.20 → 1.0.0-alpha.22

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 (154) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/ReventlessSeed.cmi +0 -0
  4. package/lib/bs/ReventlessSeed.cmt +0 -0
  5. package/lib/bs/ReventlessSeed.mlmap +1 -0
  6. package/lib/bs/compiler-info.json +1 -1
  7. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  8. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  9. package/lib/bs/src/Seed.ast +0 -0
  10. package/lib/bs/src/Seed.res +8 -2
  11. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  12. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  13. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  14. package/lib/bs/src/Seed_Client.ast +0 -0
  15. package/lib/bs/src/Seed_Client.res +169 -21
  16. package/lib/bs/src/Seed_Client.res.mjs +142 -7
  17. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  18. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmj +0 -0
  19. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  20. package/lib/bs/src/Seed_Connect.ast +0 -0
  21. package/lib/bs/src/Seed_Connect.res +258 -19
  22. package/lib/bs/src/Seed_Connect.res.mjs +158 -8
  23. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmi +0 -0
  24. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmj +0 -0
  25. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  26. package/lib/bs/src/Seed_Prompt.ast +0 -0
  27. package/lib/bs/src/Seed_Prompt.res +48 -23
  28. package/lib/bs/src/Seed_Prompt.res.mjs +33 -22
  29. package/lib/bs/src/Seed_Random-ReventlessSeed.cmi +0 -0
  30. package/lib/bs/src/Seed_Random-ReventlessSeed.cmt +0 -0
  31. package/lib/bs/src/Seed_Random.ast +0 -0
  32. package/lib/bs/src/Seed_Random.res +4 -2
  33. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  34. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  35. package/lib/bs/src/Seed_Runner.ast +0 -0
  36. package/lib/bs/src/Seed_Runner.res +7 -12
  37. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  38. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  39. package/lib/bs/src/Seed_Upload.ast +0 -0
  40. package/lib/bs/src/Seed_Upload.res +4 -2
  41. package/lib/bs/src/Seed_Users-ReventlessSeed.cmi +0 -0
  42. package/lib/bs/src/Seed_Users-ReventlessSeed.cmj +0 -0
  43. package/lib/bs/src/Seed_Users-ReventlessSeed.cmt +0 -0
  44. package/lib/bs/src/Seed_Users.ast +0 -0
  45. package/lib/bs/src/Seed_Users.res +30 -3
  46. package/lib/bs/src/Seed_Users.res.mjs +14 -1
  47. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  48. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  49. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  50. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  51. package/lib/bs/tests/Seed_ClientTest.res +146 -16
  52. package/lib/bs/tests/Seed_ClientTest.res.mjs +102 -1
  53. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  54. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  55. package/lib/bs/tests/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  56. package/lib/bs/tests/Seed_ConnectTest.ast +0 -0
  57. package/lib/bs/tests/Seed_ConnectTest.res +64 -0
  58. package/lib/bs/tests/Seed_ConnectTest.res.mjs +56 -0
  59. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  60. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  61. package/lib/bs/tests/Seed_PromptTest.ast +0 -0
  62. package/lib/bs/tests/Seed_PromptTest.res +8 -12
  63. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  64. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  65. package/lib/bs/tests/Seed_RandomTest.ast +0 -0
  66. package/lib/bs/tests/Seed_RandomTest.res +130 -90
  67. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  68. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  69. package/lib/bs/tests/Seed_UsersTest.ast +0 -0
  70. package/lib/bs/tests/Seed_UsersTest.res +77 -2
  71. package/lib/bs/tests/Seed_UsersTest.res.mjs +45 -2
  72. package/lib/ocaml/.compiler.log +2 -2
  73. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  74. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  75. package/lib/ocaml/ReventlessSeed.mlmap +1 -0
  76. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  77. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  78. package/lib/ocaml/Seed.ast +0 -0
  79. package/lib/ocaml/Seed.res +8 -2
  80. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  81. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  82. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  83. package/lib/ocaml/Seed_Client.ast +0 -0
  84. package/lib/ocaml/Seed_Client.res +169 -21
  85. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  86. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  87. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  88. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  89. package/lib/ocaml/Seed_ClientTest.res +146 -16
  90. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  91. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmj +0 -0
  92. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  93. package/lib/ocaml/Seed_Connect.ast +0 -0
  94. package/lib/ocaml/Seed_Connect.res +258 -19
  95. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmi +0 -0
  96. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmj +0 -0
  97. package/lib/ocaml/Seed_ConnectTest-ReventlessSeed.cmt +0 -0
  98. package/lib/ocaml/Seed_ConnectTest.ast +0 -0
  99. package/lib/ocaml/Seed_ConnectTest.res +64 -0
  100. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmi +0 -0
  101. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmj +0 -0
  102. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  103. package/lib/ocaml/Seed_Prompt.ast +0 -0
  104. package/lib/ocaml/Seed_Prompt.res +48 -23
  105. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmi +0 -0
  106. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  107. package/lib/ocaml/Seed_PromptTest.ast +0 -0
  108. package/lib/ocaml/Seed_PromptTest.res +8 -12
  109. package/lib/ocaml/Seed_Random-ReventlessSeed.cmi +0 -0
  110. package/lib/ocaml/Seed_Random-ReventlessSeed.cmt +0 -0
  111. package/lib/ocaml/Seed_Random.ast +0 -0
  112. package/lib/ocaml/Seed_Random.res +4 -2
  113. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmi +0 -0
  114. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  115. package/lib/ocaml/Seed_RandomTest.ast +0 -0
  116. package/lib/ocaml/Seed_RandomTest.res +130 -90
  117. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  118. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  119. package/lib/ocaml/Seed_Runner.ast +0 -0
  120. package/lib/ocaml/Seed_Runner.res +7 -12
  121. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  122. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  123. package/lib/ocaml/Seed_Upload.ast +0 -0
  124. package/lib/ocaml/Seed_Upload.res +4 -2
  125. package/lib/ocaml/Seed_Users-ReventlessSeed.cmi +0 -0
  126. package/lib/ocaml/Seed_Users-ReventlessSeed.cmj +0 -0
  127. package/lib/ocaml/Seed_Users-ReventlessSeed.cmt +0 -0
  128. package/lib/ocaml/Seed_Users.ast +0 -0
  129. package/lib/ocaml/Seed_Users.res +30 -3
  130. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmi +0 -0
  131. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  132. package/lib/ocaml/Seed_UsersTest.ast +0 -0
  133. package/lib/ocaml/Seed_UsersTest.res +77 -2
  134. package/package.json +2 -2
  135. package/src/Seed.res +8 -2
  136. package/src/Seed_Client.res +169 -21
  137. package/src/Seed_Client.res.mjs +142 -7
  138. package/src/Seed_Connect.res +258 -19
  139. package/src/Seed_Connect.res.mjs +158 -8
  140. package/src/Seed_Prompt.res +48 -23
  141. package/src/Seed_Prompt.res.mjs +33 -22
  142. package/src/Seed_Random.res +4 -2
  143. package/src/Seed_Runner.res +7 -12
  144. package/src/Seed_Upload.res +4 -2
  145. package/src/Seed_Users.res +30 -3
  146. package/src/Seed_Users.res.mjs +14 -1
  147. package/tests/Seed_ClientTest.res +146 -16
  148. package/tests/Seed_ClientTest.res.mjs +102 -1
  149. package/tests/Seed_ConnectTest.res +64 -0
  150. package/tests/Seed_ConnectTest.res.mjs +56 -0
  151. package/tests/Seed_PromptTest.res +8 -12
  152. package/tests/Seed_RandomTest.res +130 -90
  153. package/tests/Seed_UsersTest.res +77 -2
  154. package/tests/Seed_UsersTest.res.mjs +45 -2
@@ -50,6 +50,22 @@ describe("Seed_Users.parseString:", () => {
50
50
  ])
51
51
  })
52
52
 
53
+ // The half that was dropped, and the reason owner-scoped demo rows were keyed
54
+ // to ids nobody on a Cognito deployment holds: the file records the id the
55
+ // platform stamps, and reading it only for a password threw that away.
56
+ //
57
+ // Absent stays absent rather than defaulting to the username — a local file
58
+ // may omit it and let the auth adapter default it, and an AWS file records a
59
+ // sub no default could invent. Guessing here would look like an answer.
60
+ testSync("keeps the userId an entry declares, and reads a missing one as absent", () => {
61
+ let users = Seed_Users.parseString(document)
62
+ expect(users->Array.map(u => u.Seed_Users.userId))->Expect.toEqual([
63
+ Some("local-admin"),
64
+ None,
65
+ None,
66
+ ])
67
+ })
68
+
53
69
  // An entry half-written by hand is the likely flaw in this file. Dropping it
54
70
  // keeps the other accounts selectable; failing the parse would not.
55
71
  testSync("skips an entry that names no password", () => {
@@ -79,14 +95,73 @@ describe("Seed_Users.load:", () => {
79
95
  })
80
96
  })
81
97
 
98
+ // A pool that signs in on an email address takes `shopper@example.com`, not
99
+ // `shopper`, so a demo cannot find its accounts by username alone there.
100
+ describe("Seed_Users.playing:", () => {
101
+ let users = Seed_Users.parseString(`
102
+ - username: shopper@example.com
103
+ password: pw
104
+ groups: [Shopper]
105
+ demoOwner: shopper
106
+ - username: admin
107
+ password: pw
108
+ groups: [Admin]
109
+ `)
110
+
111
+ testSync("finds the account whose demoOwner names the person", () => {
112
+ expect(
113
+ Seed_Users.playing(users, "shopper")->Option.map(u => u.Seed_Users.username),
114
+ )->Expect.toEqual(Some("shopper@example.com"))
115
+ })
116
+
117
+ testSync("falls back to the username when no entry declares one", () => {
118
+ expect(
119
+ Seed_Users.playing(users, "admin")->Option.map(u => u.Seed_Users.username),
120
+ )->Expect.toEqual(Some("admin"))
121
+ })
122
+
123
+ testSync("finds nobody for a person no entry plays", () => {
124
+ expect(Seed_Users.playing(users, "merch"))->Expect.toEqual(None)
125
+ })
126
+ })
127
+
82
128
  describe("Seed_Users.label:", () => {
83
129
  testSync("names the groups an account carries", () => {
84
130
  expect(
85
- Seed_Users.label({username: "admin", password: "x", groups: ["Admin", "Shopper"]}),
131
+ Seed_Users.label({
132
+ username: "admin",
133
+ password: "x",
134
+ groups: ["Admin", "Shopper"],
135
+ userId: None,
136
+ demoOwner: None,
137
+ }),
86
138
  )->Expect.toBe("admin [Admin, Shopper]")
87
139
  })
88
140
 
89
141
  testSync("is the bare username when the file records no groups", () => {
90
- expect(Seed_Users.label({username: "admin", password: "x", groups: []}))->Expect.toBe("admin")
142
+ expect(
143
+ Seed_Users.label({
144
+ username: "admin",
145
+ password: "x",
146
+ groups: [],
147
+ userId: None,
148
+ demoOwner: None,
149
+ }),
150
+ )->Expect.toBe("admin")
151
+ })
152
+
153
+ // The menu is unchanged by the id: an operator picks an account by name and
154
+ // membership, and a Cognito sub in the list would be noise at the one moment
155
+ // they are choosing a login.
156
+ testSync("says nothing about the userId", () => {
157
+ expect(
158
+ Seed_Users.label({
159
+ username: "admin",
160
+ password: "x",
161
+ groups: ["Admin"],
162
+ userId: Some("4275e4a4-00c1-70e1-1cb5-363b06b992b5"),
163
+ demoOwner: None,
164
+ }),
165
+ )->Expect.toBe("admin [Admin]")
91
166
  })
92
167
  })
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
  import * as Seed_Users$ReventlessSeed from "../src/Seed_Users.res.mjs";
4
5
 
5
6
  let document = `
@@ -46,6 +47,14 @@ globalThis.describe("Seed_Users.parseString:", () => {
46
47
  []
47
48
  ]);
48
49
  });
50
+ globalThis.test("keeps the userId an entry declares, and reads a missing one as absent", () => {
51
+ let users = Seed_Users$ReventlessSeed.parseString(document);
52
+ globalThis.expect(users.map(u => u.userId)).toEqual([
53
+ "local-admin",
54
+ undefined,
55
+ undefined
56
+ ]);
57
+ });
49
58
  globalThis.test("skips an entry that names no password", () => {
50
59
  let users = Seed_Users$ReventlessSeed.parseString(`
51
60
  - username: admin
@@ -69,6 +78,27 @@ globalThis.describe("Seed_Users.load:", () => {
69
78
  });
70
79
  });
71
80
 
81
+ globalThis.describe("Seed_Users.playing:", () => {
82
+ let users = Seed_Users$ReventlessSeed.parseString(`
83
+ - username: shopper@example.com
84
+ password: pw
85
+ groups: [Shopper]
86
+ demoOwner: shopper
87
+ - username: admin
88
+ password: pw
89
+ groups: [Admin]
90
+ `);
91
+ globalThis.test("finds the account whose demoOwner names the person", () => {
92
+ globalThis.expect(Stdlib_Option.map(Seed_Users$ReventlessSeed.playing(users, "shopper"), u => u.username)).toEqual("shopper@example.com");
93
+ });
94
+ globalThis.test("falls back to the username when no entry declares one", () => {
95
+ globalThis.expect(Stdlib_Option.map(Seed_Users$ReventlessSeed.playing(users, "admin"), u => u.username)).toEqual("admin");
96
+ });
97
+ globalThis.test("finds nobody for a person no entry plays", () => {
98
+ globalThis.expect(Seed_Users$ReventlessSeed.playing(users, "merch")).toEqual(undefined);
99
+ });
100
+ });
101
+
72
102
  globalThis.describe("Seed_Users.label:", () => {
73
103
  globalThis.test("names the groups an account carries", () => {
74
104
  globalThis.expect(Seed_Users$ReventlessSeed.label({
@@ -77,16 +107,29 @@ globalThis.describe("Seed_Users.label:", () => {
77
107
  groups: [
78
108
  "Admin",
79
109
  "Shopper"
80
- ]
110
+ ],
111
+ userId: undefined,
112
+ demoOwner: undefined
81
113
  })).toBe("admin [Admin, Shopper]");
82
114
  });
83
115
  globalThis.test("is the bare username when the file records no groups", () => {
84
116
  globalThis.expect(Seed_Users$ReventlessSeed.label({
85
117
  username: "admin",
86
118
  password: "x",
87
- groups: []
119
+ groups: [],
120
+ userId: undefined,
121
+ demoOwner: undefined
88
122
  })).toBe("admin");
89
123
  });
124
+ globalThis.test("says nothing about the userId", () => {
125
+ globalThis.expect(Seed_Users$ReventlessSeed.label({
126
+ username: "admin",
127
+ password: "x",
128
+ groups: ["Admin"],
129
+ userId: "4275e4a4-00c1-70e1-1cb5-363b06b992b5",
130
+ demoOwner: undefined
131
+ })).toBe("admin [Admin]");
132
+ });
90
133
  });
91
134
 
92
135
  export {
@@ -1,2 +1,2 @@
1
- #Start(1788869322834)
2
- #Done(1788869322875)
1
+ #Start(1789592211625)
2
+ #Done(1789592211686)
Binary file
Binary file
@@ -3,6 +3,7 @@ Seed
3
3
  Seed_Client
4
4
  Seed_ClientTest
5
5
  Seed_Connect
6
+ Seed_ConnectTest
6
7
  Seed_Prompt
7
8
  Seed_PromptTest
8
9
  Seed_Random
Binary file
Binary file
Binary file
@@ -16,12 +16,18 @@ module Connect = Seed_Connect
16
16
  exception Failed = Seed_Types.Failed
17
17
 
18
18
  // A live target a data set seeds against: an authenticated client, whether uploads
19
- // are skipped this run, and a label. Uploads mint through the domain API's
20
- // `Upload_Presign` mutation on the client (route B), so no upload endpoint is carried.
19
+ // are skipped this run, a label, and who the run is acting as. Uploads mint through
20
+ // the domain API's `Upload_Presign` mutation on the client (route B), so no upload
21
+ // endpoint is carried.
21
22
  type connection = Seed_Connect.connection = {
22
23
  client: Seed_Client.t,
23
24
  uploadsSkipped: bool,
24
25
  label: string,
26
+ accounts: array<Seed_Users.user>,
27
+ caller: Seed_Users.user,
28
+ callerId: option<string>,
29
+ login: (~username: string, ~password: string) => promise<string>,
30
+ roleSwitch: option<Seed_Connect.roleSwitch>,
25
31
  }
26
32
 
27
33
  // A named, seedable data set. `seed` owns everything domain-specific.
Binary file
@@ -21,7 +21,8 @@ type response
21
21
  @val external setTimeout: (unit => unit, int) => unit = "setTimeout"
22
22
  @scope("Date") @val external nowMs: unit => float = "now"
23
23
 
24
- let sleep = (ms: int): promise<unit> => Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
24
+ let sleep = (ms: int): promise<unit> =>
25
+ Promise.make((resolve, _) => setTimeout(() => resolve(), ms))
25
26
 
26
27
  // ── Configuration ───────────────────────────────────────────────────────────
27
28
 
@@ -68,6 +69,12 @@ let availableRolesClaim = "availableRoles"
68
69
  /** Group claim per provider, tried in order. */
69
70
  let groupClaimNames = ["cognito:groups", "groups"]
70
71
 
72
+ /** Claim carrying the caller's id, per provider, tried in order. A Cognito JWT
73
+ says `sub`; the local dev token is a base64url `Identity.t`, whose field is
74
+ `userId`. Same shape as `groupClaimNames`, for the same reason: this harness
75
+ depends on no framework package and must not learn who signed the token. */
76
+ let callerIdClaimNames = ["sub", "userId"]
77
+
71
78
  let decodeSegment = (segment: string): option<dict<JSON.t>> =>
72
79
  try {
73
80
  segment
@@ -79,10 +86,22 @@ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
79
86
  | _ => None
80
87
  }
81
88
 
89
+ /**
90
+ * A JOSE header rather than a claims set.
91
+ *
92
+ * RFC 7515 requires `alg` on the header, and nothing puts it on a payload. The
93
+ * test matters because a JWT header is base64url JSON exactly as the payload is,
94
+ * so it decodes just as readily — and it comes first.
95
+ */
96
+ let isJoseHeader = (obj: dict<JSON.t>): bool => obj->Dict.get("alg")->Option.isSome
97
+
82
98
  /** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
83
99
  it second and a local dev token first, so trying each in turn saves this from
84
100
  having to know which platform signed the token. A segment that is not a JSON
85
- object (a signature) simply does not decode. */
101
+ object (a signature) simply does not decode, and a JOSE header is skipped:
102
+ it IS a decodable JSON object, so taking the first one that parsed read a
103
+ real Cognito token's `{kid, alg}` as its claims and found no groups and no
104
+ id in it — silently, since an unreadable token is not an error here. */
86
105
  let claims = (t: t): option<dict<JSON.t>> =>
87
106
  t.token->Option.flatMap(token =>
88
107
  token
@@ -90,7 +109,11 @@ let claims = (t: t): option<dict<JSON.t>> =>
90
109
  ->Array.reduce(None, (found, segment) =>
91
110
  switch found {
92
111
  | Some(_) => found
93
- | None => decodeSegment(segment)
112
+ | None =>
113
+ switch decodeSegment(segment) {
114
+ | Some(obj) if obj->isJoseHeader => None
115
+ | decoded => decoded
116
+ }
94
117
  }
95
118
  )
96
119
  )
@@ -118,6 +141,31 @@ let effectiveGroups = (t: t): option<array<string>> =>
118
141
  }
119
142
  )
120
143
 
144
+ /** The id the bearer presents — what an `@owner` field is stamped with, and so
145
+ the only id a seed can key owner-scoped rows to and have an account read them
146
+ back. `None` when the token is opaque to us or names no id. */
147
+ let callerId = (t: t): option<string> =>
148
+ callerIdClaimNames->Array.reduce(None, (found, name) =>
149
+ switch found {
150
+ | Some(_) => found
151
+ | None => t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(JSON.Decode.string)
152
+ }
153
+ )
154
+
155
+ /**
156
+ * The membership a narrowed token was reduced *from*, or `None` when the bearer
157
+ * is not narrowed.
158
+ *
159
+ * `availableRoles` is only written when a role was chosen, so its presence is
160
+ * the narrowing — the group claim alone cannot tell you, since a token carrying
161
+ * one group looks identical whether the account holds one or five.
162
+ */
163
+ let narrowedFrom = (t: t): option<array<string>> =>
164
+ switch t->claimStrings(availableRolesClaim) {
165
+ | Some(available) if available->Array.length > 0 => Some(available)
166
+ | _ => None
167
+ }
168
+
121
169
  /** One line naming the identity the bearer carries, and — when it was narrowed —
122
170
  the membership it was narrowed from. The second half is the point: without it
123
171
  a refusal looks like a misconfigured grant rather than a role switch. */
@@ -178,8 +226,9 @@ let login = async (t: t): unit => {
178
226
  let detail = await res->responseText
179
227
  throw(
180
228
  Failed(
181
- `login as "${username}" failed with HTTP ${(res->responseStatus)
182
- ->Int.toString}: ${detail}`,
229
+ `login as "${username}" failed with HTTP ${res
230
+ ->responseStatus
231
+ ->Int.toString}: ${detail}`,
183
232
  ),
184
233
  )
185
234
  }
@@ -254,7 +303,19 @@ let attempts = 4
254
303
  * fixed delay would have the whole batch retry in lockstep and rebuild the same
255
304
  * burst that provoked the fault.
256
305
  */
257
- let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
306
+ /** What a document came back as. `Errors` carries both the raw array — so a
307
+ caller can classify it, e.g. as a refusal — and the message `gql` would have
308
+ thrown, so the two paths cannot describe the same failure differently. */
309
+ type failure = {errors: JSON.t, message: string}
310
+ type outcome = Data(JSON.t) | Errors(failure)
311
+
312
+ /**
313
+ * Sends one document and reports what came back, resending a transient fault
314
+ * along the way. Returning the errors rather than throwing is what lets a caller
315
+ * treat a refusal as an *answer* — which the authorization probe needs and
316
+ * seeding must never do.
317
+ */
318
+ let gqlOutcome = async (t: t, ~query: string, ~label: string): outcome => {
258
319
  let headers = Dict.fromArray([("content-type", "application/json")])
259
320
  switch t.token {
260
321
  | Some(token) => headers->Dict.set("authorization", `Bearer ${token}`)
@@ -263,10 +324,9 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
263
324
  let body = JSON.stringify(
264
325
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
265
326
  )
266
- let rec attempt = async (n: int): JSON.t => {
327
+ let rec attempt = async (n: int): outcome => {
267
328
  let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
268
- | _ =>
269
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
329
+ | _ => throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
270
330
  }
271
331
  let json = await res->responseJson
272
332
  switch json->field("errors") {
@@ -283,20 +343,52 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
283
343
  | Some(summary) => `\n identity: ${summary}`
284
344
  | None => ""
285
345
  }
286
- throw(
287
- Failed(
288
- `${label} failed\n query: ${query}\n response: ${JSON.stringify(
289
- errors,
290
- )}${identity}${tried}`,
291
- ),
292
- )
346
+ Errors({
347
+ errors,
348
+ message: `${label} failed\n query: ${query}\n response: ${JSON.stringify(
349
+ errors,
350
+ )}${identity}${tried}`,
351
+ })
293
352
  }
294
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
353
+ | None => Data(json->field("data")->Option.getOr(JSON.Encode.null))
295
354
  }
296
355
  }
297
356
  await attempt(1)
298
357
  }
299
358
 
359
+ let gql = async (t: t, ~query: string, ~label: string): JSON.t =>
360
+ switch await gqlOutcome(t, ~query, ~label) {
361
+ | Data(data) => data
362
+ | Errors({message}) => throw(Failed(message))
363
+ }
364
+
365
+ /**
366
+ * Whether the endpoint let this caller through, for a probe that means to find
367
+ * out rather than to succeed.
368
+ *
369
+ * Three outcomes and not two: "not refused" and "worked" are different claims,
370
+ * and collapsing them would let a misspelled field name read as a passing
371
+ * authorization check. `Granted` covers a domain rejection too — a
372
+ * `CommandRejected` arrives inside `data` with no GraphQL error, which is
373
+ * precisely how a probe confirms access without writing anything.
374
+ */
375
+ type access =
376
+ | Granted
377
+ | Refused
378
+ | // Served, and carrying nothing. Its own case because the two platforms deny a
379
+ // read differently: AppSync answers `Unauthorized`, while the local resolver's
380
+ // interceptor returns an empty connection — so on local an unauthorized read
381
+ // and an empty view are the same response, and no caller can tell them apart.
382
+ // Naming it lets a probe say "consistent with a denial" instead of guessing.
383
+ Empty
384
+ | Broke(string)
385
+
386
+ let checkAccess = async (t: t, ~query: string, ~label: string): access =>
387
+ switch await gqlOutcome(t, ~query, ~label) {
388
+ | Data(_) => Granted
389
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
390
+ }
391
+
300
392
  // ── Commands ────────────────────────────────────────────────────────────────
301
393
 
302
394
  let commandResultSelection = `__typename
@@ -328,6 +420,61 @@ let send = async (t: t, m: mutation, ~tolerate: array<string>=[]): option<string
328
420
  }
329
421
  }
330
422
 
423
+ /**
424
+ * Whether this caller may issue one command — asked with the very document
425
+ * `send` would have sent, so what the probe tests is what the seed actually
426
+ * uses rather than a hand-built lookalike.
427
+ *
428
+ * Pair it with a payload the domain would refuse anyway. Then `Granted` arrives
429
+ * as a `CommandRejected` inside `data`, which appends nothing: the probe learns
430
+ * that the door opened without walking through it, and a regression that wrongly
431
+ * opens a door still writes no events.
432
+ */
433
+ /** Refusal codes a command result carries. The two platforms answer a refused
434
+ command differently — AppSync rejects at the field and produces a GraphQL
435
+ `Unauthorized` error, while the local resolver runs the command handler and
436
+ answers `CommandRejected { errorCode: "Forbidden" }` inside `data`. Reading
437
+ only the first reports the second as allowed. */
438
+ let deniedCommandCodes = ["Forbidden", "Unauthorized", "AccessDenied"]
439
+
440
+ let isDeniedResult = (result: JSON.t): bool =>
441
+ switch (result->nodeString("errorCode"), result->nodeString("errorDetail")) {
442
+ | (Some(code), _) if deniedCommandCodes->Array.includes(code) => true
443
+ | (_, Some(detail)) => detail->String.toLowerCase->String.includes("not authorized")
444
+ | _ => false
445
+ }
446
+
447
+ let checkCommandAccess = async (t: t, m: mutation): access =>
448
+ switch await gqlOutcome(
449
+ t,
450
+ ~query=`mutation { r: ${m.field}(${m.args->renderArgs}) { ${commandResultSelection} } }`,
451
+ ~label=m->describe,
452
+ ) {
453
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
454
+ // No GraphQL error is not yet a grant: the local platform's refusal lives in
455
+ // the command result, so the domain's own answer has to be read too. Any other
456
+ // rejection IS a grant — the door opened and the domain refused what came
457
+ // through it, which is exactly the outcome a non-writing probe wants.
458
+ | Data(data) =>
459
+ data->field("r")->Option.getOr(JSON.Encode.null)->isDeniedResult ? Refused : Granted
460
+ }
461
+
462
+ /** Whether this caller may read one connection field. One row is enough: the
463
+ door is answered by the first request, not by the page behind it — but
464
+ whether a row came back is what separates a grant from a silent denial. */
465
+ let checkQueryAccess = async (t: t, ~field as fieldName: string): access => {
466
+ let query = `{ ${fieldName}(first: 1) { edges { node { id } } } }`
467
+ switch await gqlOutcome(t, ~query, ~label=fieldName) {
468
+ | Errors({errors, message}) => errors->isDenied ? Refused : Broke(message)
469
+ | Data(data) =>
470
+ let edges = switch data->field(fieldName)->Option.flatMap(c => c->field("edges")) {
471
+ | Some(Array(rows)) => rows->Array.length
472
+ | _ => 0
473
+ }
474
+ edges > 0 ? Granted : Empty
475
+ }
476
+ }
477
+
331
478
  let sendAll = async (t: t, mutations: array<mutation>): unit =>
332
479
  for i in 0 to mutations->Array.length - 1 {
333
480
  switch mutations->Array.get(i) {
@@ -342,12 +489,12 @@ let sendAll = async (t: t, mutations: array<mutation>): unit =>
342
489
  * Walks a Relay-style connection to the end. Connections page at 50 by default,
343
490
  * so any count taken from a single request silently truncates.
344
491
  */
345
- // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
492
+ let // `~args` is spliced into the connection's argument list — `"includeRetired: true"`
346
493
  // is the case it exists for, and the only way to read rows the resolvers withhold
347
494
  // by default. Literal GraphQL rather than a typed argument list because a seed
348
495
  // writes the query it means; the alternative is a builder that has to grow a case
349
496
  // per argument the platform adds.
350
- let queryAllNodes = async (
497
+ queryAllNodes = async (
351
498
  t: t,
352
499
  ~field as fieldName: string,
353
500
  ~selection: string,
@@ -463,8 +610,9 @@ let waitForIds = async (
463
610
  missing->Array.length > 10 ? ` (+${(missing->Array.length - 10)->Int.toString} more)` : ""
464
611
  throw(
465
612
  Failed(
466
- `only ${(found->Array.length)->Int.toString}/${(ids->Array.length)
467
- ->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
613
+ `only ${found->Array.length->Int.toString}/${ids
614
+ ->Array.length
615
+ ->Int.toString} ids reached ${fieldName} within ${timeoutMs->Int.toString}ms.\n missing: ${shown}${extra}`,
468
616
  ),
469
617
  )
470
618
  } else {
Binary file