@trackunit/serverside-utils 0.4.25-alpha-27ad777c16e.0 → 0.4.28

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/index.cjs.js CHANGED
@@ -1,7 +1,108 @@
1
1
  'use strict';
2
2
 
3
- var jwtDecode = require('jwt-decode');
4
3
  var zod = require('zod');
4
+ var jwtDecode = require('jwt-decode');
5
+
6
+ const GraphqlErrorSchema = zod.z.object({
7
+ message: zod.z.string(),
8
+ });
9
+ const readEnvironmentVariable = (key) => {
10
+ try {
11
+ return process.env[key];
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ };
17
+ /**
18
+ * Resolves the public GraphQL base URL for the current SDK environment.
19
+ *
20
+ * The environment is read from the `SDK_ENVIRONMENT` variable and defaults to
21
+ * `PROD` when unset.
22
+ *
23
+ * @returns {string} The public GraphQL endpoint for the resolved environment.
24
+ * @throws {Error} When `SDK_ENVIRONMENT` is set to an unknown value.
25
+ */
26
+ const getGraphqlUrl = () => {
27
+ const sdkEnvironment = readEnvironmentVariable("SDK_ENVIRONMENT") ?? "PROD";
28
+ switch (sdkEnvironment.toUpperCase()) {
29
+ case "DEV":
30
+ return "https://dev.iris.trackunit.com/api/graphql";
31
+ case "STAGE":
32
+ return "https://stage.iris.trackunit.com/api/graphql";
33
+ case "PROD":
34
+ return "https://iris.trackunit.com/api/graphql";
35
+ default:
36
+ throw new Error(`Unknown SDK environment: ${sdkEnvironment}`);
37
+ }
38
+ };
39
+ /**
40
+ * Appends the operation name to the base URL so each call stays attributable by
41
+ * its unique operation name when observed in GraphQL Hive.
42
+ */
43
+ const buildOperationUrl = (baseUrl, operationName) => `${baseUrl}/${operationName}`;
44
+ const getErrorBody = async (response) => {
45
+ try {
46
+ const text = await response.text();
47
+ return text.length > 200 ? `${text.slice(0, 200)}...` : text;
48
+ }
49
+ catch {
50
+ return "";
51
+ }
52
+ };
53
+ /**
54
+ * Executes a query against the public GraphQL API and returns its validated
55
+ * `data` payload.
56
+ *
57
+ * The operation name is appended to the URL so each call stays attributable by
58
+ * its unique operation name when observed in GraphQL Hive.
59
+ *
60
+ * @template TData The shape of the validated `data` payload.
61
+ * @param props - The properties of the query execution.
62
+ * @param props.baseUrl - The base GraphQL URL, e.g. from {@link getGraphqlUrl}.
63
+ * @param props.userToken - The user token used to authenticate the request.
64
+ * @param props.operationName - The GraphQL operation name. Must be unique so calls stay traceable.
65
+ * @param props.query - The GraphQL query document.
66
+ * @param props.variables - Optional variables passed to the query.
67
+ * @param props.dataSchema - Zod schema used to validate and type the `data` payload.
68
+ * @returns {Promise<TData>} The validated `data` payload of the response.
69
+ * @throws {Error} When the HTTP response is not 2xx.
70
+ * @throws {Error} When the response body cannot be parsed against the expected shape.
71
+ * @throws {Error} When the response contains GraphQL `errors`.
72
+ * @throws {Error} When the response contains no `data`.
73
+ */
74
+ const executePublicQuery = async ({ baseUrl, userToken, operationName, query, variables, dataSchema, }) => {
75
+ const url = buildOperationUrl(baseUrl, operationName);
76
+ const response = await fetch(url, {
77
+ method: "POST",
78
+ headers: {
79
+ Authorization: `Bearer ${userToken}`,
80
+ "Content-Type": "application/json",
81
+ },
82
+ body: JSON.stringify({ operationName, query, variables }),
83
+ });
84
+ if (!response.ok) {
85
+ const errorBody = await getErrorBody(response);
86
+ throw new Error(`Failed to execute ${operationName} at ${url}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`);
87
+ }
88
+ const responseData = await response.json();
89
+ const parseResult = zod.z
90
+ .object({
91
+ data: dataSchema.nullish(),
92
+ errors: zod.z.array(GraphqlErrorSchema).optional(),
93
+ })
94
+ .safeParse(responseData);
95
+ if (!parseResult.success) {
96
+ throw new Error(`Invalid ${operationName} response from ${url}: ${parseResult.error.message}`);
97
+ }
98
+ if (parseResult.data.errors && parseResult.data.errors.length > 0) {
99
+ throw new Error(`GraphQL error in ${operationName}: ${parseResult.data.errors.map(error => error.message).join(", ")}`);
100
+ }
101
+ if (parseResult.data.data === undefined || parseResult.data.data === null) {
102
+ throw new Error(`No data returned from ${operationName} at ${url}`);
103
+ }
104
+ return parseResult.data.data;
105
+ };
5
106
 
6
107
  /**
7
108
  * Header key for the account admin iris app token.
@@ -243,9 +344,171 @@ const getDatabricksAccessToken = async ({ context, databricksHost, databricksCli
243
344
  };
244
345
  };
245
346
 
347
+ /**
348
+ * Operation names are intentionally verbose and unique so the calls are easy to
349
+ * trace back to this utility (e.g. when inspecting GraphQL Hive). The
350
+ * `ServersideUtils` prefix already attributes every call to this utility.
351
+ */
352
+ const USER_PERMISSIONS_OPERATION_NAME = "ServersideUtilsHasScopesUserPermissions";
353
+ const USER_PERMISSIONS_QUERY = `query ${USER_PERMISSIONS_OPERATION_NAME}($securableIds: [ID!]!) {
354
+ userCurrent {
355
+ user {
356
+ permissions(securableIds: $securableIds) {
357
+ securableId
358
+ permissions
359
+ }
360
+ }
361
+ }
362
+ }`;
363
+ const UserPermissionsDataSchema = zod.z.object({
364
+ userCurrent: zod.z
365
+ .object({
366
+ user: zod.z
367
+ .object({
368
+ permissions: zod.z
369
+ .array(zod.z
370
+ .object({
371
+ securableId: zod.z.string(),
372
+ permissions: zod.z.array(zod.z.string().nullable()).nullable(),
373
+ })
374
+ .nullable())
375
+ .nullable(),
376
+ })
377
+ .nullable(),
378
+ })
379
+ .nullable(),
380
+ });
381
+ /**
382
+ * The only scope prefix currently supported. Scopes are configured by the
383
+ * developer in the Iris app manifest and follow the `<securable>.<...>` shape
384
+ * (e.g. `account.group.manage`). Only account-securable scopes can be checked
385
+ * against the current account.
386
+ */
387
+ const SUPPORTED_SCOPE_PREFIX = "account";
388
+ const getScopePrefix = (scope) => scope.split(".")[0] ?? "";
389
+ const getCurrentUserAccountPermissions = async ({ baseUrl, userToken, accountId, }) => {
390
+ const data = await executePublicQuery({
391
+ baseUrl,
392
+ userToken,
393
+ operationName: USER_PERMISSIONS_OPERATION_NAME,
394
+ query: USER_PERMISSIONS_QUERY,
395
+ variables: { securableIds: [accountId] },
396
+ dataSchema: UserPermissionsDataSchema,
397
+ });
398
+ return (data.userCurrent?.user?.permissions ?? [])
399
+ .flatMap(securablePermission => securablePermission?.permissions ?? [])
400
+ .filter((permission) => permission !== null);
401
+ };
402
+ /**
403
+ * Checks whether the current logged-in user has all required scopes on their
404
+ * current account, using the public GraphQL API.
405
+ *
406
+ * The current account id is read from the user token, then the user's scopes
407
+ * for that account are fetched via `userCurrent.user.permissions`.
408
+ *
409
+ * Only scopes with the `account` prefix are supported. A scope with any other
410
+ * prefix throws, since this utility can only evaluate scopes against the
411
+ * current account.
412
+ *
413
+ * @param props - The properties of the request.
414
+ * @param props.context - Hono request context.
415
+ * @param props.scopes - Scopes that the current user must have, as configured in
416
+ * the Iris app manifest. An empty list requires nothing, so the check resolves to
417
+ * `true` without a lookup (but a missing user token still throws).
418
+ * @returns {Promise<boolean>} True when the user has all requested scopes,
419
+ * or when no scopes were requested.
420
+ * @throws {Error} When the request has no user token.
421
+ * @throws {Error} When a scope does not use the `account` prefix.
422
+ * @throws {Error} When the user token has no account id (via `getAccountId`).
423
+ * @throws {Error} When the scope lookup cannot be completed: a non-2xx HTTP
424
+ * response, GraphQL `errors`, or an unparseable/empty response body. These signal
425
+ * "could not check" rather than "user lacks scope", so callers that gate on
426
+ * the boolean should treat a thrown error as a failure to verify, not a denial.
427
+ */
428
+ const hasScopes = async ({ context, scopes }) => {
429
+ const userToken = getUserToken({ context });
430
+ if (!userToken) {
431
+ throw new Error("Missing user token");
432
+ }
433
+ if (scopes.length === 0) {
434
+ return true;
435
+ }
436
+ const unsupportedScope = scopes.find(scope => getScopePrefix(scope) !== SUPPORTED_SCOPE_PREFIX);
437
+ if (unsupportedScope !== undefined) {
438
+ throw new Error(`Unsupported scope "${unsupportedScope}": only "${SUPPORTED_SCOPE_PREFIX}" scopes are currently supported`);
439
+ }
440
+ const { accountId } = getAccountId({ context });
441
+ const baseUrl = getGraphqlUrl();
442
+ const grantedScopes = await getCurrentUserAccountPermissions({ baseUrl, userToken, accountId });
443
+ return scopes.every(scope => grantedScopes.includes(scope));
444
+ };
445
+
446
+ const toScopeList = (scopes) => typeof scopes === "string" ? [scopes] : scopes;
447
+ /**
448
+ * Creates a Hono middleware that guards routes by account scopes.
449
+ *
450
+ * The current user must hold every scope passed in for the request to
451
+ * continue. When a scope is missing the middleware short-circuits with a
452
+ * `403` (or a custom response); when no user token is present it responds with
453
+ * a `401`. On success it calls `next()` so the matched handler runs.
454
+ *
455
+ * Scopes match what the developer configures in the Iris app manifest and must
456
+ * use the `account` prefix (e.g. `account.group.manage`); other prefixes throw.
457
+ *
458
+ * If the scope check itself cannot be completed (the token has no account id,
459
+ * or the GraphQL lookup fails), the error propagates and Hono responds with a `500`
460
+ * unless an `onError` handler is provided. In all of these cases the matched handler
461
+ * does not run, so the guard fails closed.
462
+ *
463
+ * @param scopes - A single scope or list of scopes the user must have.
464
+ * @param options - Optional overrides for the failure responses.
465
+ * @returns {MiddlewareHandler} A Hono middleware handler.
466
+ * @example
467
+ * ```typescript
468
+ * // Guard every route in the extension:
469
+ * app.use(requireScopes("account.view"));
470
+ *
471
+ * // Guard a subset of routes:
472
+ * app.post("/groups/*", requireScopes("account.group.manage"));
473
+ *
474
+ * // Require multiple scopes:
475
+ * app.use(requireScopes(["account.view", "account.group.manage"]));
476
+ * ```
477
+ */
478
+ const requireScopes = (scopes, options = {}) => {
479
+ const requiredScopes = toScopeList(scopes);
480
+ return async (context, next) => {
481
+ const userToken = getUserToken({ context });
482
+ if (!userToken) {
483
+ return options.onUnauthorized ? options.onUnauthorized(context) : context.json({ error: "Unauthorized" }, 401);
484
+ }
485
+ let granted;
486
+ try {
487
+ granted = await hasScopes({
488
+ context,
489
+ scopes: requiredScopes,
490
+ });
491
+ }
492
+ catch (error) {
493
+ if (options.onError) {
494
+ return options.onError(context, error);
495
+ }
496
+ throw error;
497
+ }
498
+ if (!granted) {
499
+ return options.onForbidden ? options.onForbidden(context) : context.json({ error: "Forbidden" }, 403);
500
+ }
501
+ await next();
502
+ };
503
+ };
504
+
246
505
  exports.ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER = ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER;
247
506
  exports.FORWARDED_AUTHORIZATION_HEADER = FORWARDED_AUTHORIZATION_HEADER;
507
+ exports.executePublicQuery = executePublicQuery;
248
508
  exports.getAccountId = getAccountId;
249
509
  exports.getDatabricksAccessToken = getDatabricksAccessToken;
510
+ exports.getGraphqlUrl = getGraphqlUrl;
250
511
  exports.getSecret = getSecret;
251
512
  exports.getUserToken = getUserToken;
513
+ exports.hasScopes = hasScopes;
514
+ exports.requireScopes = requireScopes;
package/index.esm.js CHANGED
@@ -1,5 +1,106 @@
1
- import jwtDecode from 'jwt-decode';
2
1
  import { z } from 'zod';
2
+ import jwtDecode from 'jwt-decode';
3
+
4
+ const GraphqlErrorSchema = z.object({
5
+ message: z.string(),
6
+ });
7
+ const readEnvironmentVariable = (key) => {
8
+ try {
9
+ return process.env[key];
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ };
15
+ /**
16
+ * Resolves the public GraphQL base URL for the current SDK environment.
17
+ *
18
+ * The environment is read from the `SDK_ENVIRONMENT` variable and defaults to
19
+ * `PROD` when unset.
20
+ *
21
+ * @returns {string} The public GraphQL endpoint for the resolved environment.
22
+ * @throws {Error} When `SDK_ENVIRONMENT` is set to an unknown value.
23
+ */
24
+ const getGraphqlUrl = () => {
25
+ const sdkEnvironment = readEnvironmentVariable("SDK_ENVIRONMENT") ?? "PROD";
26
+ switch (sdkEnvironment.toUpperCase()) {
27
+ case "DEV":
28
+ return "https://dev.iris.trackunit.com/api/graphql";
29
+ case "STAGE":
30
+ return "https://stage.iris.trackunit.com/api/graphql";
31
+ case "PROD":
32
+ return "https://iris.trackunit.com/api/graphql";
33
+ default:
34
+ throw new Error(`Unknown SDK environment: ${sdkEnvironment}`);
35
+ }
36
+ };
37
+ /**
38
+ * Appends the operation name to the base URL so each call stays attributable by
39
+ * its unique operation name when observed in GraphQL Hive.
40
+ */
41
+ const buildOperationUrl = (baseUrl, operationName) => `${baseUrl}/${operationName}`;
42
+ const getErrorBody = async (response) => {
43
+ try {
44
+ const text = await response.text();
45
+ return text.length > 200 ? `${text.slice(0, 200)}...` : text;
46
+ }
47
+ catch {
48
+ return "";
49
+ }
50
+ };
51
+ /**
52
+ * Executes a query against the public GraphQL API and returns its validated
53
+ * `data` payload.
54
+ *
55
+ * The operation name is appended to the URL so each call stays attributable by
56
+ * its unique operation name when observed in GraphQL Hive.
57
+ *
58
+ * @template TData The shape of the validated `data` payload.
59
+ * @param props - The properties of the query execution.
60
+ * @param props.baseUrl - The base GraphQL URL, e.g. from {@link getGraphqlUrl}.
61
+ * @param props.userToken - The user token used to authenticate the request.
62
+ * @param props.operationName - The GraphQL operation name. Must be unique so calls stay traceable.
63
+ * @param props.query - The GraphQL query document.
64
+ * @param props.variables - Optional variables passed to the query.
65
+ * @param props.dataSchema - Zod schema used to validate and type the `data` payload.
66
+ * @returns {Promise<TData>} The validated `data` payload of the response.
67
+ * @throws {Error} When the HTTP response is not 2xx.
68
+ * @throws {Error} When the response body cannot be parsed against the expected shape.
69
+ * @throws {Error} When the response contains GraphQL `errors`.
70
+ * @throws {Error} When the response contains no `data`.
71
+ */
72
+ const executePublicQuery = async ({ baseUrl, userToken, operationName, query, variables, dataSchema, }) => {
73
+ const url = buildOperationUrl(baseUrl, operationName);
74
+ const response = await fetch(url, {
75
+ method: "POST",
76
+ headers: {
77
+ Authorization: `Bearer ${userToken}`,
78
+ "Content-Type": "application/json",
79
+ },
80
+ body: JSON.stringify({ operationName, query, variables }),
81
+ });
82
+ if (!response.ok) {
83
+ const errorBody = await getErrorBody(response);
84
+ throw new Error(`Failed to execute ${operationName} at ${url}: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`);
85
+ }
86
+ const responseData = await response.json();
87
+ const parseResult = z
88
+ .object({
89
+ data: dataSchema.nullish(),
90
+ errors: z.array(GraphqlErrorSchema).optional(),
91
+ })
92
+ .safeParse(responseData);
93
+ if (!parseResult.success) {
94
+ throw new Error(`Invalid ${operationName} response from ${url}: ${parseResult.error.message}`);
95
+ }
96
+ if (parseResult.data.errors && parseResult.data.errors.length > 0) {
97
+ throw new Error(`GraphQL error in ${operationName}: ${parseResult.data.errors.map(error => error.message).join(", ")}`);
98
+ }
99
+ if (parseResult.data.data === undefined || parseResult.data.data === null) {
100
+ throw new Error(`No data returned from ${operationName} at ${url}`);
101
+ }
102
+ return parseResult.data.data;
103
+ };
3
104
 
4
105
  /**
5
106
  * Header key for the account admin iris app token.
@@ -241,4 +342,162 @@ const getDatabricksAccessToken = async ({ context, databricksHost, databricksCli
241
342
  };
242
343
  };
243
344
 
244
- export { ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER, FORWARDED_AUTHORIZATION_HEADER, getAccountId, getDatabricksAccessToken, getSecret, getUserToken };
345
+ /**
346
+ * Operation names are intentionally verbose and unique so the calls are easy to
347
+ * trace back to this utility (e.g. when inspecting GraphQL Hive). The
348
+ * `ServersideUtils` prefix already attributes every call to this utility.
349
+ */
350
+ const USER_PERMISSIONS_OPERATION_NAME = "ServersideUtilsHasScopesUserPermissions";
351
+ const USER_PERMISSIONS_QUERY = `query ${USER_PERMISSIONS_OPERATION_NAME}($securableIds: [ID!]!) {
352
+ userCurrent {
353
+ user {
354
+ permissions(securableIds: $securableIds) {
355
+ securableId
356
+ permissions
357
+ }
358
+ }
359
+ }
360
+ }`;
361
+ const UserPermissionsDataSchema = z.object({
362
+ userCurrent: z
363
+ .object({
364
+ user: z
365
+ .object({
366
+ permissions: z
367
+ .array(z
368
+ .object({
369
+ securableId: z.string(),
370
+ permissions: z.array(z.string().nullable()).nullable(),
371
+ })
372
+ .nullable())
373
+ .nullable(),
374
+ })
375
+ .nullable(),
376
+ })
377
+ .nullable(),
378
+ });
379
+ /**
380
+ * The only scope prefix currently supported. Scopes are configured by the
381
+ * developer in the Iris app manifest and follow the `<securable>.<...>` shape
382
+ * (e.g. `account.group.manage`). Only account-securable scopes can be checked
383
+ * against the current account.
384
+ */
385
+ const SUPPORTED_SCOPE_PREFIX = "account";
386
+ const getScopePrefix = (scope) => scope.split(".")[0] ?? "";
387
+ const getCurrentUserAccountPermissions = async ({ baseUrl, userToken, accountId, }) => {
388
+ const data = await executePublicQuery({
389
+ baseUrl,
390
+ userToken,
391
+ operationName: USER_PERMISSIONS_OPERATION_NAME,
392
+ query: USER_PERMISSIONS_QUERY,
393
+ variables: { securableIds: [accountId] },
394
+ dataSchema: UserPermissionsDataSchema,
395
+ });
396
+ return (data.userCurrent?.user?.permissions ?? [])
397
+ .flatMap(securablePermission => securablePermission?.permissions ?? [])
398
+ .filter((permission) => permission !== null);
399
+ };
400
+ /**
401
+ * Checks whether the current logged-in user has all required scopes on their
402
+ * current account, using the public GraphQL API.
403
+ *
404
+ * The current account id is read from the user token, then the user's scopes
405
+ * for that account are fetched via `userCurrent.user.permissions`.
406
+ *
407
+ * Only scopes with the `account` prefix are supported. A scope with any other
408
+ * prefix throws, since this utility can only evaluate scopes against the
409
+ * current account.
410
+ *
411
+ * @param props - The properties of the request.
412
+ * @param props.context - Hono request context.
413
+ * @param props.scopes - Scopes that the current user must have, as configured in
414
+ * the Iris app manifest. An empty list requires nothing, so the check resolves to
415
+ * `true` without a lookup (but a missing user token still throws).
416
+ * @returns {Promise<boolean>} True when the user has all requested scopes,
417
+ * or when no scopes were requested.
418
+ * @throws {Error} When the request has no user token.
419
+ * @throws {Error} When a scope does not use the `account` prefix.
420
+ * @throws {Error} When the user token has no account id (via `getAccountId`).
421
+ * @throws {Error} When the scope lookup cannot be completed: a non-2xx HTTP
422
+ * response, GraphQL `errors`, or an unparseable/empty response body. These signal
423
+ * "could not check" rather than "user lacks scope", so callers that gate on
424
+ * the boolean should treat a thrown error as a failure to verify, not a denial.
425
+ */
426
+ const hasScopes = async ({ context, scopes }) => {
427
+ const userToken = getUserToken({ context });
428
+ if (!userToken) {
429
+ throw new Error("Missing user token");
430
+ }
431
+ if (scopes.length === 0) {
432
+ return true;
433
+ }
434
+ const unsupportedScope = scopes.find(scope => getScopePrefix(scope) !== SUPPORTED_SCOPE_PREFIX);
435
+ if (unsupportedScope !== undefined) {
436
+ throw new Error(`Unsupported scope "${unsupportedScope}": only "${SUPPORTED_SCOPE_PREFIX}" scopes are currently supported`);
437
+ }
438
+ const { accountId } = getAccountId({ context });
439
+ const baseUrl = getGraphqlUrl();
440
+ const grantedScopes = await getCurrentUserAccountPermissions({ baseUrl, userToken, accountId });
441
+ return scopes.every(scope => grantedScopes.includes(scope));
442
+ };
443
+
444
+ const toScopeList = (scopes) => typeof scopes === "string" ? [scopes] : scopes;
445
+ /**
446
+ * Creates a Hono middleware that guards routes by account scopes.
447
+ *
448
+ * The current user must hold every scope passed in for the request to
449
+ * continue. When a scope is missing the middleware short-circuits with a
450
+ * `403` (or a custom response); when no user token is present it responds with
451
+ * a `401`. On success it calls `next()` so the matched handler runs.
452
+ *
453
+ * Scopes match what the developer configures in the Iris app manifest and must
454
+ * use the `account` prefix (e.g. `account.group.manage`); other prefixes throw.
455
+ *
456
+ * If the scope check itself cannot be completed (the token has no account id,
457
+ * or the GraphQL lookup fails), the error propagates and Hono responds with a `500`
458
+ * unless an `onError` handler is provided. In all of these cases the matched handler
459
+ * does not run, so the guard fails closed.
460
+ *
461
+ * @param scopes - A single scope or list of scopes the user must have.
462
+ * @param options - Optional overrides for the failure responses.
463
+ * @returns {MiddlewareHandler} A Hono middleware handler.
464
+ * @example
465
+ * ```typescript
466
+ * // Guard every route in the extension:
467
+ * app.use(requireScopes("account.view"));
468
+ *
469
+ * // Guard a subset of routes:
470
+ * app.post("/groups/*", requireScopes("account.group.manage"));
471
+ *
472
+ * // Require multiple scopes:
473
+ * app.use(requireScopes(["account.view", "account.group.manage"]));
474
+ * ```
475
+ */
476
+ const requireScopes = (scopes, options = {}) => {
477
+ const requiredScopes = toScopeList(scopes);
478
+ return async (context, next) => {
479
+ const userToken = getUserToken({ context });
480
+ if (!userToken) {
481
+ return options.onUnauthorized ? options.onUnauthorized(context) : context.json({ error: "Unauthorized" }, 401);
482
+ }
483
+ let granted;
484
+ try {
485
+ granted = await hasScopes({
486
+ context,
487
+ scopes: requiredScopes,
488
+ });
489
+ }
490
+ catch (error) {
491
+ if (options.onError) {
492
+ return options.onError(context, error);
493
+ }
494
+ throw error;
495
+ }
496
+ if (!granted) {
497
+ return options.onForbidden ? options.onForbidden(context) : context.json({ error: "Forbidden" }, 403);
498
+ }
499
+ await next();
500
+ };
501
+ };
502
+
503
+ export { ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER, FORWARDED_AUTHORIZATION_HEADER, executePublicQuery, getAccountId, getDatabricksAccessToken, getGraphqlUrl, getSecret, getUserToken, hasScopes, requireScopes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/serverside-utils",
3
- "version": "0.4.25-alpha-27ad777c16e.0",
3
+ "version": "0.4.28",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Resolves the public GraphQL base URL for the current SDK environment.
4
+ *
5
+ * The environment is read from the `SDK_ENVIRONMENT` variable and defaults to
6
+ * `PROD` when unset.
7
+ *
8
+ * @returns {string} The public GraphQL endpoint for the resolved environment.
9
+ * @throws {Error} When `SDK_ENVIRONMENT` is set to an unknown value.
10
+ */
11
+ export declare const getGraphqlUrl: () => string;
12
+ /**
13
+ * The properties of a public GraphQL query execution.
14
+ */
15
+ export type ExecutePublicQueryProps<TData> = {
16
+ /** The base GraphQL URL, e.g. from {@link getGraphqlUrl}. */
17
+ readonly baseUrl: string;
18
+ /** The user token used to authenticate the request. */
19
+ readonly userToken: string;
20
+ /** The GraphQL operation name. Must be unique so calls stay traceable in GraphQL Hive. */
21
+ readonly operationName: string;
22
+ /** The GraphQL query document. */
23
+ readonly query: string;
24
+ /** Optional variables passed to the query. */
25
+ readonly variables?: Record<string, unknown>;
26
+ /** Zod schema used to validate and type the `data` payload of the response. */
27
+ readonly dataSchema: z.ZodType<TData>;
28
+ };
29
+ /**
30
+ * Executes a query against the public GraphQL API and returns its validated
31
+ * `data` payload.
32
+ *
33
+ * The operation name is appended to the URL so each call stays attributable by
34
+ * its unique operation name when observed in GraphQL Hive.
35
+ *
36
+ * @template TData The shape of the validated `data` payload.
37
+ * @param props - The properties of the query execution.
38
+ * @param props.baseUrl - The base GraphQL URL, e.g. from {@link getGraphqlUrl}.
39
+ * @param props.userToken - The user token used to authenticate the request.
40
+ * @param props.operationName - The GraphQL operation name. Must be unique so calls stay traceable.
41
+ * @param props.query - The GraphQL query document.
42
+ * @param props.variables - Optional variables passed to the query.
43
+ * @param props.dataSchema - Zod schema used to validate and type the `data` payload.
44
+ * @returns {Promise<TData>} The validated `data` payload of the response.
45
+ * @throws {Error} When the HTTP response is not 2xx.
46
+ * @throws {Error} When the response body cannot be parsed against the expected shape.
47
+ * @throws {Error} When the response contains GraphQL `errors`.
48
+ * @throws {Error} When the response contains no `data`.
49
+ */
50
+ export declare const executePublicQuery: <TData>({ baseUrl, userToken, operationName, query, variables, dataSchema, }: ExecutePublicQueryProps<TData>) => Promise<TData>;
@@ -0,0 +1,38 @@
1
+ import type { Context } from "hono";
2
+ export type HasScopesProps = {
3
+ /** Hono request context */
4
+ readonly context: Context;
5
+ /**
6
+ * Scopes the current user must have on their current account, as configured in
7
+ * the Iris app manifest (e.g. `account.group.manage`). Every scope must use the
8
+ * `account` prefix.
9
+ */
10
+ readonly scopes: ReadonlyArray<string>;
11
+ };
12
+ /**
13
+ * Checks whether the current logged-in user has all required scopes on their
14
+ * current account, using the public GraphQL API.
15
+ *
16
+ * The current account id is read from the user token, then the user's scopes
17
+ * for that account are fetched via `userCurrent.user.permissions`.
18
+ *
19
+ * Only scopes with the `account` prefix are supported. A scope with any other
20
+ * prefix throws, since this utility can only evaluate scopes against the
21
+ * current account.
22
+ *
23
+ * @param props - The properties of the request.
24
+ * @param props.context - Hono request context.
25
+ * @param props.scopes - Scopes that the current user must have, as configured in
26
+ * the Iris app manifest. An empty list requires nothing, so the check resolves to
27
+ * `true` without a lookup (but a missing user token still throws).
28
+ * @returns {Promise<boolean>} True when the user has all requested scopes,
29
+ * or when no scopes were requested.
30
+ * @throws {Error} When the request has no user token.
31
+ * @throws {Error} When a scope does not use the `account` prefix.
32
+ * @throws {Error} When the user token has no account id (via `getAccountId`).
33
+ * @throws {Error} When the scope lookup cannot be completed: a non-2xx HTTP
34
+ * response, GraphQL `errors`, or an unparseable/empty response body. These signal
35
+ * "could not check" rather than "user lacks scope", so callers that gate on
36
+ * the boolean should treat a thrown error as a failure to verify, not a denial.
37
+ */
38
+ export declare const hasScopes: ({ context, scopes }: HasScopesProps) => Promise<boolean>;
package/src/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ export { executePublicQuery, getGraphqlUrl, type ExecutePublicQueryProps } from "./execute-public-query";
1
2
  export { getAccountId } from "./get-account-id";
2
3
  export { getDatabricksAccessToken, type GetDatabricksAccessTokenProps } from "./get-databricks-access-token";
3
4
  export { getSecret } from "./get-secret";
4
5
  export { getUserToken } from "./get-user-token";
6
+ export { hasScopes, type HasScopesProps } from "./has-scopes";
5
7
  export { ACCOUNT_ADMIN_IRIS_APP_TOKEN_HEADER, FORWARDED_AUTHORIZATION_HEADER } from "./headers";
8
+ export { requireScopes, type RequireScopesOptions } from "./require-scopes";
@@ -0,0 +1,46 @@
1
+ import type { Context, MiddlewareHandler } from "hono";
2
+ export type RequireScopesOptions = {
3
+ /** Response returned when no user token is present (defaults to 401) */
4
+ readonly onUnauthorized?: (context: Context) => Response | Promise<Response>;
5
+ /** Response returned when the user is missing a required scope (defaults to 403) */
6
+ readonly onForbidden?: (context: Context) => Response | Promise<Response>;
7
+ /**
8
+ * Response returned when the scope check could not be completed (e.g. the
9
+ * token has no account id, or the GraphQL lookup failed). When omitted the error
10
+ * propagates and Hono responds with a `500`. Either way the route handler does
11
+ * not run, so the guard fails closed.
12
+ */
13
+ readonly onError?: (context: Context, error: unknown) => Response | Promise<Response>;
14
+ };
15
+ /**
16
+ * Creates a Hono middleware that guards routes by account scopes.
17
+ *
18
+ * The current user must hold every scope passed in for the request to
19
+ * continue. When a scope is missing the middleware short-circuits with a
20
+ * `403` (or a custom response); when no user token is present it responds with
21
+ * a `401`. On success it calls `next()` so the matched handler runs.
22
+ *
23
+ * Scopes match what the developer configures in the Iris app manifest and must
24
+ * use the `account` prefix (e.g. `account.group.manage`); other prefixes throw.
25
+ *
26
+ * If the scope check itself cannot be completed (the token has no account id,
27
+ * or the GraphQL lookup fails), the error propagates and Hono responds with a `500`
28
+ * unless an `onError` handler is provided. In all of these cases the matched handler
29
+ * does not run, so the guard fails closed.
30
+ *
31
+ * @param scopes - A single scope or list of scopes the user must have.
32
+ * @param options - Optional overrides for the failure responses.
33
+ * @returns {MiddlewareHandler} A Hono middleware handler.
34
+ * @example
35
+ * ```typescript
36
+ * // Guard every route in the extension:
37
+ * app.use(requireScopes("account.view"));
38
+ *
39
+ * // Guard a subset of routes:
40
+ * app.post("/groups/*", requireScopes("account.group.manage"));
41
+ *
42
+ * // Require multiple scopes:
43
+ * app.use(requireScopes(["account.view", "account.group.manage"]));
44
+ * ```
45
+ */
46
+ export declare const requireScopes: (scopes: string | ReadonlyArray<string>, options?: RequireScopesOptions) => MiddlewareHandler;