@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.3 → 3.0.0-alpha.4
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.
|
@@ -148,7 +148,7 @@ describe('listAllItems', () => {
|
|
|
148
148
|
})
|
|
149
149
|
|
|
150
150
|
// ---------------------------------------------------------------------------
|
|
151
|
-
// listAllItemsConnection —
|
|
151
|
+
// listAllItemsConnection — server-side filter/sort
|
|
152
152
|
// ---------------------------------------------------------------------------
|
|
153
153
|
describe('listAllItemsConnection', () => {
|
|
154
154
|
describe('default (no capability args)', () => {
|
|
@@ -193,13 +193,13 @@ describe('listAllItemsConnection', () => {
|
|
|
193
193
|
expect(r.edges[0].node).toEqual({ id: 'a' })
|
|
194
194
|
expect(r.pageInfo.hasNextPage).toBe(true)
|
|
195
195
|
expect(r.pageInfo.hasPreviousPage).toBe(false)
|
|
196
|
-
//
|
|
197
|
-
|
|
196
|
+
// The cursor encodes a real read window, not a positional index. The last row
|
|
197
|
+
// closed this one, so it names the window that follows.
|
|
198
|
+
expect(JSON.parse(util.base64Decode(r.pageInfo.endCursor)).t).toBe('next')
|
|
198
199
|
})
|
|
199
200
|
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// to the next Scan. This is the round-trip that failed before the fix.
|
|
201
|
+
// The cursor the response emits must decode back to the DynamoDB nextToken the
|
|
202
|
+
// request feeds to the next Scan.
|
|
203
203
|
test('endCursor round-trips: response endCursor → request nextToken', () => {
|
|
204
204
|
const page1 = response(makeCtx({
|
|
205
205
|
args: {},
|
|
@@ -210,16 +210,17 @@ describe('listAllItemsConnection', () => {
|
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
test('final page (nextToken null) closes the connection', () => {
|
|
213
|
+
const after = util.base64Encode(JSON.stringify({ t: 'TOK1', n: -1 }))
|
|
213
214
|
const r = response(makeCtx({
|
|
214
|
-
args: { after
|
|
215
|
+
args: { after },
|
|
215
216
|
result: { items: [{ id: 'z' }], nextToken: null },
|
|
216
217
|
}))
|
|
217
218
|
expect(r.pageInfo.hasNextPage).toBe(false)
|
|
218
219
|
expect(r.pageInfo.hasPreviousPage).toBe(true)
|
|
219
220
|
})
|
|
220
221
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
222
|
+
// A filtered Scan can return an empty page while nextToken is still set. The
|
|
223
|
+
// boundary cursor must let the client resume instead of restarting page 1.
|
|
223
224
|
test('empty-but-continuable page yields a resumable boundary cursor', () => {
|
|
224
225
|
const empty = response(makeCtx({
|
|
225
226
|
args: {},
|
|
@@ -238,7 +239,7 @@ describe('listAllItemsConnection', () => {
|
|
|
238
239
|
expect(r.pageInfo.hasNextPage).toBe(false)
|
|
239
240
|
})
|
|
240
241
|
|
|
241
|
-
//
|
|
242
|
+
// Scan cannot page backward; last/before must error, not mislead.
|
|
242
243
|
test('request rejects backward pagination (before)', () => {
|
|
243
244
|
expect(() => request(makeCtx({ args: { before: 'x' } }))).toThrow(
|
|
244
245
|
'Backward pagination',
|
|
@@ -248,6 +249,95 @@ describe('listAllItemsConnection', () => {
|
|
|
248
249
|
test('request rejects backward pagination (last)', () => {
|
|
249
250
|
expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('Backward pagination')
|
|
250
251
|
})
|
|
252
|
+
|
|
253
|
+
// Cursors minted before the read window existed named the following window.
|
|
254
|
+
test('a pre-window { token, index } cursor still resumes', () => {
|
|
255
|
+
const legacy = util.base64Encode(JSON.stringify({ token: 'TOK9', index: 3 }))
|
|
256
|
+
const req = request(makeCtx({ args: { after: legacy } }))
|
|
257
|
+
expect(req.nextToken).toBe('TOK9')
|
|
258
|
+
const r = response(makeCtx({
|
|
259
|
+
args: { after: legacy },
|
|
260
|
+
result: { items: [{ id: 'a' }], nextToken: null },
|
|
261
|
+
}))
|
|
262
|
+
expect(r.edges).toHaveLength(1)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// The defect a selective filter turns into a visibly broken list: DynamoDB
|
|
267
|
+
// applies Limit before the FilterExpression, so reading `first` rows and
|
|
268
|
+
// returning the survivors spreads one matching row over several pages, most of
|
|
269
|
+
// them blank under a live Next button.
|
|
270
|
+
describe('reads a window, serves a page', () => {
|
|
271
|
+
const { request, response } = evalResolver(
|
|
272
|
+
F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', ['Admin']),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
test('an unfiltered read examines exactly the page it serves', () => {
|
|
276
|
+
const req = request(makeCtx({ args: { first: 25 }, identity: null }))
|
|
277
|
+
expect(req.filter).toBeUndefined()
|
|
278
|
+
expect(req.limit).toBe(25)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test('a filtered read examines a wider window than the page', () => {
|
|
282
|
+
const req = request(makeCtx({ args: { first: 25 } }))
|
|
283
|
+
expect(req.filter.expression).toBe('#owner = :owner')
|
|
284
|
+
expect(req.limit).toBe(1000)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('a caller asking for more than the window gets what it asked for', () => {
|
|
288
|
+
expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5000)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// The user-visible bug: one matching row must be one page, not three.
|
|
292
|
+
test('the matches of an exhausted window are a single closed page', () => {
|
|
293
|
+
const r = response(makeCtx({
|
|
294
|
+
args: { first: 50 },
|
|
295
|
+
result: { items: [{ id: 'mine' }], nextToken: null },
|
|
296
|
+
}))
|
|
297
|
+
expect(r.edges).toHaveLength(1)
|
|
298
|
+
expect(r.pageInfo.hasNextPage).toBe(false)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('a window holding more matches than the page pages within itself', () => {
|
|
302
|
+
const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }]
|
|
303
|
+
const page1 = response(makeCtx({
|
|
304
|
+
args: { first: 2 },
|
|
305
|
+
result: { items, nextToken: 'W2' },
|
|
306
|
+
}))
|
|
307
|
+
expect(page1.edges.map(e => e.node.id)).toEqual(['a', 'b'])
|
|
308
|
+
expect(page1.pageInfo.hasNextPage).toBe(true)
|
|
309
|
+
|
|
310
|
+
// Same window, resumed past the rows already served — not the next window.
|
|
311
|
+
const req2 = request(makeCtx({ args: { first: 2, after: page1.pageInfo.endCursor } }))
|
|
312
|
+
expect(req2.nextToken).toBeNull()
|
|
313
|
+
const page2 = response(makeCtx({
|
|
314
|
+
args: { first: 2, after: page1.pageInfo.endCursor },
|
|
315
|
+
result: { items, nextToken: 'W2' },
|
|
316
|
+
}))
|
|
317
|
+
expect(page2.edges.map(e => e.node.id)).toEqual(['c', 'd'])
|
|
318
|
+
|
|
319
|
+
// The row that closes the window hands the next request the window after it.
|
|
320
|
+
const page3 = response(makeCtx({
|
|
321
|
+
args: { first: 2, after: page2.pageInfo.endCursor },
|
|
322
|
+
result: { items, nextToken: 'W2' },
|
|
323
|
+
}))
|
|
324
|
+
expect(page3.edges.map(e => e.node.id)).toEqual(['e'])
|
|
325
|
+
expect(page3.pageInfo.hasNextPage).toBe(true)
|
|
326
|
+
expect(
|
|
327
|
+
request(makeCtx({ args: { first: 2, after: page3.pageInfo.endCursor } })).nextToken,
|
|
328
|
+
).toBe('W2')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Without this the final row of a window resumes into a window with nothing
|
|
332
|
+
// left in it, which is the blank page again one step later.
|
|
333
|
+
test('the last row of a page that closes its window names the next window', () => {
|
|
334
|
+
const r = response(makeCtx({
|
|
335
|
+
args: { first: 2 },
|
|
336
|
+
result: { items: [{ id: 'a' }, { id: 'b' }], nextToken: 'W2' },
|
|
337
|
+
}))
|
|
338
|
+
expect(JSON.parse(util.base64Decode(r.edges[1].cursor))).toEqual({ t: 'W2', n: -1 })
|
|
339
|
+
expect(JSON.parse(util.base64Decode(r.edges[0].cursor))).toEqual({ t: null, n: 0 })
|
|
340
|
+
})
|
|
251
341
|
})
|
|
252
342
|
|
|
253
343
|
describe('with filterFields (per-field Eq)', () => {
|
|
@@ -669,11 +759,9 @@ describe('authorizeIndexedAccess', () => {
|
|
|
669
759
|
// ---------------------------------------------------------------------------
|
|
670
760
|
// listAllItemsConnection — @owner scoping
|
|
671
761
|
// ---------------------------------------------------------------------------
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
// strongest check available short of a deployed stack, and the only one that
|
|
676
|
-
// can catch an APPSYNC_JS-hostile construct or a mis-ordered branch.
|
|
762
|
+
// The one read path with no Lambda in it — the predicate is generated JS that
|
|
763
|
+
// AppSync runs directly, sharing no code with the paths the ReScript suites
|
|
764
|
+
// cover. Evaluating the emitted source is the strongest check short of a deploy.
|
|
677
765
|
describe('listAllItemsConnection — owner scoping', () => {
|
|
678
766
|
// (labelField, filterFields, rangeFields, sortFields, requireAttribute,
|
|
679
767
|
// ownerField, elevatedGroups)
|
|
@@ -757,16 +845,204 @@ describe('listAllItemsConnection — owner scoping', () => {
|
|
|
757
845
|
const r = request(makeCtx({ args: {}, identity: asUser('ops-1', ['Admin']) }))
|
|
758
846
|
expect(r.filter.expressionValues[':owner']).toEqual({ S: 'ops-1' })
|
|
759
847
|
})
|
|
848
|
+
|
|
849
|
+
// ── The same identities, once the scope has an index to read ──────────────
|
|
850
|
+
//
|
|
851
|
+
// The predicate moves from the FilterExpression to a key condition, so who is
|
|
852
|
+
// narrowed and who is not now selects a different PHYSICAL READ. The defect
|
|
853
|
+
// class this guards against was invisible precisely because each path was
|
|
854
|
+
// individually correct — so the identity table runs over both, rather than a
|
|
855
|
+
// parallel suite growing beside it.
|
|
856
|
+
//
|
|
857
|
+
// (labelField, filterFields, rangeFields, sortFields, requireAttribute,
|
|
858
|
+
// ownerField, elevatedGroups, retiredField, retiredValues,
|
|
859
|
+
// ownerIndex, ownerIndexSortField)
|
|
860
|
+
const indexed = () =>
|
|
861
|
+
evalResolver(
|
|
862
|
+
F.listAllItemsConnection(
|
|
863
|
+
'name', ['status'], [], [], undefined, 'customerId', ['Admin'],
|
|
864
|
+
undefined, undefined, '_owner', 'id',
|
|
865
|
+
),
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
const identities = [
|
|
869
|
+
['a plain caller', asUser('cust-a'), 'Query'],
|
|
870
|
+
['a caller in an elevated group', asUser('ops-1', ['Admin']), 'Scan'],
|
|
871
|
+
['an IAM-shaped identity with no sub', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }, 'Scan'],
|
|
872
|
+
['a wholly absent identity', null, 'Scan'],
|
|
873
|
+
]
|
|
874
|
+
|
|
875
|
+
test.each(identities)('%s reads through the operation its scope implies', (_, identity, op) => {
|
|
876
|
+
const { request } = indexed()
|
|
877
|
+
expect(request(makeCtx({ args: {}, identity })).operation).toBe(op)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('a scoped caller is keyed on their own id, not filtered on it', () => {
|
|
881
|
+
const { request } = indexed()
|
|
882
|
+
const r = request(makeCtx({ args: {}, identity: asUser('cust-a') }))
|
|
883
|
+
expect(r.index).toBe('_owner')
|
|
884
|
+
expect(r.query.expression).toBe('#owner = :owner')
|
|
885
|
+
expect(r.query.expressionNames['#owner']).toBe('customerId')
|
|
886
|
+
expect(r.query.expressionValues[':owner']).toEqual({ S: 'cust-a' })
|
|
887
|
+
// The whole point: no owner clause survives anywhere in the filter. A copy
|
|
888
|
+
// left there would be a second, redundant predicate — and an expressionName
|
|
889
|
+
// the filter no longer references is a ValidationException.
|
|
890
|
+
expect(r.filter).toBeUndefined()
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
// The elevated read is the one that must not have changed: it is the same
|
|
894
|
+
// request the unindexed resolver builds, argument for argument.
|
|
895
|
+
test.each([
|
|
896
|
+
['an elevated caller', asUser('ops-1', ['Admin'])],
|
|
897
|
+
['an IAM service caller', { userArn: 'arn:aws:sts::1:x', username: 'Ing' }],
|
|
898
|
+
])("%s's read is unchanged by the index existing", (_, identity) => {
|
|
899
|
+
const before = evalResolver(
|
|
900
|
+
F.listAllItemsConnection('name', ['status'], [], [], undefined, 'customerId', ['Admin']),
|
|
901
|
+
).request(makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }))
|
|
902
|
+
const after = indexed().request(
|
|
903
|
+
makeCtx({ args: { first: 20, filter: { statusEq: 'Placed' } }, identity }),
|
|
904
|
+
)
|
|
905
|
+
expect(after).toEqual(before)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
// The read window exists because a FilterExpression cuts rows AFTER `Limit`.
|
|
909
|
+
// A key condition does not, so a scoped caller with no filter of their own
|
|
910
|
+
// examines exactly the page they asked for — the arithmetic the window was
|
|
911
|
+
// hiding, gone rather than papered over.
|
|
912
|
+
test('a scoped caller with no filter examines exactly the page', () => {
|
|
913
|
+
const { request } = indexed()
|
|
914
|
+
expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(25)
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
test("a scoped caller's own filter still widens the window", () => {
|
|
918
|
+
const { request } = indexed()
|
|
919
|
+
const r = request(
|
|
920
|
+
makeCtx({ args: { first: 25, filter: { statusEq: 'Placed' } }, identity: asUser('cust-a') }),
|
|
921
|
+
)
|
|
922
|
+
expect(r.limit).toBe(1000)
|
|
923
|
+
expect(r.filter.expression).toBe('#status = :statusEq')
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
// Both branches are reachable by the SAME caller across requests — an active
|
|
927
|
+
// role switch mid-pagination flips `_exempt`. A `nextToken` continues the
|
|
928
|
+
// operation that minted it, so replaying one on the other branch would answer
|
|
929
|
+
// a different question in silence.
|
|
930
|
+
describe('the cursor path tag', () => {
|
|
931
|
+
const { request, response } = indexed()
|
|
932
|
+
const pageFor = identity =>
|
|
933
|
+
response(makeCtx({
|
|
934
|
+
args: {},
|
|
935
|
+
identity,
|
|
936
|
+
result: { items: [{ id: 'a' }], nextToken: 'TOK' },
|
|
937
|
+
})).pageInfo.endCursor
|
|
938
|
+
|
|
939
|
+
test('a Scan cursor replayed on the Query branch is refused', () => {
|
|
940
|
+
const scanCursor = pageFor(asUser('ops-1', ['Admin']))
|
|
941
|
+
expect(() =>
|
|
942
|
+
request(makeCtx({ args: { after: scanCursor }, identity: asUser('cust-a') })),
|
|
943
|
+
).toThrow('different read of this list')
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
test('a Query cursor replayed on the Scan branch is refused', () => {
|
|
947
|
+
const queryCursor = pageFor(asUser('cust-a'))
|
|
948
|
+
expect(() =>
|
|
949
|
+
request(makeCtx({ args: { after: queryCursor }, identity: asUser('ops-1', ['Admin']) })),
|
|
950
|
+
).toThrow('different read of this list')
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
test('each branch resumes its own cursor', () => {
|
|
954
|
+
expect(
|
|
955
|
+
request(makeCtx({ args: { after: pageFor(asUser('cust-a')) }, identity: asUser('cust-a') }))
|
|
956
|
+
.nextToken,
|
|
957
|
+
).toBe('TOK')
|
|
958
|
+
const ops = asUser('ops-1', ['Admin'])
|
|
959
|
+
expect(request(makeCtx({ args: { after: pageFor(ops) }, identity: ops })).nextToken).toBe('TOK')
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
// Cursors minted before the tag existed all came off a Scan, and the callers
|
|
963
|
+
// holding one are the exempt callers whose read did not change.
|
|
964
|
+
test('an untagged cursor still resumes the Scan branch', () => {
|
|
965
|
+
const legacy = util.base64Encode(JSON.stringify({ t: 'TOK9', n: 2 }))
|
|
966
|
+
const ops = asUser('ops-1', ['Admin'])
|
|
967
|
+
expect(request(makeCtx({ args: { after: legacy }, identity: ops })).nextToken).toBe('TOK9')
|
|
968
|
+
})
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
// Ordering on the index's own sort key is `ScanIndexForward`'s job across the
|
|
972
|
+
// caller's whole partition. Re-sorting the page in JS afterwards is the one
|
|
973
|
+
// way to BREAK that order — a sort over a page is not a sort over the rows.
|
|
974
|
+
describe('orderBy on the index sort key', () => {
|
|
975
|
+
const sorted = () =>
|
|
976
|
+
evalResolver(
|
|
977
|
+
F.listAllItemsConnection(
|
|
978
|
+
'name', [], [], ['id', 'placedAt'], undefined, 'customerId', ['Admin'],
|
|
979
|
+
undefined, undefined, '_owner', 'id',
|
|
980
|
+
),
|
|
981
|
+
)
|
|
982
|
+
const items = [{ id: 'c' }, { id: 'a' }, { id: 'b' }]
|
|
983
|
+
const scoped = asUser('cust-a')
|
|
984
|
+
|
|
985
|
+
test('DESC flips scanIndexForward instead of reversing a page', () => {
|
|
986
|
+
const { request } = sorted()
|
|
987
|
+
const args = { orderBy: { field: 'id', direction: 'DESC' } }
|
|
988
|
+
expect(request(makeCtx({ args, identity: scoped })).scanIndexForward).toBe(false)
|
|
989
|
+
expect(
|
|
990
|
+
request(makeCtx({ args: { orderBy: { field: 'id', direction: 'ASC' } }, identity: scoped }))
|
|
991
|
+
.scanIndexForward,
|
|
992
|
+
).toBe(true)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
test('the page DynamoDB ordered is handed back untouched', () => {
|
|
996
|
+
const { response } = sorted()
|
|
997
|
+
const r = response(makeCtx({
|
|
998
|
+
args: { orderBy: { field: 'id', direction: 'ASC' } },
|
|
999
|
+
identity: scoped,
|
|
1000
|
+
result: { items, nextToken: null },
|
|
1001
|
+
}))
|
|
1002
|
+
expect(r.edges.map(e => e.node.id)).toEqual(['c', 'a', 'b'])
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
test('any other sort field still sorts in JS', () => {
|
|
1006
|
+
const { response } = sorted()
|
|
1007
|
+
const r = response(makeCtx({
|
|
1008
|
+
args: { orderBy: { field: 'placedAt', direction: 'ASC' } },
|
|
1009
|
+
identity: scoped,
|
|
1010
|
+
result: { items: [{ placedAt: 'z' }, { placedAt: 'a' }], nextToken: null },
|
|
1011
|
+
}))
|
|
1012
|
+
expect(r.edges.map(e => e.node.placedAt)).toEqual(['a', 'z'])
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
// The exempt caller Scans, which has no ordering of its own, so the per-page
|
|
1016
|
+
// sort is still the only order they get.
|
|
1017
|
+
test('an exempt caller keeps the per-page sort on the same field', () => {
|
|
1018
|
+
const { response } = sorted()
|
|
1019
|
+
const r = response(makeCtx({
|
|
1020
|
+
args: { orderBy: { field: 'id', direction: 'ASC' } },
|
|
1021
|
+
identity: asUser('ops-1', ['Admin']),
|
|
1022
|
+
result: { items, nextToken: null },
|
|
1023
|
+
}))
|
|
1024
|
+
expect(r.edges.map(e => e.node.id)).toEqual(['a', 'b', 'c'])
|
|
1025
|
+
})
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
// Backward paging stays refused on both branches: a Query can walk backward,
|
|
1029
|
+
// but the `{t, n}` cursor is DynamoDB's forward-only continuation token, and a
|
|
1030
|
+
// door must not advertise what its cursor cannot honour.
|
|
1031
|
+
test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
|
|
1032
|
+
'a %s caller is still refused backward paging',
|
|
1033
|
+
(_, identity) => {
|
|
1034
|
+
const { request } = indexed()
|
|
1035
|
+
expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow('Backward pagination')
|
|
1036
|
+
},
|
|
1037
|
+
)
|
|
760
1038
|
})
|
|
761
1039
|
|
|
762
1040
|
// ---------------------------------------------------------------------------
|
|
763
1041
|
// By-key reads — owner scoping
|
|
764
1042
|
// ---------------------------------------------------------------------------
|
|
765
|
-
// The list resolver
|
|
766
|
-
// narrowed to their own rows could still read any row they could name.
|
|
767
|
-
//
|
|
768
|
-
// has no FilterExpression, and null rather than an error, which is what the
|
|
769
|
-
// in-process platform answers and what does not confirm the row exists.
|
|
1043
|
+
// The list resolver carried the predicate while these did not, so a caller
|
|
1044
|
+
// narrowed to their own rows could still read any row they could name. The guard
|
|
1045
|
+
// belongs in the response — GetItem has no FilterExpression — and answers null.
|
|
770
1046
|
describe('getItemById / queryByIdSort — owner scoping', () => {
|
|
771
1047
|
const asUser = (sub, groups = []) => ({
|
|
772
1048
|
username: sub,
|