@fireemu/darwin-arm64 0.8.0 → 0.9.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fireemu/darwin-arm64
2
2
 
3
3
  The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `darwin-arm64`
4
- (Rust target `aarch64-apple-darwin`), version 0.8.0.
4
+ (Rust target `aarch64-apple-darwin`), version 0.9.0.
5
5
 
6
6
  macOS 13 or newer on Apple silicon.
7
7
 
package/bin/fireemu CHANGED
Binary file
@@ -0,0 +1,224 @@
1
+ // The event a blocking function receives, built as Identity Platform's delivery builds it: the
2
+ // request carries an unsigned blocking token (`{"data":{"jwt":…}}`, the official Auth
3
+ // emulator's form), and the codebase's own firebase-functions parsers turn it into the user
4
+ // record and the event context, exactly as the SDK's wrapped handler does before it calls the
5
+ // handler (firebase-functions `common/providers/identity` `wrapHandler`). The runner keeps its
6
+ // own response and error handling (blocking-response.mjs, blocking-error.mjs).
7
+ import { dirname, join } from "node:path";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+
10
+ /** A blocking token's payload, decoded without verification as the SDK does under the emulator. */
11
+ export function decodeBlockingToken(jwt) {
12
+ if (typeof jwt !== "string") throw new TypeError("the blocking token is not a string");
13
+ const parts = jwt.split(".");
14
+ if (parts.length !== 3) throw new TypeError("the blocking token is not a JWT");
15
+ let payload;
16
+ try {
17
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
18
+ } catch {
19
+ throw new TypeError("the blocking token's payload is not JSON");
20
+ }
21
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
22
+ throw new TypeError("the blocking token's payload is not an object");
23
+ }
24
+ // The SDK's unsafeDecodeAuthBlockingToken names the subject as the uid.
25
+ payload.uid = payload.sub;
26
+ return payload;
27
+ }
28
+
29
+ /**
30
+ * The handler's `{user, context}` for a request that carries a blocking token, or undefined
31
+ * for one that does not. `parsers` are the SDK's `parseAuthUserRecord` and
32
+ * `parseAuthEventContext`; the user record is parsed for the user events only.
33
+ */
34
+ export function blockingEvent(body, parsers, projectId) {
35
+ const jwt = body?.data?.jwt;
36
+ if (jwt === undefined) return undefined;
37
+ const decoded = decodeBlockingToken(jwt);
38
+ const user =
39
+ decoded.event_type === "beforeCreate" || decoded.event_type === "beforeSignIn"
40
+ ? parsers.parseAuthUserRecord(decoded.user_record)
41
+ : undefined;
42
+ return { user, context: parsers.parseAuthEventContext(decoded, projectId) };
43
+ }
44
+
45
+ function packageRoot(require) {
46
+ let root = dirname(require.resolve("firebase-functions"));
47
+ for (;;) {
48
+ const candidate = join(root, "package.json");
49
+ if (existsSync(candidate) && JSON.parse(readFileSync(candidate, "utf8")).name === "firebase-functions")
50
+ return root;
51
+ const parent = dirname(root);
52
+ if (parent === root) throw new Error("cannot locate the firebase-functions package root");
53
+ root = parent;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The SDK's parsers from the codebase's firebase-functions (`lib/common/providers/identity.js`,
59
+ * not in its public exports), or null when that SDK does not expose both there; the runner then
60
+ * uses [`portedIdentityParsers`].
61
+ */
62
+ export function loadIdentityParsers(require) {
63
+ try {
64
+ const identity = require(join(packageRoot(require), "lib", "common", "providers", "identity.js"));
65
+ const { parseAuthUserRecord, parseAuthEventContext } = identity;
66
+ if (typeof parseAuthUserRecord !== "function" || typeof parseAuthEventContext !== "function")
67
+ return null;
68
+ return { parseAuthUserRecord, parseAuthEventContext };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ // A port of firebase-functions 7.3.2 `lib/common/providers/identity.js` (MIT License, Copyright
75
+ // Google LLC): the event a handler receives when the codebase's firebase-functions does not
76
+ // expose its own parsers. Identity Platform delivers the same token to every SDK version, so
77
+ // the event is built as that SDK builds it rather than refused (an SDK's own parsers are used
78
+ // whenever they can be loaded).
79
+ const EVENT_MAPPING = {
80
+ beforeCreate: "providers/cloud.auth/eventTypes/user.beforeCreate",
81
+ beforeSignIn: "providers/cloud.auth/eventTypes/user.beforeSignIn",
82
+ beforeSendEmail: "providers/cloud.auth/eventTypes/user.beforeSendEmail",
83
+ beforeSendSms: "providers/cloud.auth/eventTypes/user.beforeSendSms",
84
+ };
85
+
86
+ const utcDate = (value) => (value ? new Date(value).toUTCString() : null);
87
+
88
+ function parseMetadata(metadata) {
89
+ return {
90
+ creationTime: utcDate(metadata?.creation_time),
91
+ lastSignInTime: utcDate(metadata?.last_sign_in_time),
92
+ };
93
+ }
94
+
95
+ function parseProviderData(providerData) {
96
+ return providerData.map((provider) => ({
97
+ uid: provider.uid,
98
+ displayName: provider.display_name,
99
+ email: provider.email,
100
+ photoURL: provider.photo_url,
101
+ providerId: provider.provider_id,
102
+ phoneNumber: provider.phone_number,
103
+ }));
104
+ }
105
+
106
+ function parseDate(seconds) {
107
+ if (!seconds) return null;
108
+ const date = new Date(seconds * 1000);
109
+ return isNaN(date.getTime()) ? null : date.toUTCString();
110
+ }
111
+
112
+ function parseMultiFactor(multiFactor) {
113
+ if (!multiFactor) return null;
114
+ const enrolledFactors = [];
115
+ for (const factor of multiFactor.enrolled_factors || []) {
116
+ if (!factor.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid multi-factor info response");
117
+ enrolledFactors.push({
118
+ uid: factor.uid,
119
+ factorId: factor.phone_number ? factor.factor_id || "phone" : factor.factor_id,
120
+ displayName: factor.display_name,
121
+ enrollmentTime: factor.enrollment_time ? new Date(factor.enrollment_time).toUTCString() : null,
122
+ phoneNumber: factor.phone_number,
123
+ });
124
+ }
125
+ return enrolledFactors.length > 0 ? { enrolledFactors } : null;
126
+ }
127
+
128
+ function parseAuthUserRecord(record) {
129
+ if (!record?.uid) throw new Error("INTERNAL ASSERT FAILED: Invalid user response");
130
+ return {
131
+ uid: record.uid,
132
+ email: record.email,
133
+ emailVerified: record.email_verified,
134
+ displayName: record.display_name,
135
+ photoURL: record.photo_url,
136
+ phoneNumber: record.phone_number,
137
+ disabled: record.disabled || false,
138
+ metadata: parseMetadata(record.metadata),
139
+ providerData: parseProviderData(record.provider_data),
140
+ passwordHash: record.password_hash,
141
+ passwordSalt: record.password_salt,
142
+ customClaims: record.custom_claims,
143
+ tenantId: record.tenant_id,
144
+ tokensValidAfterTime: parseDate(record.tokens_valid_after_time),
145
+ multiFactor: parseMultiFactor(record.multi_factor),
146
+ };
147
+ }
148
+
149
+ function parseAdditionalUserInfo(decoded) {
150
+ let profile;
151
+ let username;
152
+ if (decoded.raw_user_info) {
153
+ try {
154
+ profile = JSON.parse(decoded.raw_user_info);
155
+ } catch {
156
+ // The SDK only logs an unparsable profile.
157
+ }
158
+ }
159
+ if (profile) {
160
+ if (decoded.sign_in_method === "github.com") username = profile.login;
161
+ if (decoded.sign_in_method === "twitter.com") username = profile.screen_name;
162
+ }
163
+ return {
164
+ providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
165
+ profile,
166
+ username,
167
+ isNewUser: decoded.event_type === "beforeCreate",
168
+ recaptchaScore: decoded.recaptcha_score,
169
+ email: decoded.email,
170
+ phoneNumber: decoded.phone_number,
171
+ };
172
+ }
173
+
174
+ function parseAuthCredential(decoded, time) {
175
+ if (
176
+ !decoded.sign_in_attributes &&
177
+ !decoded.oauth_id_token &&
178
+ !decoded.oauth_access_token &&
179
+ !decoded.oauth_refresh_token
180
+ ) {
181
+ return null;
182
+ }
183
+ return {
184
+ claims: decoded.sign_in_attributes,
185
+ idToken: decoded.oauth_id_token,
186
+ accessToken: decoded.oauth_access_token,
187
+ refreshToken: decoded.oauth_refresh_token,
188
+ expirationTime: decoded.oauth_expires_in
189
+ ? new Date(time + decoded.oauth_expires_in * 1000).toUTCString()
190
+ : undefined,
191
+ secret: decoded.oauth_token_secret,
192
+ providerId: decoded.sign_in_method === "emailLink" ? "password" : decoded.sign_in_method,
193
+ signInMethod: decoded.sign_in_method,
194
+ };
195
+ }
196
+
197
+ function parseAuthEventContext(decoded, projectId, time = new Date().getTime()) {
198
+ const eventType =
199
+ (EVENT_MAPPING[decoded.event_type] || decoded.event_type) +
200
+ (decoded.sign_in_method ? `:${decoded.sign_in_method}` : "");
201
+ return {
202
+ locale: decoded.locale,
203
+ ipAddress: decoded.ip_address,
204
+ userAgent: decoded.user_agent,
205
+ eventId: decoded.event_id,
206
+ eventType,
207
+ authType: decoded.user_record ? "USER" : "UNAUTHENTICATED",
208
+ resource: {
209
+ service: "identitytoolkit.googleapis.com",
210
+ name: decoded.tenant_id
211
+ ? `projects/${projectId}/tenants/${decoded.tenant_id}`
212
+ : `projects/${projectId}`,
213
+ },
214
+ timestamp: new Date(decoded.iat * 1000).toUTCString(),
215
+ additionalUserInfo: parseAdditionalUserInfo(decoded),
216
+ credential: parseAuthCredential(decoded, time),
217
+ emailType: decoded.email_type,
218
+ smsType: decoded.sms_type,
219
+ params: {},
220
+ };
221
+ }
222
+
223
+ /** The ported parsers ([`loadIdentityParsers`] prefers the codebase's own). */
224
+ export const portedIdentityParsers = Object.freeze({ parseAuthUserRecord, parseAuthEventContext });
@@ -103,6 +103,20 @@ function freezeResponse(snapshot) {
103
103
  return snapshot;
104
104
  }
105
105
 
106
+ // The customClaims text as the function's JSON.stringify gives it: what the Functions SDK sends
107
+ // Identity Platform, which production reads back as `customAttributes` with its key order
108
+ // (AUTH-TENANT-BLOCKING recording 2026-09-28). The runner owns this member; the daemon uses the
109
+ // text only when it parses to the claims it applies.
110
+ const CUSTOM_CLAIMS_TEXT = "fireemuCustomClaimsText";
111
+
112
+ function attachCustomClaimsText(snapshot) {
113
+ delete snapshot[CUSTOM_CLAIMS_TEXT];
114
+ // validateBlockingResult has admitted only an object, null or no claims.
115
+ const claims = snapshot.userRecord.customClaims;
116
+ if (claims != null) snapshot[CUSTOM_CLAIMS_TEXT] = JSON.stringify(claims);
117
+ return snapshot;
118
+ }
119
+
106
120
  export function blockingResult(value, eventType, HttpsError) {
107
121
  // Preserve the runner's established no-result behaviour.
108
122
  if (!value || typeof value !== "object") return {};
@@ -117,21 +131,25 @@ export function blockingResult(value, eventType, HttpsError) {
117
131
  } else {
118
132
  const userRecord = {};
119
133
  const updateMask = [];
120
- for (const [publicName, wireName] of [
121
- ["displayName", "displayName"],
122
- ["photoURL", "photoUrl"],
123
- ["disabled", "disabled"],
124
- ["emailVerified", "emailVerified"],
125
- ["customClaims", "customClaims"],
126
- ["sessionClaims", "sessionClaims"],
134
+ // Each field goes under its public name, as the Functions SDK's generateResponsePayload
135
+ // sends it: Identity Platform does not apply `photoURL` (it reads `photoUrl`), so a
136
+ // function's photoURL is ignored (AUTH-TENANT-BLOCKING recording 2026-09-28,
137
+ // ordering#lookup-profile; the official Auth emulator reads `photoUrl` only too).
138
+ for (const name of [
139
+ "displayName",
140
+ "photoURL",
141
+ "disabled",
142
+ "emailVerified",
143
+ "customClaims",
144
+ "sessionClaims",
127
145
  ]) {
128
- if (Object.prototype.hasOwnProperty.call(value, publicName)) {
129
- const field = value[publicName];
146
+ if (Object.prototype.hasOwnProperty.call(value, name)) {
147
+ const field = value[name];
130
148
  // Like the Functions SDK's getUpdateMask, undefined means no update;
131
149
  // null, false, and an empty string are still explicit updates.
132
150
  if (field === undefined) continue;
133
- userRecord[wireName] = field;
134
- updateMask.push(wireName);
151
+ userRecord[name] = field;
152
+ updateMask.push(name);
135
153
  }
136
154
  }
137
155
  if (updateMask.length === 0) return {};
@@ -142,5 +160,5 @@ export function blockingResult(value, eventType, HttpsError) {
142
160
  }
143
161
  const snapshot = snapshotResponse(candidate, HttpsError);
144
162
  validateBlockingResult(snapshot, eventType, HttpsError);
145
- return freezeResponse(snapshot);
163
+ return freezeResponse(attachCustomClaimsText(snapshot));
146
164
  }
@@ -17,6 +17,7 @@ import { execFileSync, spawn } from "node:child_process";
17
17
  import { url as inspectorUrl } from "node:inspector";
18
18
  import { instrumentCallables } from "./callable-app-check.mjs";
19
19
  import { blockingFailure } from "./blocking-error.mjs";
20
+ import { blockingEvent, loadIdentityParsers, portedIdentityParsers } from "./blocking-event.mjs";
20
21
  import { blockingResult } from "./blocking-response.mjs";
21
22
  import { boundLogMessage, createInvocationLogger } from "./log-context.mjs";
22
23
  import { invocationFailure } from "./invocation-error.mjs";
@@ -831,6 +832,15 @@ function callableAppCheck(instrumentation, fn) {
831
832
  };
832
833
  }
833
834
 
835
+ // Identity Platform's blocking events. The email and SMS events are discovered as well; the
836
+ // daemon serves them only where production parity asks for it (the official emulator serves
837
+ // beforeCreate and beforeSignIn only).
838
+ const BLOCKING_AUTH_EVENTS = ["beforeCreate", "beforeSignIn", "beforeSendEmail", "beforeSendSms"];
839
+
840
+ function isBlockingAuthEvent(eventType) {
841
+ return BLOCKING_AUTH_EVENTS.some((event) => eventType.endsWith(event));
842
+ }
843
+
834
844
  function blockingAuthTrigger(eventType, options) {
835
845
  return {
836
846
  type: "blockingAuth",
@@ -874,7 +884,7 @@ function describe(name, fn, instrumentation) {
874
884
  if (ep.callableTrigger) return { ...base, trigger: callable() };
875
885
  if (ep.blockingTrigger) {
876
886
  const eventType = String(ep.blockingTrigger.eventType || "");
877
- if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
887
+ if (isBlockingAuthEvent(eventType)) {
878
888
  return {
879
889
  ...base,
880
890
  trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
@@ -911,7 +921,7 @@ function describe(name, fn, instrumentation) {
911
921
  }
912
922
  if (ep.blockingTrigger) {
913
923
  const eventType = String(ep.blockingTrigger.eventType || "");
914
- if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
924
+ if (isBlockingAuthEvent(eventType)) {
915
925
  return {
916
926
  ...base,
917
927
  trigger: blockingAuthTrigger(eventType, ep.blockingTrigger.options),
@@ -1009,7 +1019,7 @@ function describe(name, fn, instrumentation) {
1009
1019
  }
1010
1020
  if (t.blockingTrigger) {
1011
1021
  const eventType = String(t.blockingTrigger.eventType || "");
1012
- if (eventType.endsWith("beforeCreate") || eventType.endsWith("beforeSignIn")) {
1022
+ if (isBlockingAuthEvent(eventType)) {
1013
1023
  return {
1014
1024
  ...base,
1015
1025
  trigger: blockingAuthTrigger(eventType, t.blockingTrigger.options),
@@ -1160,6 +1170,8 @@ async function makeHttpServer(functions, manifest) {
1160
1170
  }
1161
1171
  }
1162
1172
  const HttpsErrors = await firebaseHttpsErrorConstructors(require);
1173
+ // The SDK's own parsers build a blocking event from its token (blocking-event.mjs).
1174
+ const identityParsers = loadIdentityParsers(require);
1163
1175
  const app = express();
1164
1176
  const admission = createHttpAdmission({
1165
1177
  secret: process.env.FIREEMU_RUNNER_SECRET || "",
@@ -1280,8 +1292,18 @@ async function makeHttpServer(functions, manifest) {
1280
1292
  if (!lifetime.canStart()) return;
1281
1293
  try {
1282
1294
  if (blocking) {
1283
- const user = req.body?.data?.user;
1284
- const context = req.body?.data?.context || {};
1295
+ // A token is parsed by the codebase's own firebase-functions, as Identity Platform's
1296
+ // delivery is, or by the port of its parsers when that SDK does not expose them.
1297
+ const hasToken = req.body?.data?.jwt !== undefined;
1298
+ const parsed = hasToken
1299
+ ? blockingEvent(
1300
+ req.body,
1301
+ identityParsers || portedIdentityParsers,
1302
+ process.env.GCLOUD_PROJECT || "",
1303
+ )
1304
+ : undefined;
1305
+ const user = parsed ? parsed.user : req.body?.data?.user;
1306
+ const context = parsed ? parsed.context : req.body?.data?.context || {};
1285
1307
  const value = await (spec.generation === 1 ? fn.run(user, context) : fn.run({ ...context, data: user }));
1286
1308
  if (lifetime.canStart()) {
1287
1309
  // Materialization can call user getters/toJSON. Keep it inside this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fireemu/darwin-arm64",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "The fireemu daemon for darwin-arm64",
5
5
  "homepage": "https://github.com/t-k/fireemu#readme",
6
6
  "repository": {