@trackunit/serverside-utils 0.0.2
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.
- package/README.md +20 -0
- package/index.cjs.js +251 -0
- package/index.d.ts +1 -0
- package/index.esm.js +244 -0
- package/package.json +22 -0
- package/src/get-account-id.d.ts +35 -0
- package/src/get-databricks-access-token.d.ts +42 -0
- package/src/get-secret.d.ts +35 -0
- package/src/get-user-token.d.ts +26 -0
- package/src/headers.d.ts +14 -0
- package/src/index.d.ts +5 -0
- package/src/token-utils.d.ts +7 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Trackunit serverside-utils
|
|
2
|
+
|
|
3
|
+
The `@trackunit/serverside-utils` package provides utilities for building serverside extensions in the Iris App SDK.
|
|
4
|
+
|
|
5
|
+
This library is exposed publicly for use in the Trackunit [Iris App SDK](https://www.npmjs.com/package/@trackunit/iris-app).
|
|
6
|
+
|
|
7
|
+
For more info and a full guide on Iris App SDK Development, please visit our [Developer Hub](https://developers.trackunit.com/).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
yarn add @trackunit/serverside-utils
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Trackunit
|
|
16
|
+
|
|
17
|
+
This package was developed by Trackunit ApS.
|
|
18
|
+
Trackunit is the leading SaaS-based IoT solution for the construction industry, offering an ecosystem of hardware, fleet management software & telematics.
|
|
19
|
+
|
|
20
|
+

|
package/index.cjs.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jwtDecode = require('jwt-decode');
|
|
4
|
+
var zod = require('zod');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Header key for the account admin iris app token.
|
|
8
|
+
* Used by the SDK server to pass admin tokens to serverside functions.
|
|
9
|
+
*
|
|
10
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
11
|
+
*/
|
|
12
|
+
const ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER = "tu-account-admin-iris-app-token";
|
|
13
|
+
/**
|
|
14
|
+
* Header key for the forwarded authorization token.
|
|
15
|
+
* The original user authorization token is moved to this header.
|
|
16
|
+
*
|
|
17
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
18
|
+
*/
|
|
19
|
+
const FORWARDED_AUTHORIZATION_HEADER = "tu-forwarded-authorization";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Removes the "Bearer " prefix from a token string if present.
|
|
23
|
+
*
|
|
24
|
+
* @param token - The token string, possibly prefixed with "Bearer "
|
|
25
|
+
* @returns {string} The token without the "Bearer " prefix
|
|
26
|
+
*/
|
|
27
|
+
const stripBearerPrefix = (token) => {
|
|
28
|
+
return token.replace(/^(?:Bearer\s+)+/i, "").trim();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Retrieves the user token from the request headers.
|
|
33
|
+
*
|
|
34
|
+
* @param options - The options object
|
|
35
|
+
* @param options.context - Hono request context
|
|
36
|
+
* @returns {string | undefined} The user token (without "Bearer " prefix), or undefined if not found
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* import { getUserToken } from "@trackunit/serverside-utils";
|
|
40
|
+
*
|
|
41
|
+
* app.get("/data", async (c) => {
|
|
42
|
+
* const token = getUserToken({ context: c });
|
|
43
|
+
* if (!token) {
|
|
44
|
+
* return c.json({ error: "Unauthorized" }, 401);
|
|
45
|
+
* }
|
|
46
|
+
* // Use token for API calls...
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
const getUserToken = ({ context }) => {
|
|
51
|
+
const forwardedAuth = context.req.header(FORWARDED_AUTHORIZATION_HEADER);
|
|
52
|
+
if (forwardedAuth) {
|
|
53
|
+
return stripBearerPrefix(forwardedAuth);
|
|
54
|
+
}
|
|
55
|
+
// Fall back to standard Authorization header
|
|
56
|
+
const authorization = context.req.header("authorization");
|
|
57
|
+
if (authorization) {
|
|
58
|
+
return stripBearerPrefix(authorization);
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const JwtAccountPayloadSchema = zod.z.object({
|
|
64
|
+
accountId: zod.z.string().optional(),
|
|
65
|
+
assumedAccountId: zod.z.string().optional(),
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* Extracts the account ID from the user token in the request.
|
|
69
|
+
*
|
|
70
|
+
* Trackunit tokens may contain both `accountId` (token owner) and `assumedAccountId`
|
|
71
|
+
* (account being acted upon). This function returns the effective account ID,
|
|
72
|
+
* preferring `assumedAccountId` when present.
|
|
73
|
+
*
|
|
74
|
+
* @param options - The options object
|
|
75
|
+
* @param options.context - Hono request context
|
|
76
|
+
* @returns {AccountIdResult} The effective account ID and the raw assumedAccountId (if present)
|
|
77
|
+
* @throws Error if no user token is found or the token has no account ID
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* // Simple usage — just get the effective account ID:
|
|
81
|
+
* const { accountId } = getAccountId({ context: c });
|
|
82
|
+
*
|
|
83
|
+
* // When you need to know if the user is assuming an account:
|
|
84
|
+
* const { accountId, assumedAccountId } = getAccountId({ context: c });
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
const getAccountId = ({ context }) => {
|
|
88
|
+
const userToken = getUserToken({ context });
|
|
89
|
+
if (!userToken) {
|
|
90
|
+
throw new Error("Missing user token");
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const tokenWithoutBearer = stripBearerPrefix(userToken);
|
|
94
|
+
const payload = jwtDecode(tokenWithoutBearer);
|
|
95
|
+
const parseResult = JwtAccountPayloadSchema.safeParse(payload);
|
|
96
|
+
if (!parseResult.success) {
|
|
97
|
+
throw new Error("JWT payload missing accountId");
|
|
98
|
+
}
|
|
99
|
+
const { accountId, assumedAccountId } = parseResult.data;
|
|
100
|
+
const effectiveAccountId = assumedAccountId ?? accountId;
|
|
101
|
+
if (!effectiveAccountId) {
|
|
102
|
+
throw new Error("JWT payload missing accountId");
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
accountId: effectiveAccountId,
|
|
106
|
+
assumedAccountId,
|
|
107
|
+
tokenAccountId: accountId,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error instanceof Error && error.message === "JWT payload missing accountId") {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
115
|
+
throw new Error(`Failed to extract accountId from user token: ${message}`);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const SecretsResponseSchema = zod.z.object({
|
|
120
|
+
secrets: zod.z.record(zod.z.string(), zod.z.string()),
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* Fetches a secret from the SDK Server.
|
|
124
|
+
*
|
|
125
|
+
* By default fetches **app-level** secrets (`GET /secrets`).
|
|
126
|
+
* Pass `accountScoped: true` to fetch **account-scoped** secrets (`GET /secrets/:accountId`).
|
|
127
|
+
*
|
|
128
|
+
* For local development, set the secret as an environment variable with the same name
|
|
129
|
+
* as the secretKey (e.g., `API_KEY=xxx` in your `.env` file).
|
|
130
|
+
*
|
|
131
|
+
* @param options - The options object
|
|
132
|
+
* @param options.context - Hono request context
|
|
133
|
+
* @param options.secretKey - Key name of the secret
|
|
134
|
+
* @param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
135
|
+
* @returns {Promise<string>} The secret value
|
|
136
|
+
* @throws Error if required headers or env vars are missing, or secret not found
|
|
137
|
+
* @example
|
|
138
|
+
* ```typescript
|
|
139
|
+
* // App-level secret (default)
|
|
140
|
+
* const apiKey = await getSecret({ context: c, secretKey: "API_KEY" });
|
|
141
|
+
*
|
|
142
|
+
* // Account-scoped secret
|
|
143
|
+
* const accountKey = await getSecret({ context: c, secretKey: "API_KEY", accountScoped: true });
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
const getSecret = async ({ context, secretKey, accountScoped = false, }) => {
|
|
147
|
+
// Local development: use env var with same name as secretKey
|
|
148
|
+
try {
|
|
149
|
+
const envValue = process.env[secretKey];
|
|
150
|
+
if (envValue) {
|
|
151
|
+
return envValue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Ignore error when getting secret from environment variable. Deno blocks access to process.env in serverside functions.
|
|
156
|
+
}
|
|
157
|
+
const adminToken = context.req.header(ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER);
|
|
158
|
+
if (!adminToken) {
|
|
159
|
+
throw new Error(`Missing required header: ${ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER}`);
|
|
160
|
+
}
|
|
161
|
+
const sdkServerUrl = process.env.SDK_SERVER_URL;
|
|
162
|
+
if (!sdkServerUrl) {
|
|
163
|
+
throw new Error("Missing required environment variable: SDK_SERVER_URL");
|
|
164
|
+
}
|
|
165
|
+
const secretsPath = accountScoped ? `/secrets/${getAccountId({ context }).accountId}` : "/secrets";
|
|
166
|
+
const secretsUrl = new URL(secretsPath, sdkServerUrl).href;
|
|
167
|
+
const res = await fetch(secretsUrl, {
|
|
168
|
+
headers: { Authorization: adminToken },
|
|
169
|
+
});
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
let errorBody = "";
|
|
172
|
+
try {
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
errorBody = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Ignore body read errors
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Failed to fetch secret "${secretKey}" from ${secretsUrl}: ${res.status} ${res.statusText}${errorBody ? ` - ${errorBody}` : ""}`);
|
|
180
|
+
}
|
|
181
|
+
const responseData = await res.json();
|
|
182
|
+
const parseResult = SecretsResponseSchema.safeParse(responseData);
|
|
183
|
+
if (!parseResult.success) {
|
|
184
|
+
throw new Error(`Invalid secrets response from ${secretsUrl}: ${parseResult.error.message}`);
|
|
185
|
+
}
|
|
186
|
+
const secret = parseResult.data.secrets[secretKey];
|
|
187
|
+
if (!secret) {
|
|
188
|
+
throw new Error(`Secret "${secretKey}" not found in response from ${secretsUrl}. Available keys: ${Object.keys(parseResult.data.secrets).join(", ") || "(none)"}`);
|
|
189
|
+
}
|
|
190
|
+
return secret;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const DatabricksTokenResponseSchema = zod.z.object({
|
|
194
|
+
access_token: zod.z.string(),
|
|
195
|
+
expires_in: zod.z.number().optional(),
|
|
196
|
+
});
|
|
197
|
+
const buildDatabricksUrl = (host, path) => {
|
|
198
|
+
const trimmed = host.trim();
|
|
199
|
+
const base = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
200
|
+
return new URL(path, base).toString();
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Obtains a Databricks OAuth access token using the client credentials flow.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
*
|
|
207
|
+
* const { databricksAccessToken } = await getDatabricksAccessToken({ context: c });
|
|
208
|
+
* @param props - The properties of the request.
|
|
209
|
+
* @param props.context - The context of the request.
|
|
210
|
+
* @param props.databricksHost - The host of the Databricks instance.
|
|
211
|
+
* @param props.databricksClientId - The client ID of the Databricks application.
|
|
212
|
+
* @param props.databricksClientSecret - The client secret of the Databricks application.
|
|
213
|
+
* @param props.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
214
|
+
* @param props.scope - The scope of the request to the Databricks token endpoint. Defaults to `all-apis`.@param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
215
|
+
* @returns {Promise<{ databricksAccessToken: string; databricksTokenExpiresIn?: number }>} The access token and optional expiry in seconds.
|
|
216
|
+
*/
|
|
217
|
+
const getDatabricksAccessToken = async ({ context, databricksHost, databricksClientId, databricksClientSecret, accountScoped = false, scope = "all-apis", }) => {
|
|
218
|
+
const clientId = databricksClientId ?? (await getSecret({ context, secretKey: "DATABRICKS_CLIENT_ID", accountScoped }));
|
|
219
|
+
const clientSecret = databricksClientSecret ?? (await getSecret({ context, secretKey: "DATABRICKS_CLIENT_SECRET", accountScoped }));
|
|
220
|
+
const rawHost = databricksHost ?? (await getSecret({ context, secretKey: "DATABRICKS_HOST", accountScoped }));
|
|
221
|
+
const tokenUrl = buildDatabricksUrl(rawHost, "/oidc/v1/token");
|
|
222
|
+
const credentials = btoa(`${clientId}:${clientSecret}`);
|
|
223
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: {
|
|
226
|
+
Authorization: `Basic ${credentials}`,
|
|
227
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
228
|
+
},
|
|
229
|
+
body: `grant_type=client_credentials&scope=${scope}`,
|
|
230
|
+
});
|
|
231
|
+
if (!tokenResponse.ok) {
|
|
232
|
+
const errorBody = await tokenResponse.text();
|
|
233
|
+
throw new Error(`Failed to obtain Databricks access token: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorBody}`);
|
|
234
|
+
}
|
|
235
|
+
const responseData = await tokenResponse.json();
|
|
236
|
+
const parseResult = DatabricksTokenResponseSchema.safeParse(responseData);
|
|
237
|
+
if (!parseResult.success) {
|
|
238
|
+
throw new Error(`Invalid token response: ${parseResult.error.message}`);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
databricksAccessToken: parseResult.data.access_token,
|
|
242
|
+
databricksTokenExpiresIn: parseResult.data.expires_in,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
exports.ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER = ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER;
|
|
247
|
+
exports.FORWARDED_AUTHORIZATION_HEADER = FORWARDED_AUTHORIZATION_HEADER;
|
|
248
|
+
exports.getAccountId = getAccountId;
|
|
249
|
+
exports.getDatabricksAccessToken = getDatabricksAccessToken;
|
|
250
|
+
exports.getSecret = getSecret;
|
|
251
|
+
exports.getUserToken = getUserToken;
|
package/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index";
|
package/index.esm.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import jwtDecode from 'jwt-decode';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Header key for the account admin iris app token.
|
|
6
|
+
* Used by the SDK server to pass admin tokens to serverside functions.
|
|
7
|
+
*
|
|
8
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
9
|
+
*/
|
|
10
|
+
const ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER = "tu-account-admin-iris-app-token";
|
|
11
|
+
/**
|
|
12
|
+
* Header key for the forwarded authorization token.
|
|
13
|
+
* The original user authorization token is moved to this header.
|
|
14
|
+
*
|
|
15
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
16
|
+
*/
|
|
17
|
+
const FORWARDED_AUTHORIZATION_HEADER = "tu-forwarded-authorization";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Removes the "Bearer " prefix from a token string if present.
|
|
21
|
+
*
|
|
22
|
+
* @param token - The token string, possibly prefixed with "Bearer "
|
|
23
|
+
* @returns {string} The token without the "Bearer " prefix
|
|
24
|
+
*/
|
|
25
|
+
const stripBearerPrefix = (token) => {
|
|
26
|
+
return token.replace(/^(?:Bearer\s+)+/i, "").trim();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Retrieves the user token from the request headers.
|
|
31
|
+
*
|
|
32
|
+
* @param options - The options object
|
|
33
|
+
* @param options.context - Hono request context
|
|
34
|
+
* @returns {string | undefined} The user token (without "Bearer " prefix), or undefined if not found
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* import { getUserToken } from "@trackunit/serverside-utils";
|
|
38
|
+
*
|
|
39
|
+
* app.get("/data", async (c) => {
|
|
40
|
+
* const token = getUserToken({ context: c });
|
|
41
|
+
* if (!token) {
|
|
42
|
+
* return c.json({ error: "Unauthorized" }, 401);
|
|
43
|
+
* }
|
|
44
|
+
* // Use token for API calls...
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
const getUserToken = ({ context }) => {
|
|
49
|
+
const forwardedAuth = context.req.header(FORWARDED_AUTHORIZATION_HEADER);
|
|
50
|
+
if (forwardedAuth) {
|
|
51
|
+
return stripBearerPrefix(forwardedAuth);
|
|
52
|
+
}
|
|
53
|
+
// Fall back to standard Authorization header
|
|
54
|
+
const authorization = context.req.header("authorization");
|
|
55
|
+
if (authorization) {
|
|
56
|
+
return stripBearerPrefix(authorization);
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const JwtAccountPayloadSchema = z.object({
|
|
62
|
+
accountId: z.string().optional(),
|
|
63
|
+
assumedAccountId: z.string().optional(),
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* Extracts the account ID from the user token in the request.
|
|
67
|
+
*
|
|
68
|
+
* Trackunit tokens may contain both `accountId` (token owner) and `assumedAccountId`
|
|
69
|
+
* (account being acted upon). This function returns the effective account ID,
|
|
70
|
+
* preferring `assumedAccountId` when present.
|
|
71
|
+
*
|
|
72
|
+
* @param options - The options object
|
|
73
|
+
* @param options.context - Hono request context
|
|
74
|
+
* @returns {AccountIdResult} The effective account ID and the raw assumedAccountId (if present)
|
|
75
|
+
* @throws Error if no user token is found or the token has no account ID
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* // Simple usage — just get the effective account ID:
|
|
79
|
+
* const { accountId } = getAccountId({ context: c });
|
|
80
|
+
*
|
|
81
|
+
* // When you need to know if the user is assuming an account:
|
|
82
|
+
* const { accountId, assumedAccountId } = getAccountId({ context: c });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
const getAccountId = ({ context }) => {
|
|
86
|
+
const userToken = getUserToken({ context });
|
|
87
|
+
if (!userToken) {
|
|
88
|
+
throw new Error("Missing user token");
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const tokenWithoutBearer = stripBearerPrefix(userToken);
|
|
92
|
+
const payload = jwtDecode(tokenWithoutBearer);
|
|
93
|
+
const parseResult = JwtAccountPayloadSchema.safeParse(payload);
|
|
94
|
+
if (!parseResult.success) {
|
|
95
|
+
throw new Error("JWT payload missing accountId");
|
|
96
|
+
}
|
|
97
|
+
const { accountId, assumedAccountId } = parseResult.data;
|
|
98
|
+
const effectiveAccountId = assumedAccountId ?? accountId;
|
|
99
|
+
if (!effectiveAccountId) {
|
|
100
|
+
throw new Error("JWT payload missing accountId");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
accountId: effectiveAccountId,
|
|
104
|
+
assumedAccountId,
|
|
105
|
+
tokenAccountId: accountId,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof Error && error.message === "JWT payload missing accountId") {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
113
|
+
throw new Error(`Failed to extract accountId from user token: ${message}`);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const SecretsResponseSchema = z.object({
|
|
118
|
+
secrets: z.record(z.string(), z.string()),
|
|
119
|
+
});
|
|
120
|
+
/**
|
|
121
|
+
* Fetches a secret from the SDK Server.
|
|
122
|
+
*
|
|
123
|
+
* By default fetches **app-level** secrets (`GET /secrets`).
|
|
124
|
+
* Pass `accountScoped: true` to fetch **account-scoped** secrets (`GET /secrets/:accountId`).
|
|
125
|
+
*
|
|
126
|
+
* For local development, set the secret as an environment variable with the same name
|
|
127
|
+
* as the secretKey (e.g., `API_KEY=xxx` in your `.env` file).
|
|
128
|
+
*
|
|
129
|
+
* @param options - The options object
|
|
130
|
+
* @param options.context - Hono request context
|
|
131
|
+
* @param options.secretKey - Key name of the secret
|
|
132
|
+
* @param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
133
|
+
* @returns {Promise<string>} The secret value
|
|
134
|
+
* @throws Error if required headers or env vars are missing, or secret not found
|
|
135
|
+
* @example
|
|
136
|
+
* ```typescript
|
|
137
|
+
* // App-level secret (default)
|
|
138
|
+
* const apiKey = await getSecret({ context: c, secretKey: "API_KEY" });
|
|
139
|
+
*
|
|
140
|
+
* // Account-scoped secret
|
|
141
|
+
* const accountKey = await getSecret({ context: c, secretKey: "API_KEY", accountScoped: true });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
const getSecret = async ({ context, secretKey, accountScoped = false, }) => {
|
|
145
|
+
// Local development: use env var with same name as secretKey
|
|
146
|
+
try {
|
|
147
|
+
const envValue = process.env[secretKey];
|
|
148
|
+
if (envValue) {
|
|
149
|
+
return envValue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Ignore error when getting secret from environment variable. Deno blocks access to process.env in serverside functions.
|
|
154
|
+
}
|
|
155
|
+
const adminToken = context.req.header(ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER);
|
|
156
|
+
if (!adminToken) {
|
|
157
|
+
throw new Error(`Missing required header: ${ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER}`);
|
|
158
|
+
}
|
|
159
|
+
const sdkServerUrl = process.env.SDK_SERVER_URL;
|
|
160
|
+
if (!sdkServerUrl) {
|
|
161
|
+
throw new Error("Missing required environment variable: SDK_SERVER_URL");
|
|
162
|
+
}
|
|
163
|
+
const secretsPath = accountScoped ? `/secrets/${getAccountId({ context }).accountId}` : "/secrets";
|
|
164
|
+
const secretsUrl = new URL(secretsPath, sdkServerUrl).href;
|
|
165
|
+
const res = await fetch(secretsUrl, {
|
|
166
|
+
headers: { Authorization: adminToken },
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
let errorBody = "";
|
|
170
|
+
try {
|
|
171
|
+
const text = await res.text();
|
|
172
|
+
errorBody = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Ignore body read errors
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`Failed to fetch secret "${secretKey}" from ${secretsUrl}: ${res.status} ${res.statusText}${errorBody ? ` - ${errorBody}` : ""}`);
|
|
178
|
+
}
|
|
179
|
+
const responseData = await res.json();
|
|
180
|
+
const parseResult = SecretsResponseSchema.safeParse(responseData);
|
|
181
|
+
if (!parseResult.success) {
|
|
182
|
+
throw new Error(`Invalid secrets response from ${secretsUrl}: ${parseResult.error.message}`);
|
|
183
|
+
}
|
|
184
|
+
const secret = parseResult.data.secrets[secretKey];
|
|
185
|
+
if (!secret) {
|
|
186
|
+
throw new Error(`Secret "${secretKey}" not found in response from ${secretsUrl}. Available keys: ${Object.keys(parseResult.data.secrets).join(", ") || "(none)"}`);
|
|
187
|
+
}
|
|
188
|
+
return secret;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const DatabricksTokenResponseSchema = z.object({
|
|
192
|
+
access_token: z.string(),
|
|
193
|
+
expires_in: z.number().optional(),
|
|
194
|
+
});
|
|
195
|
+
const buildDatabricksUrl = (host, path) => {
|
|
196
|
+
const trimmed = host.trim();
|
|
197
|
+
const base = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
198
|
+
return new URL(path, base).toString();
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Obtains a Databricks OAuth access token using the client credentials flow.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
*
|
|
205
|
+
* const { databricksAccessToken } = await getDatabricksAccessToken({ context: c });
|
|
206
|
+
* @param props - The properties of the request.
|
|
207
|
+
* @param props.context - The context of the request.
|
|
208
|
+
* @param props.databricksHost - The host of the Databricks instance.
|
|
209
|
+
* @param props.databricksClientId - The client ID of the Databricks application.
|
|
210
|
+
* @param props.databricksClientSecret - The client secret of the Databricks application.
|
|
211
|
+
* @param props.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
212
|
+
* @param props.scope - The scope of the request to the Databricks token endpoint. Defaults to `all-apis`.@param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
213
|
+
* @returns {Promise<{ databricksAccessToken: string; databricksTokenExpiresIn?: number }>} The access token and optional expiry in seconds.
|
|
214
|
+
*/
|
|
215
|
+
const getDatabricksAccessToken = async ({ context, databricksHost, databricksClientId, databricksClientSecret, accountScoped = false, scope = "all-apis", }) => {
|
|
216
|
+
const clientId = databricksClientId ?? (await getSecret({ context, secretKey: "DATABRICKS_CLIENT_ID", accountScoped }));
|
|
217
|
+
const clientSecret = databricksClientSecret ?? (await getSecret({ context, secretKey: "DATABRICKS_CLIENT_SECRET", accountScoped }));
|
|
218
|
+
const rawHost = databricksHost ?? (await getSecret({ context, secretKey: "DATABRICKS_HOST", accountScoped }));
|
|
219
|
+
const tokenUrl = buildDatabricksUrl(rawHost, "/oidc/v1/token");
|
|
220
|
+
const credentials = btoa(`${clientId}:${clientSecret}`);
|
|
221
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
Authorization: `Basic ${credentials}`,
|
|
225
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
226
|
+
},
|
|
227
|
+
body: `grant_type=client_credentials&scope=${scope}`,
|
|
228
|
+
});
|
|
229
|
+
if (!tokenResponse.ok) {
|
|
230
|
+
const errorBody = await tokenResponse.text();
|
|
231
|
+
throw new Error(`Failed to obtain Databricks access token: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorBody}`);
|
|
232
|
+
}
|
|
233
|
+
const responseData = await tokenResponse.json();
|
|
234
|
+
const parseResult = DatabricksTokenResponseSchema.safeParse(responseData);
|
|
235
|
+
if (!parseResult.success) {
|
|
236
|
+
throw new Error(`Invalid token response: ${parseResult.error.message}`);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
databricksAccessToken: parseResult.data.access_token,
|
|
240
|
+
databricksTokenExpiresIn: parseResult.data.expires_in,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export { ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER, FORWARDED_AUTHORIZATION_HEADER, getAccountId, getDatabricksAccessToken, getSecret, getUserToken };
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@trackunit/serverside-utils",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"repository": "https://github.com/Trackunit/manager",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.x"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"hono": "^4.12.5",
|
|
11
|
+
"jwt-decode": "^3.1.2",
|
|
12
|
+
"zod": "^3.23.8"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"hono": "^4.12.5",
|
|
16
|
+
"jwt-decode": "^3.1.2",
|
|
17
|
+
"zod": "^3.23.8"
|
|
18
|
+
},
|
|
19
|
+
"module": "./index.esm.js",
|
|
20
|
+
"main": "./index.cjs.js",
|
|
21
|
+
"types": "./index.d.ts"
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Context } from "hono";
|
|
2
|
+
type GetAccountIdOptions = {
|
|
3
|
+
/** Hono request context */
|
|
4
|
+
context: Context;
|
|
5
|
+
};
|
|
6
|
+
type AccountIdResult = {
|
|
7
|
+
/** The effective account ID to use (prefers assumedAccountId over accountId) */
|
|
8
|
+
accountId: string;
|
|
9
|
+
/** The assumed account ID from the token, present when acting on behalf of another account */
|
|
10
|
+
assumedAccountId: string | undefined;
|
|
11
|
+
/** The original account ID from the token (the token owner's account) */
|
|
12
|
+
tokenAccountId: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Extracts the account ID from the user token in the request.
|
|
16
|
+
*
|
|
17
|
+
* Trackunit tokens may contain both `accountId` (token owner) and `assumedAccountId`
|
|
18
|
+
* (account being acted upon). This function returns the effective account ID,
|
|
19
|
+
* preferring `assumedAccountId` when present.
|
|
20
|
+
*
|
|
21
|
+
* @param options - The options object
|
|
22
|
+
* @param options.context - Hono request context
|
|
23
|
+
* @returns {AccountIdResult} The effective account ID and the raw assumedAccountId (if present)
|
|
24
|
+
* @throws Error if no user token is found or the token has no account ID
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* // Simple usage — just get the effective account ID:
|
|
28
|
+
* const { accountId } = getAccountId({ context: c });
|
|
29
|
+
*
|
|
30
|
+
* // When you need to know if the user is assuming an account:
|
|
31
|
+
* const { accountId, assumedAccountId } = getAccountId({ context: c });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare const getAccountId: ({ context }: GetAccountIdOptions) => AccountIdResult;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Context } from "hono";
|
|
2
|
+
/**
|
|
3
|
+
* The properties of the request to the Databricks token endpoint.
|
|
4
|
+
*/
|
|
5
|
+
export type GetDatabricksAccessTokenProps = {
|
|
6
|
+
/** The context of the request. */
|
|
7
|
+
context: Context;
|
|
8
|
+
/** The host of the Databricks instance. */
|
|
9
|
+
databricksHost?: string;
|
|
10
|
+
/** The client ID of the Databricks application. */
|
|
11
|
+
databricksClientId?: string;
|
|
12
|
+
/** The client secret of the Databricks application. */
|
|
13
|
+
databricksClientSecret?: string;
|
|
14
|
+
/** When true, fetches account-scoped secrets instead of app-level secrets. Requires a valid user token with an accountId. */
|
|
15
|
+
accountScoped?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The scope of the request to the Databricks token endpoint.
|
|
18
|
+
* Defaults to all-apis.
|
|
19
|
+
*
|
|
20
|
+
* @see {@link https://docs.databricks.com/aws/en/dev-tools/auth/oauth-u2m#generate-an-account-level-access-token Generate an account-level access token docs}
|
|
21
|
+
*/
|
|
22
|
+
scope?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Obtains a Databricks OAuth access token using the client credentials flow.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
*
|
|
29
|
+
* const { databricksAccessToken } = await getDatabricksAccessToken({ context: c });
|
|
30
|
+
* @param props - The properties of the request.
|
|
31
|
+
* @param props.context - The context of the request.
|
|
32
|
+
* @param props.databricksHost - The host of the Databricks instance.
|
|
33
|
+
* @param props.databricksClientId - The client ID of the Databricks application.
|
|
34
|
+
* @param props.databricksClientSecret - The client secret of the Databricks application.
|
|
35
|
+
* @param props.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
36
|
+
* @param props.scope - The scope of the request to the Databricks token endpoint. Defaults to `all-apis`.@param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
37
|
+
* @returns {Promise<{ databricksAccessToken: string; databricksTokenExpiresIn?: number }>} The access token and optional expiry in seconds.
|
|
38
|
+
*/
|
|
39
|
+
export declare const getDatabricksAccessToken: ({ context, databricksHost, databricksClientId, databricksClientSecret, accountScoped, scope, }: GetDatabricksAccessTokenProps) => Promise<{
|
|
40
|
+
databricksAccessToken: string;
|
|
41
|
+
databricksTokenExpiresIn?: number;
|
|
42
|
+
}>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Context } from "hono";
|
|
2
|
+
type GetSecretOptions<TSecretKey extends string> = {
|
|
3
|
+
/** Hono request context */
|
|
4
|
+
context: Context;
|
|
5
|
+
/** Key name of the secret (also used as env var name for local dev) */
|
|
6
|
+
secretKey: TSecretKey;
|
|
7
|
+
/** When true, fetches account-scoped secrets instead of app-level secrets. Requires a valid user token with an accountId. */
|
|
8
|
+
accountScoped?: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Fetches a secret from the SDK Server.
|
|
12
|
+
*
|
|
13
|
+
* By default fetches **app-level** secrets (`GET /secrets`).
|
|
14
|
+
* Pass `accountScoped: true` to fetch **account-scoped** secrets (`GET /secrets/:accountId`).
|
|
15
|
+
*
|
|
16
|
+
* For local development, set the secret as an environment variable with the same name
|
|
17
|
+
* as the secretKey (e.g., `API_KEY=xxx` in your `.env` file).
|
|
18
|
+
*
|
|
19
|
+
* @param options - The options object
|
|
20
|
+
* @param options.context - Hono request context
|
|
21
|
+
* @param options.secretKey - Key name of the secret
|
|
22
|
+
* @param options.accountScoped - When true, fetches account-scoped secrets (requires user token with accountId)
|
|
23
|
+
* @returns {Promise<string>} The secret value
|
|
24
|
+
* @throws Error if required headers or env vars are missing, or secret not found
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* // App-level secret (default)
|
|
28
|
+
* const apiKey = await getSecret({ context: c, secretKey: "API_KEY" });
|
|
29
|
+
*
|
|
30
|
+
* // Account-scoped secret
|
|
31
|
+
* const accountKey = await getSecret({ context: c, secretKey: "API_KEY", accountScoped: true });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare const getSecret: <TSecretKey extends string>({ context, secretKey, accountScoped, }: GetSecretOptions<TSecretKey>) => Promise<string>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context } from "hono";
|
|
2
|
+
type GetUserTokenOptions = {
|
|
3
|
+
/** Hono request context */
|
|
4
|
+
context: Context;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Retrieves the user token from the request headers.
|
|
8
|
+
*
|
|
9
|
+
* @param options - The options object
|
|
10
|
+
* @param options.context - Hono request context
|
|
11
|
+
* @returns {string | undefined} The user token (without "Bearer " prefix), or undefined if not found
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* import { getUserToken } from "@trackunit/serverside-utils";
|
|
15
|
+
*
|
|
16
|
+
* app.get("/data", async (c) => {
|
|
17
|
+
* const token = getUserToken({ context: c });
|
|
18
|
+
* if (!token) {
|
|
19
|
+
* return c.json({ error: "Unauthorized" }, 401);
|
|
20
|
+
* }
|
|
21
|
+
* // Use token for API calls...
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare const getUserToken: ({ context }: GetUserTokenOptions) => string | undefined;
|
|
26
|
+
export {};
|
package/src/headers.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header key for the account admin iris app token.
|
|
3
|
+
* Used by the SDK server to pass admin tokens to serverside functions.
|
|
4
|
+
*
|
|
5
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER = "tu-account-admin-iris-app-token";
|
|
8
|
+
/**
|
|
9
|
+
* Header key for the forwarded authorization token.
|
|
10
|
+
* The original user authorization token is moved to this header.
|
|
11
|
+
*
|
|
12
|
+
* Uses vendor prefix "tu-" (Trackunit) per RFC 6648 which deprecated the "X-" convention.
|
|
13
|
+
*/
|
|
14
|
+
export declare const FORWARDED_AUTHORIZATION_HEADER = "tu-forwarded-authorization";
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { getAccountId } from "./get-account-id";
|
|
2
|
+
export { getDatabricksAccessToken, type GetDatabricksAccessTokenProps } from "./get-databricks-access-token";
|
|
3
|
+
export { getSecret } from "./get-secret";
|
|
4
|
+
export { getUserToken } from "./get-user-token";
|
|
5
|
+
export { ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER, FORWARDED_AUTHORIZATION_HEADER } from "./headers";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes the "Bearer " prefix from a token string if present.
|
|
3
|
+
*
|
|
4
|
+
* @param token - The token string, possibly prefixed with "Bearer "
|
|
5
|
+
* @returns {string} The token without the "Bearer " prefix
|
|
6
|
+
*/
|
|
7
|
+
export declare const stripBearerPrefix: (token: string) => string;
|