@progressive-development/pd-provider-firebase-functions 1.3.0 → 1.3.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.
@@ -1,116 +1,127 @@
1
- import { getAuth } from 'firebase-admin/auth';
2
- import { moveToTrash, getTrashItem, permanentDelete, restoreFromTrash } from './trash.js';
3
- import { NotFoundError } from './errors.js';
4
- import { logger } from './logger.js';
5
-
1
+ import { NotFoundError } from "./errors.js";
2
+ import { logger } from "./logger.js";
3
+ import { getTrashItem, moveToTrash, permanentDelete, restoreFromTrash } from "./trash.js";
4
+ import { getAuth } from "firebase-admin/auth";
5
+ //#region src/auth-trash.ts
6
+ /**
7
+ * Auth-aware Trash Functions
8
+ *
9
+ * Extends the generic trash service with Firebase Auth user handling.
10
+ * Use these when the trashed document is linked to an Auth user.
11
+ */
12
+ /**
13
+ * Extract Auth UID from trash item based on options.
14
+ */
6
15
  function getAuthUidFromTrashItem(trashItem, options) {
7
- const source = options?.authUidSource ?? "documentId";
8
- if (source === "documentId") {
9
- return trashItem.originalId;
10
- }
11
- const data = trashItem.data;
12
- const uid = data[source];
13
- if (typeof uid !== "string" || !uid) {
14
- throw new Error(
15
- `Auth UID field '${source}' not found or empty in trash item data`
16
- );
17
- }
18
- return uid;
16
+ const source = options?.authUidSource ?? "documentId";
17
+ if (source === "documentId") return trashItem.originalId;
18
+ const uid = trashItem.data[source];
19
+ if (typeof uid !== "string" || !uid) throw new Error(`Auth UID field '${source}' not found or empty in trash item data`);
20
+ return uid;
19
21
  }
22
+ /**
23
+ * Move document to trash AND disable the associated Auth user.
24
+ *
25
+ * Order of operations:
26
+ * 1. Disable Auth user (if user doesn't exist, continue anyway)
27
+ * 2. Move document to trash
28
+ *
29
+ * @param collection - The collection path
30
+ * @param documentId - The document ID to trash
31
+ * @param deletedBy - UID of the user performing the deletion
32
+ * @param objectType - Type identifier for filtering
33
+ * @param options - Auth and database options
34
+ * @returns The created trash item
35
+ */
20
36
  async function moveToTrashWithAuth(collection, documentId, deletedBy, objectType, options) {
21
- const authUid = options?.authUidSource === "documentId" || !options?.authUidSource ? documentId : documentId;
22
- logger.info("Disabling Auth user before trash", {
23
- authUid,
24
- collection,
25
- documentId
26
- });
27
- try {
28
- await getAuth().updateUser(authUid, { disabled: true });
29
- logger.info("Auth user disabled", { authUid });
30
- } catch (error) {
31
- const authError = error;
32
- if (authError.code === "auth/user-not-found") {
33
- logger.info("Auth user not found, continuing with trash operation", {
34
- authUid
35
- });
36
- } else {
37
- throw error;
38
- }
39
- }
40
- const trashItem = await moveToTrash(
41
- collection,
42
- documentId,
43
- deletedBy,
44
- objectType,
45
- options?.databaseId
46
- );
47
- logger.info("Document moved to trash with Auth disabled", {
48
- trashItemId: trashItem.id,
49
- authUid
50
- });
51
- return trashItem;
37
+ const authUid = options?.authUidSource === "documentId" || !options?.authUidSource ? documentId : documentId;
38
+ logger.info("Disabling Auth user before trash", {
39
+ authUid,
40
+ collection,
41
+ documentId
42
+ });
43
+ try {
44
+ await getAuth().updateUser(authUid, { disabled: true });
45
+ logger.info("Auth user disabled", { authUid });
46
+ } catch (error) {
47
+ if (error.code === "auth/user-not-found") logger.info("Auth user not found, continuing with trash operation", { authUid });
48
+ else throw error;
49
+ }
50
+ const trashItem = await moveToTrash(collection, documentId, deletedBy, objectType, options?.databaseId);
51
+ logger.info("Document moved to trash with Auth disabled", {
52
+ trashItemId: trashItem.id,
53
+ authUid
54
+ });
55
+ return trashItem;
52
56
  }
57
+ /**
58
+ * Restore document from trash AND re-enable the Auth user.
59
+ *
60
+ * Order of operations:
61
+ * 1. Get trash item to find Auth UID
62
+ * 2. Restore document from trash
63
+ * 3. Re-enable Auth user (if user doesn't exist, log warning)
64
+ *
65
+ * @param trashItemId - The trash document ID
66
+ * @param options - Auth and database options
67
+ */
53
68
  async function restoreFromTrashWithAuth(trashItemId, options) {
54
- const trashItem = await getTrashItem(trashItemId, options?.databaseId);
55
- if (!trashItem) {
56
- throw new NotFoundError(`Trash item ${trashItemId} not found`);
57
- }
58
- const authUid = getAuthUidFromTrashItem(trashItem, options);
59
- logger.info("Restoring from trash with Auth re-enable", {
60
- trashItemId,
61
- authUid,
62
- originalCollection: trashItem.originalCollection
63
- });
64
- await restoreFromTrash(trashItemId, options?.databaseId);
65
- try {
66
- await getAuth().updateUser(authUid, { disabled: false });
67
- logger.info("Document restored and Auth user re-enabled", {
68
- trashItemId,
69
- authUid
70
- });
71
- } catch (error) {
72
- const authError = error;
73
- if (authError.code === "auth/user-not-found") {
74
- logger.warn(
75
- "Document restored but Auth user not found - manual Auth user creation required",
76
- {
77
- trashItemId,
78
- authUid
79
- }
80
- );
81
- } else {
82
- throw error;
83
- }
84
- }
69
+ const trashItem = await getTrashItem(trashItemId, options?.databaseId);
70
+ if (!trashItem) throw new NotFoundError(`Trash item ${trashItemId} not found`);
71
+ const authUid = getAuthUidFromTrashItem(trashItem, options);
72
+ logger.info("Restoring from trash with Auth re-enable", {
73
+ trashItemId,
74
+ authUid,
75
+ originalCollection: trashItem.originalCollection
76
+ });
77
+ await restoreFromTrash(trashItemId, options?.databaseId);
78
+ try {
79
+ await getAuth().updateUser(authUid, { disabled: false });
80
+ logger.info("Document restored and Auth user re-enabled", {
81
+ trashItemId,
82
+ authUid
83
+ });
84
+ } catch (error) {
85
+ if (error.code === "auth/user-not-found") logger.warn("Document restored but Auth user not found - manual Auth user creation required", {
86
+ trashItemId,
87
+ authUid
88
+ });
89
+ else throw error;
90
+ }
85
91
  }
92
+ /**
93
+ * Permanently delete from trash AND delete the Auth user.
94
+ *
95
+ * Order of operations:
96
+ * 1. Get trash item to find Auth UID
97
+ * 2. Delete from trash
98
+ * 3. Delete Auth user (ignore if user doesn't exist)
99
+ *
100
+ * @param trashItemId - The trash document ID
101
+ * @param options - Auth and database options
102
+ */
86
103
  async function permanentDeleteWithAuth(trashItemId, options) {
87
- const trashItem = await getTrashItem(trashItemId, options?.databaseId);
88
- if (!trashItem) {
89
- throw new NotFoundError(`Trash item ${trashItemId} not found`);
90
- }
91
- const authUid = getAuthUidFromTrashItem(trashItem, options);
92
- logger.info("Permanently deleting with Auth user deletion", {
93
- trashItemId,
94
- authUid
95
- });
96
- await permanentDelete(trashItemId, options?.databaseId);
97
- try {
98
- await getAuth().deleteUser(authUid);
99
- logger.info("Trash item and Auth user permanently deleted", {
100
- trashItemId,
101
- authUid
102
- });
103
- } catch (error) {
104
- const authError = error;
105
- if (authError.code === "auth/user-not-found") {
106
- logger.info("Auth user not found, trash item deleted", {
107
- trashItemId,
108
- authUid
109
- });
110
- } else {
111
- throw error;
112
- }
113
- }
104
+ const trashItem = await getTrashItem(trashItemId, options?.databaseId);
105
+ if (!trashItem) throw new NotFoundError(`Trash item ${trashItemId} not found`);
106
+ const authUid = getAuthUidFromTrashItem(trashItem, options);
107
+ logger.info("Permanently deleting with Auth user deletion", {
108
+ trashItemId,
109
+ authUid
110
+ });
111
+ await permanentDelete(trashItemId, options?.databaseId);
112
+ try {
113
+ await getAuth().deleteUser(authUid);
114
+ logger.info("Trash item and Auth user permanently deleted", {
115
+ trashItemId,
116
+ authUid
117
+ });
118
+ } catch (error) {
119
+ if (error.code === "auth/user-not-found") logger.info("Auth user not found, trash item deleted", {
120
+ trashItemId,
121
+ authUid
122
+ });
123
+ else throw error;
124
+ }
114
125
  }
115
-
126
+ //#endregion
116
127
  export { moveToTrashWithAuth, permanentDeleteWithAuth, restoreFromTrashWithAuth };
package/dist/auth.js CHANGED
@@ -1,30 +1,37 @@
1
- import { UnauthorizedError, ForbiddenError } from './errors.js';
2
- import { logger } from './logger.js';
3
-
1
+ import { ForbiddenError, UnauthorizedError } from "./errors.js";
2
+ import { logger } from "./logger.js";
3
+ //#region src/auth.ts
4
+ /**
5
+ * Require authenticated user
6
+ */
4
7
  function requireAuth(request) {
5
- if (!request.auth) {
6
- throw new UnauthorizedError();
7
- }
8
- return {
9
- uid: request.auth.uid,
10
- email: request.auth.token.email ?? null,
11
- claims: request.auth.token
12
- };
8
+ if (!request.auth) throw new UnauthorizedError();
9
+ return {
10
+ uid: request.auth.uid,
11
+ email: request.auth.token.email ?? null,
12
+ claims: request.auth.token
13
+ };
13
14
  }
15
+ /**
16
+ * Require specific role claims.
17
+ * Supports Firebase standard format: { admin: true, developer: true }
18
+ */
14
19
  function requireClaims(request, allowedRoles) {
15
- const auth = requireAuth(request);
16
- const hasRole = allowedRoles.some((role) => auth.claims[role] === true);
17
- if (!hasRole) {
18
- throw new ForbiddenError();
19
- }
20
- return auth;
20
+ const auth = requireAuth(request);
21
+ if (!allowedRoles.some((role) => auth.claims[role] === true)) throw new ForbiddenError();
22
+ return auth;
21
23
  }
24
+ /**
25
+ * Ensure user can only access own resources
26
+ */
22
27
  function requireOwnership(auth, resourceOwnerId) {
23
- const isAdmin = auth.claims.admin === true;
24
- if (!isAdmin && auth.uid !== resourceOwnerId) {
25
- logger.warn("Ownership violation", { uid: auth.uid, resourceOwnerId });
26
- throw new ForbiddenError("Cannot access other user's resources");
27
- }
28
+ if (!(auth.claims.admin === true) && auth.uid !== resourceOwnerId) {
29
+ logger.warn("Ownership violation", {
30
+ uid: auth.uid,
31
+ resourceOwnerId
32
+ });
33
+ throw new ForbiddenError("Cannot access other user's resources");
34
+ }
28
35
  }
29
-
36
+ //#endregion
30
37
  export { requireAuth, requireClaims, requireOwnership };
package/dist/changes.js CHANGED
@@ -1,69 +1,62 @@
1
+ //#region src/changes.ts
1
2
  function hasIsEqual(value) {
2
- return typeof value === "object" && value !== null && typeof value.isEqual === "function";
3
+ return typeof value === "object" && value !== null && typeof value.isEqual === "function";
3
4
  }
5
+ /** undefined, null and missing keys all count as "not set" and equal. */
4
6
  function isUnset(value) {
5
- return value === void 0 || value === null;
7
+ return value === void 0 || value === null;
6
8
  }
7
9
  function isEqualValue(a, b) {
8
- if (isUnset(a) && isUnset(b)) {
9
- return true;
10
- }
11
- if (isUnset(a) || isUnset(b)) {
12
- return false;
13
- }
14
- if (a === b) {
15
- return true;
16
- }
17
- if (typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b)) {
18
- return true;
19
- }
20
- if (hasIsEqual(a) && hasIsEqual(b)) {
21
- try {
22
- return a.isEqual(b);
23
- } catch {
24
- return false;
25
- }
26
- }
27
- if (hasIsEqual(a) || hasIsEqual(b)) {
28
- return false;
29
- }
30
- if (a instanceof Date && b instanceof Date) {
31
- return a.getTime() === b.getTime();
32
- }
33
- if (Array.isArray(a) && Array.isArray(b)) {
34
- return a.length === b.length && a.every((item, i) => isEqualValue(item, b[i]));
35
- }
36
- if (Array.isArray(a) || Array.isArray(b)) {
37
- return false;
38
- }
39
- if (typeof a === "object" && typeof b === "object") {
40
- const aObj = a;
41
- const bObj = b;
42
- const keys = /* @__PURE__ */ new Set([...Object.keys(aObj), ...Object.keys(bObj)]);
43
- for (const key of keys) {
44
- if (!isEqualValue(aObj[key], bObj[key])) {
45
- return false;
46
- }
47
- }
48
- return true;
49
- }
50
- return false;
10
+ if (isUnset(a) && isUnset(b)) return true;
11
+ if (isUnset(a) || isUnset(b)) return false;
12
+ if (a === b) return true;
13
+ if (typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b)) return true;
14
+ if (hasIsEqual(a) && hasIsEqual(b)) try {
15
+ return a.isEqual(b);
16
+ } catch {
17
+ return false;
18
+ }
19
+ if (hasIsEqual(a) || hasIsEqual(b)) return false;
20
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
21
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((item, i) => isEqualValue(item, b[i]));
22
+ if (Array.isArray(a) || Array.isArray(b)) return false;
23
+ if (typeof a === "object" && typeof b === "object") {
24
+ const aObj = a;
25
+ const bObj = b;
26
+ const keys = /* @__PURE__ */ new Set([...Object.keys(aObj), ...Object.keys(bObj)]);
27
+ for (const key of keys) if (!isEqualValue(aObj[key], bObj[key])) return false;
28
+ return true;
29
+ }
30
+ return false;
51
31
  }
32
+ /**
33
+ * Compute which top-level fields changed between two object states.
34
+ *
35
+ * Values are compared deeply (nested objects, arrays), but changes are
36
+ * reported per top-level field with the full old/new value. Firestore value
37
+ * types are compared via their own isEqual(). undefined, null and missing
38
+ * keys are treated as the same "not set" state.
39
+ *
40
+ * @param before - Previous state; undefined means "object did not exist yet"
41
+ * @param after - New state
42
+ * @param options - Optional ignore list for noisy fields (e.g. "lastEdit")
43
+ * @returns The changed fields — or undefined if nothing changed, so
44
+ * "did anything change?" is a truthiness check on the result
45
+ */
52
46
  function computeChanges(before, after, options) {
53
- const ignore = new Set(options?.ignoreFields ?? []);
54
- const keys = /* @__PURE__ */ new Set([...Object.keys(before ?? {}), ...Object.keys(after)]);
55
- const changes = {};
56
- for (const key of keys) {
57
- if (ignore.has(key)) {
58
- continue;
59
- }
60
- const oldValue = before?.[key];
61
- const newValue = after[key];
62
- if (!isEqualValue(oldValue, newValue)) {
63
- changes[key] = { old: oldValue ?? null, new: newValue ?? null };
64
- }
65
- }
66
- return Object.keys(changes).length > 0 ? changes : void 0;
47
+ const ignore = new Set(options?.ignoreFields ?? []);
48
+ const keys = /* @__PURE__ */ new Set([...Object.keys(before ?? {}), ...Object.keys(after)]);
49
+ const changes = {};
50
+ for (const key of keys) {
51
+ if (ignore.has(key)) continue;
52
+ const oldValue = before?.[key];
53
+ const newValue = after[key];
54
+ if (!isEqualValue(oldValue, newValue)) changes[key] = {
55
+ old: oldValue ?? null,
56
+ new: newValue ?? null
57
+ };
58
+ }
59
+ return Object.keys(changes).length > 0 ? changes : void 0;
67
60
  }
68
-
61
+ //#endregion
69
62
  export { computeChanges };
package/dist/counter.js CHANGED
@@ -1,20 +1,22 @@
1
- import { getDb } from './firestore.js';
2
-
3
- const COUNTERS_COLLECTION = "counters";
1
+ import { getDb } from "./firestore.js";
2
+ //#region src/counter.ts
3
+ var COUNTERS_COLLECTION = "counters";
4
+ /**
5
+ * Get next counter value atomically
6
+ * Uses Firestore transaction to prevent race conditions
7
+ */
4
8
  async function getNextCounter(counterName, databaseId) {
5
- const db = getDb(databaseId);
6
- const counterRef = db.collection(COUNTERS_COLLECTION).doc(counterName);
7
- return db.runTransaction(async (transaction) => {
8
- const doc = await transaction.get(counterRef);
9
- const current = doc.exists ? doc.data()?.value ?? 0 : 0;
10
- const next = current + 1;
11
- transaction.set(
12
- counterRef,
13
- { value: next, updatedAt: /* @__PURE__ */ new Date() },
14
- { merge: true }
15
- );
16
- return next;
17
- });
9
+ const db = getDb(databaseId);
10
+ const counterRef = db.collection(COUNTERS_COLLECTION).doc(counterName);
11
+ return db.runTransaction(async (transaction) => {
12
+ const doc = await transaction.get(counterRef);
13
+ const next = (doc.exists ? doc.data()?.value ?? 0 : 0) + 1;
14
+ transaction.set(counterRef, {
15
+ value: next,
16
+ updatedAt: /* @__PURE__ */ new Date()
17
+ }, { merge: true });
18
+ return next;
19
+ });
18
20
  }
19
-
21
+ //#endregion
20
22
  export { getNextCounter };
package/dist/errors.js CHANGED
@@ -1,41 +1,42 @@
1
- const SC_EMAIL_ALREADY_USED = -160;
2
- class AppError extends Error {
3
- constructor(message, code, statusCode) {
4
- super(message);
5
- this.code = code;
6
- this.statusCode = statusCode;
7
- this.name = this.constructor.name;
8
- }
9
- }
10
- class ValidationError extends AppError {
11
- constructor(message) {
12
- super(message, "VALIDATION_ERROR", 400);
13
- }
14
- }
15
- class UnauthorizedError extends AppError {
16
- constructor(message = "Authentication required") {
17
- super(message, "UNAUTHORIZED", 401);
18
- }
19
- }
20
- class ForbiddenError extends AppError {
21
- constructor(message = "Access denied") {
22
- super(message, "FORBIDDEN", 403);
23
- }
24
- }
25
- class NotFoundError extends AppError {
26
- constructor(message) {
27
- super(message, "NOT_FOUND", 404);
28
- }
29
- }
30
- class ConflictError extends AppError {
31
- constructor(message, code = "CONFLICT") {
32
- super(message, code, 409);
33
- }
34
- }
35
- class EmailAlreadyUsedError extends AppError {
36
- constructor() {
37
- super("Email already used", "EMAIL_ALREADY_USED", SC_EMAIL_ALREADY_USED);
38
- }
39
- }
40
-
1
+ //#region src/errors.ts
2
+ var SC_EMAIL_ALREADY_USED = -160;
3
+ var AppError = class extends Error {
4
+ constructor(message, code, statusCode) {
5
+ super(message);
6
+ this.code = code;
7
+ this.statusCode = statusCode;
8
+ this.name = this.constructor.name;
9
+ }
10
+ };
11
+ var ValidationError = class extends AppError {
12
+ constructor(message) {
13
+ super(message, "VALIDATION_ERROR", 400);
14
+ }
15
+ };
16
+ var UnauthorizedError = class extends AppError {
17
+ constructor(message = "Authentication required") {
18
+ super(message, "UNAUTHORIZED", 401);
19
+ }
20
+ };
21
+ var ForbiddenError = class extends AppError {
22
+ constructor(message = "Access denied") {
23
+ super(message, "FORBIDDEN", 403);
24
+ }
25
+ };
26
+ var NotFoundError = class extends AppError {
27
+ constructor(message) {
28
+ super(message, "NOT_FOUND", 404);
29
+ }
30
+ };
31
+ var ConflictError = class extends AppError {
32
+ constructor(message, code = "CONFLICT") {
33
+ super(message, code, 409);
34
+ }
35
+ };
36
+ var EmailAlreadyUsedError = class extends AppError {
37
+ constructor() {
38
+ super("Email already used", "EMAIL_ALREADY_USED", SC_EMAIL_ALREADY_USED);
39
+ }
40
+ };
41
+ //#endregion
41
42
  export { AppError, ConflictError, EmailAlreadyUsedError, ForbiddenError, NotFoundError, SC_EMAIL_ALREADY_USED, UnauthorizedError, ValidationError };
package/dist/firestore.js CHANGED
@@ -1,14 +1,20 @@
1
- import { getFirestore } from 'firebase-admin/firestore';
2
-
3
- const dbCache = /* @__PURE__ */ new Map();
1
+ import { getFirestore } from "firebase-admin/firestore";
2
+ //#region src/firestore.ts
3
+ var dbCache = /* @__PURE__ */ new Map();
4
+ /**
5
+ * Get Firestore instance (cached)
6
+ *
7
+ * Uses the default Firebase app - consumer must call initializeApp() first.
8
+ * @param databaseId Optional named database
9
+ */
4
10
  function getDb(databaseId) {
5
- const cacheKey = databaseId ?? "default";
6
- if (!dbCache.has(cacheKey)) {
7
- const db = databaseId ? getFirestore(databaseId) : getFirestore();
8
- db.settings({ ignoreUndefinedProperties: true });
9
- dbCache.set(cacheKey, db);
10
- }
11
- return dbCache.get(cacheKey);
11
+ const cacheKey = databaseId ?? "default";
12
+ if (!dbCache.has(cacheKey)) {
13
+ const db = databaseId ? getFirestore(databaseId) : getFirestore();
14
+ db.settings({ ignoreUndefinedProperties: true });
15
+ dbCache.set(cacheKey, db);
16
+ }
17
+ return dbCache.get(cacheKey);
12
18
  }
13
-
19
+ //#endregion
14
20
  export { getDb };