@koloseum/utils 0.2.28 → 0.3.0

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.
@@ -0,0 +1,77 @@
1
+ import type { Database } from "@koloseum/types/database";
2
+ import type { CustomError, Microservice, MicroserviceGroup, UserWithCustomMetadata } from "@koloseum/types/general";
3
+ import type { SupabaseClient, FunctionInvokeOptions, PostgrestError } from "@supabase/supabase-js";
4
+ export declare const Instance: {
5
+ /**
6
+ * Calls a Supabase Edge function.
7
+ * @param {boolean} browser - Whether the function is being called in the browser
8
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
9
+ * @param {string} path - The path to the Edge function
10
+ * @param {FunctionInvokeOptions} [options] - The options to use for the function invocation; defaults to `{ method: "GET" }`
11
+ * @returns The response from the Edge function
12
+ */
13
+ callEdgeFunction: (browser: boolean, supabase: SupabaseClient<Database>, path: string, options?: FunctionInvokeOptions) => Promise<{
14
+ code: number;
15
+ data?: any;
16
+ error?: string;
17
+ context?: Record<string, any>;
18
+ }>;
19
+ /**
20
+ * Checks if a user is authorised to access a microservice.
21
+ * @param {SupabaseClient<Database>} supabase - The Supabase client.
22
+ * @param {UserWithCustomMetadata} user - The user to check.
23
+ * @param {Object} options - The options for the function.
24
+ * @param {MicroserviceGroup} options.microserviceGroup - The microservice group.
25
+ * @param {Microservice<MicroserviceGroup> | string} options.microservice - The microservice.
26
+ * @param {string} options.playersUrl - The URL to the Players microservice.
27
+ * @param {(role: string) => string} options.requestedPermission - A function that returns the requested permission for a given role.
28
+ * @returns {Promise<{ isAuthorised?: boolean; redirect?: { code: number; url: string }; error?: CustomError }>} The result of the function.
29
+ */
30
+ isUserAuthorised: (supabase: SupabaseClient<Database>, user: UserWithCustomMetadata, options: {
31
+ microserviceGroup: MicroserviceGroup;
32
+ microservice: Microservice<MicroserviceGroup> | string;
33
+ playersUrl?: string;
34
+ requestedPermission?: (role: string) => string;
35
+ }) => Promise<{
36
+ isAuthorised?: boolean;
37
+ redirect?: {
38
+ code: number;
39
+ url: string;
40
+ };
41
+ error?: CustomError;
42
+ }>;
43
+ /**
44
+ * Sends a welcome notification to a new user.
45
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
46
+ * @param {UserWithCustomMetadata} user - The user to send the notification to
47
+ * @param {MicroserviceGroup} microserviceGroup - The microservice group the user belongs to
48
+ * @returns An object with an `error` if any has occurred
49
+ */
50
+ sendWelcomeNotification: (supabase: SupabaseClient<Database>, user: UserWithCustomMetadata, microserviceGroup: MicroserviceGroup) => Promise<{
51
+ error?: CustomError;
52
+ }>;
53
+ };
54
+ export declare const Exception: {
55
+ /**
56
+ * Returns a custom error object.
57
+ * @param {number} code - The error code; defaults to `500` if not provided or invalid
58
+ * @param {string} message - The error message
59
+ * @returns An object with the error `code` and `message`
60
+ */
61
+ customError: (code: number | undefined, message: string) => CustomError;
62
+ /**
63
+ * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
64
+ * @param {CustomError} error - The error object
65
+ * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
66
+ */
67
+ parseLoadError: (error: CustomError) => Error | never;
68
+ /**
69
+ * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
70
+ * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
71
+ * @param {boolean} clientSafe - Whether to clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; defaults to `true`
72
+ * @returns An object with an `error` if any has occurred
73
+ */
74
+ parsePostgrestError: (postgrestError: PostgrestError | null, clientSafe?: boolean) => {
75
+ error?: CustomError;
76
+ };
77
+ };
package/dist/server.js ADDED
@@ -0,0 +1,300 @@
1
+ import { Status } from "./general.js";
2
+ import { error as svelteError } from "@sveltejs/kit";
3
+ import { FunctionsFetchError, FunctionsHttpError, FunctionsRelayError } from "@supabase/supabase-js";
4
+ /* INSTANCE HELPERS */
5
+ export const Instance = {
6
+ /**
7
+ * Calls a Supabase Edge function.
8
+ * @param {boolean} browser - Whether the function is being called in the browser
9
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
10
+ * @param {string} path - The path to the Edge function
11
+ * @param {FunctionInvokeOptions} [options] - The options to use for the function invocation; defaults to `{ method: "GET" }`
12
+ * @returns The response from the Edge function
13
+ */
14
+ callEdgeFunction: async (browser, supabase, path, options = { method: "GET" }) => {
15
+ try {
16
+ // Only add Origin header in browser environment
17
+ if (browser)
18
+ options.headers = { ...options.headers, Origin: window.location.origin };
19
+ // Fetch response with additional options for better cross-origin handling
20
+ const { data, error } = await supabase.functions.invoke(path, options);
21
+ // Handle different error types
22
+ if (error) {
23
+ // Define context
24
+ const context = {};
25
+ // Define error code and message
26
+ const code = Number(error.context?.status) || 500;
27
+ let message = error.context?.statusText || Status.ERROR;
28
+ // Handle HTTP errors
29
+ if (error instanceof FunctionsHttpError) {
30
+ try {
31
+ // Get content type header safely
32
+ const contentType = error.context?.headers?.get("content-type") || "";
33
+ // If content type is JSON, get error data
34
+ if (contentType.includes("application/json")) {
35
+ try {
36
+ const errorData = await error.context.json();
37
+ message = errorData.message || message;
38
+ // Assign attempt ID to context if present
39
+ if (path.includes("verify-id") && errorData.attemptId)
40
+ context.attemptId = errorData.attemptId;
41
+ }
42
+ catch (jsonError) {
43
+ console.error("Failed to parse JSON error response:", jsonError);
44
+ }
45
+ }
46
+ // If content type is plain text, get error message
47
+ else if (contentType.includes("text/plain")) {
48
+ try {
49
+ message = await error.context.text();
50
+ }
51
+ catch (textError) {
52
+ console.error("Failed to parse text error response:", textError);
53
+ }
54
+ }
55
+ }
56
+ catch (parseError) {
57
+ console.error("Failed to parse error response:", parseError);
58
+ }
59
+ }
60
+ // Handle relay and fetch errors
61
+ else if (error instanceof FunctionsRelayError || error instanceof FunctionsFetchError)
62
+ message = error.message;
63
+ // Return error
64
+ return { code: code, error: message, context };
65
+ }
66
+ // Return response
67
+ return { code: 200, data };
68
+ }
69
+ catch (unexpectedError) {
70
+ console.error("Unexpected error:", unexpectedError);
71
+ return { code: 500, error: Status.ERROR };
72
+ }
73
+ },
74
+ /**
75
+ * Checks if a user is authorised to access a microservice.
76
+ * @param {SupabaseClient<Database>} supabase - The Supabase client.
77
+ * @param {UserWithCustomMetadata} user - The user to check.
78
+ * @param {Object} options - The options for the function.
79
+ * @param {MicroserviceGroup} options.microserviceGroup - The microservice group.
80
+ * @param {Microservice<MicroserviceGroup> | string} options.microservice - The microservice.
81
+ * @param {string} options.playersUrl - The URL to the Players microservice.
82
+ * @param {(role: string) => string} options.requestedPermission - A function that returns the requested permission for a given role.
83
+ * @returns {Promise<{ isAuthorised?: boolean; redirect?: { code: number; url: string }; error?: CustomError }>} The result of the function.
84
+ */
85
+ isUserAuthorised: async (supabase, user, options) => {
86
+ // Destructure options
87
+ const { microserviceGroup, microservice, playersUrl } = options;
88
+ // Return true if microservice group is public
89
+ if (microserviceGroup === "public")
90
+ return { isAuthorised: true };
91
+ // Validate user metadata and app metadata
92
+ if (!user.user_metadata || !user.app_metadata)
93
+ return { error: Exception.customError(400, "User metadata is required.") };
94
+ // Get user's roles and the role prefix
95
+ const roles = [];
96
+ const rolePrefix = microserviceGroup === "backroom" ? "backroom" : microserviceGroup.slice(0, -1);
97
+ for (const role of user.app_metadata.roles) {
98
+ if (role === "player") {
99
+ if (microserviceGroup === "players")
100
+ roles.push(role);
101
+ else
102
+ continue;
103
+ }
104
+ if (role.startsWith(`${rolePrefix}_`))
105
+ roles.push(role.replace(`${rolePrefix}_`, ""));
106
+ }
107
+ // Redirect to Players microservices if user does not have any roles for the microservice group
108
+ if (roles.length === 0 && microserviceGroup !== "players") {
109
+ // Return error if Players URL is not provided
110
+ if (!playersUrl)
111
+ return { error: Exception.customError(400, "Players URL is required.") };
112
+ // Redirect to Players microservices
113
+ return { redirect: { code: 307, url: playersUrl } };
114
+ }
115
+ // Destructure role
116
+ const [role] = roles;
117
+ // Define condition variables
118
+ const isPlayer = microserviceGroup === "players" && role === "player";
119
+ const isSuperuser = (microserviceGroup === "backroom" || microserviceGroup === "lounges") && role === "superuser";
120
+ let isAuthorised = false;
121
+ // Grant access if Player (accessing Players microservices), superuser, or account microservice
122
+ if (isPlayer || isSuperuser || microservice === "account")
123
+ isAuthorised = true;
124
+ // Evaluate access
125
+ else if (microserviceGroup !== "players") {
126
+ // Return error if requested permission is not provided
127
+ if (!options.requestedPermission)
128
+ return { error: Exception.customError(400, "Requested permission is required.") };
129
+ // Get user's role and requested app permission
130
+ const requestedPermission = options.requestedPermission(role);
131
+ // Check if user has the requested permission
132
+ const { data, error: isAuthorisedError } = await supabase
133
+ .schema("compliance")
134
+ .rpc("authorise", { requested_permission: requestedPermission });
135
+ if (isAuthorisedError)
136
+ return Exception.parsePostgrestError(isAuthorisedError);
137
+ // Assign result of authorisation check
138
+ isAuthorised = data;
139
+ }
140
+ // If user has completed Player registration
141
+ if (roles.includes("player")) {
142
+ // Prepare person data
143
+ let personData = {};
144
+ if (user.app_metadata.person_data) {
145
+ const { first_name: firstName, last_name: lastName, pseudonym } = user.app_metadata.person_data;
146
+ personData = { firstName, lastName, phone: user.phone, pseudonym };
147
+ }
148
+ // Validate SuprSend subscriber
149
+ const { code, error: validationError } = await Instance.callEdgeFunction(false, supabase, `suprsend/validate-subscriber?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: personData });
150
+ if (validationError)
151
+ return { error: Exception.customError(code, validationError) };
152
+ // Send welcome notification if not already sent
153
+ const { error: welcomeError } = await Instance.sendWelcomeNotification(supabase, user, microserviceGroup);
154
+ if (welcomeError)
155
+ return { error: welcomeError };
156
+ }
157
+ // Return result
158
+ return { isAuthorised };
159
+ },
160
+ /**
161
+ * Sends a welcome notification to a new user.
162
+ * @param {SupabaseClient<Database>} supabase - The Supabase client
163
+ * @param {UserWithCustomMetadata} user - The user to send the notification to
164
+ * @param {MicroserviceGroup} microserviceGroup - The microservice group the user belongs to
165
+ * @returns An object with an `error` if any has occurred
166
+ */
167
+ sendWelcomeNotification: async (supabase, user, microserviceGroup) => {
168
+ // Backroom
169
+ if (microserviceGroup === "backroom" && !user.user_metadata.backroom?.welcome_notification_sent) {
170
+ // Use atomic update with timestamp to prevent race conditions
171
+ const currentTime = new Date().toISOString();
172
+ const { data: updatedUser, error: updateError } = await supabase.auth.updateUser({
173
+ data: {
174
+ backroom: {
175
+ ...user.user_metadata.backroom,
176
+ welcome_notification_sent: true,
177
+ welcome_notification_timestamp: currentTime
178
+ }
179
+ }
180
+ });
181
+ if (updateError)
182
+ return { error: Exception.customError(updateError.status ?? 500, updateError.message) };
183
+ // Only send notification if flag was successfully updated
184
+ const updatedBackroom = updatedUser.user?.user_metadata?.backroom;
185
+ if (updatedBackroom?.welcome_notification_sent === true &&
186
+ updatedBackroom?.welcome_notification_timestamp === currentTime) {
187
+ // Define SuprSend workflow body
188
+ const workflowBody = {
189
+ name: "welcome-to-backroom",
190
+ template: "welcome-to-backroom",
191
+ notification_category: "system",
192
+ users: [
193
+ {
194
+ distinct_id: user.id,
195
+ $skip_create: true
196
+ }
197
+ ]
198
+ };
199
+ // Send welcome notification
200
+ const { code, error: workflowError } = await Instance.callEdgeFunction(false, supabase, `suprsend/trigger-workflow?user-id=${user.id}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: workflowBody });
201
+ // If notification fails, revert the flag and return error
202
+ if (workflowError) {
203
+ await supabase.auth.updateUser({
204
+ data: {
205
+ backroom: {
206
+ ...user.user_metadata.backroom,
207
+ welcome_notification_sent: false,
208
+ welcome_notification_timestamp: undefined
209
+ }
210
+ }
211
+ });
212
+ return { error: Exception.customError(code, workflowError) };
213
+ }
214
+ // Update user object in memory
215
+ user.user_metadata.backroom = updatedBackroom;
216
+ }
217
+ }
218
+ // Return empty object
219
+ return {};
220
+ }
221
+ };
222
+ /* EXCEPTION HELPERS */
223
+ export const Exception = {
224
+ /**
225
+ * Returns a custom error object.
226
+ * @param {number} code - The error code; defaults to `500` if not provided or invalid
227
+ * @param {string} message - The error message
228
+ * @returns An object with the error `code` and `message`
229
+ */
230
+ customError: (code, message) => !code || code < 400 || code > 599 ? { code: 500, message: Status.ERROR } : { code, message },
231
+ /**
232
+ * Parses a `CustomError` object within a page/layout server load function and returns an error to be thrown.
233
+ * @param {CustomError} error - The error object
234
+ * @returns A new `Error` object if the error is unexpected, or a Svelte error otherwise
235
+ */
236
+ parseLoadError: (error) => error.code === 500 ? new Error(error.message) : svelteError(error.code, { message: error.message }),
237
+ /**
238
+ * Parses a `PostgrestError` object and returns a custom error object if any has occurred.
239
+ * @param {PostgrestError | null} postgrestError - The `PostgrestError` object, or `null` if no error occurred
240
+ * @param {boolean} clientSafe - Whether to clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; defaults to `true`
241
+ * @returns An object with an `error` if any has occurred
242
+ */
243
+ parsePostgrestError: (postgrestError, clientSafe = true) => {
244
+ // Return undefined if no error occurred
245
+ let error;
246
+ if (!postgrestError)
247
+ return { error };
248
+ // Get custom error code from hint
249
+ const customErrorCode = Number(postgrestError.hint);
250
+ // Clamp 5xx errors down to 422 to prevent Sentry from capturing them as unhandled; see https://koloseum-technologies.sentry.io/issues/6766267685/
251
+ let statusCode = clientSafe ? (customErrorCode >= 500 ? 422 : customErrorCode) : customErrorCode;
252
+ // Return custom error if hint is a number between 400 and 599
253
+ if (!isNaN(customErrorCode) && customErrorCode >= 400 && customErrorCode <= 599) {
254
+ error = Exception.customError(statusCode, postgrestError.message);
255
+ return { error };
256
+ }
257
+ // Map Postgrest error codes to custom error codes
258
+ const errorMap = [
259
+ { code: "08", status: 503 },
260
+ { code: "09", status: 500 },
261
+ { code: "0L", status: 403 },
262
+ { code: "0P", status: 403 },
263
+ { code: "23503", status: 409 },
264
+ { code: "23505", status: 409 },
265
+ { code: "25006", status: 405 },
266
+ { code: "25", status: 500 },
267
+ { code: "28", status: 403 },
268
+ { code: "2D", status: 500 },
269
+ { code: "38", status: 500 },
270
+ { code: "39", status: 500 },
271
+ { code: "3B", status: 500 },
272
+ { code: "40", status: 500 },
273
+ { code: "53400", status: 500 },
274
+ { code: "53", status: 503 },
275
+ { code: "54", status: 500 },
276
+ { code: "55", status: 500 },
277
+ { code: "57", status: 500 },
278
+ { code: "58", status: 500 },
279
+ { code: "F0", status: 500 },
280
+ { code: "HV", status: 500 },
281
+ { code: "P0001", status: 400 },
282
+ { code: "P0", status: 500 },
283
+ { code: "XX", status: 500 },
284
+ { code: "42883", status: 404 },
285
+ { code: "42P01", status: 404 },
286
+ { code: "42P17", status: 500 },
287
+ { code: "42501", status: 403 }
288
+ ];
289
+ // Return custom error if Postgrest error code is found
290
+ for (const { code, status } of errorMap)
291
+ if (postgrestError.code === code || postgrestError.code.startsWith(code)) {
292
+ statusCode = clientSafe ? (status >= 500 ? 422 : status) : status;
293
+ error = Exception.customError(statusCode, Status.ERROR);
294
+ return { error };
295
+ }
296
+ // Return generic error
297
+ error = Exception.customError(clientSafe ? 422 : 500, Status.ERROR);
298
+ return { error };
299
+ }
300
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koloseum/utils",
3
- "version": "0.2.28",
3
+ "version": "0.3.0",
4
4
  "author": "Koloseum Technologies Limited",
5
5
  "type": "module",
6
6
  "description": "Utility logic for use across Koloseum web apps (TypeScript)",
@@ -15,9 +15,31 @@
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/koloseum-technologies/npm-utils.git"
17
17
  },
18
- "main": "./dist/utils.js",
19
18
  "exports": {
20
- ".": "./dist/utils.js"
19
+ "./client": "./dist/client.js",
20
+ "./formatting": "./dist/formatting.js",
21
+ "./general": "./dist/general.js",
22
+ "./platform": "./dist/platform.js",
23
+ "./server": "./dist/server.js"
24
+ },
25
+ "typesVersions": {
26
+ "*": {
27
+ "client": [
28
+ "./dist/client.d.ts"
29
+ ],
30
+ "formatting": [
31
+ "./dist/formatting.d.ts"
32
+ ],
33
+ "general": [
34
+ "./dist/general.d.ts"
35
+ ],
36
+ "platform": [
37
+ "./dist/platform.d.ts"
38
+ ],
39
+ "server": [
40
+ "./dist/server.d.ts"
41
+ ]
42
+ }
21
43
  },
22
44
  "files": [
23
45
  "./dist/**/*"