pyric-admin 0.1.0-alpha.11 → 0.1.0-alpha.13

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