pyric-admin 0.1.0-alpha.10

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