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,100 @@
1
+ /**
2
+ * `pyric-admin/firestore` — Admin-shape Firestore.
3
+ *
4
+ * The implementation lives at `pyric/sandbox/admin-firestore` (the
5
+ * pyric-internal admin-compat layer). This subpath is the consumer-
6
+ * facing entry that exposes it under the `pyric-admin` namespace,
7
+ * mirroring `firebase-admin/firestore`.
8
+ *
9
+ * Everything from the internal module is re-exported unchanged EXCEPT
10
+ * `getFirestore`, which is wrapped here to also accept:
11
+ * - a {@link PyricAdminApp} handle, and
12
+ * - no argument at all (resolves the default app from `pyric-admin/app`).
13
+ * Both paths mirror `firebase-admin/firestore`'s `getFirestore(app?)`
14
+ * default-app resolution and throw the captured `app/no-app` error when
15
+ * nothing is initialized. The original `getFirestore(ctx)` context form
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}.
35
+ */
36
+ export * from 'pyric/sandbox/admin-firestore';
37
+
38
+ import {
39
+ getFirestore as baseGetFirestore,
40
+ getAdminFirestore as baseGetAdminFirestore,
41
+ type SandboxFirestore,
42
+ } from 'pyric/sandbox/admin-firestore';
43
+ import type { SandboxContext } from 'pyric/sandbox';
44
+ import {
45
+ ADMIN_APP_TARGET,
46
+ getApp,
47
+ isSandboxAdminApp,
48
+ type PyricAdminApp,
49
+ } from '../app/index.js';
50
+ import { assertAdminAppActive } from '../app/lifecycle.js';
51
+
52
+ /** Narrow a `PyricAdminApp` to the {@link SandboxContext} the admin
53
+ * firestore backend runs against. Sandbox apps expose their `Sandbox` —
54
+ * LOCAL and REMOTE alike: the base resolvers are remote-aware (they
55
+ * dispatch a remote-branded sandbox to the channel-backed arm), so no
56
+ * guard is needed here. The context's captured auth is IRRELEVANT on the
57
+ * admin path (rules are bypassed, so no rule reads `request.auth`); it is
58
+ * normalised to `withAuth(null)` only to obtain a context. Prod apps
59
+ * require firebase-admin's real Firestore, which the in-process backend
60
+ * does not model. */
61
+ function adminAppToContext(app: PyricAdminApp): SandboxContext {
62
+ if (isSandboxAdminApp(app)) {
63
+ return app.sandbox.withAuth(null);
64
+ }
65
+ throw new Error(
66
+ 'pyric-admin/firestore: getFirestore() default-app resolution supports ' +
67
+ 'sandbox apps; a prod app requires firebase-admin/firestore directly.',
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Return the admin Firestore handle.
73
+ *
74
+ * - `getFirestore(ctx)` — the original context form (rules-APPLIED for the
75
+ * ctx's captured identity). Unchanged; idempotent per `SandboxContext`.
76
+ * This is the pyric-internal rules-simulation shape, not a firebase-admin
77
+ * shape, so it keeps rule evaluation.
78
+ * - `getFirestore(app)` — resolves a {@link PyricAdminApp}'s sandbox to the
79
+ * rules-BYPASS admin lens (firebase-admin parity, #394).
80
+ * - `getFirestore()` — resolves the default app to the rules-BYPASS admin
81
+ * lens; throws `app/no-app` when nothing is initialized.
82
+ *
83
+ * The app forms mirror `firebase-admin/firestore`'s `getFirestore(app?)`,
84
+ * which bypasses security rules — so a Cloud Function's admin write lands the
85
+ * same way it does in production, instead of being denied as `request.auth ==
86
+ * null` by the sandbox's anon lens (the #394 deny-direction divergence).
87
+ */
88
+ export function getFirestore(
89
+ target?: SandboxContext | PyricAdminApp,
90
+ ): SandboxFirestore {
91
+ if (target === undefined) {
92
+ return baseGetAdminFirestore(adminAppToContext(getApp()));
93
+ }
94
+ if (typeof target === 'object' && target !== null && ADMIN_APP_TARGET in target) {
95
+ const app = target as PyricAdminApp;
96
+ assertAdminAppActive(app);
97
+ return baseGetAdminFirestore(adminAppToContext(app));
98
+ }
99
+ return baseGetFirestore(target as SandboxContext);
100
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * `pyric-admin/messaging` — the `firebase-admin/messaging` send-plane mirror
3
+ * (surface `messaging-admin`, rows `messaging-admin#1`–`#39` in
4
+ * `packages/conformance/registry/messaging.ts`).
5
+ *
6
+ * A sandbox-branded app produces an in-process {@link Messaging} over the
7
+ * per-sandbox {@link MessagingBroker} from `pyric/messaging/internal`.
8
+ * Sharing the `Sandbox` with the client mirrors closes the loop: `send()`
9
+ * here routes deliveries into `pyric/messaging`'s `onMessage` /
10
+ * `pyric/messaging/sw`'s `onBackgroundMessage` by the captured visibility
11
+ * rule.
12
+ *
13
+ * Rejections carry the broker's captured `google.rpc` envelopes and are
14
+ * re-wrapped here the way firebase-admin wraps the wire: the FcmError
15
+ * `errorCode` maps through {@link MessagingClientErrorCode}
16
+ * (`INVALID_ARGUMENT` → `messaging/invalid-argument`, `UNREGISTERED` →
17
+ * `messaging/registration-token-not-registered` — the captured
18
+ * `adminThrowCode`), with the envelope's message text.
19
+ *
20
+ * ── Default app (climb-gated) ───────────────────────────────────────────────
21
+ * `getMessaging()` with no app first resolves the registered `'[DEFAULT]'`
22
+ * app (mirroring firebase-admin, including the exact `app/no-app` throw).
23
+ * Only when NO default exists AND `PYRIC_CLIMB=1` does the mirror mint an
24
+ * implicit, UNREGISTERED sandbox app — the conformance suites' headless
25
+ * degenerate case. Outside the climb it preserves the captured no-app error.
26
+ *
27
+ * Mirror-owned `FirebaseMessagingError` and `MessagingClientErrorCode`
28
+ * preserve the observable names, codes, and static members used by callers.
29
+ */
30
+
31
+ import { initializeSandbox } from 'pyric/sandbox';
32
+ import {
33
+ getMessagingBroker,
34
+ BrokerSendError,
35
+ type BrokerMessage,
36
+ type MessagingBroker,
37
+ type TopicManagementOutcome,
38
+ } from 'pyric/messaging/internal';
39
+
40
+ import {
41
+ ADMIN_APP_TARGET,
42
+ getApp,
43
+ type PyricAdminApp,
44
+ type SandboxAdminApp,
45
+ } from '../app/index.js';
46
+ import { assertAdminAppActive } from '../app/lifecycle.js';
47
+
48
+ type ErrorCodeInfo = { code: string; message: string };
49
+ export class MessagingClientErrorCode {
50
+ static readonly INVALID_ARGUMENT = {
51
+ code: 'invalid-argument',
52
+ message: 'Invalid argument provided.',
53
+ };
54
+ static readonly INVALID_REGISTRATION_TOKEN = {
55
+ code: 'invalid-registration-token',
56
+ message: 'Invalid registration token provided.',
57
+ };
58
+ static readonly REGISTRATION_TOKEN_NOT_REGISTERED = {
59
+ code: 'registration-token-not-registered',
60
+ message: 'The provided registration token is not registered.',
61
+ };
62
+ }
63
+
64
+ export class FirebaseMessagingError extends Error {
65
+ readonly code: string;
66
+
67
+ constructor(info: ErrorCodeInfo, message?: string) {
68
+ super(message ?? info.message);
69
+ this.name = 'FirebaseMessagingError';
70
+ this.code = `messaging/${info.code}`;
71
+ }
72
+ }
73
+
74
+ const MessagingError = FirebaseMessagingError;
75
+ const ErrorCodes = MessagingClientErrorCode as unknown as Record<string, ErrorCodeInfo>;
76
+
77
+ /** Mirror-owned structural messaging types. */
78
+ export type Notification = NonNullable<BrokerMessage['notification']>;
79
+ export type FcmOptions = NonNullable<BrokerMessage['fcmOptions']>;
80
+ export type WebpushConfig = NonNullable<BrokerMessage['webpush']>;
81
+ export type WebpushFcmOptions = NonNullable<WebpushConfig['fcmOptions']>;
82
+ export type WebpushNotification = Record<string, unknown>;
83
+ export type ApnsConfig = Record<string, unknown>;
84
+ export type ApnsPayload = Record<string, unknown>;
85
+ export type Aps = Record<string, unknown>;
86
+ export type ApsAlert = string | Record<string, unknown>;
87
+ export type CriticalSound = Record<string, unknown>;
88
+ export type ApnsFcmOptions = Record<string, unknown>;
89
+ export type AndroidConfig = Record<string, unknown>;
90
+ export type AndroidNotification = Record<string, unknown>;
91
+ export type LightSettings = Record<string, unknown>;
92
+ export type AndroidFcmOptions = Record<string, unknown>;
93
+ export type DataMessagePayload = Record<string, string>;
94
+ export type NotificationMessagePayload = Record<string, string>;
95
+ export type MessagingPayload = Record<string, unknown>;
96
+ export type MessagingOptions = Record<string, unknown>;
97
+
98
+ export interface BaseMessage extends Omit<BrokerMessage, 'token' | 'topic' | 'condition'> {}
99
+ export interface TokenMessage extends BaseMessage { token: string }
100
+ export interface TopicMessage extends BaseMessage { topic: string }
101
+ export interface ConditionMessage extends BaseMessage { condition: string }
102
+ export type Message = TokenMessage | TopicMessage | ConditionMessage;
103
+ export interface MulticastMessage extends BaseMessage { tokens: string[] }
104
+ export interface SendResponse { success: boolean; messageId?: string; error?: FirebaseMessagingError }
105
+ export interface BatchResponse { responses: SendResponse[]; successCount: number; failureCount: number }
106
+ export interface MessagingTopicManagementResponse {
107
+ successCount: number;
108
+ failureCount: number;
109
+ errors: Array<{ index: number; error: FirebaseMessagingError }>;
110
+ }
111
+
112
+ /** Wire FcmError errorCode → firebase-admin client error info. */
113
+ function clientErrorInfoFor(fcmErrorCode: string | undefined): ErrorCodeInfo {
114
+ if (fcmErrorCode === 'UNREGISTERED') return ErrorCodes.REGISTRATION_TOKEN_NOT_REGISTERED!;
115
+ return ErrorCodes.INVALID_ARGUMENT!;
116
+ }
117
+
118
+ /** Re-wrap a broker rejection exactly as firebase-admin wraps the wire envelope. */
119
+ function rethrowWrapped(error: unknown): never {
120
+ if (error instanceof BrokerSendError) {
121
+ throw new MessagingError(clientErrorInfoFor(error.errorCode), error.envelope.error.message);
122
+ }
123
+ throw error;
124
+ }
125
+
126
+ function invalidArgument(message: string): Error & { readonly code: string } {
127
+ return new MessagingError(ErrorCodes.INVALID_ARGUMENT!, message);
128
+ }
129
+
130
+ // ── The sandbox Messaging service ────────────────────────────────────────────
131
+
132
+ /**
133
+ * The sandbox `Messaging` service mirror, implementing the send plane over
134
+ * the broker.
135
+ */
136
+ export class Messaging {
137
+ private readonly broker: MessagingBroker;
138
+ private readonly boundApp: PyricAdminApp;
139
+
140
+ constructor(app: SandboxAdminApp) {
141
+ this.boundApp = app;
142
+ this.broker = getMessagingBroker(app.sandbox);
143
+ }
144
+
145
+ /** The app this `Messaging` instance is bound to (upstream `get app(): App`). */
146
+ get app(): PyricAdminApp {
147
+ return this.boundApp;
148
+ }
149
+
150
+ /**
151
+ * Send one message. Resolves the FCM resource name
152
+ * `projects/<projectId>/messages/<id>` — numeric id for topic/condition
153
+ * targets, UUID-form for token targets. `dryRun` validates on the
154
+ * identical path and returns the SAME shape with a fake id (captured
155
+ * `dryRunSameShapeAsReal` / `realSendEnvelopeIdentical` parity).
156
+ */
157
+ async send(message: Message, dryRun?: boolean): Promise<string> {
158
+ try {
159
+ return this.broker.send(message as BrokerMessage, { validateOnly: dryRun === true }).name;
160
+ } catch (error) {
161
+ rethrowWrapped(error);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Send up to 500 messages; the resolved `BatchResponse.responses` array is
167
+ * ordered to match the input, one entry per message.
168
+ */
169
+ async sendEach(messages: Message[], dryRun?: boolean): Promise<BatchResponse> {
170
+ if (!Array.isArray(messages) || messages.length === 0) {
171
+ throw invalidArgument('messages must be a non-empty array');
172
+ }
173
+ if (messages.length > 500) {
174
+ throw invalidArgument('messages list must not contain more than 500 items');
175
+ }
176
+ const responses: SendResponse[] = [];
177
+ for (const message of messages) {
178
+ try {
179
+ const name = this.broker.send(message as BrokerMessage, {
180
+ validateOnly: dryRun === true,
181
+ }).name;
182
+ responses.push({ success: true, messageId: name });
183
+ } catch (error) {
184
+ const wrapped =
185
+ error instanceof BrokerSendError
186
+ ? new MessagingError(clientErrorInfoFor(error.errorCode), error.envelope.error.message)
187
+ : error;
188
+ responses.push({
189
+ success: false,
190
+ error: wrapped as unknown as SendResponse['error'],
191
+ });
192
+ }
193
+ }
194
+ const successCount = responses.filter((r) => r.success).length;
195
+ return { responses, successCount, failureCount: responses.length - successCount };
196
+ }
197
+
198
+ /** Fan a `MulticastMessage` (up to 500 tokens) out through {@link sendEach}. */
199
+ async sendEachForMulticast(message: MulticastMessage, dryRun?: boolean): Promise<BatchResponse> {
200
+ if (!Array.isArray(message?.tokens) || message.tokens.length === 0) {
201
+ throw invalidArgument('tokens must be a non-empty array');
202
+ }
203
+ if (message.tokens.length > 500) {
204
+ throw invalidArgument('tokens list must not contain more than 500 items');
205
+ }
206
+ const { tokens, ...base } = message;
207
+ return this.sendEach(
208
+ tokens.map((token) => ({ ...base, token }) as Message),
209
+ dryRun,
210
+ );
211
+ }
212
+
213
+ /** Subscribe one or many registration tokens to a topic. */
214
+ async subscribeToTopic(
215
+ tokenOrTokens: string | string[],
216
+ topic: string,
217
+ ): Promise<MessagingTopicManagementResponse> {
218
+ return this.manageTopic(tokenOrTokens, topic, 'subscribe');
219
+ }
220
+
221
+ /** Unsubscribe one or many registration tokens from a topic. */
222
+ async unsubscribeFromTopic(
223
+ tokenOrTokens: string | string[],
224
+ topic: string,
225
+ ): Promise<MessagingTopicManagementResponse> {
226
+ return this.manageTopic(tokenOrTokens, topic, 'unsubscribe');
227
+ }
228
+
229
+ private manageTopic(
230
+ tokenOrTokens: string | string[],
231
+ topic: string,
232
+ action: 'subscribe' | 'unsubscribe',
233
+ ): MessagingTopicManagementResponse {
234
+ const tokens = Array.isArray(tokenOrTokens) ? tokenOrTokens : [tokenOrTokens];
235
+ if (tokens.length === 0) {
236
+ throw invalidArgument('registration token(s) must be a non-empty string or a non-empty array');
237
+ }
238
+ let outcome: TopicManagementOutcome;
239
+ try {
240
+ outcome =
241
+ action === 'subscribe'
242
+ ? this.broker.subscribeToTopic(tokens, topic)
243
+ : this.broker.unsubscribeFromTopic(tokens, topic);
244
+ } catch (error) {
245
+ rethrowWrapped(error);
246
+ }
247
+ return {
248
+ successCount: outcome.successCount,
249
+ failureCount: outcome.failureCount,
250
+ errors: outcome.errors.map(({ index, reason }) => ({
251
+ index,
252
+ error: (reason === 'unregistered-token'
253
+ ? new MessagingError(ErrorCodes.REGISTRATION_TOKEN_NOT_REGISTERED!)
254
+ : new MessagingError(
255
+ ErrorCodes.INVALID_REGISTRATION_TOKEN ?? ErrorCodes.INVALID_ARGUMENT!,
256
+ )) as unknown as MessagingTopicManagementResponse['errors'][number]['error'],
257
+ })),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Force HTTP/1.1 for batch sends — deprecated upstream; the sandbox has
263
+ * no HTTP transport, so this is a recorded no-op.
264
+ */
265
+ enableLegacyHttpTransport(): void {
266
+ // No transport to reconfigure in the sandbox.
267
+ }
268
+ }
269
+
270
+ // ── Dispatch + default-app resolution ────────────────────────────────────────
271
+
272
+ const sandboxInstances = new WeakMap<PyricAdminApp, Messaging>();
273
+
274
+ /**
275
+ * The implicit conformance-climb app: minted only under `PYRIC_CLIMB=1`
276
+ * when no `'[DEFAULT]'` app is registered, and deliberately NOT placed in
277
+ * the app registry (so `getApps()` and later `initializeApp()` calls are
278
+ * unaffected by the WIP messaging surface).
279
+ */
280
+ let climbDefaultApp: SandboxAdminApp | null = null;
281
+
282
+ function resolveApp(app?: PyricAdminApp): PyricAdminApp {
283
+ if (app !== undefined) return app;
284
+ try {
285
+ return getApp();
286
+ } catch (error) {
287
+ if (process.env.PYRIC_CLIMB === '1') {
288
+ climbDefaultApp ??= {
289
+ [ADMIN_APP_TARGET]: 'sandbox',
290
+ sandbox: initializeSandbox(),
291
+ name: '[pyric-messaging-climb]',
292
+ };
293
+ return climbDefaultApp;
294
+ }
295
+ throw error; // firebase-admin's exact app/no-app
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Return the broker-backed `Messaging` service for the default or given
301
+ * sandbox admin app.
302
+ */
303
+ export function getMessaging(app?: PyricAdminApp): Messaging {
304
+ const resolved = resolveApp(app);
305
+ assertAdminAppActive(resolved);
306
+ const existing = sandboxInstances.get(resolved);
307
+ if (existing !== undefined) return existing;
308
+ const instance = new Messaging(resolved);
309
+ sandboxInstances.set(resolved, instance);
310
+ return instance;
311
+ }
312
+
313
+ /**
314
+ * Namespaced / legacy accessor — the `admin.messaging(app?)` equivalent of
315
+ * {@link getMessaging}.
316
+ */
317
+ export function messaging(app?: PyricAdminApp): Messaging {
318
+ return getMessaging(app);
319
+ }