Skip to content

Commit 6ba216d

Browse files
committed
feat: add API to list user devices
1 parent 56b62e0 commit 6ba216d

14 files changed

+263
-22
lines changed

cdk/BackendStack.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type CustomDomainDetails,
1818
} from './resources/api/CustomDomain.js'
1919
import { ApiHealthCheck } from './resources/api/HealthCheck.js'
20-
import { DeviceManagementAPI } from './resources/DeviceManagementAPI.js'
20+
import { CreateDeviceAPI } from './resources/DeviceManagementAPI.js'
2121
import { DevicesAPI } from './resources/DevicesAPI.js'
2222
import { EmailConfirmationTokens } from './resources/EmailConfirmationTokens.js'
2323
import { JWKS } from './resources/JWKS.js'
@@ -92,7 +92,7 @@ export class BackendStack extends Stack {
9292
new CfnOutput(this, 'publicDevicesTableIdIndexName', {
9393
exportName: `${this.stackName}:publicDevicesTableIdIndexName`,
9494
description: 'name of the public devices table id index',
95-
value: publicDevices.idIndex,
95+
value: publicDevices.publicDevicesTableIdIndex,
9696
})
9797

9898
const api = new API(this)
@@ -140,6 +140,7 @@ export class BackendStack extends Stack {
140140
api.addRoute('GET /share/status', shareAPI.sharingStatusFingerprintFn)
141141
api.addRoute('GET /device/{id}', shareAPI.sharingStatusFn)
142142
api.addRoute('GET /device/{id}/jwt', shareAPI.deviceJwtFn)
143+
api.addRoute('GET /user/devices', shareAPI.listUserDevicesFn)
143144

144145
const devicesAPI = new DevicesAPI(this, {
145146
baseLayer,
@@ -148,7 +149,7 @@ export class BackendStack extends Stack {
148149
})
149150
api.addRoute('GET /devices', devicesAPI.devicesFn)
150151

151-
const credentialsAPI = new DeviceManagementAPI(this, {
152+
const createDeviceAPI = new CreateDeviceAPI(this, {
152153
baseLayer,
153154
lambdaSources,
154155
openSSLContainerImage: {
@@ -164,8 +165,7 @@ export class BackendStack extends Stack {
164165
},
165166
publicDevices,
166167
})
167-
168-
api.addRoute('POST /device', credentialsAPI.createDevice)
168+
api.addRoute('POST /device', createDeviceAPI.createDevice)
169169

170170
// User accounts
171171
const emailConfirmationTokens = new EmailConfirmationTokens(this)

cdk/packBackendLambdas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type BackendLambdas = {
77
sharingStatusFingerprint: PackedLambda
88
devicesData: PackedLambda
99
createDevice: PackedLambda
10+
listUserDevices: PackedLambda
1011
openSSL: PackedLambda
1112
apiHealthCheck: PackedLambda
1213
createCNAMERecord: PackedLambda
@@ -24,6 +25,7 @@ export const packBackendLambdas = async (): Promise<BackendLambdas> => ({
2425
sharingStatusFingerprint: await pack('sharingStatusFingerprint'),
2526
devicesData: await pack('devicesData'),
2627
createDevice: await pack('createDevice'),
28+
listUserDevices: await pack('listUserDevices'),
2729
openSSL: await pack('openSSL'),
2830
apiHealthCheck: await pack('apiHealthCheck'),
2931
createCNAMERecord: await packLambdaFromPath(

cdk/resources/DeviceManagementAPI.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { BackendLambdas } from '../packBackendLambdas.js'
99
import { STACK_NAME } from '../stackConfig.js'
1010
import type { PublicDevices } from './PublicDevices.js'
1111

12-
export class DeviceManagementAPI extends Construct {
12+
export class CreateDeviceAPI extends Construct {
1313
public readonly createDevice: Lambda.IFunction
1414

1515
constructor(
@@ -29,7 +29,7 @@ export class DeviceManagementAPI extends Construct {
2929
publicDevices: PublicDevices
3030
},
3131
) {
32-
super(parent, 'deviceManagementAPI')
32+
super(parent, 'createDeviceAPI')
3333

3434
const openSSLFn = new Lambda.Function(this, 'openSSLFn', {
3535
handler: Lambda.Handler.FROM_IMAGE,
@@ -60,7 +60,7 @@ export class DeviceManagementAPI extends Construct {
6060
BACKEND_STACK_NAME: STACK_NAME,
6161
OPENSSL_LAMBDA_FUNCTION_NAME: openSSLFn.functionName,
6262
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
63-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
63+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
6464
},
6565
},
6666
).fn

cdk/resources/DevicesAPI.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class DevicesAPI extends Construct {
3131
layers: [baseLayer],
3232
environment: {
3333
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
34-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
34+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
3535
PUBLIC_DEVICES_TABLE_MODEL_TTL_INDEX_NAME:
3636
publicDevices.publicDevicesTableModelTTLIndex,
3737
},

cdk/resources/PublicDevices.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { Construct } from 'constructs'
77
export class PublicDevices extends Construct {
88
public readonly publicDevicesTable: DynamoDB.Table
99
public readonly publicDevicesTableModelTTLIndex = 'modelTTLIndex'
10-
public readonly idIndex = 'idIndex'
10+
public readonly publicDevicesTableOwnerEmailIndex = 'ownerEmailIndex'
11+
public readonly publicDevicesTableIdIndex = 'idIndex'
1112
constructor(parent: Construct) {
1213
super(parent, 'public-devices')
1314

@@ -26,7 +27,7 @@ export class PublicDevices extends Construct {
2627
})
2728

2829
this.publicDevicesTable.addGlobalSecondaryIndex({
29-
indexName: this.idIndex,
30+
indexName: this.publicDevicesTableIdIndex,
3031
partitionKey: {
3132
name: 'id',
3233
type: DynamoDB.AttributeType.STRING,
@@ -51,5 +52,19 @@ export class PublicDevices extends Construct {
5152
projectionType: DynamoDB.ProjectionType.INCLUDE,
5253
nonKeyAttributes: ['id', 'deviceId'],
5354
})
55+
56+
this.publicDevicesTable.addGlobalSecondaryIndex({
57+
indexName: this.publicDevicesTableOwnerEmailIndex,
58+
partitionKey: {
59+
name: 'ownerEmail',
60+
type: DynamoDB.AttributeType.STRING,
61+
},
62+
sortKey: {
63+
name: 'ttl',
64+
type: DynamoDB.AttributeType.NUMBER,
65+
},
66+
projectionType: DynamoDB.ProjectionType.INCLUDE,
67+
nonKeyAttributes: ['id', 'deviceId', 'model'],
68+
})
5469
}
5570
}

cdk/resources/ShareAPI.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class ShareAPI extends Construct {
99
public readonly sharingStatusFn: Lambda.IFunction
1010
public readonly deviceJwtFn: Lambda.IFunction
1111
public readonly sharingStatusFingerprintFn: Lambda.IFunction
12+
public readonly listUserDevicesFn: Lambda.IFunction
1213
constructor(
1314
parent: Construct,
1415
{
@@ -26,6 +27,7 @@ export class ShareAPI extends Construct {
2627
| 'sharingStatus'
2728
| 'sharingStatusFingerprint'
2829
| 'deviceJwt'
30+
| 'listUserDevices'
2931
>
3032
},
3133
) {
@@ -40,7 +42,7 @@ export class ShareAPI extends Construct {
4042
layers: [baseLayer],
4143
environment: {
4244
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
43-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
45+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
4446
IS_TEST: this.node.getContext('isTest') === true ? '1' : '0',
4547
},
4648
},
@@ -56,7 +58,7 @@ export class ShareAPI extends Construct {
5658
layers: [baseLayer],
5759
environment: {
5860
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
59-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
61+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
6062
},
6163
},
6264
).fn
@@ -72,7 +74,7 @@ export class ShareAPI extends Construct {
7274
layers: [baseLayer],
7375
environment: {
7476
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
75-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
77+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
7678
},
7779
},
7880
).fn
@@ -90,10 +92,26 @@ export class ShareAPI extends Construct {
9092
layers: [baseLayer, jwtLayer],
9193
environment: {
9294
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
93-
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.idIndex,
95+
PUBLIC_DEVICES_ID_INDEX_NAME: publicDevices.publicDevicesTableIdIndex,
9496
},
9597
},
9698
).fn
9799
publicDevices.publicDevicesTable.grantReadData(this.deviceJwtFn)
100+
101+
this.listUserDevicesFn = new PackedLambdaFn(
102+
this,
103+
'listUserDevicesFn',
104+
lambdaSources.listUserDevices,
105+
{
106+
description: 'List user devices',
107+
layers: [baseLayer, jwtLayer],
108+
environment: {
109+
PUBLIC_DEVICES_TABLE_NAME: publicDevices.publicDevicesTable.tableName,
110+
PUBLIC_DEVICES_OWNER_EMAIL_INDEX_NAME:
111+
publicDevices.publicDevicesTableOwnerEmailIndex,
112+
},
113+
},
114+
).fn
115+
publicDevices.publicDevicesTable.grantReadData(this.listUserDevicesFn)
98116
}
99117
}

devices/hasItems.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { AttributeValue } from '@aws-sdk/client-dynamodb'
2+
3+
export const hasItems = (
4+
res: unknown,
5+
): res is { Items: Record<string, AttributeValue>[] } =>
6+
res !== null && typeof res === 'object' && 'Items' in res

devices/listDevicesByEmail.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { QueryCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb'
2+
import { unmarshall } from '@aws-sdk/util-dynamodb'
3+
import { normalizeEmail } from '../users/normalizeEmail.js'
4+
import { hasItems } from './hasItems.js'
5+
import type { PublicDeviceRecord } from './publicDevicesRepo.js'
6+
7+
export const listDevicesByEmail =
8+
({
9+
db,
10+
TableName,
11+
emailIndexName,
12+
}: {
13+
db: DynamoDBClient
14+
TableName: string
15+
emailIndexName: string
16+
}) =>
17+
async (email: string): Promise<Array<PublicDeviceRecord>> => {
18+
const res = await db.send(
19+
new QueryCommand({
20+
TableName,
21+
IndexName: emailIndexName,
22+
KeyConditionExpression: '#ownerEmail = :email',
23+
ExpressionAttributeNames: {
24+
'#ownerEmail': 'ownerEmail',
25+
},
26+
ExpressionAttributeValues: {
27+
':email': {
28+
S: normalizeEmail(email),
29+
},
30+
},
31+
}),
32+
)
33+
if (!hasItems(res)) return []
34+
return res.Items.map((i) => unmarshall(i) as PublicDeviceRecord)
35+
}

features/DeviceJWT.feature.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
exampleContext:
3+
fingerprint: 92b.y7i24q
4+
deviceId: ed468852-a96d-4d88-9447-252730c396c2
5+
publicDeviceId: outfling-swanherd-attaghan
6+
API: "https://api.nordicsemi.world/2024-04-15"
7+
---
8+
9+
# Acquiring a JWT for use with the hello.nrfcloud.com backend API
10+
11+
> The device history is stored by the hello.nrfcloud.com backend but should only
12+
> be accessible for devices, that are currently shared. Therefore a JWT can be
13+
> generated by the nrfcloud.com/map backend that confirms that the device is
14+
> shared and this JWT is then presented to the nrfcloud.com backend when
15+
> retrieving the history.
16+
17+
## Background
18+
19+
Given I have the device id for a shared `thingy91x` device in `deviceId` and the
20+
public device id in `publicDeviceId`
21+
22+
## Retrieve the JWT
23+
24+
When I `GET` to `${API}/device/${publicDeviceId}/jwt`
25+
26+
Then the status code of the last response should be `201`
27+
28+
And I should receive a `https://github.com/hello-nrfcloud/proto-map/device-jwt`
29+
response
30+
31+
And I store `jwt` of the last response into `jwt`
32+
33+
Then the JWT in `jwt` for the audience `hello.nrfcloud.com` should encode the
34+
payload
35+
36+
```json
37+
{
38+
"@context": "https://github.com/hello-nrfcloud/proto-map/device-jwt",
39+
"id": "${publicDeviceId}",
40+
"deviceId": "${deviceId}",
41+
"model": "thingy91x"
42+
}
43+
```
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
exampleContext:
3+
API: https://api.nordicsemi.world/2024-04-15
4+
5+
jwt: eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6Ijg1NDdiNWIyLTdiNDctNDFlNC1iZjJkLTdjZGZmNDhiM2VhNCJ9.eyJAY29udGV4dCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9oZWxsby1ucmZjbG91ZC9wcm90by1tYXAvdXNlci1qd3QiLCJlbWFpbCI6ImVkYjJiZDM3QGV4YW1wbGUuY29tIiwiaWF0IjoxNzIyODcxNTYyLCJleHAiOjE3MjI5NTc5NjIsImF1ZCI6ImhlbGxvLm5yZmNsb3VkLmNvbSJ9.ALiHjxR7HIjuYQBvPVh5-GMs-2f-pMGs_FTz-x0HGzQ4amLASeUGEZ7X_y-_mgZpYu8VKGm6be0LtIIx9DgYBff1ASfmQH327rub0a2-DjXW-JUJQn_6t6H6_JhvPZ9jWBSzy3Tbpp9NmTUNmHgEwzyoctnmgp0oo26VEwc4r6YGQWkZ
6+
bulkOpsRequestId: e360a26b-0175-428e-bebf-9125a5a4db04
7+
needs:
8+
- Add Device
9+
---
10+
11+
# List devices
12+
13+
> Users can list their devices.
14+
15+
## List devices
16+
17+
Given the `Authorization` header of the next request is `Bearer ${jwt}`
18+
19+
When I `GET` `${API}/user/devices`
20+
21+
Then the status code of the last response should be `200`
22+
23+
And I should receive a
24+
`https://github.com/hello-nrfcloud/proto-map/user-devices` response
25+
26+
And `{"len": $count($.devices)}` of the last response should match
27+
28+
```json
29+
{ "len": 1 }
30+
```
31+
32+
And `$.devices[0]` of the last response should match
33+
34+
```json
35+
{
36+
"model": "thingy91x"
37+
}
38+
```

0 commit comments

Comments
 (0)