pyric-admin 0.1.0-alpha.10 → 0.1.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md.orig CHANGED
@@ -21,6 +21,7 @@ loads `firebase-admin` directly.
21
21
  | `pyric-admin/auth` | Admin Auth shape |
22
22
  | `pyric-admin/database` | Admin Realtime Database shape |
23
23
  | `pyric-admin/storage` | Admin Storage shape |
24
+ | `pyric-admin/messaging` | Admin Cloud Messaging send plane |
24
25
 
25
26
  ## Explicit sandbox setup
26
27
 
@@ -46,3 +47,4 @@ without sandbox activation and therefore resolves Firebase Admin directly.
46
47
  - [Auth API](https://pyric.dev/docs/pyric-admin-auth-reference-api/)
47
48
  - [Realtime Database API](https://pyric.dev/docs/pyric-admin-database-reference-api/)
48
49
  - [Storage API](https://pyric.dev/docs/pyric-admin-storage-reference-api/)
50
+ - [Messaging API](https://pyric.dev/docs/pyric-admin-messaging-reference-api/)
@@ -14,6 +14,24 @@
14
14
  * default-app resolution and throw the captured `app/no-app` error when
15
15
  * nothing is initialized. The original `getFirestore(ctx)` context form
16
16
  * (the load-bearing shape the existing suite uses) is preserved verbatim.
17
+ *
18
+ * RULES-BYPASS PARITY (#394): the two APP-resolution forms — `getFirestore(app)`
19
+ * and no-arg `getFirestore()` — mirror `firebase-admin/firestore`'s
20
+ * `getFirestore(app?)`, and REAL firebase-admin bypasses security rules. So both
21
+ * app forms resolve to the rules-BYPASS admin lens (`getAdminFirestore` →
22
+ * `{ mode: 'admin' }`), exactly as `pyric-admin/database`'s `getDatabase(app)`
23
+ * and `pyric-admin/storage`'s `getStorage(app)` already do. Previously the app
24
+ * forms routed through the anon-lens `getFirestore(sandbox.withAuth(null))`,
25
+ * which evaluates `request.auth == null` and is DENIED by any real ruleset — a
26
+ * deny-direction divergence from production that blocked the RTDB-trigger →
27
+ * Firestore-stamp pattern (a Cloud Function's admin write).
28
+ *
29
+ * The `getFirestore(ctx)` CONTEXT form is UNCHANGED and stays rules-ENFORCED
30
+ * (its captured identity is load-bearing for the rules-simulation suite). No
31
+ * page ever reaches this module: a page's `firebase/firestore` resolves to
32
+ * `pyric/firestore` (the rules-enforced client), not `pyric-admin`; only the
33
+ * `firebase-admin/*` → `pyric-admin/*` swap in the trusted functions child
34
+ * imports this. See {@link getFirestore}.
17
35
  */
18
36
  export * from 'pyric/sandbox/admin-firestore';
19
37
  import { type SandboxFirestore } from 'pyric/sandbox/admin-firestore';
@@ -22,11 +40,19 @@ import { type PyricAdminApp } from '../app/index.js';
22
40
  /**
23
41
  * Return the admin Firestore handle.
24
42
  *
25
- * - `getFirestore(ctx)` — the original context form (rules-applied for the
43
+ * - `getFirestore(ctx)` — the original context form (rules-APPLIED for the
26
44
  * ctx's captured identity). Unchanged; idempotent per `SandboxContext`.
27
- * - `getFirestore(app)` resolves a {@link PyricAdminApp}'s sandbox.
28
- * - `getFirestore()` resolves the default app; throws `app/no-app` when
29
- * nothing is initialized.
45
+ * This is the pyric-internal rules-simulation shape, not a firebase-admin
46
+ * shape, so it keeps rule evaluation.
47
+ * - `getFirestore(app)` — resolves a {@link PyricAdminApp}'s sandbox to the
48
+ * rules-BYPASS admin lens (firebase-admin parity, #394).
49
+ * - `getFirestore()` — resolves the default app to the rules-BYPASS admin
50
+ * lens; throws `app/no-app` when nothing is initialized.
51
+ *
52
+ * The app forms mirror `firebase-admin/firestore`'s `getFirestore(app?)`,
53
+ * which bypasses security rules — so a Cloud Function's admin write lands the
54
+ * same way it does in production, instead of being denied as `request.auth ==
55
+ * null` by the sandbox's anon lens (the #394 deny-direction divergence).
30
56
  */
31
57
  export declare function getFirestore(target?: SandboxContext | PyricAdminApp): SandboxFirestore;
32
58
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/firestore/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,cAAc,+BAA+B,CAAC;AAE9C,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAoBzB;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAC1B,MAAM,CAAC,EAAE,cAAc,GAAG,aAAa,GACtC,gBAAgB,CAUlB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/firestore/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,cAAc,+BAA+B,CAAC;AAE9C,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAIL,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAsBzB;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAC1B,MAAM,CAAC,EAAE,cAAc,GAAG,aAAa,GACtC,gBAAgB,CAUlB"}
@@ -14,18 +14,38 @@
14
14
  * default-app resolution and throw the captured `app/no-app` error when
15
15
  * nothing is initialized. The original `getFirestore(ctx)` context form
16
16
  * (the load-bearing shape the existing suite uses) is preserved verbatim.
17
+ *
18
+ * RULES-BYPASS PARITY (#394): the two APP-resolution forms — `getFirestore(app)`
19
+ * and no-arg `getFirestore()` — mirror `firebase-admin/firestore`'s
20
+ * `getFirestore(app?)`, and REAL firebase-admin bypasses security rules. So both
21
+ * app forms resolve to the rules-BYPASS admin lens (`getAdminFirestore` →
22
+ * `{ mode: 'admin' }`), exactly as `pyric-admin/database`'s `getDatabase(app)`
23
+ * and `pyric-admin/storage`'s `getStorage(app)` already do. Previously the app
24
+ * forms routed through the anon-lens `getFirestore(sandbox.withAuth(null))`,
25
+ * which evaluates `request.auth == null` and is DENIED by any real ruleset — a
26
+ * deny-direction divergence from production that blocked the RTDB-trigger →
27
+ * Firestore-stamp pattern (a Cloud Function's admin write).
28
+ *
29
+ * The `getFirestore(ctx)` CONTEXT form is UNCHANGED and stays rules-ENFORCED
30
+ * (its captured identity is load-bearing for the rules-simulation suite). No
31
+ * page ever reaches this module: a page's `firebase/firestore` resolves to
32
+ * `pyric/firestore` (the rules-enforced client), not `pyric-admin`; only the
33
+ * `firebase-admin/*` → `pyric-admin/*` swap in the trusted functions child
34
+ * imports this. See {@link getFirestore}.
17
35
  */
18
36
  export * from 'pyric/sandbox/admin-firestore';
19
- import { getFirestore as baseGetFirestore, } from 'pyric/sandbox/admin-firestore';
37
+ import { getFirestore as baseGetFirestore, getAdminFirestore as baseGetAdminFirestore, } from 'pyric/sandbox/admin-firestore';
20
38
  import { ADMIN_APP_TARGET, getApp, isSandboxAdminApp, } from '../app/index.js';
21
39
  import { assertAdminAppActive } from '../app/lifecycle.js';
22
- /** Narrow a `PyricAdminApp` to the anonymous {@link SandboxContext} the
23
- * admin firestore backend runs against. Sandbox apps expose their
24
- * `Sandbox` — LOCAL and REMOTE alike: the base `getFirestore` is
25
- * remote-aware (it dispatches a remote-branded sandbox to the
26
- * channel-backed arm), so no guard is needed here. Prod apps require
27
- * firebase-admin's real Firestore, which the in-process backend does
28
- * not model. */
40
+ /** Narrow a `PyricAdminApp` to the {@link SandboxContext} the admin
41
+ * firestore backend runs against. Sandbox apps expose their `Sandbox` —
42
+ * LOCAL and REMOTE alike: the base resolvers are remote-aware (they
43
+ * dispatch a remote-branded sandbox to the channel-backed arm), so no
44
+ * guard is needed here. The context's captured auth is IRRELEVANT on the
45
+ * admin path (rules are bypassed, so no rule reads `request.auth`); it is
46
+ * normalised to `withAuth(null)` only to obtain a context. Prod apps
47
+ * require firebase-admin's real Firestore, which the in-process backend
48
+ * does not model. */
29
49
  function adminAppToContext(app) {
30
50
  if (isSandboxAdminApp(app)) {
31
51
  return app.sandbox.withAuth(null);
@@ -36,20 +56,28 @@ function adminAppToContext(app) {
36
56
  /**
37
57
  * Return the admin Firestore handle.
38
58
  *
39
- * - `getFirestore(ctx)` — the original context form (rules-applied for the
59
+ * - `getFirestore(ctx)` — the original context form (rules-APPLIED for the
40
60
  * ctx's captured identity). Unchanged; idempotent per `SandboxContext`.
41
- * - `getFirestore(app)` resolves a {@link PyricAdminApp}'s sandbox.
42
- * - `getFirestore()` resolves the default app; throws `app/no-app` when
43
- * nothing is initialized.
61
+ * This is the pyric-internal rules-simulation shape, not a firebase-admin
62
+ * shape, so it keeps rule evaluation.
63
+ * - `getFirestore(app)` — resolves a {@link PyricAdminApp}'s sandbox to the
64
+ * rules-BYPASS admin lens (firebase-admin parity, #394).
65
+ * - `getFirestore()` — resolves the default app to the rules-BYPASS admin
66
+ * lens; throws `app/no-app` when nothing is initialized.
67
+ *
68
+ * The app forms mirror `firebase-admin/firestore`'s `getFirestore(app?)`,
69
+ * which bypasses security rules — so a Cloud Function's admin write lands the
70
+ * same way it does in production, instead of being denied as `request.auth ==
71
+ * null` by the sandbox's anon lens (the #394 deny-direction divergence).
44
72
  */
45
73
  export function getFirestore(target) {
46
74
  if (target === undefined) {
47
- return baseGetFirestore(adminAppToContext(getApp()));
75
+ return baseGetAdminFirestore(adminAppToContext(getApp()));
48
76
  }
49
77
  if (typeof target === 'object' && target !== null && ADMIN_APP_TARGET in target) {
50
78
  const app = target;
51
79
  assertAdminAppActive(app);
52
- return baseGetFirestore(adminAppToContext(app));
80
+ return baseGetAdminFirestore(adminAppToContext(app));
53
81
  }
54
82
  return baseGetFirestore(target);
55
83
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/firestore/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,cAAc,+BAA+B,CAAC;AAE9C,OAAO,EACL,YAAY,IAAI,gBAAgB,GAEjC,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACL,gBAAgB,EAChB,MAAM,EACN,iBAAiB,GAElB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;;;;;iBAMiB;AACjB,SAAS,iBAAiB,CAAC,GAAkB;IAC3C,IAAI,iBAAiB,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,OAAO,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,IAAI,KAAK,CACb,wEAAwE;QACtE,sEAAsE,CACzE,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,YAAY,CAC1B,MAAuC;IAEvC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,gBAAgB,CAAC,iBAAiB,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACvD,CAAC;IACD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,gBAAgB,IAAI,MAAM,EAAE,CAAC;QAChF,MAAM,GAAG,GAAG,MAAuB,CAAC;QACpC,oBAAoB,CAAC,GAAG,CAAC,CAAC;QAC1B,OAAO,gBAAgB,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,CAAC;IAClD,CAAC;IACD,OAAO,gBAAgB,CAAC,MAAwB,CAAC,CAAC;AACpD,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/firestore/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,cAAc,+BAA+B,CAAC;AAE9C,OAAO,EACL,YAAY,IAAI,gBAAgB,EAChC,iBAAiB,IAAI,qBAAqB,GAE3C,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACL,gBAAgB,EAChB,MAAM,EACN,iBAAiB,GAElB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAE3D;;;;;;;;sBAQsB;AACtB,SAAS,iBAAiB,CAAC,GAAkB;IAC3C,IAAI,iBAAiB,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,OAAO,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,IAAI,KAAK,CACb,wEAAwE;QACtE,sEAAsE,CACzE,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,YAAY,CAC1B,MAAuC;IAEvC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,qBAAqB,CAAC,iBAAiB,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC5D,CAAC;IACD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,IAAI,gBAAgB,IAAI,MAAM,EAAE,CAAC;QAChF,MAAM,GAAG,GAAG,MAAuB,CAAC;QACpC,oBAAoB,CAAC,GAAG,CAAC,CAAC;QAC1B,OAAO,qBAAqB,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAC,CAAC;IACvD,CAAC;IACD,OAAO,gBAAgB,CAAC,MAAwB,CAAC,CAAC;AACpD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyric-admin",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://pyric.dev",
6
6
  "repository": {
@@ -33,10 +33,15 @@
33
33
  "./storage": {
34
34
  "types": "./dist/storage/index.d.ts",
35
35
  "import": "./dist/storage/index.js"
36
+ },
37
+ "./messaging": {
38
+ "types": "./dist/messaging/index.d.ts",
39
+ "import": "./dist/messaging/index.js"
36
40
  }
37
41
  },
38
42
  "files": [
39
43
  "dist",
44
+ "src",
40
45
  "README.md",
41
46
  "LICENSE"
42
47
  ],
@@ -46,7 +51,7 @@
46
51
  "typecheck": "bun x tsc -p tsconfig.json --noEmit"
47
52
  },
48
53
  "dependencies": {
49
- "pyric": "^0.1.0-alpha.10"
54
+ "pyric": "^0.1.0-alpha.12"
50
55
  },
51
56
  "devDependencies": {
52
57
  "@types/bun": "latest",
@@ -0,0 +1,235 @@
1
+ /**
2
+ * `pyric-admin/app` — sandbox-only admin app registry.
3
+ *
4
+ * Production selection happens before this module loads: activated Node
5
+ * processes resolve `firebase-admin/app` here, while inactive applications
6
+ * continue resolving their own `firebase-admin/app` package unchanged.
7
+ */
8
+ import {
9
+ REMOTE_SANDBOX_FACTORY,
10
+ type RemoteSandboxFactory,
11
+ type RemoteSandboxFactoryOptions,
12
+ type Sandbox,
13
+ } from 'pyric/sandbox';
14
+ import { assertAdminAppActive, markAdminAppDeleted } from './lifecycle.js';
15
+
16
+ /** Brand carried by every sandbox admin app. */
17
+ export const ADMIN_APP_TARGET = Symbol.for('pyric.admin.app.target');
18
+ export type PyricAdminAppTarget = 'sandbox';
19
+
20
+ /** firebase-admin's default app name. */
21
+ export const DEFAULT_APP_NAME = '[DEFAULT]';
22
+
23
+ export interface SandboxAdminApp {
24
+ readonly [ADMIN_APP_TARGET]: 'sandbox';
25
+ readonly sandbox: Sandbox;
26
+ readonly name: string;
27
+ }
28
+
29
+ export type PyricAdminApp = SandboxAdminApp;
30
+ export type InitializeAdminAppConfig = { sandbox: Sandbox };
31
+
32
+ /**
33
+ * Firebase Functions' ESM runtime statically imports this credential factory
34
+ * while linking its database provider. Pyric initializes the sandbox app
35
+ * before that provider executes, so the factory is not used by supported
36
+ * Functions flows. Keep the named export link-compatible, but fail clearly if
37
+ * application code asks the development sandbox for production credentials.
38
+ */
39
+ export function applicationDefault(): never {
40
+ throw new Error(
41
+ 'pyric-admin/app: applicationDefault() is unavailable in the sandbox. ' +
42
+ 'Pyric development does not use production credentials.',
43
+ );
44
+ }
45
+
46
+ /** Local error with the observable firebase-admin app-error shape. */
47
+ class FirebaseAppError extends Error {
48
+ readonly code: string;
49
+
50
+ constructor(code: string, message: string) {
51
+ super(message);
52
+ this.name = 'FirebaseAppError';
53
+ this.code = `app/${code}`;
54
+ }
55
+ }
56
+
57
+ // Node may evaluate this ESM-only mirror through distinct require(esm) and
58
+ // import module records when the register hook rewrites both CJS and ESM
59
+ // Firebase consumers. Firebase Admin's app registry is process-wide, so keep
60
+ // the mirror registry behind Symbol.for as well: both module records must see
61
+ // the same default app and sandbox handle.
62
+ const APP_REGISTRY = Symbol.for('pyric.admin.app.registry');
63
+ const AMBIENT_APPS = Symbol.for('pyric.admin.app.ambientApps');
64
+ interface GlobalAppRegistry {
65
+ [APP_REGISTRY]?: Map<string, PyricAdminApp>;
66
+ [AMBIENT_APPS]?: WeakSet<PyricAdminApp>;
67
+ }
68
+ const globalRegistry = globalThis as GlobalAppRegistry;
69
+ const appRegistry = globalRegistry[APP_REGISTRY] ??= new Map<string, PyricAdminApp>();
70
+ const ambientApps = globalRegistry[AMBIENT_APPS] ??= new WeakSet<PyricAdminApp>();
71
+
72
+ function validateAppName(name: unknown): asserts name is string {
73
+ if (typeof name !== 'string' || name === '') {
74
+ throw new FirebaseAppError(
75
+ 'invalid-app-name',
76
+ `Invalid Firebase app name "${String(name)}" provided. App name must be a non-empty string.`,
77
+ );
78
+ }
79
+ }
80
+
81
+ function alreadyExists(name: string, code: 'duplicate-app' | 'invalid-app-options'): FirebaseAppError {
82
+ return new FirebaseAppError(
83
+ code,
84
+ `A Firebase app named "${name}" already exists with a different configuration.`,
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Initialize a sandbox admin app.
90
+ *
91
+ * An explicit `{ sandbox }` config binds an in-process or remote sandbox.
92
+ * A bare call resolves the remote sandbox factory installed by
93
+ * `@pyric/cli/register`. Production callers must import `firebase-admin/app`
94
+ * without Pyric activation instead of passing production options here.
95
+ */
96
+ export function initializeApp(
97
+ config?: InitializeAdminAppConfig,
98
+ name: string = DEFAULT_APP_NAME,
99
+ ): PyricAdminApp {
100
+ validateAppName(name);
101
+ const existing = appRegistry.get(name);
102
+
103
+ if (config === undefined) {
104
+ if (existing !== undefined) {
105
+ if (ambientApps.has(existing)) return existing;
106
+ throw alreadyExists(name, 'invalid-app-options');
107
+ }
108
+ const app = initializeAmbientApp(name);
109
+ appRegistry.set(name, app);
110
+ ambientApps.add(app);
111
+ return app;
112
+ }
113
+
114
+ if (!isSandboxConfig(config)) {
115
+ throw new TypeError(
116
+ 'pyric-admin/app is a sandbox-only mirror. Production applications must ' +
117
+ 'load firebase-admin/app without Pyric activation.',
118
+ );
119
+ }
120
+
121
+ if (existing !== undefined) {
122
+ if (!ambientApps.has(existing) && existing.sandbox === config.sandbox) return existing;
123
+ throw alreadyExists(
124
+ name,
125
+ ambientApps.has(existing) ? 'invalid-app-options' : 'duplicate-app',
126
+ );
127
+ }
128
+
129
+ const app: SandboxAdminApp = {
130
+ [ADMIN_APP_TARGET]: 'sandbox',
131
+ sandbox: config.sandbox,
132
+ name,
133
+ };
134
+ appRegistry.set(name, app);
135
+ return app;
136
+ }
137
+
138
+ /** Return the registered app for `name`. */
139
+ export function getApp(name: string = DEFAULT_APP_NAME): PyricAdminApp {
140
+ validateAppName(name);
141
+ const app = appRegistry.get(name);
142
+ if (app === undefined) {
143
+ const lead = name === DEFAULT_APP_NAME
144
+ ? 'The default Firebase app does not exist. '
145
+ : `Firebase app named "${name}" does not exist. `;
146
+ throw new FirebaseAppError(
147
+ 'no-app',
148
+ lead + 'Make sure you call initializeApp() before using any of the Firebase services.',
149
+ );
150
+ }
151
+ return app;
152
+ }
153
+
154
+ /** Return a copy of the app registry. */
155
+ export function getApps(): PyricAdminApp[] {
156
+ return Array.from(appRegistry.values());
157
+ }
158
+
159
+ /** Remove a sandbox app from the registry. */
160
+ export function deleteApp(app: PyricAdminApp): Promise<void> {
161
+ if (typeof app !== 'object' || app === null || !(ADMIN_APP_TARGET in app)) {
162
+ throw new FirebaseAppError('invalid-argument', 'Invalid app argument.');
163
+ }
164
+ assertAdminAppActive(app);
165
+ const existing = getApp(app.name);
166
+ appRegistry.delete(existing.name);
167
+ markAdminAppDeleted(existing);
168
+ return Promise.resolve();
169
+ }
170
+
171
+ function initializeAmbientApp(name: string): PyricAdminApp {
172
+ const env = process.env.PYRIC_SANDBOX;
173
+ if (env === undefined || env.trim() === '') {
174
+ throw new Error(
175
+ 'pyric-admin/app is a sandbox-only mirror and no sandbox is active. ' +
176
+ 'Run under `pyric dev`, set PYRIC_SANDBOX with @pyric/cli/register, ' +
177
+ 'or load firebase-admin/app without Pyric activation for production.',
178
+ );
179
+ }
180
+
181
+ const opts = parsePyricSandboxEnv(env);
182
+ if (process.env.NODE_ENV === 'production' && process.env.PYRIC_SANDBOX_FORCE !== '1') {
183
+ throw new Error(
184
+ 'pyric-admin: PYRIC_SANDBOX is set but NODE_ENV is "production" — ' +
185
+ 'refusing to route firebase-admin to a development sandbox. ' +
186
+ 'Unset PYRIC_SANDBOX in production, or set PYRIC_SANDBOX_FORCE=1 ' +
187
+ 'if this routing is intentional.',
188
+ );
189
+ }
190
+
191
+ const factory = (globalThis as { [REMOTE_SANDBOX_FACTORY]?: RemoteSandboxFactory })[
192
+ REMOTE_SANDBOX_FACTORY
193
+ ];
194
+ if (typeof factory !== 'function') {
195
+ throw new Error(
196
+ `pyric-admin: PYRIC_SANDBOX=${env} is set but no remote sandbox ` +
197
+ "factory is installed (globalThis[Symbol.for('pyric.remote.sandboxFactory')] is absent). " +
198
+ 'Run your server under `pyric dev`, or add `--import @pyric/cli/register` to NODE_OPTIONS.',
199
+ );
200
+ }
201
+
202
+ const sandbox = factory(opts);
203
+ process.stderr.write(
204
+ `pyric: firebase-admin routed to sandbox${opts.url !== undefined ? ` at ${opts.url}` : ''}\n`,
205
+ );
206
+ return { [ADMIN_APP_TARGET]: 'sandbox', sandbox, name };
207
+ }
208
+
209
+ function parsePyricSandboxEnv(env: string): RemoteSandboxFactoryOptions {
210
+ const value = env.trim();
211
+ if (value === 'remote') return {};
212
+ if (value.startsWith('remote:')) {
213
+ const url = value.slice('remote:'.length).trim();
214
+ if (url === '') {
215
+ throw new Error(
216
+ 'pyric-admin: PYRIC_SANDBOX=remote: has an empty url. Use ' +
217
+ 'PYRIC_SANDBOX=remote to auto-discover the running `pyric dev`, ' +
218
+ 'or PYRIC_SANDBOX=remote:<url> with the host url.',
219
+ );
220
+ }
221
+ return { url };
222
+ }
223
+ throw new Error(
224
+ `pyric-admin: unrecognized PYRIC_SANDBOX value "${env}". Supported ` +
225
+ 'values: "remote" (auto-discover the running `pyric dev`) or "remote:<url>".',
226
+ );
227
+ }
228
+
229
+ function isSandboxConfig(config: InitializeAdminAppConfig): config is { sandbox: Sandbox } {
230
+ return typeof config === 'object' && config !== null && 'sandbox' in config;
231
+ }
232
+
233
+ export function isSandboxAdminApp(app: PyricAdminApp): app is SandboxAdminApp {
234
+ return app[ADMIN_APP_TARGET] === 'sandbox';
235
+ }
@@ -0,0 +1,17 @@
1
+ const deletedApps = new WeakSet<object>();
2
+
3
+ /** Internal lifecycle guard shared by every pyric-admin service factory. */
4
+ export function assertAdminAppActive(app: object & { readonly name: string }): void {
5
+ if (!deletedApps.has(app)) return;
6
+ const error = new Error(
7
+ `Firebase app named "${app.name}" has already been deleted.`,
8
+ ) as Error & { code: string };
9
+ error.name = 'FirebaseAppError';
10
+ error.code = 'app/app-deleted';
11
+ throw error;
12
+ }
13
+
14
+ /** Tombstone a wrapper while leaving the caller-owned Sandbox alive. */
15
+ export function markAdminAppDeleted(app: object): void {
16
+ deletedApps.add(app);
17
+ }