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

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 (54) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/compiler-info.json +1 -1
  4. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  5. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  6. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  7. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  8. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  9. package/lib/bs/src/Seed_Client.ast +0 -0
  10. package/lib/bs/src/Seed_Client.res +108 -1
  11. package/lib/bs/src/Seed_Client.res.mjs +103 -1
  12. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  13. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  14. package/lib/bs/src/Seed_Connect.ast +0 -0
  15. package/lib/bs/src/Seed_Connect.res +7 -0
  16. package/lib/bs/src/Seed_Connect.res.mjs +4 -0
  17. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  18. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  19. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  20. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  21. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  22. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  23. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  24. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  25. package/lib/bs/tests/Seed_ClientTest.res +98 -0
  26. package/lib/bs/tests/Seed_ClientTest.res.mjs +103 -0
  27. package/lib/ocaml/.compiler.log +2 -2
  28. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  29. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  30. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  31. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  32. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  33. package/lib/ocaml/Seed_Client.ast +0 -0
  34. package/lib/ocaml/Seed_Client.res +108 -1
  35. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  36. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  37. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  38. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  39. package/lib/ocaml/Seed_ClientTest.res +98 -0
  40. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  41. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  42. package/lib/ocaml/Seed_Connect.ast +0 -0
  43. package/lib/ocaml/Seed_Connect.res +7 -0
  44. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  45. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  46. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  47. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  48. package/package.json +2 -2
  49. package/src/Seed_Client.res +108 -1
  50. package/src/Seed_Client.res.mjs +103 -1
  51. package/src/Seed_Connect.res +7 -0
  52. package/src/Seed_Connect.res.mjs +4 -0
  53. package/tests/Seed_ClientTest.res +98 -0
  54. package/tests/Seed_ClientTest.res.mjs +103 -0
@@ -52,6 +52,87 @@ let endpoint = (t: t): string => t.config.endpoint
52
52
  token is a valid bearer for `endpoint`. */
53
53
  let useToken = (t: t, token: string): unit => t.token = Some(token)
54
54
 
55
+ // ── What the bearer actually grants ─────────────────────────────────────────
56
+ //
57
+ // An account's listed groups are not necessarily the ones its token carries:
58
+ // both platforms narrow a token to the single role the caller last chose to act
59
+ // as. So a seed can log in as an account that looks eligible and be refused, and
60
+ // the account list cannot explain it — only the token can.
61
+
62
+ /** Claim names a narrowed token carries. These mirror the cross-provider
63
+ contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
64
+ imported because this harness deliberately depends on no framework package. */
65
+ let activeRoleClaim = "activeRole"
66
+ let availableRolesClaim = "availableRoles"
67
+
68
+ /** Group claim per provider, tried in order. */
69
+ let groupClaimNames = ["cognito:groups", "groups"]
70
+
71
+ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
72
+ try {
73
+ segment
74
+ ->NodeBuffer.fromStringBase64Url
75
+ ->NodeBuffer.toStringUtf8
76
+ ->JSON.parseOrThrow
77
+ ->JSON.Decode.object
78
+ } catch {
79
+ | _ => None
80
+ }
81
+
82
+ /** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
83
+ it second and a local dev token first, so trying each in turn saves this from
84
+ having to know which platform signed the token. A segment that is not a JSON
85
+ object (a signature) simply does not decode. */
86
+ let claims = (t: t): option<dict<JSON.t>> =>
87
+ t.token->Option.flatMap(token =>
88
+ token
89
+ ->String.split(".")
90
+ ->Array.reduce(None, (found, segment) =>
91
+ switch found {
92
+ | Some(_) => found
93
+ | None => decodeSegment(segment)
94
+ }
95
+ )
96
+ )
97
+
98
+ // Arrays on the group claim, comma-joined on `availableRoles`.
99
+ let asStrings = (value: JSON.t): option<array<string>> =>
100
+ switch value {
101
+ | Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
102
+ | String(joined) =>
103
+ Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
104
+ | _ => None
105
+ }
106
+
107
+ let claimStrings = (t: t, name: string): option<array<string>> =>
108
+ t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
109
+
110
+ /** The groups the bearer actually presents — what every enforcement point reads.
111
+ `None` when the token is opaque to us, which is not an error: it just means
112
+ this cannot add anything. */
113
+ let effectiveGroups = (t: t): option<array<string>> =>
114
+ groupClaimNames->Array.reduce(None, (found, name) =>
115
+ switch found {
116
+ | Some(_) => found
117
+ | None => t->claimStrings(name)
118
+ }
119
+ )
120
+
121
+ /** One line naming the identity the bearer carries, and — when it was narrowed —
122
+ the membership it was narrowed from. The second half is the point: without it
123
+ a refusal looks like a misconfigured grant rather than a role switch. */
124
+ let identitySummary = (t: t): option<string> =>
125
+ t
126
+ ->effectiveGroups
127
+ ->Option.map(groups => {
128
+ let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
129
+ switch t->claimStrings(availableRolesClaim) {
130
+ | Some(available) if available->Array.length > 0 =>
131
+ `${held} — narrowed to this role from ${available->Array.join(", ")}`
132
+ | _ => held
133
+ }
134
+ })
135
+
55
136
  // ── JSON helpers ────────────────────────────────────────────────────────────
56
137
 
57
138
  let field = (json: JSON.t, key: string): option<JSON.t> =>
@@ -138,6 +219,21 @@ let isTransient = (errors: JSON.t): bool =>
138
219
  )
139
220
  }
140
221
 
222
+ /** An authorization refusal, in either platform's vocabulary — AppSync answers
223
+ `errorType: "Unauthorized"`, the local server carries it in the message. */
224
+ let isDenied = (errors: JSON.t): bool =>
225
+ switch errors->JSON.Decode.array {
226
+ | None => false
227
+ | Some(entries) =>
228
+ entries->Array.some(e =>
229
+ switch (e->nodeString("errorType"), e->nodeString("message")) {
230
+ | (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
231
+ | (_, Some(message)) => message->String.includes("Not Authorized")
232
+ | _ => false
233
+ }
234
+ )
235
+ }
236
+
141
237
  let attempts = 4
142
238
 
143
239
  /**
@@ -180,8 +276,19 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
180
276
  await attempt(n + 1)
181
277
  } else {
182
278
  let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
279
+ // A refusal is the one failure the response cannot explain on its own:
280
+ // it names the field, never the caller. The account list can't either —
281
+ // it holds membership, and a narrowed token carries less.
282
+ let identity = switch isDenied(errors) ? t->identitySummary : None {
283
+ | Some(summary) => `\n identity: ${summary}`
284
+ | None => ""
285
+ }
183
286
  throw(
184
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}${tried}`),
287
+ Failed(
288
+ `${label} failed\n query: ${query}\n response: ${JSON.stringify(
289
+ errors,
290
+ )}${identity}${tried}`,
291
+ ),
185
292
  )
186
293
  }
187
294
  | None => json->field("data")->Option.getOr(JSON.Encode.null)
Binary file
@@ -57,3 +57,101 @@ describe("Seed_Client.isTransient:", () => {
57
57
  expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
58
58
  })
59
59
  })
60
+
61
+ // An account's listed groups are not what authorizes it — the token's are. Both
62
+ // platforms narrow a token to the single role the caller last chose, so a seed
63
+ // can log in as an account the user list shows as `[Admin, Shopper]` and present
64
+ // a token carrying `Shopper` alone. The refusal that follows names the field and
65
+ // not the caller, and the account list cannot explain it either. These pin the
66
+ // reading-back that turns that into a sentence.
67
+
68
+ let b64url = payload =>
69
+ payload
70
+ ->NodeBuffer.fromStringUtf8
71
+ ->NodeBuffer.toStringBase64Url
72
+
73
+ let jwt = claims =>
74
+ `header.${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
75
+
76
+ let localToken = claims =>
77
+ `${b64url(JSON.stringify(JSON.Encode.object(Dict.fromArray(claims))))}.signature`
78
+
79
+ let strings = values => JSON.Encode.array(values->Array.map(JSON.Encode.string))
80
+
81
+ let clientWith = token => {
82
+ let c = Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
83
+ c->Seed_Client.useToken(token)
84
+ c
85
+ }
86
+
87
+ describe("Seed_Client.effectiveGroups:", () => {
88
+ // A Cognito id token carries its payload in the second segment, a local dev
89
+ // token in the first. Neither platform should have to announce which it is.
90
+ testSync("reads the payload wherever the provider put it", () => {
91
+ expect(
92
+ clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))->Seed_Client.effectiveGroups,
93
+ )->toEqual(Some(["Admin", "Shopper"]))
94
+ expect(
95
+ clientWith(localToken([("groups", strings(["Merchandiser"]))]))->Seed_Client.effectiveGroups,
96
+ )->toEqual(Some(["Merchandiser"]))
97
+ })
98
+
99
+ // An opaque bearer is not an error — it just means this can add nothing, and
100
+ // the failure reports what it always did.
101
+ testSync("says nothing about a token it cannot read", () => {
102
+ expect(clientWith("not-a-token")->Seed_Client.effectiveGroups)->toEqual(None)
103
+ expect(
104
+ Seed_Client.make(~config={endpoint: "http://example.invalid/graphql"})
105
+ ->Seed_Client.effectiveGroups,
106
+ )->toEqual(None)
107
+ })
108
+ })
109
+
110
+ describe("Seed_Client.identitySummary:", () => {
111
+ // The case that cost the debugging: eligible in the user list, refused by the
112
+ // token. Naming what it was narrowed FROM is what points at the role switch.
113
+ testSync("names the membership a narrowed token gave up", () =>
114
+ expect(
115
+ clientWith(
116
+ jwt([
117
+ ("cognito:groups", strings(["Shopper"])),
118
+ ("availableRoles", JSON.Encode.string("Admin,Shopper")),
119
+ ]),
120
+ )->Seed_Client.identitySummary,
121
+ )->toEqual(Some("Shopper — narrowed to this role from Admin, Shopper"))
122
+ )
123
+
124
+ // An ordinary login is not narrowed and has nothing to explain.
125
+ testSync("stays quiet about an unnarrowed token", () =>
126
+ expect(
127
+ clientWith(jwt([("cognito:groups", strings(["Admin", "Shopper"]))]))
128
+ ->Seed_Client.identitySummary,
129
+ )->toEqual(Some("Admin, Shopper"))
130
+ )
131
+
132
+ // A token granting nothing reads as a sentence rather than an empty string.
133
+ testSync("says so when the token carries no groups at all", () =>
134
+ expect(
135
+ clientWith(jwt([("cognito:groups", strings([]))]))->Seed_Client.identitySummary,
136
+ )->toEqual(Some("no groups"))
137
+ )
138
+ })
139
+
140
+ describe("Seed_Client.isDenied:", () => {
141
+ // AppSync types it; the local server carries it in the message.
142
+ testSync("recognises a refusal in either platform's vocabulary", () => {
143
+ expect(Seed_Client.isDenied(errors([errorOfType("Unauthorized")])))->toBe(true)
144
+ expect(
145
+ Seed_Client.isDenied(
146
+ errors([Dict.fromArray([("message", JSON.Encode.string("Not Authorized to access X"))])]),
147
+ ),
148
+ )->toBe(true)
149
+ })
150
+
151
+ // Attaching an identity to an unrelated failure would point the reader at the
152
+ // wrong thing.
153
+ testSync("leaves every other failure alone", () => {
154
+ expect(Seed_Client.isDenied(errors([errorOfType("ValidationException")])))->toBe(false)
155
+ expect(Seed_Client.isDenied(errors([])))->toBe(false)
156
+ })
157
+ })
Binary file
@@ -38,6 +38,13 @@ let make = async (
38
38
  let token = await login(~username, ~password)
39
39
  let client = Seed_Client.make(~config={endpoint: endpoint})
40
40
  client->Seed_Client.useToken(token)
41
+ // What the bearer grants, which is not always what the account list showed: a
42
+ // token narrowed to one role presents that role alone. Said at login so the
43
+ // run starts from the identity it will actually be refused or served under.
44
+ switch Seed_Client.identitySummary(client) {
45
+ | Some(summary) => Console.log(`Acting as: ${summary}`)
46
+ | None => ()
47
+ }
41
48
  {client, uploadsSkipped, label}
42
49
  }
43
50
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-seed",
3
- "version": "1.0.0-alpha.19",
3
+ "version": "1.0.0-alpha.20",
4
4
  "description": "Reusable harness for seeding a Reventless platform through its public GraphQL command API",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
7
7
  "yaml": "^2.8.3",
8
- "@reventlessdev/rescript-node": "2.0.0-alpha.9"
8
+ "@reventlessdev/rescript-node": "2.0.0-alpha.10"
9
9
  },
10
10
  "devDependencies": {
11
11
  "rescript": "12.3.0",
@@ -52,6 +52,87 @@ let endpoint = (t: t): string => t.config.endpoint
52
52
  token is a valid bearer for `endpoint`. */
53
53
  let useToken = (t: t, token: string): unit => t.token = Some(token)
54
54
 
55
+ // ── What the bearer actually grants ─────────────────────────────────────────
56
+ //
57
+ // An account's listed groups are not necessarily the ones its token carries:
58
+ // both platforms narrow a token to the single role the caller last chose to act
59
+ // as. So a seed can log in as an account that looks eligible and be refused, and
60
+ // the account list cannot explain it — only the token can.
61
+
62
+ /** Claim names a narrowed token carries. These mirror the cross-provider
63
+ contract in `ReventlessCore.Auth_ActiveRole`; spelled out rather than
64
+ imported because this harness deliberately depends on no framework package. */
65
+ let activeRoleClaim = "activeRole"
66
+ let availableRolesClaim = "availableRoles"
67
+
68
+ /** Group claim per provider, tried in order. */
69
+ let groupClaimNames = ["cognito:groups", "groups"]
70
+
71
+ let decodeSegment = (segment: string): option<dict<JSON.t>> =>
72
+ try {
73
+ segment
74
+ ->NodeBuffer.fromStringBase64Url
75
+ ->NodeBuffer.toStringUtf8
76
+ ->JSON.parseOrThrow
77
+ ->JSON.Decode.object
78
+ } catch {
79
+ | _ => None
80
+ }
81
+
82
+ /** The bearer's payload, from whichever segment carries it — a Cognito JWT puts
83
+ it second and a local dev token first, so trying each in turn saves this from
84
+ having to know which platform signed the token. A segment that is not a JSON
85
+ object (a signature) simply does not decode. */
86
+ let claims = (t: t): option<dict<JSON.t>> =>
87
+ t.token->Option.flatMap(token =>
88
+ token
89
+ ->String.split(".")
90
+ ->Array.reduce(None, (found, segment) =>
91
+ switch found {
92
+ | Some(_) => found
93
+ | None => decodeSegment(segment)
94
+ }
95
+ )
96
+ )
97
+
98
+ // Arrays on the group claim, comma-joined on `availableRoles`.
99
+ let asStrings = (value: JSON.t): option<array<string>> =>
100
+ switch value {
101
+ | Array(entries) => Some(entries->Array.filterMap(JSON.Decode.string))
102
+ | String(joined) =>
103
+ Some(joined->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
104
+ | _ => None
105
+ }
106
+
107
+ let claimStrings = (t: t, name: string): option<array<string>> =>
108
+ t->claims->Option.flatMap(c => c->Dict.get(name))->Option.flatMap(asStrings)
109
+
110
+ /** The groups the bearer actually presents — what every enforcement point reads.
111
+ `None` when the token is opaque to us, which is not an error: it just means
112
+ this cannot add anything. */
113
+ let effectiveGroups = (t: t): option<array<string>> =>
114
+ groupClaimNames->Array.reduce(None, (found, name) =>
115
+ switch found {
116
+ | Some(_) => found
117
+ | None => t->claimStrings(name)
118
+ }
119
+ )
120
+
121
+ /** One line naming the identity the bearer carries, and — when it was narrowed —
122
+ the membership it was narrowed from. The second half is the point: without it
123
+ a refusal looks like a misconfigured grant rather than a role switch. */
124
+ let identitySummary = (t: t): option<string> =>
125
+ t
126
+ ->effectiveGroups
127
+ ->Option.map(groups => {
128
+ let held = groups->Array.length == 0 ? "no groups" : groups->Array.join(", ")
129
+ switch t->claimStrings(availableRolesClaim) {
130
+ | Some(available) if available->Array.length > 0 =>
131
+ `${held} — narrowed to this role from ${available->Array.join(", ")}`
132
+ | _ => held
133
+ }
134
+ })
135
+
55
136
  // ── JSON helpers ────────────────────────────────────────────────────────────
56
137
 
57
138
  let field = (json: JSON.t, key: string): option<JSON.t> =>
@@ -138,6 +219,21 @@ let isTransient = (errors: JSON.t): bool =>
138
219
  )
139
220
  }
140
221
 
222
+ /** An authorization refusal, in either platform's vocabulary — AppSync answers
223
+ `errorType: "Unauthorized"`, the local server carries it in the message. */
224
+ let isDenied = (errors: JSON.t): bool =>
225
+ switch errors->JSON.Decode.array {
226
+ | None => false
227
+ | Some(entries) =>
228
+ entries->Array.some(e =>
229
+ switch (e->nodeString("errorType"), e->nodeString("message")) {
230
+ | (Some(errorType), _) if errorType->String.includes("Unauthorized") => true
231
+ | (_, Some(message)) => message->String.includes("Not Authorized")
232
+ | _ => false
233
+ }
234
+ )
235
+ }
236
+
141
237
  let attempts = 4
142
238
 
143
239
  /**
@@ -180,8 +276,19 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
180
276
  await attempt(n + 1)
181
277
  } else {
182
278
  let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
279
+ // A refusal is the one failure the response cannot explain on its own:
280
+ // it names the field, never the caller. The account list can't either —
281
+ // it holds membership, and a narrowed token carries less.
282
+ let identity = switch isDenied(errors) ? t->identitySummary : None {
283
+ | Some(summary) => `\n identity: ${summary}`
284
+ | None => ""
285
+ }
183
286
  throw(
184
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}${tried}`),
287
+ Failed(
288
+ `${label} failed\n query: ${query}\n response: ${JSON.stringify(
289
+ errors,
290
+ )}${identity}${tried}`,
291
+ ),
185
292
  )
186
293
  }
187
294
  | None => json->field("data")->Option.getOr(JSON.Encode.null)
@@ -30,6 +30,69 @@ function useToken(t, token) {
30
30
  t.token = token;
31
31
  }
32
32
 
33
+ let availableRolesClaim = "availableRoles";
34
+
35
+ let groupClaimNames = [
36
+ "cognito:groups",
37
+ "groups"
38
+ ];
39
+
40
+ function decodeSegment(segment) {
41
+ try {
42
+ return Stdlib_JSON.Decode.object(JSON.parse(Buffer.from(segment, "base64url").toString("utf8")));
43
+ } catch (exn) {
44
+ return;
45
+ }
46
+ }
47
+
48
+ function claims(t) {
49
+ return Stdlib_Option.flatMap(t.token, token => Stdlib_Array.reduce(token.split("."), undefined, (found, segment) => {
50
+ if (found !== undefined) {
51
+ return found;
52
+ } else {
53
+ return decodeSegment(segment);
54
+ }
55
+ }));
56
+ }
57
+
58
+ function asStrings(value) {
59
+ if (Array.isArray(value)) {
60
+ return Stdlib_Array.filterMap(value, Stdlib_JSON.Decode.string);
61
+ }
62
+ switch (typeof value) {
63
+ case "string" :
64
+ return value.split(",").map(prim => prim.trim()).filter(s => s !== "");
65
+ default:
66
+ return;
67
+ }
68
+ }
69
+
70
+ function claimStrings(t, name) {
71
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(claims(t), c => c[name]), asStrings);
72
+ }
73
+
74
+ function effectiveGroups(t) {
75
+ return Stdlib_Array.reduce(groupClaimNames, undefined, (found, name) => {
76
+ if (found !== undefined) {
77
+ return found;
78
+ } else {
79
+ return claimStrings(t, name);
80
+ }
81
+ });
82
+ }
83
+
84
+ function identitySummary(t) {
85
+ return Stdlib_Option.map(effectiveGroups(t), groups => {
86
+ let held = groups.length === 0 ? "no groups" : groups.join(", ");
87
+ let available = claimStrings(t, availableRolesClaim);
88
+ if (available !== undefined && available.length !== 0) {
89
+ return held + ` — narrowed to this role from ` + available.join(", ");
90
+ } else {
91
+ return held;
92
+ }
93
+ });
94
+ }
95
+
33
96
  function field(json, key) {
34
97
  if (typeof json === "object" && json !== null && !Array.isArray(json)) {
35
98
  return json[key];
@@ -125,6 +188,31 @@ function isTransient(errors) {
125
188
  }
126
189
  }
127
190
 
191
+ function isDenied(errors) {
192
+ let entries = Stdlib_JSON.Decode.array(errors);
193
+ if (entries !== undefined) {
194
+ return entries.some(e => {
195
+ let match = nodeString(e, "errorType");
196
+ let match$1 = nodeString(e, "message");
197
+ if (match !== undefined) {
198
+ if (match.includes("Unauthorized")) {
199
+ return true;
200
+ } else if (match$1 !== undefined) {
201
+ return match$1.includes("Not Authorized");
202
+ } else {
203
+ return false;
204
+ }
205
+ } else if (match$1 !== undefined) {
206
+ return match$1.includes("Not Authorized");
207
+ } else {
208
+ return false;
209
+ }
210
+ });
211
+ } else {
212
+ return false;
213
+ }
214
+ }
215
+
128
216
  async function gql(t, query, label) {
129
217
  let headers = Object.fromEntries([[
130
218
  "content-type",
@@ -163,9 +251,11 @@ async function gql(t, query, label) {
163
251
  return await attempt(n + 1 | 0);
164
252
  }
165
253
  let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
254
+ let summary = isDenied(errors) ? identitySummary(t) : undefined;
255
+ let identity = summary !== undefined ? `\n identity: ` + summary : "";
166
256
  throw {
167
257
  RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
168
- _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + tried,
258
+ _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + identity + tried,
169
259
  Error: new Error()
170
260
  };
171
261
  };
@@ -304,6 +394,8 @@ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
304
394
  return Stdlib_Option.getOr(result, 0);
305
395
  }
306
396
 
397
+ let activeRoleClaim = "activeRole";
398
+
307
399
  let attempts = 4;
308
400
 
309
401
  export {
@@ -312,12 +404,22 @@ export {
312
404
  currentToken,
313
405
  endpoint,
314
406
  useToken,
407
+ activeRoleClaim,
408
+ availableRolesClaim,
409
+ groupClaimNames,
410
+ decodeSegment,
411
+ claims,
412
+ asStrings,
413
+ claimStrings,
414
+ effectiveGroups,
415
+ identitySummary,
315
416
  field,
316
417
  asString,
317
418
  nodeString,
318
419
  login,
319
420
  transientErrorTypes,
320
421
  isTransient,
422
+ isDenied,
321
423
  attempts,
322
424
  gql,
323
425
  commandResultSelection,
@@ -38,6 +38,13 @@ let make = async (
38
38
  let token = await login(~username, ~password)
39
39
  let client = Seed_Client.make(~config={endpoint: endpoint})
40
40
  client->Seed_Client.useToken(token)
41
+ // What the bearer grants, which is not always what the account list showed: a
42
+ // token narrowed to one role presents that role alone. Said at login so the
43
+ // run starts from the identity it will actually be refused or served under.
44
+ switch Seed_Client.identitySummary(client) {
45
+ | Some(summary) => Console.log(`Acting as: ${summary}`)
46
+ | None => ()
47
+ }
41
48
  {client, uploadsSkipped, label}
42
49
  }
43
50
 
@@ -15,6 +15,10 @@ async function make(label, endpoint, login, localDefaultsOpt) {
15
15
  endpoint: endpoint
16
16
  });
17
17
  Seed_Client$ReventlessSeed.useToken(client, token);
18
+ let summary = Seed_Client$ReventlessSeed.identitySummary(client);
19
+ if (summary !== undefined) {
20
+ console.log(`Acting as: ` + summary);
21
+ }
18
22
  return {
19
23
  client: client,
20
24
  uploadsSkipped: uploadsSkipped,