@exposify/firebase 0.0.0-stage → 0.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Exposify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,178 @@
1
- # Temporary Holding Version
1
+ # @exposify/firebase: setup guide
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Make a Firebase app usable by AI agents: one Cloud Function serves MCP, with
4
+ tools for the Firestore collections you name, the callable functions you
5
+ register, and your bundled docs. The caller's Firebase ID token goes
6
+ straight to the Firestore REST API, so **your Security Rules are the
7
+ authorization layer**. The agent can do exactly what that signed-in user can
8
+ do from your app, nothing more. The function never uses the Admin SDK.
9
+
10
+ For the design rationale, see
11
+ `docs/superpowers/specs/2026-10-10-exposify-firebase-design.md` in the repo
12
+ root.
13
+
14
+ ## Prerequisites
15
+
16
+ - A Firebase project with Cloud Functions (`firebase init functions`; Node 20+,
17
+ TypeScript or JavaScript) and Firestore.
18
+ - The Firebase CLI for `firebase deploy`.
19
+
20
+ ## 1. Scaffold
21
+
22
+ From your Firebase project root (where `firebase.json` is):
23
+
24
+ ```bash
25
+ npx exposify-firebase init
26
+ ```
27
+
28
+ It reads `firebase.json` to find your functions directory and
29
+ `firestore.rules` to propose collections, then:
30
+
31
+ - writes `functions/src/mcp.ts` (or `mcp.js` for JavaScript projects): the
32
+ MCP function, with every collection found in your rules listed as
33
+ readable and all writes commented out;
34
+ - adds `export { mcp } from "./mcp.js";` to your functions entry file.
35
+
36
+ It never overwrites an existing `mcp.ts`, so re-running is safe. `--dry-run`
37
+ prints the plan without writing.
38
+
39
+ The rules reading is a proposal, not an analysis: a `match /todos/{id}`
40
+ block becomes the `todos` collection, and a rule like
41
+ `resource.data.uid == request.auth.uid` becomes `owner: "uid"`. Review the
42
+ list before deploying.
43
+
44
+ ## 2. Install and deploy
45
+
46
+ ```bash
47
+ cd functions && npm install @exposify/firebase
48
+ firebase deploy --only functions:mcp
49
+ ```
50
+
51
+ ## 3. Connect an agent
52
+
53
+ Point an MCP client at the function's URL
54
+ (`https://<region>-<project>.cloudfunctions.net/mcp`) with the header
55
+ `Authorization: Bearer <Firebase ID token>`. In your app,
56
+ `await auth.currentUser.getIdToken()` returns one. ID tokens expire after an
57
+ hour, so this is good for a quick try, not a lasting connection.
58
+
59
+ Without a token, Firestore evaluates rules with `request.auth == null`, so
60
+ the agent gets whatever your rules allow anonymous users.
61
+
62
+ ### Lasting connections: the hosted broker
63
+
64
+ Set `EXPOSIFY_KEY` (the secret key from your project in the Exposify
65
+ dashboard) in the function's environment and the broker turns on by itself,
66
+ with no code change. Users then sign in from their MCP client: a request
67
+ without a connection gets a `401` that points the client at
68
+ `<function URL>/.well-known/oauth-protected-resource`, which names the
69
+ broker as the authorization server, and the client sends the user through
70
+ sign-in. Tool calls then reach Firestore with the `Authorization` the broker
71
+ resolves for that user, never the broker token itself, so your rules still
72
+ decide. Requests without a connection are refused rather
73
+ than run anonymously; pass `authRequired: false` to allow them. The
74
+ exception: when the project has no sign-in method yet (add the Firebase
75
+ preset in the dashboard), agents use the function signed out, as the
76
+ anonymous user, with no sign-in asked. The startup log says which it is
77
+ (`[agentready] How agents sign in: ...`).
78
+
79
+ `auth: hostedBroker()` does the same explicitly. If the function is
80
+ reached through a URL other than the one it sees (a custom domain or a
81
+ Hosting rewrite), set it with `hostedBroker({ resource: "https://…/mcp" })`.
82
+ Behind `*.cloudfunctions.net/<name>` and `*.run.app` the right URL is worked
83
+ out for you. See [`docs/hosted-broker.md`](../../docs/hosted-broker.md).
84
+
85
+ ## Configuration
86
+
87
+ ```ts
88
+ import { onRequest } from "firebase-functions/v2/https";
89
+ import { aiReady } from "@exposify/firebase";
90
+
91
+ export const mcp = onRequest(
92
+ aiReady({
93
+ collections: {
94
+ todos: {
95
+ description: "The user's todo items",
96
+ owner: "uid",
97
+ fields: {
98
+ title: { type: "string" },
99
+ done: { type: "boolean" },
100
+ due: { type: "string", format: "date-time" },
101
+ },
102
+ required: ["title"],
103
+ },
104
+ "users/{userId}/notes": {},
105
+ },
106
+ writes: ["todos.*", "users/{userId}/notes.create"],
107
+ functions: {
108
+ refundOrder: {
109
+ description: "Refund an order. Reversible within 30 days.",
110
+ input: { properties: { orderId: { type: "string" } }, required: ["orderId"] },
111
+ },
112
+ },
113
+ }),
114
+ );
115
+ ```
116
+
117
+ | Option | Default | What it does |
118
+ |---|---|---|
119
+ | `collections` | none | Collections agents can use, keyed by path. `{param}` placeholders make subcollections; each becomes a tool argument. An array of names is shorthand for no per-collection config. |
120
+ | `collections[k].fields` | none | Field schema (a JSON Schema subset). Limits filters and `select` to these names and types writes. `format: "date-time"` stores a string as a Firestore timestamp. Without it, any field name and any object are accepted. |
121
+ | `collections[k].owner` | none | The field holding the owner's uid. `list_*` filters on it, `create_*` sets it from the token, `update_*` refuses to change it. |
122
+ | `writes` | `[]` | `"<collection>.create"`, `.update`, `.delete` or `.*`. |
123
+ | `functions` | `{}` | Callable functions (`onCall`), invoked with the caller's token. `url` overrides the address. |
124
+ | `functionsRegion` | `us-central1` | Region of those callable functions. |
125
+ | `docs` | `["docs/**/*.md"]` | Markdown files, relative to the functions directory, served through `search_docs`. |
126
+ | `docsAuth` | `"public"` (`"required"` with the hosted broker) | `"required"` hides `search_docs` from callers without a token. |
127
+ | `maxDocs` | `100` | Cap on documents per `list_*` call. |
128
+ | `projectId`, `databaseId` | from the environment, `(default)` | Set automatically in deployed functions. |
129
+ | `auth`, `authRequired` | the hosted broker when `EXPOSIFY_KEY` is set, else none | A resolver that turns the incoming headers into the ones sent to Firebase (e.g. `hostedBroker()`, Exposify's hosted broker), and whether a missing result is a 401 (default: true with the hosted broker, false otherwise). |
130
+
131
+ ## Tools
132
+
133
+ For each collection (tool names use the collection ids, snake_cased:
134
+ `users/{userId}/notes` → `users_notes`):
135
+
136
+ - `list_<c>`: `where` (operators as in the client SDK: `==`, `!=`, `<`, `<=`,
137
+ `>`, `>=`, `array-contains`, `in`, `not-in`, `array-contains-any`),
138
+ `orderBy`, `limit`, `select`.
139
+ - `get_<c>`: one document by `id`.
140
+ - `create_<c>`, `update_<c>`, `delete_<c>`: only when opted in. Updates and
141
+ deletes take one document id; there are no bulk writes. Updates change
142
+ only the given fields and fail if the document doesn't exist.
143
+ - `call_<fn>`: each registered callable function.
144
+ - `search_docs`: when docs are bundled.
145
+
146
+ Documents come back as `{ id, path, data, createTime, updateTime }`, with
147
+ timestamps as ISO strings and references as document paths.
148
+
149
+ Errors from Firestore reach the agent as they are (`HTTP 403: Missing or
150
+ insufficient permissions`, or a missing-index error with the link to create
151
+ the index), so it can correct its call. 5xx errors are replaced by a generic
152
+ message.
153
+
154
+ ## Common rule shapes
155
+
156
+ - **Owner field** (`allow read: if resource.data.uid == request.auth.uid`):
157
+ set `owner: "uid"`. Firestore rejects a query that doesn't filter on the
158
+ field, so without `owner` the agent has to add the filter itself (the 403
159
+ it gets says so).
160
+ - **Per-user subcollection** (`match /users/{userId}/notes/{id}` with
161
+ `request.auth.uid == userId`): key the collection
162
+ `"users/{userId}/notes"`; the agent passes its own uid as `userId`.
163
+
164
+ ## Local testing with the emulators
165
+
166
+ Under `firebase emulators:start --only functions,firestore`, the Functions
167
+ emulator sets `FIRESTORE_EMULATOR_HOST`, so the MCP function talks to the
168
+ Firestore emulator and its rules. The function's URL is
169
+ `http://127.0.0.1:5001/<project>/us-central1/mcp`. The emulator accepts
170
+ unsigned ID tokens, which is how this package's integration test runs two
171
+ users against real rules (`test/integration/`).
172
+
173
+ ## Not supported yet
174
+
175
+ Realtime Database, Storage, collection-group queries, transactions and
176
+ batched writes, pagination cursors, inferring field schemas from existing
177
+ documents, and the `oauth` option (Firebase Auth isn't an OAuth
178
+ authorization server).
package/dist/cli.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/init.ts
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ import { dirname, join } from "path";
6
+
7
+ // bin/rules.ts
8
+ function collectionsFromRules(source) {
9
+ const text = stripComments(source);
10
+ const blocks = [];
11
+ collectBlocks(text, blocks);
12
+ const out = /* @__PURE__ */ new Map();
13
+ for (const block of blocks) {
14
+ const docsAt = block.path.indexOf("documents");
15
+ if (docsAt === -1) continue;
16
+ const segments = block.path.slice(docsAt + 1);
17
+ if (segments.length === 0 || segments.length % 2 !== 0) continue;
18
+ if (segments.some((s) => /^\{[^}]*=\*\*\}$/.test(s))) continue;
19
+ const last = segments[segments.length - 1];
20
+ if (!/^\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(last)) continue;
21
+ const collectionSegments = segments.slice(0, -1).map((seg, i) => i % 2 === 1 ? renameReserved(seg, segments[i - 1]) : seg);
22
+ if (collectionSegments.some((s, i) => i % 2 === 0 && s.includes("{"))) continue;
23
+ const key = collectionSegments.join("/");
24
+ if (out.has(key)) continue;
25
+ const owner = ownerField(block.body);
26
+ out.set(key, owner ? { key, owner } : { key });
27
+ }
28
+ return [...out.values()];
29
+ }
30
+ function renameReserved(segment, collection) {
31
+ return segment === "{id}" ? `{${collection.replace(/[^A-Za-z0-9_]/g, "_")}Id}` : segment;
32
+ }
33
+ function ownerField(body) {
34
+ const m = body.match(/resource\.data\.([A-Za-z_][A-Za-z0-9_]*)\s*==\s*request\.auth\.uid/) ?? body.match(/request\.auth\.uid\s*==\s*resource\.data\.([A-Za-z_][A-Za-z0-9_]*)/);
35
+ return m?.[1];
36
+ }
37
+ function collectBlocks(text, out) {
38
+ const stack = [{ block: null, path: [] }];
39
+ const matchRe = /^match\s+(\/(?:[^\s{]|\{[^}\s]*\})*)\s*\{/;
40
+ const enclosingBlock = () => {
41
+ for (let j = stack.length - 1; j >= 0; j--) if (stack[j].block) return stack[j].block;
42
+ return null;
43
+ };
44
+ for (let i = 0; i < text.length; i++) {
45
+ const c = text[i];
46
+ if (c === "m" && !/[A-Za-z0-9_.]/.test(text[i - 1] ?? " ")) {
47
+ const m = text.slice(i, i + 512).match(matchRe);
48
+ if (m) {
49
+ const path = [...stack[stack.length - 1].path, ...m[1].split("/").filter(Boolean)];
50
+ const block2 = { path, body: "" };
51
+ out.push(block2);
52
+ stack.push({ block: block2, path });
53
+ i += m[0].length - 1;
54
+ continue;
55
+ }
56
+ }
57
+ if (c === "{") {
58
+ const block2 = enclosingBlock();
59
+ if (block2) block2.body += c;
60
+ stack.push({ block: null, path: stack[stack.length - 1].path });
61
+ continue;
62
+ }
63
+ if (c === "}") {
64
+ const popped = stack.length > 1 ? stack.pop() : null;
65
+ const block2 = enclosingBlock();
66
+ if (block2 && !popped?.block) block2.body += c;
67
+ continue;
68
+ }
69
+ const block = enclosingBlock();
70
+ if (block) block.body += c;
71
+ }
72
+ }
73
+ function stripComments(source) {
74
+ return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
75
+ }
76
+
77
+ // bin/init.ts
78
+ var GENERATED_MARKER = "Generated by `exposify-firebase init`";
79
+ function planInit(root) {
80
+ const warnings = [];
81
+ const firebaseJsonPath = join(root, "firebase.json");
82
+ let firebaseJson = {};
83
+ if (existsSync(firebaseJsonPath)) {
84
+ try {
85
+ firebaseJson = JSON.parse(readFileSync(firebaseJsonPath, "utf8"));
86
+ } catch {
87
+ warnings.push("firebase.json isn't valid JSON; assuming the default functions/ directory and firestore.rules");
88
+ }
89
+ } else {
90
+ warnings.push("no firebase.json here; run this from your Firebase project root (`firebase init functions` creates one)");
91
+ }
92
+ const functionsCfg = Array.isArray(firebaseJson.functions) ? firebaseJson.functions.find((f) => !f.codebase || f.codebase === "default") ?? firebaseJson.functions[0] : firebaseJson.functions;
93
+ const functionsDir = (functionsCfg?.source ?? "functions").replace(/\/+$/, "");
94
+ const firestoreCfg = Array.isArray(firebaseJson.firestore) ? firebaseJson.firestore[0] : firebaseJson.firestore;
95
+ const rulesRel = firestoreCfg?.rules ?? "firestore.rules";
96
+ const pkgPath = join(root, functionsDir, "package.json");
97
+ let pkg = {};
98
+ if (existsSync(pkgPath)) {
99
+ try {
100
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
101
+ } catch {
102
+ warnings.push(`${functionsDir}/package.json isn't valid JSON`);
103
+ }
104
+ } else {
105
+ warnings.push(`no ${functionsDir}/package.json; run \`firebase init functions\` first`);
106
+ }
107
+ if (pkg.dependencies && !pkg.dependencies["firebase-functions"]) {
108
+ warnings.push(`${functionsDir}/package.json doesn't depend on firebase-functions; the generated file imports it`);
109
+ }
110
+ const kind = existsSync(join(root, functionsDir, "tsconfig.json")) ? "ts" : pkg.type === "module" ? "esm" : "cjs";
111
+ const entryRel = kind === "ts" ? "src/index.ts" : pkg.main ?? "index.js";
112
+ const entryFile = `${functionsDir}/${entryRel.replace(/^\.\//, "")}`;
113
+ const entryDir = entryFile.slice(0, entryFile.lastIndexOf("/"));
114
+ const mcpFile = `${entryDir}/mcp.${kind === "ts" ? "ts" : "js"}`;
115
+ let collections = [];
116
+ const rulesPath = join(root, rulesRel);
117
+ const rulesFile = existsSync(rulesPath) ? rulesRel : null;
118
+ if (rulesFile) {
119
+ collections = collectionsFromRules(readFileSync(rulesPath, "utf8"));
120
+ if (collections.length === 0) warnings.push(`found no collection match blocks in ${rulesRel}; add collections to ${mcpFile} by hand`);
121
+ } else {
122
+ warnings.push(`no ${rulesRel}; add collections to ${mcpFile} by hand`);
123
+ }
124
+ const actions = [];
125
+ if (existsSync(join(root, mcpFile))) {
126
+ actions.push({ kind: "skip", file: mcpFile, reason: "already exists; leaving it as is" });
127
+ } else {
128
+ actions.push({ kind: "write", file: mcpFile, reason: "the MCP function", content: mcpSource(kind, collections) });
129
+ }
130
+ const entryPath = join(root, entryFile);
131
+ const entrySource = existsSync(entryPath) ? readFileSync(entryPath, "utf8") : null;
132
+ if (entrySource === null) {
133
+ warnings.push(`no ${entryFile}; export mcp from your functions entry file yourself`);
134
+ } else if (/(['"])\.\/mcp(\.js)?\1/.test(entrySource)) {
135
+ actions.push({ kind: "skip", file: entryFile, reason: "already exports mcp" });
136
+ } else {
137
+ actions.push({ kind: "append", file: entryFile, reason: "export the new function", content: exportLine(kind) });
138
+ }
139
+ return { functionsDir, kind, mcpFile, entryFile, rulesFile, collections, actions, warnings };
140
+ }
141
+ function exportLine(kind) {
142
+ if (kind === "cjs") return 'exports.mcp = require("./mcp").mcp;\n';
143
+ return 'export { mcp } from "./mcp.js";\n';
144
+ }
145
+ function mcpSource(kind, collections) {
146
+ const imports = kind === "cjs" ? 'const { onRequest } = require("firebase-functions/v2/https");\nconst { aiReady } = require("@exposify/firebase");\n' : 'import { onRequest } from "firebase-functions/v2/https";\nimport { aiReady } from "@exposify/firebase";\n';
147
+ const entries = collections.length ? collections.map((c) => ` ${JSON.stringify(c.key)}: {${c.owner ? ` owner: ${JSON.stringify(c.owner)} ` : ""}},`).join("\n") : ' // "todos": { owner: "uid" },';
148
+ const writes = collections.length ? collections.map((c) => ` // ${JSON.stringify(`${c.key}.*`)},`).join("\n") : ' // "todos.*",';
149
+ const exportStart = kind === "cjs" ? "exports.mcp = onRequest(" : "export const mcp = onRequest(";
150
+ return `// ${GENERATED_MARKER}. Yours to edit; re-running init won't overwrite it.
151
+ //
152
+ // Agents connect to this function's URL with a signed-in user's Firebase ID
153
+ // token. Every Firestore call runs as that user, so your Security Rules
154
+ // decide what they can read and write.
155
+ ${imports}
156
+ ${exportStart}
157
+ aiReady({
158
+ // Collections agents can read (list_* and get_* tools). Proposed from
159
+ // your firestore.rules; remove any agents shouldn't see. \`owner\` is the
160
+ // field your rules compare to request.auth.uid.
161
+ collections: {
162
+ ${entries}
163
+ },
164
+ // Writes are opt-in: uncomment to add create_*, update_* and delete_* tools.
165
+ writes: [
166
+ ${writes}
167
+ ],
168
+ }),
169
+ );
170
+ `;
171
+ }
172
+ function applyPlan(root, plan) {
173
+ for (const action of plan.actions) {
174
+ const path = join(root, action.file);
175
+ if (action.kind === "write") {
176
+ mkdirSync(dirname(path), { recursive: true });
177
+ writeFileSync(path, action.content, { flag: "wx" });
178
+ } else if (action.kind === "append") {
179
+ appendFileSync(path, `
180
+ ${action.content}`);
181
+ }
182
+ }
183
+ }
184
+ function nextSteps(plan) {
185
+ return `Next:
186
+ 1. cd ${plan.functionsDir} && npm install @exposify/firebase
187
+ 2. Review ${plan.mcpFile}: which collections agents see, and which writes to allow.
188
+ 3. firebase deploy --only functions:mcp
189
+ 4. Point an MCP client at the function's URL (the deploy prints it), with a
190
+ header "Authorization: Bearer <ID token>". In your app,
191
+ await auth.currentUser.getIdToken() gives you one; it expires after an
192
+ hour. For long-lived agent access, use Exposify's hosted broker:
193
+ auth: hostedBroker() with EXPOSIFY_KEY set (see docs/hosted-broker.md).`;
194
+ }
195
+
196
+ // bin/cli.ts
197
+ var USAGE = `Usage: exposify-firebase init [--dry-run]
198
+
199
+ Run from your Firebase project root (where firebase.json is). Writes an MCP
200
+ function to your functions source directory, with collections proposed from
201
+ firestore.rules (reads only; writes are commented out), and exports it from
202
+ your functions entry file. Safe to re-run: existing files are left alone.`;
203
+ function main(argv) {
204
+ const [command, ...flags] = argv;
205
+ if (command !== "init" || flags.some((f) => f !== "--dry-run")) {
206
+ console.log(USAGE);
207
+ return command === "--help" || command === "-h" ? 0 : 1;
208
+ }
209
+ const root = process.cwd();
210
+ const plan = planInit(root);
211
+ for (const w of plan.warnings) console.warn(`warning: ${w}`);
212
+ if (plan.collections.length) {
213
+ console.log(`Collections from ${plan.rulesFile}: ${plan.collections.map((c) => c.key).join(", ")}`);
214
+ }
215
+ for (const a of plan.actions) {
216
+ const verb = a.kind === "write" ? "create" : a.kind === "append" ? "update" : "skip ";
217
+ console.log(` ${verb} ${a.file} (${a.reason})`);
218
+ }
219
+ if (flags.includes("--dry-run")) return 0;
220
+ try {
221
+ applyPlan(root, plan);
222
+ } catch (err) {
223
+ console.error(`init failed: ${err.message}`);
224
+ return 1;
225
+ }
226
+ console.log(`
227
+ ${nextSteps(plan)}`);
228
+ return 0;
229
+ }
230
+ process.exitCode = main(process.argv.slice(2));