@nurama/sdk 1.4.0 → 1.4.1

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.
@@ -5,6 +5,16 @@ import {
5
5
  type CreateJoinLinkPayload,
6
6
  type UpdateJoinLinkPayload,
7
7
  type RedeemJoinLinkResult,
8
+ type ListJoinRequestsResponse,
9
+ type ListMyJoinRequestsResponse,
10
+ type ApproveJoinRequestResponse,
11
+ type RejectJoinRequestRequest,
12
+ type RejectJoinRequestResponse,
13
+ type UpdateJoinRequestCooldownRequest,
14
+ type ProjectJoinRequest,
15
+ type ListBansResponse,
16
+ type CreateResourceBanRequest,
17
+ type ResourceBan,
8
18
  } from '@nurama/types';
9
19
 
10
20
  // --- Query Parameter Interfaces ---
@@ -49,6 +59,129 @@ export default function createJoinLinkMethods(client: NuramaClient) {
49
59
  });
50
60
  },
51
61
 
62
+ /** The queue of people waiting on a decision for this project. */
63
+ async getJoinRequests(params: { projectId: string }): Promise<ListJoinRequestsResponse> {
64
+ if (!params?.projectId) throw new Error('projectId is required.');
65
+ return client._request<ListJoinRequestsResponse>({
66
+ method: 'GET',
67
+ endpoint: '/v1/join-links/requests',
68
+ params,
69
+ sendJWT: true,
70
+ });
71
+ },
72
+
73
+ /**
74
+ * The caller's own requests still waiting on a decision.
75
+ *
76
+ * Needs no `projectId`: it is scoped server-side to the caller, who is not
77
+ * a member of the projects they are waiting on and so cannot read those
78
+ * projects' queues.
79
+ */
80
+ async getMyJoinRequests(): Promise<ListMyJoinRequestsResponse> {
81
+ return client._request<ListMyJoinRequestsResponse>({
82
+ method: 'GET',
83
+ endpoint: '/v1/join-links/requests/mine',
84
+ sendJWT: true,
85
+ });
86
+ },
87
+
88
+ /**
89
+ * Approve a request and admit the person.
90
+ *
91
+ * Seats and the link's remaining uses are re-checked here, not when the
92
+ * request was made — a queue outlives the capacity that existed when it was
93
+ * filled, so approval can legitimately fail on a seat limit.
94
+ */
95
+ async approveJoinRequest(requestId: string, projectId: string): Promise<ApproveJoinRequestResponse> {
96
+ if (!requestId) throw new Error('requestId is required.');
97
+ if (!projectId) throw new Error('projectId is required.');
98
+ return client._request<ApproveJoinRequestResponse>({
99
+ method: 'POST',
100
+ endpoint: `/v1/join-links/requests/${requestId}/approve`,
101
+ body: { projectId },
102
+ sendJWT: true,
103
+ });
104
+ },
105
+
106
+ /**
107
+ * Reject a request, optionally barring the person.
108
+ *
109
+ * Passing `ban` is destructive beyond the refusal: a ban REMOVES an existing
110
+ * membership, so it can take away access the person already had in scope.
111
+ * It is a separate field for that reason, never implied by the rejection.
112
+ */
113
+ async rejectJoinRequest(requestId: string, data: RejectJoinRequestRequest): Promise<RejectJoinRequestResponse> {
114
+ if (!requestId) throw new Error('requestId is required.');
115
+ if (!data?.projectId) throw new Error('projectId is required.');
116
+ return client._request<RejectJoinRequestResponse>({
117
+ method: 'POST',
118
+ endpoint: `/v1/join-links/requests/${requestId}/reject`,
119
+ body: data,
120
+ sendJWT: true,
121
+ });
122
+ },
123
+
124
+ /**
125
+ * Change or lift the cooldown on a rejected request.
126
+ *
127
+ * Only a rejected request has one; `reapplyAfter: null` lifts it, letting
128
+ * the person ask again straight away. A barred person is refused — the ban
129
+ * already blocks every path, so lifting that is the action that matters.
130
+ */
131
+ async updateJoinRequestCooldown(
132
+ requestId: string,
133
+ data: UpdateJoinRequestCooldownRequest,
134
+ ): Promise<ProjectJoinRequest> {
135
+ if (!requestId) throw new Error('requestId is required.');
136
+ if (!data?.projectId) throw new Error('projectId is required.');
137
+ return client._request<ProjectJoinRequest>({
138
+ method: 'PATCH',
139
+ endpoint: `/v1/join-links/requests/${requestId}/cooldown`,
140
+ body: { projectId: data.projectId, reapplyAfter: data.reapplyAfter ?? null },
141
+ sendJWT: true,
142
+ });
143
+ },
144
+
145
+ /**
146
+ * Bar someone, without a request to refuse.
147
+ *
148
+ * Destructive: the ban REMOVES every membership the person holds in its
149
+ * scope, so a `workspace` ban clears them out of every project in it. An
150
+ * owner cannot be barred, and neither can you bar yourself.
151
+ */
152
+ async createResourceBan(data: CreateResourceBanRequest): Promise<ResourceBan> {
153
+ if (!data?.projectId) throw new Error('projectId is required.');
154
+ if (!data?.userId) throw new Error('userId is required.');
155
+ if (!data?.scope) throw new Error('scope is required.');
156
+ return client._request<ResourceBan>({
157
+ method: 'POST',
158
+ endpoint: '/v1/join-links/bans',
159
+ body: data,
160
+ sendJWT: true,
161
+ });
162
+ },
163
+
164
+ /** People barred from this project, newest first. */
165
+ async getResourceBans(params: { projectId: string }): Promise<ListBansResponse> {
166
+ if (!params?.projectId) throw new Error('projectId is required.');
167
+ return client._request<ListBansResponse>({
168
+ method: 'GET',
169
+ endpoint: '/v1/join-links/bans',
170
+ params,
171
+ sendJWT: true,
172
+ });
173
+ },
174
+
175
+ /** Lift a ban. It does not restore any membership the ban removed. */
176
+ async liftResourceBan(banId: string): Promise<ResourceBan> {
177
+ if (!banId) throw new Error('banId is required.');
178
+ return client._request<ResourceBan>({
179
+ method: 'DELETE',
180
+ endpoint: `/v1/join-links/bans/${banId}`,
181
+ sendJWT: true,
182
+ });
183
+ },
184
+
52
185
  /** Update a join link's settings (role, mode, domains, expiry, enable/disable). */
53
186
  async updateJoinLink(linkId: string, data: UpdateJoinLinkPayload): Promise<JoinLinkResponse> {
54
187
  if (!linkId) throw new Error('linkId is required.');
@@ -0,0 +1,64 @@
1
+ import NuramaClient from '../NuramaClient.js';
2
+
3
+ /**
4
+ * One application a user has authorised over OAuth.
5
+ *
6
+ * A grant is the record of consent: the scopes approved, and the tie binding
7
+ * every token issued under it. Revoking one withdraws the application's
8
+ * access.
9
+ */
10
+ export interface OAuthGrantSummary {
11
+ /** Opaque identifier; pass to `revokeGrant`. */
12
+ grantId: string;
13
+ clientId: string | null;
14
+ /**
15
+ * The application's name.
16
+ *
17
+ * Chosen by the application itself when `verified` is false — treat it as
18
+ * display text from an untrusted source, never as identification.
19
+ */
20
+ clientName: string | null;
21
+ /**
22
+ * Whether Nurama configured this application.
23
+ *
24
+ * False for one that registered itself through Dynamic Client Registration:
25
+ * it picked its own name and nobody reviewed it.
26
+ */
27
+ verified: boolean;
28
+ /** Scopes the user approved, from the backend's token-scope registry. */
29
+ scopes: string[];
30
+ grantedAt: string;
31
+ expiresAt: string | null;
32
+ }
33
+
34
+ export default function createOAuthGrantMethods(client: NuramaClient) {
35
+ return {
36
+ /**
37
+ * List the applications the caller has authorised, newest first.
38
+ * Expired grants are omitted by the server.
39
+ */
40
+ async listGrants(): Promise<OAuthGrantSummary[]> {
41
+ const res = await client._request<{ results: OAuthGrantSummary[] }>({
42
+ method: 'GET',
43
+ endpoint: '/v1/oauth-grants',
44
+ sendJWT: true,
45
+ bypassCache: true,
46
+ });
47
+ return res.results;
48
+ },
49
+
50
+ /**
51
+ * Withdraw an application's access: the grant and every token issued
52
+ * under it. An access token already in flight stays valid until it
53
+ * expires, so revocation is not instantaneous for a request under way.
54
+ */
55
+ async revokeGrant(grantId: string): Promise<void> {
56
+ if (!grantId) throw new Error('grantId is required.');
57
+ return client._request<void>({
58
+ method: 'DELETE',
59
+ endpoint: `/v1/oauth-grants/${grantId}`,
60
+ sendJWT: true,
61
+ });
62
+ },
63
+ };
64
+ }
@@ -110,6 +110,33 @@ const LIFECYCLE_LISTENER_EVENTS = new Set([
110
110
  'reconnect',
111
111
  ]);
112
112
 
113
+ /** First reconnect delay; socket.io doubles it per failed attempt, up to RECONNECTION_DELAY_MAX, ±50% jitter. */
114
+ const RECONNECTION_DELAY = 1000;
115
+ const RECONNECTION_DELAY_MAX = 30000;
116
+
117
+ /**
118
+ * Extra first-retry delay, up to this, after the SERVER closed the connection ("transport close"): a ws deploy
119
+ * or restart drops every client of that server at once, and without it they would all reconnect within the
120
+ * same second. Network drops ("ping timeout", "transport error") keep the normal 1s retry.
121
+ */
122
+ const SERVER_CLOSE_RECONNECT_SPREAD = 10000;
123
+
124
+ /**
125
+ * Spread reconnects after a server-side close (see SERVER_CLOSE_RECONNECT_SPREAD). Sets the manager's next
126
+ * delay from the socket's 'disconnect', which socket.io emits before it schedules the retry, and restores the
127
+ * normal delay on 'connect'. Channels share one manager, so the last channel's draw wins, which is fine.
128
+ */
129
+ const spreadReconnectAfterServerClose = (socket: any): void => {
130
+ const manager = socket.io;
131
+ if (typeof manager?.reconnectionDelay !== 'function') return;
132
+ socket.on('disconnect', (reason: string) => {
133
+ if (reason === 'transport close') {
134
+ manager.reconnectionDelay(RECONNECTION_DELAY + Math.random() * SERVER_CLOSE_RECONNECT_SPREAD);
135
+ }
136
+ });
137
+ socket.on('connect', () => manager.reconnectionDelay(RECONNECTION_DELAY));
138
+ };
139
+
113
140
  export default function createSocketMethods(client: NuramaClient) {
114
141
  // Track active socket connections
115
142
  const activeConnections: Record<string, SocketChannel> = {};
@@ -215,9 +242,12 @@ export default function createSocketMethods(client: NuramaClient) {
215
242
 
216
243
  // Reconnection configuration for resilience
217
244
  reconnection: socketOptions.autoReconnect,
218
- reconnectionAttempts: 10, // Maximum reconnection attempts before giving up
219
- reconnectionDelay: 1000, // Start with 1 second delay
220
- reconnectionDelayMax: 30000, // Max delay of 30 seconds between attempts
245
+ // Never give up: the server may refuse connections for a while (busy, or a deploy draining), and after a
246
+ // final failure socket.io stops for good without telling the app (the manager's 'reconnect_failed' never
247
+ // reaches socket listeners). Retries settle at one every ~30s.
248
+ reconnectionAttempts: Infinity,
249
+ reconnectionDelay: RECONNECTION_DELAY,
250
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX,
221
251
  randomizationFactor: 0.5, // Add jitter to avoid thundering herd problem
222
252
 
223
253
  // Connection timeout
@@ -244,14 +274,27 @@ export default function createSocketMethods(client: NuramaClient) {
244
274
  // handlers across reconnections, so they will fire once connected.
245
275
  activeConnections[channel] = socketChannel;
246
276
 
277
+ spreadReconnectAfterServerClose(socket);
278
+
247
279
  // Setup event listeners
248
280
  return new Promise((resolve, reject) => {
249
281
  let settled = false;
282
+ let hasConnected = false;
250
283
 
251
284
  // Handle connection (initial or after reconnection)
252
285
  socket.on('connect', () => {
253
286
  _log(`Connected to channel: ${channel}`);
254
287
  activeConnections[channel] = socketChannel;
288
+ if (hasConnected) {
289
+ // A second 'connect' on the same socket is socket.io reconnecting it (the manager emits 'reconnect',
290
+ // but socket listeners never see that). Notify app-level onReconnect listeners so they can
291
+ // REST-refetch any gap of server->client messages missed while the transport was down (socket.io has
292
+ // no replay across a reconnect).
293
+ _log(`Reconnected to channel ${channel}`);
294
+ const reconnectListeners = socketChannel.listeners['reconnect'] || new Set();
295
+ reconnectListeners.forEach(listener => listener({ type: 'reconnect' }));
296
+ }
297
+ hasConnected = true;
255
298
  if (!settled) {
256
299
  settled = true;
257
300
  resolve(socketChannel);
@@ -270,23 +313,6 @@ export default function createSocketMethods(client: NuramaClient) {
270
313
  }
271
314
  });
272
315
 
273
- // Handle reconnection attempts
274
- socket.on('reconnect_attempt', (attempt: number) => {
275
- _log(`Reconnection attempt ${attempt} for channel: ${channel}`);
276
- });
277
-
278
- // Handle successful reconnection
279
- socket.on('reconnect', (attempt: number) => {
280
- _log(`Reconnected to channel ${channel} after ${attempt} attempts`);
281
- // Ensure activeConnections is up to date after reconnect
282
- activeConnections[channel] = socketChannel;
283
- // Notify app-level onReconnect listeners so they can REST-refetch any
284
- // gap of server->client messages missed while the transport was down
285
- // (socket.io has no replay across a reconnect).
286
- const reconnectListeners = socketChannel.listeners['reconnect'] || new Set();
287
- reconnectListeners.forEach(listener => listener({ type: 'reconnect', attempt }));
288
- });
289
-
290
316
  // Handle reconnection failure — only reject when socket.io gives up entirely
291
317
  socket.on('reconnect_failed', () => {
292
318
  _log(`Reconnection failed for channel ${channel} after maximum attempts`);
@@ -423,9 +449,9 @@ export default function createSocketMethods(client: NuramaClient) {
423
449
 
424
450
  // Reconnection configuration for resilience
425
451
  reconnection: socketOptions.autoReconnect,
426
- reconnectionAttempts: 10,
427
- reconnectionDelay: 1000,
428
- reconnectionDelayMax: 30000,
452
+ reconnectionAttempts: Infinity, // as connect(): never give up silently
453
+ reconnectionDelay: RECONNECTION_DELAY,
454
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX,
429
455
  randomizationFactor: 0.5,
430
456
 
431
457
  // Connection timeout
@@ -446,6 +472,8 @@ export default function createSocketMethods(client: NuramaClient) {
446
472
  listeners: {},
447
473
  };
448
474
 
475
+ spreadReconnectAfterServerClose(socket);
476
+
449
477
  // Setup event listeners
450
478
  return new Promise((resolve, reject) => {
451
479
  // Handle connection
@@ -461,16 +489,6 @@ export default function createSocketMethods(client: NuramaClient) {
461
489
  reject(error);
462
490
  });
463
491
 
464
- // Handle reconnection attempts
465
- socket.on('reconnect_attempt', (attempt: number) => {
466
- _log(`Reconnection attempt ${attempt} for public channel: ${channel}`);
467
- });
468
-
469
- // Handle successful reconnection
470
- socket.on('reconnect', (attempt: number) => {
471
- _log(`Reconnected to public channel ${channel} after ${attempt} attempts`);
472
- });
473
-
474
492
  // Handle reconnection failure
475
493
  socket.on('reconnect_failed', () => {
476
494
  _log(`Reconnection failed for public channel ${channel} after maximum attempts`);
@@ -22,7 +22,14 @@ export type TokenScope =
22
22
  | 'assets:read'
23
23
  | 'assets:write'
24
24
  | 'projects:read'
25
- | 'workspaces:read';
25
+ | 'projects:write'
26
+ | 'workspaces:read'
27
+ | 'workspaces:write'
28
+ | 'convos:read'
29
+ | 'convos:write'
30
+ | 'notifications:read'
31
+ | 'notifications:write'
32
+ | 'ai:use';
26
33
 
27
34
  /**
28
35
  * The shape returned by `listTokens` and (without the `token` field)
@@ -50,6 +57,16 @@ export interface CreateTokenData {
50
57
  expiresAt?: string | null;
51
58
  }
52
59
 
60
+ export interface UpdateTokenScopesData {
61
+ /**
62
+ * The COMPLETE new scope set, not a delta — the same shape
63
+ * `createToken` takes, so what a token may do is always stated in
64
+ * full rather than accumulated through edits nobody can review.
65
+ * At least one scope.
66
+ */
67
+ scopes: TokenScope[];
68
+ }
69
+
53
70
  export interface CreateTokenResponse extends TokenSummary {
54
71
  /**
55
72
  * The raw secret. Returned ONLY in this response. The server keeps a
@@ -97,6 +114,28 @@ export default function createTokenMethods(client: NuramaClient) {
97
114
  });
98
115
  },
99
116
 
117
+ /**
118
+ * Replace the scopes on one of the caller's Personal Access Tokens.
119
+ *
120
+ * `scopes` is the complete new set, not a delta. This exists because
121
+ * the scope registry grows over time: a token minted before a scope
122
+ * existed cannot hold it, and without this the only remedy is minting
123
+ * a replacement and reconfiguring everywhere the old one is used.
124
+ *
125
+ * The change takes effect on the token's very next request. Scopes
126
+ * remain a ceiling, never a floor — widening a token never lets it do
127
+ * anything its owner cannot already do.
128
+ */
129
+ async updateTokenScopes(tokenId: string, data: UpdateTokenScopesData): Promise<TokenSummary> {
130
+ if (!tokenId) throw new Error('tokenId is required.');
131
+ return client._request<TokenSummary>({
132
+ method: 'PATCH',
133
+ endpoint: `/v1/tokens/${tokenId}`,
134
+ body: data,
135
+ sendJWT: true,
136
+ });
137
+ },
138
+
100
139
  /**
101
140
  * Revoke one of the caller's Personal Access Tokens. The revocation
102
141
  * is immediate — the token will return 401 on the very next request.
package/src/version.ts CHANGED
@@ -14,9 +14,9 @@ export interface SDKVersionInfo {
14
14
  }
15
15
 
16
16
  export const SDK_VERSION: SDKVersionInfo = {
17
- version: '1.4.0',
18
- buildTimestamp: '2026-10-03T16:23:07.694Z',
19
- buildHash: '60ddbec0797c9d1bdf96aaf49b69dda5',
20
- gitCommit: 'c000da4',
17
+ version: '1.4.1',
18
+ buildTimestamp: '2026-10-10T06:58:40.258Z',
19
+ buildHash: 'f6505e2eb9725dc8d239bb401ced8f88',
20
+ gitCommit: '1c00e18',
21
21
  dirty: false,
22
22
  };