@progressive-development/pd-provider-firebase-functions 1.3.0 → 1.4.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.
- package/bin/prepare-functions-deploy.mjs +141 -0
- package/bin/prepare-functions-deploy.spec.mjs +108 -0
- package/dist/auth-trash.js +118 -107
- package/dist/auth.js +30 -23
- package/dist/changes.js +53 -60
- package/dist/counter.js +19 -17
- package/dist/errors.js +41 -40
- package/dist/firestore.js +17 -11
- package/dist/history.js +74 -45
- package/dist/import-templates.js +102 -106
- package/dist/index.js +16 -15
- package/dist/logger.js +11 -8
- package/dist/mail.js +56 -35
- package/dist/response.js +35 -26
- package/dist/script-helpers.js +55 -44
- package/dist/trash.js +105 -72
- package/dist/types.js +10 -9
- package/dist/visits.js +124 -82
- package/package.json +15 -8
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Makes a Firebase Functions package.json cloud-ready.
|
|
4
|
+
*
|
|
5
|
+
* The cloud build installs with npm, and npm does not know the `workspace:`
|
|
6
|
+
* protocol — a single `workspace:*` ends the deploy with EUNSUPPORTEDPROTOCOL
|
|
7
|
+
* before anything is uploaded. So every workspace dependency is replaced by
|
|
8
|
+
* something npm can resolve. Two cases, and the difference is whether the
|
|
9
|
+
* package is visible to the registry:
|
|
10
|
+
*
|
|
11
|
+
* PUBLISHED → `^<local version>`. The cloud fetches it from the registry.
|
|
12
|
+
* So that this never silently deploys an old build: publish
|
|
13
|
+
* first, then deploy.
|
|
14
|
+
* PRIVATE → `file:vendor/<name>.tgz`. A private package exists in no
|
|
15
|
+
* registry; it is packed here and lands in the functions folder,
|
|
16
|
+
* which is uploaded anyway. Packed is what `files` names — i.e.
|
|
17
|
+
* `dist`, which must be built BEFORE (see the predeploy chain in
|
|
18
|
+
* firebase.json).
|
|
19
|
+
*
|
|
20
|
+
* No configuration: pnpm links every workspace dependency into the functions
|
|
21
|
+
* package's node_modules as a symlink, and its real path is the source folder.
|
|
22
|
+
*
|
|
23
|
+
* Usage (firebase.json → functions.predeploy, run inside the functions package):
|
|
24
|
+
* pnpm --filter <functions-package> exec pd-prepare-functions-deploy [--dry-run] [dir]
|
|
25
|
+
*
|
|
26
|
+
* CAUTION: the script rewrites package.json for good. To continue working:
|
|
27
|
+
* git checkout -- packages/functions/package.json && pnpm install
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import {
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
realpathSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from "node:fs";
|
|
38
|
+
import { join, resolve } from "node:path";
|
|
39
|
+
import { pathToFileURL } from "node:url";
|
|
40
|
+
|
|
41
|
+
/** Fixed tarball name per package — otherwise vendor/ collects one corpse per version. */
|
|
42
|
+
export function vendorFileName(name) {
|
|
43
|
+
return `${name.replace(/[@/]/g, "-").replace(/^-/, "")}.tgz`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a workspace dependency really lives: follow pnpm's symlink.
|
|
48
|
+
* @returns {{ dir: string, version: string, private: boolean, main: string }}
|
|
49
|
+
*/
|
|
50
|
+
export function findWorkspacePackage(functionsDir, name) {
|
|
51
|
+
const link = join(functionsDir, "node_modules", ...name.split("/"));
|
|
52
|
+
if (!existsSync(link)) {
|
|
53
|
+
throw new Error(`${name}: nicht in node_modules — erst pnpm install.`);
|
|
54
|
+
}
|
|
55
|
+
const dir = realpathSync(link);
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
57
|
+
return {
|
|
58
|
+
dir,
|
|
59
|
+
version: pkg.version,
|
|
60
|
+
private: pkg.private === true,
|
|
61
|
+
main: pkg.main ?? "dist/index.js",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pure part: the rewritten dependency map plus what changed.
|
|
67
|
+
* `lookup(name)` answers like findWorkspacePackage.
|
|
68
|
+
*/
|
|
69
|
+
export function rewriteDependencies(dependencies, lookup) {
|
|
70
|
+
const out = { ...(dependencies ?? {}) };
|
|
71
|
+
const changes = [];
|
|
72
|
+
for (const [name, spec] of Object.entries(out)) {
|
|
73
|
+
if (!String(spec).startsWith("workspace:")) continue;
|
|
74
|
+
const source = lookup(name);
|
|
75
|
+
const to = source.private
|
|
76
|
+
? `file:vendor/${vendorFileName(name)}`
|
|
77
|
+
: `^${source.version}`;
|
|
78
|
+
out[name] = to;
|
|
79
|
+
changes.push({ name, from: spec, to, private: source.private, source });
|
|
80
|
+
}
|
|
81
|
+
return { dependencies: out, changes };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Packs a private package into vendor/ and returns the tarball name. */
|
|
85
|
+
export function packPrivate(functionsDir, name, source) {
|
|
86
|
+
const entry = join(source.dir, source.main);
|
|
87
|
+
if (!existsSync(entry)) {
|
|
88
|
+
// Without dist the tarball would be empty and the failure would surface in the cloud.
|
|
89
|
+
throw new Error(`${name}: ${entry} fehlt — erst bauen, dann deployen.`);
|
|
90
|
+
}
|
|
91
|
+
const vendor = join(functionsDir, "vendor");
|
|
92
|
+
mkdirSync(vendor, { recursive: true });
|
|
93
|
+
const produced = execFileSync("npm", ["pack", "--pack-destination", vendor], {
|
|
94
|
+
cwd: source.dir,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
})
|
|
97
|
+
.trim()
|
|
98
|
+
.split("\n")
|
|
99
|
+
.pop();
|
|
100
|
+
const target = vendorFileName(name);
|
|
101
|
+
renameSync(join(vendor, produced), join(vendor, target));
|
|
102
|
+
return target;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Runs the whole thing for one functions directory.
|
|
107
|
+
* @returns the list of changes (empty when nothing was a workspace dependency)
|
|
108
|
+
*/
|
|
109
|
+
export function prepare(functionsDir, { dryRun = false, log = console.log } = {}) {
|
|
110
|
+
const pkgPath = join(functionsDir, "package.json");
|
|
111
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
112
|
+
const { dependencies, changes } = rewriteDependencies(pkg.dependencies, (name) =>
|
|
113
|
+
findWorkspacePackage(functionsDir, name)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
for (const change of changes) {
|
|
117
|
+
const kind = change.private ? `privat, ${change.source.version}` : "veröffentlicht";
|
|
118
|
+
log(` ${change.name}: ${change.from} -> ${change.to} (${kind})`);
|
|
119
|
+
if (change.private && !dryRun) packPrivate(functionsDir, change.name, change.source);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (changes.length === 0) {
|
|
123
|
+
log("Keine Workspace-Abhängigkeiten offen.");
|
|
124
|
+
} else if (dryRun) {
|
|
125
|
+
log(`Dry-Run: ${pkgPath} bleibt unverändert.`);
|
|
126
|
+
} else {
|
|
127
|
+
writeFileSync(pkgPath, JSON.stringify({ ...pkg, dependencies }, null, 2) + "\n");
|
|
128
|
+
log(`${pkgPath} für den Deploy umgeschrieben.`);
|
|
129
|
+
}
|
|
130
|
+
return changes;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// CLI — argv[1] may be the symlinked path of a pnpm bin shim while
|
|
134
|
+
// import.meta.url is the real one; compare real paths.
|
|
135
|
+
const invokedAs = process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href;
|
|
136
|
+
if (invokedAs === import.meta.url) {
|
|
137
|
+
const args = process.argv.slice(2);
|
|
138
|
+
const dryRun = args.includes("--dry-run");
|
|
139
|
+
const dir = resolve(args.find((a) => !a.startsWith("--")) ?? process.cwd());
|
|
140
|
+
prepare(dir, { dryRun });
|
|
141
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prepare-functions-deploy: veröffentlicht → ^version, privat → vendor-Tarball,
|
|
3
|
+
* Auffinden über pnpm-Symlinks, Dry-Run schreibt nichts.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
findWorkspacePackage,
|
|
12
|
+
prepare,
|
|
13
|
+
rewriteDependencies,
|
|
14
|
+
vendorFileName,
|
|
15
|
+
} from "./prepare-functions-deploy.mjs";
|
|
16
|
+
|
|
17
|
+
describe("vendorFileName", () => {
|
|
18
|
+
it("macht aus einem Scope-Namen einen Dateinamen", () => {
|
|
19
|
+
expect(vendorFileName("@scope/pkg")).toBe("scope-pkg.tgz");
|
|
20
|
+
expect(vendorFileName("plain")).toBe("plain.tgz");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("rewriteDependencies", () => {
|
|
25
|
+
const lookup = (name) =>
|
|
26
|
+
name === "private-model"
|
|
27
|
+
? { dir: "/x", version: "0.1.0", private: true, main: "dist/index.js" }
|
|
28
|
+
: { dir: "/y", version: "1.3.1", private: false, main: "dist/index.js" };
|
|
29
|
+
|
|
30
|
+
it("ersetzt nur workspace:-Abhängigkeiten", () => {
|
|
31
|
+
const { dependencies, changes } = rewriteDependencies(
|
|
32
|
+
{ "@pd/functions": "workspace:*", "private-model": "workspace:^", express: "^4.0.0" },
|
|
33
|
+
lookup
|
|
34
|
+
);
|
|
35
|
+
expect(dependencies).toEqual({
|
|
36
|
+
"@pd/functions": "^1.3.1",
|
|
37
|
+
"private-model": "file:vendor/private-model.tgz",
|
|
38
|
+
express: "^4.0.0",
|
|
39
|
+
});
|
|
40
|
+
expect(changes.map((c) => c.name)).toEqual(["@pd/functions", "private-model"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ohne dependencies passiert nichts", () => {
|
|
44
|
+
expect(rewriteDependencies(undefined, lookup)).toEqual({ dependencies: {}, changes: [] });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** Ein Mini-Workspace: functions/ mit pnpm-artigen Symlinks auf zwei Pakete. */
|
|
49
|
+
function fakeWorkspace() {
|
|
50
|
+
const root = mkdtempSync(join(tmpdir(), "pd-prepare-"));
|
|
51
|
+
const functions = join(root, "functions");
|
|
52
|
+
const model = join(root, "model");
|
|
53
|
+
const published = join(root, "published");
|
|
54
|
+
mkdirSync(join(functions, "node_modules", "@pd"), { recursive: true });
|
|
55
|
+
mkdirSync(model);
|
|
56
|
+
mkdirSync(published);
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(model, "package.json"),
|
|
59
|
+
JSON.stringify({ name: "app-model", version: "0.2.0", private: true, main: "dist/index.js" })
|
|
60
|
+
);
|
|
61
|
+
writeFileSync(
|
|
62
|
+
join(published, "package.json"),
|
|
63
|
+
JSON.stringify({ name: "@pd/functions", version: "1.3.1" })
|
|
64
|
+
);
|
|
65
|
+
symlinkSync(model, join(functions, "node_modules", "app-model"), "dir");
|
|
66
|
+
symlinkSync(published, join(functions, "node_modules", "@pd", "functions"), "dir");
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(functions, "package.json"),
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
name: "functions",
|
|
71
|
+
dependencies: { "app-model": "workspace:*", "@pd/functions": "workspace:*", "firebase-admin": "^13.0.0" },
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
return { functions, model };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("findWorkspacePackage", () => {
|
|
78
|
+
it("folgt dem Symlink und liest die package.json der Quelle", () => {
|
|
79
|
+
const { functions } = fakeWorkspace();
|
|
80
|
+
const src = findWorkspacePackage(functions, "@pd/functions");
|
|
81
|
+
expect(src).toMatchObject({ version: "1.3.1", private: false });
|
|
82
|
+
expect(findWorkspacePackage(functions, "app-model").private).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("meldet ein fehlendes Paket verständlich", () => {
|
|
86
|
+
const { functions } = fakeWorkspace();
|
|
87
|
+
expect(() => findWorkspacePackage(functions, "nope")).toThrow(/pnpm install/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("prepare", () => {
|
|
92
|
+
it("dry-run: meldet die Änderungen, schreibt nichts, packt nichts", () => {
|
|
93
|
+
const { functions } = fakeWorkspace();
|
|
94
|
+
const before = readFileSync(join(functions, "package.json"), "utf8");
|
|
95
|
+
const lines = [];
|
|
96
|
+
const changes = prepare(functions, { dryRun: true, log: (l) => lines.push(l) });
|
|
97
|
+
expect(changes.map((c) => c.to)).toEqual(["file:vendor/app-model.tgz", "^1.3.1"]);
|
|
98
|
+
expect(readFileSync(join(functions, "package.json"), "utf8")).toBe(before);
|
|
99
|
+
expect(lines.at(-1)).toMatch(/Dry-Run/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("scharf: privates Paket ohne dist bricht ab, bevor geschrieben wird", () => {
|
|
103
|
+
const { functions } = fakeWorkspace();
|
|
104
|
+
const before = readFileSync(join(functions, "package.json"), "utf8");
|
|
105
|
+
expect(() => prepare(functions, { log: () => {} })).toThrow(/erst bauen/);
|
|
106
|
+
expect(readFileSync(join(functions, "package.json"), "utf8")).toBe(before);
|
|
107
|
+
});
|
|
108
|
+
});
|
package/dist/auth-trash.js
CHANGED
|
@@ -1,116 +1,127 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 {
|
|
2
|
-
import { logger } from
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
return value === void 0 || value === null;
|
|
6
8
|
}
|
|
7
9
|
function isEqualValue(a, b) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 };
|