pyric-admin 0.1.0-alpha.7

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,785 @@
1
+ /**
2
+ * `pyric-admin/auth` — Phase 3 (branded-app dispatch) + Phase 4b
3
+ * (minimal in-memory sandbox backend).
4
+ *
5
+ * Mirrors `firebase-admin/auth` for a useful subset of methods. The
6
+ * `app` argument is the branded handle from `pyric-admin/app`
7
+ * ({@link PyricAdminApp}); the brand symbol picks the backend:
8
+ *
9
+ * - **Prod path** (`app[ADMIN_APP_TARGET] === 'prod'`) — delegates to
10
+ * `firebase-admin/auth`'s `getAuth(app.adminApp)` and returns the
11
+ * production `Auth` instance unchanged. Drop-in replacement for
12
+ * `firebase-admin/auth` so agents already calling it keep their
13
+ * exact behavior (tenants, providers, MFA, session cookies, action
14
+ * codes, all present on the returned handle).
15
+ *
16
+ * - **Sandbox path** (`app[ADMIN_APP_TARGET] === 'sandbox'`) — backed
17
+ * by an in-memory store keyed off `app.sandbox`. Implements the
18
+ * core user-management subset listed below. Tokens are NOT real
19
+ * JWTs — they're deterministic strings the same sandbox backend
20
+ * parses on `verifyIdToken`. The sandbox backend is intentionally
21
+ * minimal: enough to exercise agent code paths that use
22
+ * `createUser` / `getUser` / `setCustomUserClaims` /
23
+ * `createCustomToken` / `verifyIdToken`, not enough to model a
24
+ * real identity platform.
25
+ *
26
+ * - **Remote sandbox arm** — when the sandbox carries `pyric/sandbox`'s
27
+ * remote brand (a Node-side handle onto the browser-hosted
28
+ * SharedWorker sandbox from `pyric-tools`' `connectRemoteSandbox()`),
29
+ * user CRUD relays over the handle's worker channel instead of the
30
+ * in-memory store, so server-created users land in the ONE user pool
31
+ * the browser app + Studio share. See the "Remote sandbox arm"
32
+ * section below for the details (including the extra methods it
33
+ * supports: `updateUser`, `listUsers`).
34
+ *
35
+ * Surface scope on the sandbox backend (what works):
36
+ *
37
+ * - {@link getAuth}
38
+ * - `Auth.createCustomToken(uid, claims?)` — returns a deterministic
39
+ * `pyric-sandbox-custom:${uid}:${json}` string; no signing.
40
+ * - `Auth.verifyIdToken(token)` — parses tokens minted by
41
+ * `createCustomToken`; returns a {@link DecodedIdToken}-shaped
42
+ * object.
43
+ * - `Auth.createUser(properties)` — stores a {@link UserRecord}
44
+ * in an in-memory `Map<uid, UserRecord>`. Auto-generates a `uid`
45
+ * when one is not supplied.
46
+ * - `Auth.getUser(uid)` — Map lookup.
47
+ * - `Auth.getUserByEmail(email)` — linear scan.
48
+ * - `Auth.deleteUser(uid)` — Map delete.
49
+ * - `Auth.setCustomUserClaims(uid, claims)` — updates the stored
50
+ * `UserRecord.customClaims`.
51
+ *
52
+ * Sandbox backend — explicitly NOT implemented (throws
53
+ * `'not implemented in pyric-admin/auth sandbox backend'` so callers
54
+ * get a clear remediation message):
55
+ *
56
+ * - Tenancy: `tenantManager` and any per-tenant call.
57
+ * - Identity providers: `createProviderConfig`, `getProviderConfig`,
58
+ * `listProviderConfigs`, `updateProviderConfig`,
59
+ * `deleteProviderConfig`.
60
+ * - Multi-factor: `MultiFactorSettings` on UserRecord is always
61
+ * `undefined`; MFA enrollment is unsupported.
62
+ * - Session cookies: `createSessionCookie`, `verifySessionCookie`.
63
+ * - Action codes / password reset / email link sign-in:
64
+ * `generatePasswordResetLink`, `generateEmailVerificationLink`,
65
+ * `generateSignInWithEmailLink`, `generateVerifyAndChangeEmailLink`.
66
+ * - Bulk operations: `listUsers`, `getUsers`, `deleteUsers`,
67
+ * `importUsers`.
68
+ * - Revocation: `revokeRefreshTokens`.
69
+ * - `getUserByPhoneNumber`, `getUserByProviderUid`.
70
+ * - `updateUser` — not required by the brief.
71
+ *
72
+ * Real `firebase-admin` types are imported with `import type` so
73
+ * signatures stay identical to upstream — there's no shape drift to
74
+ * keep in sync.
75
+ */
76
+ import { isRemoteSandbox, } from 'pyric/sandbox';
77
+ import { ADMIN_APP_TARGET, getApp, } from '../app/index.js';
78
+ // ─── Sandbox backend ────────────────────────────────────────────────────
79
+ /**
80
+ * Per-sandbox in-memory store. One instance per `Sandbox` (tracked in
81
+ * the {@link sandboxStores} WeakMap below). Holds the user table; the
82
+ * token format is stateless (`createCustomToken` mints, `verifyIdToken`
83
+ * parses) so it doesn't need to live here.
84
+ *
85
+ * `usersByUid` is the canonical index. `getUserByEmail` does a linear
86
+ * scan over its values — the sandbox is for development and agent test
87
+ * runs, not production traffic, so an extra index isn't worth the
88
+ * write-path complexity.
89
+ */
90
+ class AuthStore {
91
+ usersByUid = new Map();
92
+ /** Monotonic counter for auto-generated uids. Reset along with the
93
+ * user map when the sandbox calls `reset()`. */
94
+ nextAutoUid = 1;
95
+ /**
96
+ * Mint an auto-uid in the same shape Firebase Auth uses (28 chars,
97
+ * URL-safe alphabet). The sandbox doesn't need cryptographic
98
+ * collision resistance — it needs a stable, debuggable identifier
99
+ * that doesn't collide *within one sandbox session*. A counter plus
100
+ * a constant prefix is enough; padding keeps the visual width
101
+ * roughly consistent with prod uids.
102
+ */
103
+ mintUid() {
104
+ const n = String(this.nextAutoUid++).padStart(20, '0');
105
+ return `pyric-sandbox-${n}`;
106
+ }
107
+ /** Wipe state. Called on `sandbox.reset()`. */
108
+ clear() {
109
+ this.usersByUid.clear();
110
+ this.nextAutoUid = 1;
111
+ }
112
+ }
113
+ /**
114
+ * One {@link AuthStore} per `Sandbox`. WeakMap so a sandbox that gets
115
+ * GC'd by its host takes its auth state with it — no manual disposal
116
+ * needed.
117
+ */
118
+ const sandboxStores = new WeakMap();
119
+ /**
120
+ * Tracks which sandboxes already have a `session_boundary` listener
121
+ * attached. Without this guard, calling `getAuth(app)` twice for the
122
+ * same sandbox would register two listeners that each clear the store
123
+ * on reset — harmless functionally, but a noisy leak.
124
+ */
125
+ const sandboxesWithReset = new WeakSet();
126
+ /**
127
+ * Get-or-create the auth store for a sandbox, and on first creation
128
+ * subscribe to `session_boundary` events so the store wipes itself when
129
+ * the sandbox is reset. The subscription is attached once per sandbox.
130
+ *
131
+ * `dispose` is also a session boundary; we clear on either phase so a
132
+ * disposed-and-replaced sandbox doesn't hand its successor stale state.
133
+ * (In practice WeakMap GC handles that, but clearing eagerly costs
134
+ * nothing.)
135
+ */
136
+ function storeFor(sandbox) {
137
+ let store = sandboxStores.get(sandbox);
138
+ if (store)
139
+ return store;
140
+ store = new AuthStore();
141
+ sandboxStores.set(sandbox, store);
142
+ if (!sandboxesWithReset.has(sandbox)) {
143
+ sandboxesWithReset.add(sandbox);
144
+ sandbox.onEvent((event) => {
145
+ if (event.kind === 'session_boundary') {
146
+ store.clear();
147
+ }
148
+ });
149
+ }
150
+ return store;
151
+ }
152
+ /**
153
+ * Token format minted by `createCustomToken` and parsed by
154
+ * `verifyIdToken`. Exported as a constant so tests can lock the shape.
155
+ *
156
+ * Layout: `pyric-sandbox-custom:${uid}:${jsonClaims}`
157
+ *
158
+ * - The prefix lets `verifyIdToken` reject foreign tokens with a clear
159
+ * "not a sandbox token" error rather than NaN'ing out.
160
+ * - `uid` is colon-free per the auto-uid format above.
161
+ * - `jsonClaims` is the JSON-stringified developer claims (or `{}` when
162
+ * none were provided). Round-trips losslessly through `JSON.parse`.
163
+ *
164
+ * NOT a JWT. NOT signed. Do not use this token format to talk to any
165
+ * real Firebase service — it only round-trips through this same
166
+ * sandbox backend.
167
+ */
168
+ export const SANDBOX_TOKEN_PREFIX = 'pyric-sandbox-custom';
169
+ /**
170
+ * Mint a deterministic sandbox token (the {@link SANDBOX_TOKEN_PREFIX}
171
+ * format). Stateless — shared verbatim by the local and remote sandbox
172
+ * arms, so a token minted against either round-trips through
173
+ * {@link verifySandboxIdToken} on the other.
174
+ */
175
+ function mintSandboxCustomToken(uid, developerClaims) {
176
+ const claims = developerClaims ?? {};
177
+ const token = `${SANDBOX_TOKEN_PREFIX}:${uid}:${JSON.stringify(claims)}`;
178
+ return Promise.resolve(token);
179
+ }
180
+ /**
181
+ * Parse a token minted by {@link mintSandboxCustomToken}. Returns a
182
+ * `DecodedIdToken`-shaped object — every required field is filled with a
183
+ * sandbox-appropriate placeholder (`iss`/`aud` = `pyric-sandbox`, time
184
+ * fields = now), and the developer claims are spread onto the result so
185
+ * `decoded.role` etc. work the same way they do in prod.
186
+ *
187
+ * Throws on any token that doesn't match the
188
+ * `${SANDBOX_TOKEN_PREFIX}:${uid}:${json}` shape — including real JWTs
189
+ * that another verifier would parse. The sandbox backends are
190
+ * intentionally not drop-ins for production token verification.
191
+ */
192
+ function verifySandboxIdToken(idToken) {
193
+ if (typeof idToken !== 'string' || !idToken.startsWith(`${SANDBOX_TOKEN_PREFIX}:`)) {
194
+ return Promise.reject(new Error('pyric-admin/auth: verifyIdToken on the sandbox backend only ' +
195
+ 'accepts tokens minted by this sandbox\'s createCustomToken. ' +
196
+ `Token prefix must be "${SANDBOX_TOKEN_PREFIX}:".`));
197
+ }
198
+ // The token is `prefix:uid:json`. Split on the first two colons
199
+ // only — the JSON payload may itself contain colons (e.g. inside
200
+ // a string value) so `split(':')` with no limit would corrupt it.
201
+ const firstColon = idToken.indexOf(':');
202
+ const secondColon = idToken.indexOf(':', firstColon + 1);
203
+ if (secondColon < 0) {
204
+ return Promise.reject(new Error(`pyric-admin/auth: verifyIdToken received a malformed sandbox token (missing claims segment): ${idToken}`));
205
+ }
206
+ const uid = idToken.slice(firstColon + 1, secondColon);
207
+ const jsonClaims = idToken.slice(secondColon + 1);
208
+ let claims;
209
+ try {
210
+ claims = JSON.parse(jsonClaims);
211
+ }
212
+ catch (e) {
213
+ return Promise.reject(new Error(`pyric-admin/auth: verifyIdToken failed to parse sandbox token claims as JSON: ${e.message}`));
214
+ }
215
+ const nowSec = Math.floor(Date.now() / 1000);
216
+ // `DecodedIdToken` requires `aud`/`iss`/`sub`/`uid`/`auth_time`/
217
+ // `exp`/`iat`/`firebase` to be present; the sandbox fills them
218
+ // with placeholders so consumers that read them get sensible
219
+ // values rather than `undefined`. Developer claims are spread on
220
+ // top so they shadow nothing critical.
221
+ const decoded = {
222
+ aud: 'pyric-sandbox',
223
+ auth_time: nowSec,
224
+ exp: nowSec + 3600,
225
+ firebase: {
226
+ identities: {},
227
+ sign_in_provider: 'custom',
228
+ },
229
+ iat: nowSec,
230
+ iss: 'pyric-sandbox',
231
+ sub: uid,
232
+ uid,
233
+ ...claims,
234
+ };
235
+ return Promise.resolve(decoded);
236
+ }
237
+ /**
238
+ * Build the in-memory `Auth` handle for a sandbox app. Returns an
239
+ * object structurally compatible with `firebase-admin/auth`'s `Auth`
240
+ * (cast at the boundary) where the documented method subset is wired
241
+ * to the in-memory store and everything else throws the canonical
242
+ * `not implemented in pyric-admin/auth sandbox backend` error.
243
+ *
244
+ * The cast-to-`Auth` at the return is deliberate — `firebase-admin`'s
245
+ * `Auth` is a large class surface (tenants, providers, MFA, session
246
+ * cookies) that this backend doesn't model. Implementing the entire
247
+ * surface as throwing stubs would be ~30 unused methods of noise; the
248
+ * cast acknowledges the divergence in one place and lets the rest of
249
+ * the file focus on the methods that actually work.
250
+ */
251
+ function makeSandboxAuth(sandbox) {
252
+ const store = storeFor(sandbox);
253
+ /** Canonical "not implemented" error for surface that the sandbox
254
+ * backend doesn't model. Threaded through every stub so the message
255
+ * is identical wherever it's hit. */
256
+ const notImplemented = (method) => new Error(`pyric-admin/auth: ${method} is not implemented in pyric-admin/auth sandbox backend`);
257
+ /**
258
+ * Convert a `CreateRequest` to a `UserRecord`. `firebase-admin`'s
259
+ * `UserRecord` is a class with `readonly` fields; we build a plain
260
+ * object with the same shape and cast it. The sandbox doesn't need
261
+ * the class's `toJSON()` or its provider-merging logic — it needs the
262
+ * field set that the documented method subset reads back.
263
+ *
264
+ * Defaults match upstream defaults: `emailVerified: false`,
265
+ * `disabled: false`, empty `providerData`, present `metadata` with
266
+ * sandbox-current timestamps.
267
+ */
268
+ const toUserRecord = (uid, props, customClaims) => {
269
+ const now = new Date().toUTCString();
270
+ const record = {
271
+ uid,
272
+ email: props.email,
273
+ emailVerified: props.emailVerified ?? false,
274
+ displayName: props.displayName ?? undefined,
275
+ photoURL: props.photoURL ?? undefined,
276
+ phoneNumber: props.phoneNumber ?? undefined,
277
+ disabled: props.disabled ?? false,
278
+ metadata: {
279
+ creationTime: now,
280
+ lastSignInTime: '',
281
+ toJSON: () => ({ creationTime: now, lastSignInTime: '' }),
282
+ },
283
+ providerData: [],
284
+ customClaims,
285
+ tenantId: null,
286
+ toJSON: () => ({ uid, email: props.email }),
287
+ };
288
+ return record;
289
+ };
290
+ // The handle. Methods that round-trip to the store are real; the
291
+ // rest throw via `notImplemented`. Cast to `Auth` at return.
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ const handle = {
294
+ /** Stamp the app on the handle so debugging shows where it came
295
+ * from. The prod handle has an `app` getter too — matching the
296
+ * shape keeps consumers that inspect `auth.app` from breaking. */
297
+ get app() {
298
+ throw notImplemented('auth.app');
299
+ },
300
+ /**
301
+ * Mint a deterministic sandbox token. Format is fixed at
302
+ * `${SANDBOX_TOKEN_PREFIX}:${uid}:${JSON.stringify(claims ?? {})}`.
303
+ * Round-trips through {@link verifyIdToken} on the same sandbox
304
+ * backend; rejected by every other token verifier. Shared
305
+ * implementation: {@link mintSandboxCustomToken}.
306
+ */
307
+ createCustomToken(uid, developerClaims) {
308
+ return mintSandboxCustomToken(uid, developerClaims);
309
+ },
310
+ /**
311
+ * Parse a token minted by {@link createCustomToken}. Shared
312
+ * implementation: {@link verifySandboxIdToken}.
313
+ */
314
+ verifyIdToken(idToken, _checkRevoked) {
315
+ return verifySandboxIdToken(idToken);
316
+ },
317
+ /**
318
+ * Store a {@link UserRecord} in the in-memory map. If the caller
319
+ * supplied a `uid`, it's used as-is (and a conflict throws
320
+ * `auth/uid-already-exists`-style); otherwise a sandbox-shaped
321
+ * uid is minted.
322
+ */
323
+ createUser(properties) {
324
+ const uid = properties.uid ?? store.mintUid();
325
+ if (store.usersByUid.has(uid)) {
326
+ return Promise.reject(new Error(`pyric-admin/auth: createUser failed — uid "${uid}" already exists in the sandbox auth store`));
327
+ }
328
+ const record = toUserRecord(uid, properties);
329
+ store.usersByUid.set(uid, record);
330
+ return Promise.resolve(record);
331
+ },
332
+ /** Map lookup; rejects with a `user-not-found` message on miss. */
333
+ getUser(uid) {
334
+ const record = store.usersByUid.get(uid);
335
+ if (!record) {
336
+ return Promise.reject(new Error(`pyric-admin/auth: getUser failed — no user with uid "${uid}"`));
337
+ }
338
+ return Promise.resolve(record);
339
+ },
340
+ /** Linear scan. Sandbox-scale data only — see class JSDoc. */
341
+ getUserByEmail(email) {
342
+ for (const record of store.usersByUid.values()) {
343
+ if (record.email === email)
344
+ return Promise.resolve(record);
345
+ }
346
+ return Promise.reject(new Error(`pyric-admin/auth: getUserByEmail failed — no user with email "${email}"`));
347
+ },
348
+ /** Idempotent: removing a nonexistent uid is a no-op (matches
349
+ * upstream's "successful response" behavior on missing users for
350
+ * the admin SDK's delete semantics — the SDK does throw, but the
351
+ * test fixtures consume the throw as a non-fatal). We throw on
352
+ * miss to match upstream's stricter contract on `deleteUser`. */
353
+ deleteUser(uid) {
354
+ if (!store.usersByUid.has(uid)) {
355
+ return Promise.reject(new Error(`pyric-admin/auth: deleteUser failed — no user with uid "${uid}"`));
356
+ }
357
+ store.usersByUid.delete(uid);
358
+ return Promise.resolve();
359
+ },
360
+ /**
361
+ * Update the stored UserRecord's `customClaims`. Passing `null`
362
+ * clears them (matches the upstream contract:
363
+ * `customUserClaims: object | null`).
364
+ *
365
+ * The UserRecord is rewritten — `customClaims` is `readonly` on
366
+ * the upstream type, so an in-place mutation would type-error.
367
+ * We rebuild the record from the prior props and the new claims,
368
+ * preserving every other field.
369
+ */
370
+ setCustomUserClaims(uid, customUserClaims) {
371
+ const prior = store.usersByUid.get(uid);
372
+ if (!prior) {
373
+ return Promise.reject(new Error(`pyric-admin/auth: setCustomUserClaims failed — no user with uid "${uid}"`));
374
+ }
375
+ const updated = {
376
+ ...prior,
377
+ customClaims: customUserClaims === null
378
+ ? undefined
379
+ : customUserClaims,
380
+ toJSON: prior.toJSON.bind(prior),
381
+ };
382
+ store.usersByUid.set(uid, updated);
383
+ return Promise.resolve();
384
+ },
385
+ // ─── Explicitly-not-implemented surface ─────────────────────────
386
+ //
387
+ // The rest of `BaseAuth` (and `Auth`) — tenants, providers, MFA,
388
+ // session cookies, action codes, bulk ops, refresh-token
389
+ // revocation, phone/provider lookups, `updateUser`. Each throws
390
+ // the canonical `not implemented in pyric-admin/auth sandbox
391
+ // backend` error so the caller knows the surface exists upstream
392
+ // but isn't modelled here.
393
+ updateUser() {
394
+ return Promise.reject(notImplemented('updateUser'));
395
+ },
396
+ getUserByPhoneNumber() {
397
+ return Promise.reject(notImplemented('getUserByPhoneNumber'));
398
+ },
399
+ getUserByProviderUid() {
400
+ return Promise.reject(notImplemented('getUserByProviderUid'));
401
+ },
402
+ getUsers() {
403
+ return Promise.reject(notImplemented('getUsers'));
404
+ },
405
+ deleteUsers() {
406
+ return Promise.reject(notImplemented('deleteUsers'));
407
+ },
408
+ listUsers() {
409
+ return Promise.reject(notImplemented('listUsers'));
410
+ },
411
+ importUsers() {
412
+ return Promise.reject(notImplemented('importUsers'));
413
+ },
414
+ revokeRefreshTokens() {
415
+ return Promise.reject(notImplemented('revokeRefreshTokens'));
416
+ },
417
+ createSessionCookie() {
418
+ return Promise.reject(notImplemented('createSessionCookie'));
419
+ },
420
+ verifySessionCookie() {
421
+ return Promise.reject(notImplemented('verifySessionCookie'));
422
+ },
423
+ generatePasswordResetLink() {
424
+ return Promise.reject(notImplemented('generatePasswordResetLink'));
425
+ },
426
+ generateEmailVerificationLink() {
427
+ return Promise.reject(notImplemented('generateEmailVerificationLink'));
428
+ },
429
+ generateSignInWithEmailLink() {
430
+ return Promise.reject(notImplemented('generateSignInWithEmailLink'));
431
+ },
432
+ generateVerifyAndChangeEmailLink() {
433
+ return Promise.reject(notImplemented('generateVerifyAndChangeEmailLink'));
434
+ },
435
+ createProviderConfig() {
436
+ return Promise.reject(notImplemented('createProviderConfig'));
437
+ },
438
+ getProviderConfig() {
439
+ return Promise.reject(notImplemented('getProviderConfig'));
440
+ },
441
+ listProviderConfigs() {
442
+ return Promise.reject(notImplemented('listProviderConfigs'));
443
+ },
444
+ updateProviderConfig() {
445
+ return Promise.reject(notImplemented('updateProviderConfig'));
446
+ },
447
+ deleteProviderConfig() {
448
+ return Promise.reject(notImplemented('deleteProviderConfig'));
449
+ },
450
+ get tenantManager() {
451
+ throw notImplemented('tenantManager');
452
+ },
453
+ get projectConfigManager() {
454
+ throw notImplemented('projectConfigManager');
455
+ },
456
+ };
457
+ return handle;
458
+ }
459
+ // ─── Remote sandbox arm (remote sandbox, slice 1) ───────────────────────
460
+ //
461
+ // The app's `Sandbox` is a Node-side handle onto the browser-hosted
462
+ // SharedWorker sandbox (`pyric/sandbox`'s remote brand). User CRUD relays
463
+ // over the handle's worker channel as the existing admin auth ops
464
+ // (`auth.adminCreateUser` / `auth.adminUpdateUser` / `auth.adminDeleteUser`
465
+ // / `auth.listUsers`) so server-created users land in the ONE user pool
466
+ // the browser app + Studio + agents share — an in-memory `AuthStore` keyed
467
+ // off a remote handle would be a private user table the browser never
468
+ // sees. Auth ops are never lensed (they operate the worker's user pool
469
+ // directly), so no `actAs` is pinned here, unlike the RTDB arm.
470
+ //
471
+ // Single-user lookups (`getUser` / `getUserByEmail`) go through
472
+ // `auth.listUsers` + a client-side filter: the worker protocol has no
473
+ // dedicated single-lookup op, and O(n) over the wire is fine at sandbox
474
+ // scale (per the design spike — add an op if it ever matters).
475
+ //
476
+ // Tokens stay stateless and local: `createCustomToken` / `verifyIdToken`
477
+ // are the same string transforms as the local arm
478
+ // ({@link mintSandboxCustomToken} / {@link verifySandboxIdToken}), so a
479
+ // token minted server-side verifies against any pyric-admin backend.
480
+ /** Map a firebase-admin `CreateRequest` onto the worker's sandbox
481
+ * create-user request. `null`s (upstream "clear") become "unset" — a
482
+ * fresh user has nothing to clear. `multiFactor` isn't modeled. */
483
+ function toSandboxCreateRequest(props) {
484
+ return {
485
+ uid: props.uid,
486
+ email: props.email,
487
+ password: props.password,
488
+ displayName: props.displayName ?? undefined,
489
+ phoneNumber: props.phoneNumber ?? undefined,
490
+ photoUrl: props.photoURL ?? undefined,
491
+ disabled: props.disabled,
492
+ emailVerified: props.emailVerified,
493
+ };
494
+ }
495
+ /** Convert the worker's `AuthUserRecord` (emulator-REST-shaped, from
496
+ * `pyric/auth`) into a firebase-admin `UserRecord`-shaped object — the
497
+ * same field set the local arm's `toUserRecord` fills. */
498
+ function fromAuthUserRecord(r) {
499
+ const metadata = {
500
+ creationTime: r.createdAt,
501
+ lastSignInTime: r.lastLoginAt ?? '',
502
+ toJSON: () => ({ creationTime: r.createdAt, lastSignInTime: r.lastLoginAt ?? '' }),
503
+ };
504
+ const record = {
505
+ uid: r.uid,
506
+ email: r.email ?? undefined,
507
+ emailVerified: r.emailVerified,
508
+ displayName: r.displayName ?? undefined,
509
+ photoURL: r.photoUrl ?? undefined,
510
+ phoneNumber: r.phoneNumber ?? undefined,
511
+ disabled: r.disabled,
512
+ metadata,
513
+ providerData: r.providerUserInfo.map((p) => ({
514
+ providerId: p.providerId,
515
+ uid: r.uid,
516
+ displayName: r.displayName ?? undefined,
517
+ email: r.email ?? undefined,
518
+ photoURL: r.photoUrl ?? undefined,
519
+ phoneNumber: r.phoneNumber ?? undefined,
520
+ toJSON: () => ({ providerId: p.providerId, uid: r.uid }),
521
+ })),
522
+ customClaims: Object.keys(r.customClaims).length > 0
523
+ ? r.customClaims
524
+ : undefined,
525
+ tenantId: null,
526
+ toJSON: () => ({ uid: r.uid, email: r.email ?? undefined }),
527
+ };
528
+ return record;
529
+ }
530
+ /**
531
+ * Build the remote `Auth` handle: the documented CRUD subset relays over
532
+ * the worker channel; tokens are the shared stateless transforms; the
533
+ * rest of the surface throws the canonical "not implemented" error (same
534
+ * cast-at-the-boundary rationale as {@link makeSandboxAuth}).
535
+ *
536
+ * Differences from the local in-memory arm, all deliberate:
537
+ * - `updateUser` and `listUsers` WORK (the worker has the ops; the
538
+ * local arm predates them and still throws).
539
+ * - User mutations emit auth `SandboxEvent`s in the worker (visible to
540
+ * Studio/agents) and are visible to the browser app immediately.
541
+ */
542
+ function makeRemoteAuth(sandbox) {
543
+ const channel = sandbox.channel;
544
+ const notImplemented = (method) => new Error(`pyric-admin/auth: ${method} is not implemented in pyric-admin/auth remote sandbox backend`);
545
+ const listRecords = async () => (await channel.op({ method: 'auth.listUsers' }));
546
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
547
+ const handle = {
548
+ get app() {
549
+ throw notImplemented('auth.app');
550
+ },
551
+ /** Stateless mint — identical to the local arm; needs no relay. */
552
+ createCustomToken(uid, developerClaims) {
553
+ return mintSandboxCustomToken(uid, developerClaims);
554
+ },
555
+ /** Stateless parse — identical to the local arm; needs no relay. */
556
+ verifyIdToken(idToken, _checkRevoked) {
557
+ return verifySandboxIdToken(idToken);
558
+ },
559
+ /** Relays `auth.adminCreateUser`. Uid conflicts / invalid emails /
560
+ * weak passwords reject with the worker backend's `auth/*` error. */
561
+ async createUser(properties) {
562
+ const record = await channel.op({
563
+ method: 'auth.adminCreateUser',
564
+ request: toSandboxCreateRequest(properties),
565
+ });
566
+ return fromAuthUserRecord(record);
567
+ },
568
+ /** `auth.listUsers` + client-side filter (see module note). */
569
+ async getUser(uid) {
570
+ const record = (await listRecords()).find((u) => u.uid === uid);
571
+ if (!record) {
572
+ throw new Error(`pyric-admin/auth: getUser failed — no user with uid "${uid}"`);
573
+ }
574
+ return fromAuthUserRecord(record);
575
+ },
576
+ /** `auth.listUsers` + client-side filter (see module note). */
577
+ async getUserByEmail(email) {
578
+ const record = (await listRecords()).find((u) => u.email === email);
579
+ if (!record) {
580
+ throw new Error(`pyric-admin/auth: getUserByEmail failed — no user with email "${email}"`);
581
+ }
582
+ return fromAuthUserRecord(record);
583
+ },
584
+ /** Relays `auth.listUsers`. The whole pool fits one page at sandbox
585
+ * scale, so `pageToken` is never set; `maxResults` is honored. */
586
+ async listUsers(maxResults, _pageToken) {
587
+ let records = await listRecords();
588
+ if (maxResults !== undefined)
589
+ records = records.slice(0, maxResults);
590
+ return { users: records.map(fromAuthUserRecord) };
591
+ },
592
+ /**
593
+ * Relays `auth.adminUpdateUser` for the fields the worker models
594
+ * (`displayName` / `email` / `password` / `disabled` /
595
+ * `emailVerified`). Fields it can't express (`photoURL`,
596
+ * `phoneNumber`, `multiFactor`, provider links) throw rather than
597
+ * silently dropping a requested change.
598
+ */
599
+ async updateUser(uid, properties) {
600
+ const unsupported = ['photoURL', 'phoneNumber', 'multiFactor', 'providerToLink', 'providersToUnlink'].filter((k) => properties[k] !== undefined);
601
+ if (unsupported.length > 0) {
602
+ throw notImplemented(`updateUser({ ${unsupported.join(', ')} })`);
603
+ }
604
+ const request = {
605
+ displayName: properties.displayName,
606
+ email: properties.email,
607
+ password: properties.password,
608
+ disabled: properties.disabled,
609
+ emailVerified: properties.emailVerified,
610
+ };
611
+ const record = await channel.op({
612
+ method: 'auth.adminUpdateUser',
613
+ uid,
614
+ request: request,
615
+ });
616
+ return fromAuthUserRecord(record);
617
+ },
618
+ /** Relays `auth.adminDeleteUser`. A missing uid rejects with the
619
+ * worker backend's `auth/user-not-found` error (matches upstream's
620
+ * strict delete contract, like the local arm). */
621
+ async deleteUser(uid) {
622
+ await channel.op({ method: 'auth.adminDeleteUser', uid });
623
+ },
624
+ /** Relays `auth.adminUpdateUser` with a `customClaims` replacement —
625
+ * `null` clears (the worker's UpdateUserRequest.customClaims
626
+ * replaces the whole map, admin `setCustomUserClaims` semantics). */
627
+ async setCustomUserClaims(uid, customUserClaims) {
628
+ await channel.op({
629
+ method: 'auth.adminUpdateUser',
630
+ uid,
631
+ request: { customClaims: customUserClaims ?? {} },
632
+ });
633
+ },
634
+ // ─── Explicitly-not-implemented surface (parity with local) ──────
635
+ getUserByPhoneNumber() {
636
+ return Promise.reject(notImplemented('getUserByPhoneNumber'));
637
+ },
638
+ getUserByProviderUid() {
639
+ return Promise.reject(notImplemented('getUserByProviderUid'));
640
+ },
641
+ getUsers() {
642
+ return Promise.reject(notImplemented('getUsers'));
643
+ },
644
+ deleteUsers() {
645
+ return Promise.reject(notImplemented('deleteUsers'));
646
+ },
647
+ importUsers() {
648
+ return Promise.reject(notImplemented('importUsers'));
649
+ },
650
+ revokeRefreshTokens() {
651
+ return Promise.reject(notImplemented('revokeRefreshTokens'));
652
+ },
653
+ createSessionCookie() {
654
+ return Promise.reject(notImplemented('createSessionCookie'));
655
+ },
656
+ verifySessionCookie() {
657
+ return Promise.reject(notImplemented('verifySessionCookie'));
658
+ },
659
+ generatePasswordResetLink() {
660
+ return Promise.reject(notImplemented('generatePasswordResetLink'));
661
+ },
662
+ generateEmailVerificationLink() {
663
+ return Promise.reject(notImplemented('generateEmailVerificationLink'));
664
+ },
665
+ generateSignInWithEmailLink() {
666
+ return Promise.reject(notImplemented('generateSignInWithEmailLink'));
667
+ },
668
+ generateVerifyAndChangeEmailLink() {
669
+ return Promise.reject(notImplemented('generateVerifyAndChangeEmailLink'));
670
+ },
671
+ createProviderConfig() {
672
+ return Promise.reject(notImplemented('createProviderConfig'));
673
+ },
674
+ getProviderConfig() {
675
+ return Promise.reject(notImplemented('getProviderConfig'));
676
+ },
677
+ listProviderConfigs() {
678
+ return Promise.reject(notImplemented('listProviderConfigs'));
679
+ },
680
+ updateProviderConfig() {
681
+ return Promise.reject(notImplemented('updateProviderConfig'));
682
+ },
683
+ deleteProviderConfig() {
684
+ return Promise.reject(notImplemented('deleteProviderConfig'));
685
+ },
686
+ get tenantManager() {
687
+ throw notImplemented('tenantManager');
688
+ },
689
+ get projectConfigManager() {
690
+ throw notImplemented('projectConfigManager');
691
+ },
692
+ };
693
+ return handle;
694
+ }
695
+ // ─── Dispatch ────────────────────────────────────────────────────────────
696
+ /**
697
+ * Type guard for the prod arm of {@link PyricAdminApp}. Reads the
698
+ * brand symbol from `pyric-admin/app` — no structural sniffing.
699
+ */
700
+ function isProdAdminApp(app) {
701
+ return app[ADMIN_APP_TARGET] === 'prod';
702
+ }
703
+ /**
704
+ * Type guard for the sandbox arm of {@link PyricAdminApp}. Brand-based,
705
+ * symmetric with {@link isProdAdminApp}.
706
+ */
707
+ function isSandboxAdminApp(app) {
708
+ return app[ADMIN_APP_TARGET] === 'sandbox';
709
+ }
710
+ /**
711
+ * Return an `Auth` handle for the given app — or for the DEFAULT app when
712
+ * called with no argument (mirrors firebase-admin's no-arg `getAuth()`:
713
+ * resolves `'[DEFAULT]'` through `pyric-admin/app`'s registry and throws
714
+ * `app/no-app` when nothing has been initialized). Works on all three
715
+ * arms — local sandbox, remote sandbox, prod — since dispatch happens on
716
+ * whatever brand the resolved app carries.
717
+ *
718
+ * Dispatches on the brand symbol set by `pyric-admin/app`'s
719
+ * `initializeApp`:
720
+ * - `'prod'` → delegates to `firebase-admin/auth`'s `getAuth` against
721
+ * `app.adminApp`. The returned `Auth` is the production object,
722
+ * unmodified.
723
+ * - `'sandbox'` → returns an in-memory `Auth` handle backed by an
724
+ * {@link AuthStore} keyed off `app.sandbox`. Repeat calls for the
725
+ * same sandbox share the store, so writes are visible across
726
+ * handles (matches upstream `getAuth(app)` idempotency).
727
+ *
728
+ * The firebase-admin import is dynamic (`require` at call time) so the
729
+ * module's top-level evaluation stays cheap and so sandbox-only
730
+ * consumers do not pay the firebase-admin initialization cost. Mirrors
731
+ * how `pyric/firestore` defers its firebase init: backends are
732
+ * pay-for-what-you-use.
733
+ *
734
+ * @example
735
+ * ```ts
736
+ * import { initializeApp } from 'pyric-admin/app';
737
+ * import { initializeSandbox } from 'pyric/sandbox';
738
+ * import { getAuth } from 'pyric-admin/auth';
739
+ *
740
+ * const sandbox = initializeSandbox();
741
+ * const app = initializeApp({ sandbox });
742
+ * const auth = getAuth(app);
743
+ *
744
+ * const user = await auth.createUser({ uid: 'alice', email: 'a@e.com' });
745
+ * const token = await auth.createCustomToken(user.uid, { role: 'admin' });
746
+ * const decoded = await auth.verifyIdToken(token);
747
+ * console.log(decoded.uid, decoded.role); // 'alice' 'admin'
748
+ * ```
749
+ */
750
+ export function getAuth(app) {
751
+ if (app === undefined) {
752
+ // No-arg mirror of firebase-admin's `getAuth()` — resolve the
753
+ // '[DEFAULT]' app from the registry (throws app/no-app on a miss).
754
+ app = getApp();
755
+ }
756
+ if (app === null || typeof app !== 'object' || !(ADMIN_APP_TARGET in app)) {
757
+ throw new TypeError('pyric-admin/auth: getAuth expected a PyricAdminApp (from pyric-admin/app#initializeApp). ' +
758
+ 'Received a value with no ADMIN_APP_TARGET brand. Pass the handle returned by ' +
759
+ '`initializeApp({ credential })` (prod) or `initializeApp({ sandbox })` (sandbox).');
760
+ }
761
+ if (isSandboxAdminApp(app)) {
762
+ // Remote-branded sandboxes dispatch BEFORE the local arm: the local
763
+ // in-memory store would be a private server-side user table the
764
+ // browser never sees (and its `sandbox.onEvent` reset wiring throws
765
+ // on remote handles by design).
766
+ if (isRemoteSandbox(app.sandbox)) {
767
+ return makeRemoteAuth(app.sandbox);
768
+ }
769
+ return makeSandboxAuth(app.sandbox);
770
+ }
771
+ if (isProdAdminApp(app)) {
772
+ // Prod path — defer the firebase-admin import to call time so the
773
+ // module load is cheap. `require` works in Bun + Node ESM via
774
+ // module-interop and avoids top-level `await` (which would force
775
+ // every consumer to live in an async module graph).
776
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
777
+ const { getAuth: getAdminAuth } = require('firebase-admin/auth');
778
+ return getAdminAuth(app.adminApp);
779
+ }
780
+ // Branded with a value the dispatch table doesn't know — future
781
+ // target string from a newer `pyric-admin/app` we haven't been
782
+ // updated for.
783
+ throw new TypeError(`pyric-admin/auth: getAuth received a PyricAdminApp with an unrecognized ADMIN_APP_TARGET value: ${String(app[ADMIN_APP_TARGET])}`);
784
+ }
785
+ //# sourceMappingURL=index.js.map