diff --git a/.coderabbit.yaml b/.coderabbit.yaml
deleted file mode 100644
index 0ef4746e7..000000000
--- a/.coderabbit.yaml
+++ /dev/null
@@ -1,106 +0,0 @@
-# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
-
-language: en-US
-
-reviews:
- # Enable high-level summary of changes
- high_level_summary: false
-
- # Add a poem... just kidding, disable it
- poem: false
-
- # Collapse walkthrough to keep PR comments clean
- collapse_walkthrough: true
-
- # Auto-review on every push
- auto_review:
- enabled: true
- drafts: true
-
- # Sparse-checkout / review scope: include .github explicitly; exclude generated and vendor paths
- path_filters:
- - ".github/**"
- - "!dist/**"
- - "!lib/**"
- - "!upload/**"
- - "!node_modules/**"
- - "!package-lock.json"
- - "!.pubnub.yml"
- - "!.vscode/**"
-
- # Path-based review instructions
- path_instructions:
- - path: "src/core/**"
- instructions: |
- This is the core SDK module. Pay close attention to:
- - Backward compatibility of public API changes
- - Proper TypeScript typing (strict mode is enabled)
- - No platform-specific code (Node.js, Web, React Native specifics belong in their respective platform directories)
- - Thread safety considerations for shared state
- - path: "src/core/endpoints/**"
- instructions: |
- These are REST API endpoint implementations. Review for:
- - Correct request/response type definitions
- - Proper error handling and status code mapping
- - Consistent parameter validation
- - Adherence to PubNub REST API contracts
- - path: "src/core/types/**"
- instructions: |
- TypeScript type definitions. Ensure:
- - Types are precise and not overly permissive (avoid `any`)
- - Exported types maintain backward compatibility
- - Proper use of generics and utility types
- - path: "src/event-engine/**"
- instructions: |
- State-machine-based subscription management. Review for:
- - Correct state transitions and edge cases
- - No leaked subscriptions or event listeners
- - Proper cleanup on state exit
- - path: "src/entities/**"
- instructions: |
- High-level subscription API (Channel, ChannelGroup, etc.). Review for:
- - Proper event handler lifecycle management
- - Memory leak prevention (listener cleanup)
- - path: "src/transport/**"
- instructions: |
- Platform-specific HTTP transport implementations. Review for:
- - Proper timeout and cancellation handling
- - Correct header management
- - Error propagation consistency across platforms
- - path: "src/node/**"
- instructions: "Node.js platform implementation. Ensure no browser/DOM APIs are used."
- - path: "src/web/**"
- instructions: "Browser platform implementation. Ensure no Node.js-specific APIs (fs, crypto, etc.) are used."
- - path: "src/react_native/**"
- instructions: "React Native platform implementation. Verify compatibility with RN runtime."
- - path: "test/**"
- instructions: |
- Test files. Review for:
- - Adequate coverage of edge cases
- - Proper use of mocks/stubs (Sinon + Nock)
- - No flaky patterns (hardcoded timeouts, race conditions)
- - Tests that actually assert meaningful behavior
- - path: ".github/**"
- instructions: |
- GitHub Actions workflows and repo automation. Review for:
- - Valid workflow YAML (triggers, concurrency, job dependencies)
- - Safe use of secrets and minimal required permissions
- - Reasonable timeouts and matrix coverage; actionlint/YAMLlint findings should be addressed
-
- # Tools configuration
- tools:
- # Enable GitHub checks integration
- github-checks:
- enabled: true
- timeout_ms: 120000
- yamllint:
- enabled: true
- actionlint:
- enabled: true
-
-chat:
- auto_reply: true
-
-knowledge_base:
- mcp:
- usage: enabled
diff --git a/README.md b/README.md
index f84c42e4a..fc48c49e7 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
-# PubNub JavaScript SDK (V4)
+
+
+# PubNub JavaScript SDK
[](https://www.codacy.com/app/PubNub/javascript?utm_source=github.com&utm_medium=referral&utm_content=pubnub/javascript&utm_campaign=Badge_Grade)
[]()
diff --git a/lib/core/components/token_manager.js b/lib/core/components/token_manager.js
index e9a9da18a..da59b4eb7 100644
--- a/lib/core/components/token_manager.js
+++ b/lib/core/components/token_manager.js
@@ -117,6 +117,9 @@ class TokenManager {
const patternDataSync = this.extractDataSyncScopes(parsed.pat);
if (patternDataSync)
((_b = result.patterns) !== null && _b !== void 0 ? _b : (result.patterns = {})).dataSync = patternDataSync;
+ const categories = this.extractCategories(parsed.cat);
+ if (categories)
+ result.categories = categories;
if (parsed.meta && Object.keys(parsed.meta).length > 0)
result.meta = parsed.meta;
return result;
@@ -156,6 +159,26 @@ class TokenManager {
permissionsResult.read = true;
return permissionsResult;
}
+ /**
+ * Extract category-level permissions from the token `cat` section.
+ *
+ * `chan` maps to `channels` and `uuid` maps to `uuids`. `get` is set from the `32` bit. The
+ * section is omitted when `cat` is absent or empty.
+ *
+ * @param cat - Raw `cat` section decoded from the token.
+ *
+ * @returns Human-readable category permissions, or `undefined` when none are present.
+ */
+ extractCategories(cat) {
+ if (!cat)
+ return undefined;
+ const result = {};
+ if (typeof cat.chan === 'number')
+ result.channels = { get: (cat.chan & 32) === 32 };
+ if (typeof cat.uuid === 'number')
+ result.uuids = { get: (cat.uuid & 32) === 32 };
+ return Object.keys(result).length > 0 ? result : undefined;
+ }
/**
* Extract DataSync permission scopes from a token permissions section.
*
diff --git a/lib/core/endpoints/access_manager/grant.js b/lib/core/endpoints/access_manager/grant.js
index e1a3ab37f..3f6ff7da4 100644
--- a/lib/core/endpoints/access_manager/grant.js
+++ b/lib/core/endpoints/access_manager/grant.js
@@ -60,27 +60,28 @@ const JOIN_PERMISSION = false;
*/
class GrantRequest extends request_1.AbstractRequest {
constructor(parameters) {
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
- var _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
+ var _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x;
super();
this.parameters = parameters;
// Apply defaults.
- (_a = (_l = this.parameters).channels) !== null && _a !== void 0 ? _a : (_l.channels = []);
- (_b = (_m = this.parameters).channelGroups) !== null && _b !== void 0 ? _b : (_m.channelGroups = []);
- (_c = (_o = this.parameters).uuids) !== null && _c !== void 0 ? _c : (_o.uuids = []);
- (_d = (_p = this.parameters).read) !== null && _d !== void 0 ? _d : (_p.read = READ_PERMISSION);
- (_e = (_q = this.parameters).write) !== null && _e !== void 0 ? _e : (_q.write = WRITE_PERMISSION);
- (_f = (_r = this.parameters).delete) !== null && _f !== void 0 ? _f : (_r.delete = DELETE_PERMISSION);
- (_g = (_s = this.parameters).get) !== null && _g !== void 0 ? _g : (_s.get = GET_PERMISSION);
- (_h = (_t = this.parameters).update) !== null && _h !== void 0 ? _h : (_t.update = UPDATE_PERMISSION);
- (_j = (_u = this.parameters).manage) !== null && _j !== void 0 ? _j : (_u.manage = MANAGE_PERMISSION);
- (_k = (_v = this.parameters).join) !== null && _k !== void 0 ? _k : (_v.join = JOIN_PERMISSION);
+ (_a = (_m = this.parameters).channels) !== null && _a !== void 0 ? _a : (_m.channels = []);
+ (_b = (_o = this.parameters).channelGroups) !== null && _b !== void 0 ? _b : (_o.channelGroups = []);
+ (_c = (_p = this.parameters).uuids) !== null && _c !== void 0 ? _c : (_p.uuids = []);
+ (_d = (_q = this.parameters).categories) !== null && _d !== void 0 ? _d : (_q.categories = []);
+ (_e = (_r = this.parameters).read) !== null && _e !== void 0 ? _e : (_r.read = READ_PERMISSION);
+ (_f = (_s = this.parameters).write) !== null && _f !== void 0 ? _f : (_s.write = WRITE_PERMISSION);
+ (_g = (_t = this.parameters).delete) !== null && _g !== void 0 ? _g : (_t.delete = DELETE_PERMISSION);
+ (_h = (_u = this.parameters).get) !== null && _h !== void 0 ? _h : (_u.get = GET_PERMISSION);
+ (_j = (_v = this.parameters).update) !== null && _j !== void 0 ? _j : (_v.update = UPDATE_PERMISSION);
+ (_k = (_w = this.parameters).manage) !== null && _k !== void 0 ? _k : (_w.manage = MANAGE_PERMISSION);
+ (_l = (_x = this.parameters).join) !== null && _l !== void 0 ? _l : (_x.join = JOIN_PERMISSION);
}
operation() {
return operations_1.default.PNAccessManagerGrant;
}
validate() {
- const { keySet: { subscribeKey, publishKey, secretKey }, uuids = [], channels = [], channelGroups = [], authKeys = [], } = this.parameters;
+ const { keySet: { subscribeKey, publishKey, secretKey }, uuids = [], channels = [], channelGroups = [], categories = [], authKeys = [], read, write, manage, delete: del, update, join, get, ttl, } = this.parameters;
if (!subscribeKey)
return 'Missing Subscribe Key';
if (!publishKey)
@@ -91,6 +92,16 @@ class GrantRequest extends request_1.AbstractRequest {
return 'authKeys are required for grant request on uuids';
if (uuids.length && (channels.length !== 0 || channelGroups.length !== 0))
return 'Both channel/channel group and uuid cannot be used in the same request';
+ if (categories.length > 0) {
+ if (authKeys.length === 0)
+ return 'authKeys are required for grant request on categories';
+ if (categories.some((category) => category !== 'channels' && category !== 'uuids'))
+ return 'Invalid category: only channels and uuids are supported';
+ if (read || write || manage || del || update || join)
+ return 'Category permissions must be exactly the get permission';
+ if (ttl === 1 && get)
+ return 'One-minute category grants are not supported';
+ }
}
parse(response) {
return __awaiter(this, void 0, void 0, function* () {
@@ -101,8 +112,8 @@ class GrantRequest extends request_1.AbstractRequest {
return `/v2/auth/grant/sub-key/${this.parameters.keySet.subscribeKey}`;
}
get queryParameters() {
- const { channels, channelGroups, authKeys, uuids, read, write, manage, delete: del, get, join, update, ttl, } = this.parameters;
- return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (channels && (channels === null || channels === void 0 ? void 0 : channels.length) > 0 ? { channel: channels.join(',') } : {})), (channelGroups && (channelGroups === null || channelGroups === void 0 ? void 0 : channelGroups.length) > 0 ? { 'channel-group': channelGroups.join(',') } : {})), (authKeys && (authKeys === null || authKeys === void 0 ? void 0 : authKeys.length) > 0 ? { auth: authKeys.join(',') } : {})), (uuids && (uuids === null || uuids === void 0 ? void 0 : uuids.length) > 0 ? { 'target-uuid': uuids.join(',') } : {})), { r: read ? '1' : '0', w: write ? '1' : '0', m: manage ? '1' : '0', d: del ? '1' : '0', g: get ? '1' : '0', j: join ? '1' : '0', u: update ? '1' : '0' }), (ttl || ttl === 0 ? { ttl } : {}));
+ const { channels, channelGroups, categories, authKeys, uuids, read, write, manage, delete: del, get, join, update, ttl, } = this.parameters;
+ return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (channels && (channels === null || channels === void 0 ? void 0 : channels.length) > 0 ? { channel: channels.join(',') } : {})), (channelGroups && (channelGroups === null || channelGroups === void 0 ? void 0 : channelGroups.length) > 0 ? { 'channel-group': channelGroups.join(',') } : {})), (authKeys && (authKeys === null || authKeys === void 0 ? void 0 : authKeys.length) > 0 ? { auth: authKeys.join(',') } : {})), (uuids && (uuids === null || uuids === void 0 ? void 0 : uuids.length) > 0 ? { 'target-uuid': uuids.join(',') } : {})), (categories && categories.length > 0 ? { category: categories.join(',') } : {})), { r: read ? '1' : '0', w: write ? '1' : '0', m: manage ? '1' : '0', d: del ? '1' : '0', g: get ? '1' : '0', j: join ? '1' : '0', u: update ? '1' : '0' }), (ttl || ttl === 0 ? { ttl } : {}));
}
}
exports.GrantRequest = GrantRequest;
diff --git a/lib/core/endpoints/access_manager/grant_token.js b/lib/core/endpoints/access_manager/grant_token.js
index c9c175a23..791299394 100644
--- a/lib/core/endpoints/access_manager/grant_token.js
+++ b/lib/core/endpoints/access_manager/grant_token.js
@@ -42,16 +42,17 @@ class GrantTokenRequest extends request_1.AbstractRequest {
}
validate() {
const { keySet: { subscribeKey, publishKey, secretKey }, resources, patterns, } = this.parameters;
- // DataSync projections are a standalone grant target — a request carrying only projections
- // (no resources / patterns permissions) is still valid.
+ // DataSync projections and category grants are standalone grant targets — a request carrying
+ // only projections or only categories (no resources / patterns permissions) is still valid.
const hasProjections = this.buildProjections() !== undefined;
+ const hasCategories = this.buildCategories() !== undefined;
if (!subscribeKey)
return 'Missing Subscribe Key';
if (!publishKey)
return 'Missing Publish Key';
if (!secretKey)
return 'Missing Secret Key';
- if (!resources && !patterns && !hasProjections)
+ if (!resources && !patterns && !hasProjections && !hasCategories)
return 'Missing either Resources or Patterns';
// A single token can grant DataSync `users`, `channels`, `groups`, and `dataSync` together.
// together. `uuids` / `spaces` / `authorized_uuid` are the deprecated App Context terminology;
@@ -77,7 +78,7 @@ class GrantTokenRequest extends request_1.AbstractRequest {
}
});
});
- if (permissionsEmpty && !hasProjections)
+ if (permissionsEmpty && !hasProjections && !hasCategories)
return 'Missing values for either Resources or Patterns';
}
parse(response) {
@@ -153,6 +154,11 @@ class GrantTokenRequest extends request_1.AbstractRequest {
permissions.uuid = `${uuid}`;
permissions.resources = resourcePermissions;
permissions.patterns = patternPermissions;
+ // Category grants are a single integer per resource type. Omit the key entirely when none are
+ // set so tokens that don't use categories stay byte-for-byte identical.
+ const categories = this.buildCategories();
+ if (categories)
+ permissions.categories = categories;
// Merge DataSync projections into `meta` under `pn-projections`, preserving user-supplied meta.
// `pn-projections` is omitted entirely when no projections are set.
const projections = this.buildProjections();
@@ -219,6 +225,27 @@ class GrantTokenRequest extends request_1.AbstractRequest {
result.pat = pat;
return Object.keys(result).length > 0 ? result : undefined;
}
+ /**
+ * Build the `permissions.categories` payload.
+ *
+ * Category grants are a single integer per resource type, not a per-id bitmask. Only `get: true`
+ * is encoded, as `32`. Any other runtime flag is ignored so a non-TypeScript caller cannot send a
+ * value other than `32`. The payload is omitted entirely when no category is granted.
+ *
+ * @returns Encoded categories payload, or `undefined` when no category `get` is set.
+ */
+ buildCategories() {
+ var _a, _b;
+ const categories = 'categories' in this.parameters ? this.parameters.categories : undefined;
+ if (!categories)
+ return undefined;
+ const result = {};
+ if (((_a = categories.channels) === null || _a === void 0 ? void 0 : _a.get) === true)
+ result.channels = 32;
+ if (((_b = categories.uuids) === null || _b === void 0 ? void 0 : _b.get) === true)
+ result.uuids = 32;
+ return Object.keys(result).length > 0 ? result : undefined;
+ }
/**
* Extract permissions bit from permission configuration object.
*
diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts
index aa60c77c5..c8613331e 100644
--- a/lib/types/index.d.ts
+++ b/lib/types/index.d.ts
@@ -10022,6 +10022,38 @@ declare namespace PubNub {
authorizedUserId?: never;
};
+ /**
+ * Category-level token permissions.
+ *
+ * Grants enumeration of every resource of that type. A resource-level or pattern `get` does not
+ * grant this permission. The only supported operation is `get`, which the wire encodes as `32`.
+ */
+ export type CategoryTokenPermissions = {
+ /**
+ * Whether listing every resource of this type is permitted.
+ *
+ * A resource-level or pattern `get` does not grant this permission.
+ */
+ get?: boolean;
+ };
+
+ /**
+ * Category-level grants.
+ *
+ * Permissions apply to a resource type as a whole rather than to a named resource or a RegEx
+ * pattern. Only `channels` and `uuids` are supported, and only the `get` operation.
+ */
+ export type GrantCategories = {
+ /**
+ * Enumeration permission for channel metadata (`GET /v2/objects/{sub_key}/channels`).
+ */
+ channels?: CategoryTokenPermissions;
+ /**
+ * Enumeration permission for uuid metadata (`GET /v2/objects/{sub_key}/uuids`).
+ */
+ uuids?: CategoryTokenPermissions;
+ };
+
/**
* Generate token with permissions.
*
@@ -10047,6 +10079,13 @@ declare namespace PubNub {
* Keys within each scope are RegEx patterns. `users` and `uuids` are mutually exclusive.
*/
patterns?: GrantScopes;
+ /**
+ * Category-level permissions.
+ *
+ * Grants enumeration of every channel or uuid on the subscribe key. A resource-level or pattern
+ * `get` does not imply this permission. A grant that carries only `categories` is valid.
+ */
+ categories?: GrantCategories;
/**
* Extra metadata to be published with the request.
*
@@ -10127,6 +10166,13 @@ declare namespace PubNub {
*/
dataSync?: DataSyncScopePermissions;
};
+ /**
+ * Category-level permissions.
+ *
+ * Decoded from the token `cat` section. Present only when the grant requested category-level
+ * enumeration. `chan` maps to `channels` and `uuid` maps to `uuids`.
+ */
+ categories?: TokenCategories;
/**
* The uuid that is exclusively authorized to use this token to make API requests.
*/
@@ -10141,6 +10187,36 @@ declare namespace PubNub {
meta?: Payload;
};
+ /**
+ * Decoded category-level permissions.
+ *
+ * `get` is `true` when the token category bitmask includes `32`.
+ */
+ export type CategoryPermissions = {
+ /**
+ * Whether listing every resource of this type is permitted.
+ *
+ * A resource-level or pattern `get` does not grant this permission.
+ */
+ get: boolean;
+ };
+
+ /**
+ * Decoded category-level permission scopes.
+ *
+ * Decoded from the token `cat` wire keys `chan` and `uuid`.
+ */
+ export type TokenCategories = {
+ /**
+ * Enumeration permission for channel metadata.
+ */
+ channels?: CategoryPermissions;
+ /**
+ * Enumeration permission for uuid metadata.
+ */
+ uuids?: CategoryPermissions;
+ };
+
/**
* Granted resource permissions.
*
@@ -10363,7 +10439,14 @@ declare namespace PubNub {
* Common permissions audit response content.
*/
type BaseAuditResponse<
- Level extends 'channel' | 'channel+auth' | 'channel-group' | 'channel-group+auth' | 'user' | 'subkey',
+ Level extends
+ | 'channel'
+ | 'channel+auth'
+ | 'channel-group'
+ | 'channel-group+auth'
+ | 'user'
+ | 'subkey'
+ | 'category',
> = {
/**
* Permissions level.
@@ -10377,6 +10460,13 @@ declare namespace PubNub {
* Duration for which permissions has been granted.
*/
ttl?: number;
+ /**
+ * Category-level permissions.
+ *
+ * Present when the grant requested enumeration of every channel or uuid. A resource-level `get`
+ * does not populate this map. A combined grant may keep its existing `level` and still include it.
+ */
+ categories?: CategoryPermissionsMap;
};
/**
@@ -10389,6 +10479,13 @@ declare namespace PubNub {
auths: Record;
};
+ /**
+ * Category-level permissions keyed by `channels` or `uuids`.
+ *
+ * Each entry carries auth-key bits. Only `g` is meaningful for a category grant.
+ */
+ type CategoryPermissionsMap = Record<'channels' | 'uuids', AuthKeysPermissions>;
+
/**
* Single channel permissions audit result.
*/
@@ -10457,6 +10554,19 @@ declare namespace PubNub {
objects: Record>;
};
+ /**
+ * Category-level permissions grant result.
+ *
+ * Returned when the grant requested enumeration of every channel or uuid and no other resource
+ * level is reported.
+ */
+ type CategoryPermissionsResponse = BaseAuditResponse<'category'> & {
+ /**
+ * Per-category auth-key permissions.
+ */
+ categories: CategoryPermissionsMap;
+ };
+
/**
* Response with permission information.
*/
@@ -10466,7 +10576,8 @@ declare namespace PubNub {
| ChannelGroupPermissionsResponse
| ChannelGroupsPermissionsResponse
| UserPermissionsResponse
- | SubKeyPermissionsResponse;
+ | SubKeyPermissionsResponse
+ | CategoryPermissionsResponse;
/**
* Audit permissions for provided auth keys / global permissions.
@@ -10510,6 +10621,14 @@ declare namespace PubNub {
* List of App Context UUID for which permissions should be granted.
*/
uuids?: string[];
+ /**
+ * Categories for which enumeration permission should be granted.
+ *
+ * `channels` lists all channel metadata. `uuids` lists all uuid metadata. Requires {@link authKeys}.
+ * Only `get` may be `true` in the same request; `ttl: 1` with `get: true` is rejected. A
+ * resource-level `get` does not grant this permission. Sent as the `category` query parameter.
+ */
+ categories?: Array<'channels' | 'uuids'>;
/**
* List of auth keys for which permissions should be granted on specified objects.
*
diff --git a/package-lock.json b/package-lock.json
index 36f9b4519..ff0145ad5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pubnub",
- "version": "13.0.1",
+ "version": "13.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pubnub",
- "version": "13.0.1",
+ "version": "13.0.3",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"agentkeepalive": "^3.5.2",
@@ -3749,6 +3749,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@pkgr/core": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
@@ -7867,9 +7879,9 @@
"license": "Apache-2.0"
},
"node_modules/fast-uri": {
- "version": "3.0.6",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
- "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"dev": true,
"funding": [
{
diff --git a/src/core/components/token_manager.ts b/src/core/components/token_manager.ts
index ed52bd6f1..a4ed4ac94 100644
--- a/src/core/components/token_manager.ts
+++ b/src/core/components/token_manager.ts
@@ -72,6 +72,14 @@ type RawToken = {
* Additional information which has been added to the token.
*/
meta?: Payload;
+
+ /**
+ * Category-level permissions.
+ *
+ * Present only when the grant requested enumeration of every channel (`chan`) or uuid (`uuid`).
+ * Values are the `GET` bitmask (`32`).
+ */
+ cat?: Partial>;
};
// endregion
@@ -203,6 +211,9 @@ export class TokenManager {
const patternDataSync = this.extractDataSyncScopes(parsed.pat);
if (patternDataSync) (result.patterns ??= {}).dataSync = patternDataSync;
+ const categories = this.extractCategories(parsed.cat);
+ if (categories) result.categories = categories;
+
if (parsed.meta && Object.keys(parsed.meta).length > 0) result.meta = parsed.meta;
return result;
@@ -240,6 +251,26 @@ export class TokenManager {
return permissionsResult;
}
+ /**
+ * Extract category-level permissions from the token `cat` section.
+ *
+ * `chan` maps to `channels` and `uuid` maps to `uuids`. `get` is set from the `32` bit. The
+ * section is omitted when `cat` is absent or empty.
+ *
+ * @param cat - Raw `cat` section decoded from the token.
+ *
+ * @returns Human-readable category permissions, or `undefined` when none are present.
+ */
+ private extractCategories(cat?: Partial>): PAM.TokenCategories | undefined {
+ if (!cat) return undefined;
+
+ const result: PAM.TokenCategories = {};
+ if (typeof cat.chan === 'number') result.channels = { get: (cat.chan & 32) === 32 };
+ if (typeof cat.uuid === 'number') result.uuids = { get: (cat.uuid & 32) === 32 };
+
+ return Object.keys(result).length > 0 ? result : undefined;
+ }
+
/**
* Extract DataSync permission scopes from a token permissions section.
*
diff --git a/src/core/endpoints/access_manager/grant.ts b/src/core/endpoints/access_manager/grant.ts
index 731c68091..2003513ec 100644
--- a/src/core/endpoints/access_manager/grant.ts
+++ b/src/core/endpoints/access_manager/grant.ts
@@ -105,6 +105,7 @@ export class GrantRequest extends AbstractRequest 0) {
+ if (authKeys.length === 0) return 'authKeys are required for grant request on categories';
+ if (categories.some((category) => category !== 'channels' && category !== 'uuids'))
+ return 'Invalid category: only channels and uuids are supported';
+ if (read || write || manage || del || update || join)
+ return 'Category permissions must be exactly the get permission';
+ if (ttl === 1 && get) return 'One-minute category grants are not supported';
+ }
}
async parse(response: TransportResponse): Promise {
@@ -149,6 +168,7 @@ export class GrantRequest extends AbstractRequest 0 ? { 'channel-group': channelGroups.join(',') } : {}),
...(authKeys && authKeys?.length > 0 ? { auth: authKeys.join(',') } : {}),
...(uuids && uuids?.length > 0 ? { 'target-uuid': uuids.join(',') } : {}),
+ ...(categories && categories.length > 0 ? { category: categories.join(',') } : {}),
r: read ? '1' : '0',
w: write ? '1' : '0',
m: manage ? '1' : '0',
diff --git a/src/core/endpoints/access_manager/grant_token.ts b/src/core/endpoints/access_manager/grant_token.ts
index 3900da324..998a10742 100644
--- a/src/core/endpoints/access_manager/grant_token.ts
+++ b/src/core/endpoints/access_manager/grant_token.ts
@@ -80,6 +80,12 @@ type PermissionPayload = {
*/
type ProjectionsPayload = Record<'res' | 'pat', Record>;
+/**
+ *
+ * A single `GET` per resource type. Only `channels` and `uuids` are valid.
+ */
+type CategoriesPayload = Partial>;
+
/**
* Wire-level `meta` section.
*
@@ -143,14 +149,15 @@ export class GrantTokenRequest extends AbstractRequest {
@@ -203,7 +210,8 @@ export class GrantTokenRequest extends AbstractRequest> = {};
+ const permissions: Record | CategoriesPayload> =
+ {};
const resourcePermissions: PermissionPayload = {};
const patternPermissions: PermissionPayload = {};
const mapPermissions = (
@@ -277,6 +285,11 @@ export class GrantTokenRequest extends AbstractRequest 0 ? result : undefined;
}
+ /**
+ * Build the `permissions.categories` payload.
+ *
+ * Category grants are a single integer per resource type, not a per-id bitmask. Only `get: true`
+ * is encoded, Any other runtime flag is ignored so a non-TypeScript caller cannot send a
+ * value other than `32`. The payload is omitted entirely when no category is granted.
+ *
+ * @returns Encoded categories payload, or `undefined` when no category `get` is set.
+ */
+ private buildCategories(): CategoriesPayload | undefined {
+ const categories = 'categories' in this.parameters ? this.parameters.categories : undefined;
+ if (!categories) return undefined;
+
+ const result: CategoriesPayload = {};
+ if (categories.channels?.get === true) result.channels = 32;
+ if (categories.uuids?.get === true) result.uuids = 32;
+
+ return Object.keys(result).length > 0 ? result : undefined;
+ }
+
/**
* Extract permissions bit from permission configuration object.
*
diff --git a/src/core/types/api/access-manager.ts b/src/core/types/api/access-manager.ts
index f2a190320..0cbeeecfa 100644
--- a/src/core/types/api/access-manager.ts
+++ b/src/core/types/api/access-manager.ts
@@ -392,6 +392,39 @@ type AuthorizedPrincipal =
authorizedUserId?: never;
};
+/**
+ * Category-level token permissions.
+ *
+ * Grants enumeration of every resource of that type. A resource-level or pattern `get` does not
+ * grant this permission. The only supported operation is `get`, which the wire encodes as `32`.
+ */
+export type CategoryTokenPermissions = {
+ /**
+ * Whether listing every resource of this type is permitted.
+ *
+ * A resource-level or pattern `get` does not grant this permission.
+ */
+ get?: boolean;
+};
+
+/**
+ * Category-level grants.
+ *
+ * Permissions apply to a resource type as a whole rather than to a named resource or a RegEx
+ * pattern. Only `channels` and `uuids` are supported, and only the `get` operation.
+ */
+export type GrantCategories = {
+ /**
+ * Enumeration permission for channel metadata (`GET /v2/objects/{sub_key}/channels`).
+ */
+ channels?: CategoryTokenPermissions;
+
+ /**
+ * Enumeration permission for uuid metadata (`GET /v2/objects/{sub_key}/uuids`).
+ */
+ uuids?: CategoryTokenPermissions;
+};
+
/**
* Generate token with permissions.
*
@@ -420,6 +453,14 @@ type BaseGrantTokenParameters = {
*/
patterns?: GrantScopes;
+ /**
+ * Category-level permissions.
+ *
+ * Grants enumeration of every channel or uuid on the subscribe key. A resource-level or pattern
+ * `get` does not imply this permission. A grant that carries only `categories` is valid.
+ */
+ categories?: GrantCategories;
+
/**
* Extra metadata to be published with the request.
*
@@ -516,6 +557,14 @@ export type Token = {
dataSync?: DataSyncScopePermissions;
};
+ /**
+ * Category-level permissions.
+ *
+ * Decoded from the token `cat` section. Present only when the grant requested category-level
+ * enumeration. `chan` maps to `channels` and `uuid` maps to `uuids`.
+ */
+ categories?: TokenCategories;
+
/**
* The uuid that is exclusively authorized to use this token to make API requests.
*/
@@ -532,6 +581,37 @@ export type Token = {
meta?: Payload;
};
+/**
+ * Decoded category-level permissions.
+ *
+ * `get` is `true` when the token category bitmask includes `32`.
+ */
+export type CategoryPermissions = {
+ /**
+ * Whether listing every resource of this type is permitted.
+ *
+ * A resource-level or pattern `get` does not grant this permission.
+ */
+ get: boolean;
+};
+
+/**
+ * Decoded category-level permission scopes.
+ *
+ * Decoded from the token `cat` wire keys `chan` and `uuid`.
+ */
+export type TokenCategories = {
+ /**
+ * Enumeration permission for channel metadata.
+ */
+ channels?: CategoryPermissions;
+
+ /**
+ * Enumeration permission for uuid metadata.
+ */
+ uuids?: CategoryPermissions;
+};
+
/**
* Granted resource permissions.
*
@@ -784,7 +864,7 @@ type UserPermissions = {
* Common permissions audit response content.
*/
type BaseAuditResponse<
- Level extends 'channel' | 'channel+auth' | 'channel-group' | 'channel-group+auth' | 'user' | 'subkey',
+ Level extends 'channel' | 'channel+auth' | 'channel-group' | 'channel-group+auth' | 'user' | 'subkey' | 'category',
> = {
/**
* Permissions level.
@@ -800,6 +880,14 @@ type BaseAuditResponse<
* Duration for which permissions has been granted.
*/
ttl?: number;
+
+ /**
+ * Category-level permissions.
+ *
+ * Present when the grant requested enumeration of every channel or uuid. A resource-level `get`
+ * does not populate this map. A combined grant may keep its existing `level` and still include it.
+ */
+ categories?: CategoryPermissionsMap;
};
/**
@@ -812,6 +900,13 @@ type AuthKeysPermissions = {
auths: Record;
};
+/**
+ * Category-level permissions keyed by `channels` or `uuids`.
+ *
+ * Each entry carries auth-key bits. Only `g` is meaningful for a category grant.
+ */
+type CategoryPermissionsMap = Record<'channels' | 'uuids', AuthKeysPermissions>;
+
/**
* Single channel permissions audit result.
*/
@@ -882,6 +977,19 @@ type SubKeyPermissionsResponse = BaseAuditResponse<'subkey'> & {
objects: Record>;
};
+/**
+ * Category-level permissions grant result.
+ *
+ * Returned when the grant requested enumeration of every channel or uuid and no other resource
+ * level is reported.
+ */
+type CategoryPermissionsResponse = BaseAuditResponse<'category'> & {
+ /**
+ * Per-category auth-key permissions.
+ */
+ categories: CategoryPermissionsMap;
+};
+
/**
* Response with permission information.
*/
@@ -891,7 +999,8 @@ export type PermissionsResponse =
| ChannelGroupPermissionsResponse
| ChannelGroupsPermissionsResponse
| UserPermissionsResponse
- | SubKeyPermissionsResponse;
+ | SubKeyPermissionsResponse
+ | CategoryPermissionsResponse;
// region Audit
/**
@@ -943,6 +1052,15 @@ export type GrantParameters = {
*/
uuids?: string[];
+ /**
+ * Categories for which enumeration permission should be granted.
+ *
+ * `channels` lists all channel metadata. `uuids` lists all uuid metadata. Requires {@link authKeys}.
+ * Only `get` may be `true` in the same request; `ttl: 1` with `get: true` is rejected. A
+ * resource-level `get` does not grant this permission. Sent as the `category` query parameter.
+ */
+ categories?: Array<'channels' | 'uuids'>;
+
/**
* List of auth keys for which permissions should be granted on specified objects.
*
diff --git a/test/integration/components/token_manager.test.ts b/test/integration/components/token_manager.test.ts
index be4d0fe7b..abcb91008 100644
--- a/test/integration/components/token_manager.test.ts
+++ b/test/integration/components/token_manager.test.ts
@@ -2,9 +2,27 @@
/* eslint no-console: 0 */
import assert from 'assert';
+import CborSync from 'cbor-sync';
import PubNub from '../../../src/node/index';
+/**
+ * Encode a minimal access token, including the `res` / `pat` keys `parseToken` always reads.
+ */
+const encodeToken = (overrides: Record = {}) => {
+ const encoded = CborSync.encode({
+ v: 2,
+ t: 1628109699,
+ ttl: 60,
+ res: { chan: {}, grp: {} },
+ pat: { chan: {}, grp: {} },
+ sig: Buffer.alloc(32, 1),
+ ...overrides,
+ });
+
+ return Buffer.from(encoded).toString('base64');
+};
+
describe('#components/token_manager', () => {
let pubnub: PubNub;
@@ -158,6 +176,40 @@ describe('#components/token_manager', () => {
delete: true,
});
});
+
+ it('contains category enumeration permissions', () => {
+ const permissions = pubnub.parseToken(encodeToken({ cat: { chan: 32, uuid: 32 } }))!;
+
+ assert.deepEqual(permissions.categories, {
+ channels: { get: true },
+ uuids: { get: true },
+ });
+ });
+
+ it('omits uuid categories when only channel enumeration is granted', () => {
+ const permissions = pubnub.parseToken(encodeToken({ cat: { chan: 32 } }))!;
+
+ assert.deepEqual(permissions.categories?.channels, { get: true });
+ assert.strictEqual(permissions.categories?.uuids, undefined);
+ });
+
+ it('omits categories when the token has no cat section', () => {
+ const permissions = pubnub.parseToken(encodeToken())!;
+
+ assert.strictEqual(permissions.categories, undefined);
+ });
+
+ it('does not treat a resource-level get as a category grant', () => {
+ const permissions = pubnub.parseToken(
+ encodeToken({
+ res: { chan: { 'channel-1': 32 }, grp: {} },
+ pat: { chan: {}, grp: {} },
+ }),
+ )!;
+
+ assert.strictEqual(permissions.resources?.channels?.['channel-1']?.get, true);
+ assert.strictEqual(permissions.categories, undefined);
+ });
});
describe('supports token update', () => {
diff --git a/test/integration/endpoints/access.test.ts b/test/integration/endpoints/access.test.ts
index df879d7f4..9cba71482 100644
--- a/test/integration/endpoints/access.test.ts
+++ b/test/integration/endpoints/access.test.ts
@@ -518,6 +518,124 @@ describe('access endpoints', () => {
});
});
});
+
+ it('issues the correct RESTful request for categories', (done) => {
+ const scope = utils
+ .createNock()
+ .get('/v2/auth/grant/sub-key/mySubscribeKey')
+ .query(
+ (query) =>
+ query.category === 'channels,uuids' &&
+ query.auth === 'auth_key' &&
+ query.g === '1' &&
+ query.r === '0' &&
+ query.channel === undefined &&
+ query['channel-group'] === undefined &&
+ query['target-uuid'] === undefined,
+ )
+ .reply(
+ 200,
+ JSON.stringify({
+ message: 'Success',
+ payload: {
+ level: 'category',
+ subscribe_key: 'mySubscribeKey',
+ ttl: 1440,
+ categories: {
+ channels: { auths: { auth_key: { r: 0, w: 0, m: 0, d: 0, g: 1, u: 0, j: 0 } } },
+ uuids: { auths: { auth_key: { r: 0, w: 0, m: 0, d: 0, g: 1, u: 0, j: 0 } } },
+ },
+ },
+ service: 'Access Manager',
+ status: 200,
+ }),
+ { 'content-type': 'text/javascript' },
+ );
+
+ pubnub.grant({ categories: ['channels', 'uuids'], authKeys: ['auth_key'], get: true }, (status, response) => {
+ try {
+ assert.equal(status.error, false);
+ assert.equal(response!.level, 'category');
+ if (response?.level === 'category') assert.equal(response.categories.channels.auths.auth_key.g, 1);
+ assert.equal(scope.isDone(), true);
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+ });
+
+ it('issues a combined channel and category grant', (done) => {
+ const scope = utils
+ .createNock()
+ .get('/v2/auth/grant/sub-key/mySubscribeKey')
+ .query((query) => query.channel === 'ch1' && query.category === 'uuids' && query.g === '1' && query.r === '0')
+ .reply(
+ 200,
+ JSON.stringify({
+ message: 'Success',
+ payload: { level: 'channel', subscribe_key: 'mySubscribeKey', channels: {} },
+ service: 'Access Manager',
+ status: 200,
+ }),
+ { 'content-type': 'text/javascript' },
+ );
+
+ pubnub.grant({ channels: ['ch1'], categories: ['uuids'], authKeys: ['auth_key'], get: true }, (status) => {
+ try {
+ assert.equal(status.error, false);
+ assert.equal(scope.isDone(), true);
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+ });
+
+ it('rejects a category grant without authKeys', (done) => {
+ const scope = utils.createNock().get('/v2/auth/grant/sub-key/mySubscribeKey').query(true).reply(200, {});
+
+ pubnub.grant({ categories: ['channels'], get: true }, (status) => {
+ try {
+ assert.equal(status.error, true);
+ assert.equal(status.message, 'authKeys are required for grant request on categories');
+ assert.equal(scope.isDone(), false);
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+ });
+
+ it('rejects a category grant with a permission other than get', (done) => {
+ const scope = utils.createNock().get('/v2/auth/grant/sub-key/mySubscribeKey').query(true).reply(200, {});
+
+ pubnub.grant({ categories: ['channels'], authKeys: ['auth_key'], read: true }, (status) => {
+ try {
+ assert.equal(status.error, true);
+ assert.equal(status.message, 'Category permissions must be exactly the get permission');
+ assert.equal(scope.isDone(), false);
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+ });
+
+ it('rejects a one-minute category grant', (done) => {
+ const scope = utils.createNock().get('/v2/auth/grant/sub-key/mySubscribeKey').query(true).reply(200, {});
+
+ pubnub.grant({ categories: ['channels'], authKeys: ['auth_key'], get: true, ttl: 1 }, (status) => {
+ try {
+ assert.equal(status.error, true);
+ assert.equal(status.message, 'One-minute category grants are not supported');
+ assert.equal(scope.isDone(), false);
+ done();
+ } catch (error) {
+ done(error);
+ }
+ });
+ });
});
});
diff --git a/test/integration/endpoints/grant_token.test.ts b/test/integration/endpoints/grant_token.test.ts
index e29c7e5ed..1280827ce 100644
--- a/test/integration/endpoints/grant_token.test.ts
+++ b/test/integration/endpoints/grant_token.test.ts
@@ -89,6 +89,7 @@ describe('grant token endpoint', () => {
resources: Record>;
patterns: Record>;
meta: Record;
+ categories?: { channels?: number; uuids?: number };
};
};
@@ -158,6 +159,10 @@ describe('grant token endpoint', () => {
);
});
+ it('fail on empty categories', async () => {
+ await assertRejects({ ttl: 1440, categories: {} }, 'Missing values for either Resources or Patterns');
+ });
+
it('should reject mixing `users` with `uuids`', async () => {
await assertRejects(
{
@@ -617,5 +622,37 @@ describe('grant token endpoint', () => {
});
});
});
+
+ describe('##categories', () => {
+ it('should grant only category enumeration', async () => {
+ const { scope, body } = mockGrant();
+
+ const token = await pubnub.grantToken({
+ ttl: 1440,
+ categories: {
+ channels: { get: true },
+ uuids: { get: true },
+ },
+ });
+
+ assert.strictEqual(token, 'token');
+ assert.strictEqual(scope.isDone(), true);
+ assert.deepEqual(body().permissions.categories, { channels: GET, uuids: GET });
+ });
+
+ it('should encode categories alongside channel resources', async () => {
+ const { scope, body } = mockGrant();
+
+ await pubnub.grantToken({
+ ttl: 1440,
+ resources: { channels: { 'channel-1': { read: true } } },
+ categories: { uuids: { get: true } },
+ });
+
+ assert.strictEqual(scope.isDone(), true);
+ assert.deepEqual(body().permissions.resources.channels, { 'channel-1': READ });
+ assert.deepEqual(body().permissions.categories, { uuids: GET });
+ });
+ });
});
});
diff --git a/test/unit/access_manager/access_manager_grant.test.ts b/test/unit/access_manager/access_manager_grant.test.ts
new file mode 100644
index 000000000..51d45d620
--- /dev/null
+++ b/test/unit/access_manager/access_manager_grant.test.ts
@@ -0,0 +1,190 @@
+/* global describe, it, beforeEach */
+
+import assert from 'assert';
+import { GrantRequest } from '../../../src/core/endpoints/access_manager/grant';
+import { TransportResponse } from '../../../src/core/types/transport-response';
+import { KeySet } from '../../../src/core/types/api';
+import * as PAM from '../../../src/core/types/api/access-manager';
+
+describe('GrantRequest', () => {
+ let defaultKeySet: KeySet;
+
+ beforeEach(() => {
+ defaultKeySet = {
+ publishKey: 'test_publish_key',
+ subscribeKey: 'test_subscribe_key',
+ secretKey: 'test_secret_key',
+ };
+ });
+
+ describe('categories', () => {
+ it('should encode category enumeration as a comma-separated query value', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key', 'auth_key_2'],
+ categories: ['channels', 'uuids'],
+ get: true,
+ });
+
+ assert.equal(request.validate(), undefined);
+
+ const { queryParameters } = request.request();
+ assert.equal(queryParameters!.category, 'channels,uuids');
+ assert.equal(queryParameters!.auth, 'auth_key,auth_key_2');
+ assert.equal(queryParameters!.g, '1');
+ assert.equal(queryParameters!.r, '0');
+ assert.equal(queryParameters!.w, '0');
+ assert.equal(queryParameters!.m, '0');
+ assert.equal(queryParameters!.d, '0');
+ assert.equal(queryParameters!.u, '0');
+ assert.equal(queryParameters!.j, '0');
+ });
+
+ it('should omit category when none are granted', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ channels: ['ch1'],
+ authKeys: ['auth_key'],
+ });
+
+ const { queryParameters } = request.request();
+ assert.equal(queryParameters!.category, undefined);
+ assert.equal(queryParameters!.channel, 'ch1');
+ });
+
+ it('should encode a category revoke as g=0', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['channels'],
+ get: false,
+ });
+
+ assert.equal(request.validate(), undefined);
+ assert.equal(request.request().queryParameters!.category, 'channels');
+ assert.equal(request.request().queryParameters!.g, '0');
+ });
+
+ it('should require authKeys for categories', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ categories: ['channels'],
+ get: true,
+ });
+
+ assert.equal(request.validate(), 'authKeys are required for grant request on categories');
+ });
+
+ it('should reject a permission other than get', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['uuids'],
+ get: true,
+ read: true,
+ });
+
+ assert.equal(request.validate(), 'Category permissions must be exactly the get permission');
+ });
+
+ it('should reject a one-minute category grant', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['channels'],
+ get: true,
+ ttl: 1,
+ });
+
+ assert.equal(request.validate(), 'One-minute category grants are not supported');
+ });
+
+ it('should allow a one-minute category revoke', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['channels'],
+ get: false,
+ ttl: 1,
+ });
+
+ assert.equal(request.validate(), undefined);
+ });
+
+ it('should reject an unsupported category name', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['users' as 'channels'],
+ get: true,
+ });
+
+ assert.equal(request.validate(), 'Invalid category: only channels and uuids are supported');
+ });
+
+ it('should encode categories alongside channels when only get is set', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ channels: ['ch1'],
+ categories: ['uuids'],
+ get: true,
+ });
+
+ assert.equal(request.validate(), undefined);
+
+ const { queryParameters } = request.request();
+ assert.equal(queryParameters!.channel, 'ch1');
+ assert.equal(queryParameters!.category, 'uuids');
+ assert.equal(queryParameters!.g, '1');
+ });
+
+ it('should reject categories alongside channels when another permission is set', () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ channels: ['ch1'],
+ categories: ['channels'],
+ read: true,
+ });
+
+ assert.equal(request.validate(), 'Category permissions must be exactly the get permission');
+ });
+
+ it('should return category permissions from the service payload', async () => {
+ const request = new GrantRequest({
+ keySet: defaultKeySet,
+ authKeys: ['auth_key'],
+ categories: ['channels'],
+ get: true,
+ });
+ const payload: PAM.PermissionsResponse = {
+ level: 'category',
+ subscribe_key: 'test_subscribe_key',
+ ttl: 1440,
+ categories: {
+ channels: { auths: { auth_key: { r: 0, w: 0, m: 0, d: 0, g: 1, u: 0, j: 0 } } },
+ uuids: { auths: { auth_key: { r: 0, w: 0, m: 0, d: 0, g: 1, u: 0, j: 0 } } },
+ },
+ };
+ const response: TransportResponse = {
+ status: 200,
+ url: 'https://test.pubnub.com',
+ headers: { 'content-type': 'application/json' },
+ body: new TextEncoder().encode(
+ JSON.stringify({
+ status: 200,
+ message: 'Success',
+ payload,
+ service: 'Access Manager',
+ }),
+ ),
+ };
+
+ const parsed = await request.parse(response);
+ assert.equal(parsed.level, 'category');
+ if (parsed.level !== 'category') return;
+ assert.deepEqual(parsed.categories.channels.auths.auth_key, payload.categories.channels.auths.auth_key);
+ });
+ });
+});
diff --git a/test/unit/access_manager/access_manager_grant_token.test.ts b/test/unit/access_manager/access_manager_grant_token.test.ts
index a288b5bd9..60eb82c89 100644
--- a/test/unit/access_manager/access_manager_grant_token.test.ts
+++ b/test/unit/access_manager/access_manager_grant_token.test.ts
@@ -129,6 +129,37 @@ describe('GrantTokenRequest', () => {
});
assert.equal(validBothRequest.validate(), undefined);
});
+
+ it('should pass validation with only categories', () => {
+ const request = new GrantTokenRequest({
+ keySet: defaultKeySet,
+ ttl: 60,
+ categories: {
+ channels: { get: true },
+ },
+ });
+ assert.equal(request.validate(), undefined);
+ });
+
+ it('should reject empty categories', () => {
+ const request = new GrantTokenRequest({
+ keySet: defaultKeySet,
+ ttl: 60,
+ categories: {},
+ });
+ assert.equal(request.validate(), 'Missing values for either Resources or Patterns');
+ });
+
+ it('should reject categories when get is false', () => {
+ const request = new GrantTokenRequest({
+ keySet: defaultKeySet,
+ ttl: 60,
+ categories: {
+ channels: { get: false },
+ },
+ });
+ assert.equal(request.validate(), 'Missing values for either Resources or Patterns');
+ });
});
describe('terminology synonym validation', () => {
@@ -852,6 +883,43 @@ describe('GrantTokenRequest', () => {
const body = parseBodyAsString(transportRequest.body!);
assert.equal(body.permissions.resources.channels['channel-engineering-001'], 16);
});
+
+ it('should encode category get permissions as 32', () => {
+ const request = new GrantTokenRequest({
+ keySet: defaultKeySet,
+ ttl: 60,
+ categories: {
+ channels: { get: true },
+ uuids: { get: true },
+ },
+ });
+
+ const transportRequest = request.request();
+ const body = parseBodyAsString(transportRequest.body!);
+ assert.deepEqual(body.permissions.categories, { channels: 32, uuids: 32 });
+ });
+
+ it('should omit categories when none are granted', () => {
+ const request = new GrantTokenRequest(defaultParameters);
+
+ const transportRequest = request.request();
+ const body = parseBodyAsString(transportRequest.body!);
+ assert.equal(body.permissions.categories, undefined);
+ });
+
+ it('should encode categories alongside channel resources', () => {
+ const request = new GrantTokenRequest({
+ ...defaultParameters,
+ categories: {
+ uuids: { get: true },
+ },
+ });
+
+ const transportRequest = request.request();
+ const body = parseBodyAsString(transportRequest.body!);
+ assert.equal(body.permissions.resources.channels.test_channel, 3);
+ assert.deepEqual(body.permissions.categories, { uuids: 32 });
+ });
});
describe('response parsing', () => {