@marktiderman/genesis-native 0.1.0 → 0.3.0

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.
Files changed (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,300 @@
1
+ /**
2
+ * `createObservability(options)`: the observability block. Build it once at
3
+ * module scope (it starts the sinks, Sentry's `init` included), then:
4
+ *
5
+ * <GenesisNativeApp blocks={[observability]}> every error the shell catches → captureError
6
+ * export default observability.wrap(RootLayout) Sentry's `wrap` (touch and profiler boundaries)
7
+ * observability.track("auth.signed_in", { method: "email" })
8
+ *
9
+ * Its root (inside the shell) sets the user to the signed-in login's id and,
10
+ * with `useSegments` from expo-router, leaves a breadcrumb per route change
11
+ * and a `route.time_on_screen` timing for the route left. Route patterns
12
+ * (`/(app)/clients/[id]`) are used, never the values in them.
13
+ *
14
+ * Everything a sink receives has been scrubbed (see `scrub.ts`), and the
15
+ * last breadcrumbs and errors are kept, scrubbed, for the devtools overlay
16
+ * (`recent`).
17
+ */
18
+ import { createContext, useContext, useEffect, useRef, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
19
+ import { defineBlock } from "@marktiderman/genesis-features/blocks";
20
+ import { useSession } from "../auth";
21
+ import type { NativeBlock, NativeBlockRootProps } from "../blocks";
22
+ import type { ErrorReport } from "../errors";
23
+ import { checkEvent, type EventProps, type EventSchema } from "./events";
24
+ import { scrubString, scrubValue } from "./scrub";
25
+ import { scrubBreadcrumb, scrubbedError, type Breadcrumb, type ErrorContext, type ObservabilitySink, type TimingTags } from "./sink";
26
+
27
+ /** The FeatureId of the observability block. */
28
+ export const OBSERVABILITY_BLOCK_ID = "observability";
29
+
30
+ /** A caught error as the devtools overlay shows it (scrubbed). */
31
+ export interface RecentError {
32
+ at: number;
33
+ name: string;
34
+ message: string;
35
+ source: string;
36
+ fatal: boolean;
37
+ }
38
+
39
+ export interface RecentActivity {
40
+ /** Oldest first. Navigation, events and the app's breadcrumbs. */
41
+ breadcrumbs: readonly Breadcrumb[];
42
+ /** Oldest first. */
43
+ errors: readonly RecentError[];
44
+ }
45
+
46
+ export interface RecentStore {
47
+ getState(): RecentActivity;
48
+ subscribe(listener: () => void): () => void;
49
+ clear(): void;
50
+ }
51
+
52
+ export interface ObservabilityOptions<E extends EventSchema = EventSchema> {
53
+ /** Where everything goes: `sentrySink(Sentry, …)`, `consoleSink()` in development, `observeSink(Observe)`. */
54
+ sinks: readonly (ObservabilitySink | null | undefined | false)[];
55
+ /** The app's analytics events (`defineEvents`). Without it, `track` accepts nothing. */
56
+ events?: E;
57
+ /** `useSegments` from expo-router: route breadcrumbs and `route.time_on_screen` timings. */
58
+ useSegments?: () => readonly string[];
59
+ /** How many breadcrumbs and errors `recent` keeps. Default 50 and 20. */
60
+ recent?: { breadcrumbs?: number; errors?: number };
61
+ /** A sink threw, or `track` was called with something the schema doesn't allow. Default `console.warn` in development. */
62
+ onProblem?: (problem: string, error?: unknown) => void;
63
+ now?: () => number;
64
+ }
65
+
66
+ export interface ObservabilityBlock<E extends EventSchema = EventSchema> extends NativeBlock<typeof OBSERVABILITY_BLOCK_ID> {
67
+ readonly sinks: readonly ObservabilitySink[];
68
+ readonly events: E | undefined;
69
+ /** Report an error. The shell calls it for every error it catches. */
70
+ captureError(error: unknown, context?: Partial<ErrorContext>): void;
71
+ breadcrumb(crumb: Breadcrumb): void;
72
+ /** The login's id only. The block's root sets it from the session. */
73
+ setUser(id: string | null): void;
74
+ /** An analytics event from the schema. Anything else is dropped and reported. */
75
+ track<N extends keyof E & string>(name: N, props?: EventProps<E[N]>): void;
76
+ timing(name: string, ms: number, tags?: TimingTags): void;
77
+ /** The root layout wrapped by each sink that wraps (Sentry's `wrap`). Use once: `export default observability.wrap(RootLayout)`. */
78
+ wrap<P extends Record<string, unknown>>(root: ComponentType<P>): ComponentType<P>;
79
+ /** The last breadcrumbs and errors, scrubbed, for the devtools overlay. */
80
+ readonly recent: RecentStore;
81
+ /** For the shell: every error it catches. */
82
+ readonly onError: (error: Error, report: ErrorReport) => void;
83
+ readonly Root: ComponentType<NativeBlockRootProps>;
84
+ }
85
+
86
+ declare const __DEV__: boolean | undefined;
87
+
88
+ const TIMING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
89
+
90
+ function createRecentStore(maxCrumbs: number, maxErrors: number): RecentStore & { addCrumb(c: Breadcrumb): void; addError(e: RecentError): void } {
91
+ let state: RecentActivity = { breadcrumbs: [], errors: [] };
92
+ const listeners = new Set<() => void>();
93
+ const emit = () => listeners.forEach((l) => l());
94
+ const push = <T,>(list: readonly T[], item: T, max: number) => (list.length >= max ? [...list.slice(list.length - max + 1), item] : [...list, item]);
95
+ return {
96
+ getState: () => state,
97
+ subscribe(listener) {
98
+ listeners.add(listener);
99
+ return () => listeners.delete(listener);
100
+ },
101
+ clear() {
102
+ state = { breadcrumbs: [], errors: [] };
103
+ emit();
104
+ },
105
+ addCrumb(crumb) {
106
+ state = { ...state, breadcrumbs: push(state.breadcrumbs, crumb, maxCrumbs) };
107
+ emit();
108
+ },
109
+ addError(error) {
110
+ state = { ...state, errors: push(state.errors, error, maxErrors) };
111
+ emit();
112
+ },
113
+ };
114
+ }
115
+
116
+ function toError(value: unknown): Error {
117
+ return value instanceof Error ? value : new Error(typeof value === "string" ? value : "Unknown error");
118
+ }
119
+
120
+ /** Build the block once, at module scope, before the root layout renders. @stability Beta */
121
+ export function createObservability<const E extends EventSchema = EventSchema>(options: ObservabilityOptions<E>): ObservabilityBlock<E> {
122
+ const sinks = options.sinks.filter((s): s is ObservabilitySink => Boolean(s));
123
+ const now = options.now ?? Date.now;
124
+ const problem =
125
+ options.onProblem ??
126
+ ((message: string, error?: unknown) => {
127
+ if (typeof __DEV__ !== "undefined" && __DEV__) console.warn(`[observability] ${message}`, error ?? "");
128
+ });
129
+ const recent = createRecentStore(options.recent?.breadcrumbs ?? 50, options.recent?.errors ?? 20);
130
+ let route: string | null = null;
131
+ let user: string | null | undefined;
132
+
133
+ // One sink failing never stops the others, and never throws into the caller (an error handler, a render).
134
+ const each = (method: string, call: (sink: ObservabilitySink) => void) => {
135
+ for (const sink of sinks) {
136
+ try {
137
+ call(sink);
138
+ } catch (error) {
139
+ try {
140
+ problem(`${sink.name}.${method} threw`, error);
141
+ } catch {
142
+ // ignore a failing reporter
143
+ }
144
+ }
145
+ }
146
+ };
147
+
148
+ // Never throws into the caller (an effect, a handler): a record that can't be scrubbed is dropped.
149
+ const breadcrumb = (input: Breadcrumb) => {
150
+ let crumb: Breadcrumb;
151
+ try {
152
+ crumb = scrubBreadcrumb({ ...input, timestamp: input.timestamp ?? now() });
153
+ } catch (error) {
154
+ problem("breadcrumb dropped: it could not be scrubbed", error);
155
+ return;
156
+ }
157
+ recent.addCrumb(crumb);
158
+ each("breadcrumb", (s) => s.breadcrumb?.(crumb));
159
+ };
160
+
161
+ const captureError = (value: unknown, context: Partial<ErrorContext> = {}) => {
162
+ const error = toError(value);
163
+ const full: ErrorContext = { fatal: context.fatal ?? false, source: context.source ?? "app", route: context.route ?? route, componentStack: context.componentStack };
164
+ let safe: Error | undefined;
165
+ const scrubbed = () => (safe ??= scrubbedError(error));
166
+ try {
167
+ recent.addError({ at: now(), name: error.name, message: scrubString(error.message), source: full.source, fatal: full.fatal });
168
+ } catch {
169
+ // never let the overlay's list stop a report
170
+ }
171
+ const forSinks = full.componentStack ? { ...full, componentStack: scrubString(full.componentStack) } : full;
172
+ each("captureError", (s) => s.captureError?.(s.scrubsErrors ? error : scrubbed(), s.scrubsErrors ? full : forSinks));
173
+ };
174
+
175
+ const setUser = (id: string | null) => {
176
+ if (id === user) return;
177
+ user = id;
178
+ each("setUser", (s) => s.setUser?.(id));
179
+ };
180
+
181
+ const track = (name: string, props?: Readonly<Record<string, unknown>>) => {
182
+ const check = options.events ? checkEvent(options.events, name, props) : ({ ok: false, problem: "no event schema: pass `events: defineEvents({…})`" } as const);
183
+ if (!check.ok) {
184
+ problem(`track dropped: ${check.problem}`);
185
+ return;
186
+ }
187
+ recent.addCrumb({ category: "event", message: name, level: "info", data: check.props, timestamp: now() });
188
+ each("event", (s) => s.event?.(name, check.props));
189
+ };
190
+
191
+ const timing = (name: string, ms: number, tags?: TimingTags) => {
192
+ if (!Number.isFinite(ms) || ms < 0) return;
193
+ // A name like an event's (`route.time_on_screen`), never text with values in it.
194
+ if (!TIMING_NAME.test(name)) {
195
+ problem(`timing dropped: "${scrubString(name)}" is not lower-case words joined by "."`);
196
+ return;
197
+ }
198
+ let safeTags: TimingTags | undefined;
199
+ try {
200
+ safeTags = tags ? (scrubValue(tags) as TimingTags) : undefined;
201
+ } catch (error) {
202
+ problem("timing dropped: its tags could not be scrubbed", error);
203
+ return;
204
+ }
205
+ each("timing", (s) => s.timing?.(name, ms, safeTags));
206
+ };
207
+
208
+ const declaration = defineBlock<typeof OBSERVABILITY_BLOCK_ID, ComponentType<never>>({
209
+ id: OBSERVABILITY_BLOCK_ID,
210
+ platforms: ["native"],
211
+ contributes: {
212
+ privacy: {
213
+ collects: [
214
+ // genesis-cli's PRIVACY_BLOCKS.observability mirrors this (with events) for the iOS
215
+ // privacy manifest; observability/block.test.tsx keeps the two the same.
216
+ { type: "crash data", purposes: ["app functionality"], linked: true, tracking: false },
217
+ { type: "performance data", purposes: ["app functionality"], linked: true, tracking: false },
218
+ // Sentry's device and OS details, breadcrumbs and sessions.
219
+ { type: "other diagnostic data", purposes: ["app functionality"], linked: true, tracking: false },
220
+ { type: "user id", purposes: options.events ? ["app functionality", "analytics"] : ["app functionality"], linked: true, tracking: false },
221
+ ...(options.events ? [{ type: "product interaction", purposes: ["analytics"], linked: true, tracking: false }] : []),
222
+ ],
223
+ notes: [
224
+ "Crash reports, timings and analytics events carry the login's id only; emails, tokens, keys and query strings are removed on the device before anything is sent.",
225
+ ],
226
+ },
227
+ },
228
+ });
229
+
230
+ const useSegments = options.useSegments;
231
+ function RouteTracker() {
232
+ const segments = (useSegments as () => readonly string[])();
233
+ const path = `/${segments.join("/")}`;
234
+ const last = useRef<{ path: string; at: number } | null>(null);
235
+ useEffect(() => {
236
+ const at = now();
237
+ const previous = last.current;
238
+ if (previous?.path === path) return;
239
+ route = path;
240
+ breadcrumb({ category: "navigation", message: path, data: previous ? { from: previous.path } : undefined, timestamp: at });
241
+ if (previous) timing("route.time_on_screen", at - previous.at, { route: previous.path });
242
+ last.current = { path, at };
243
+ }, [path]);
244
+ return null;
245
+ }
246
+
247
+ function UserTracker() {
248
+ const { userId } = useSession();
249
+ useEffect(() => setUser(userId ?? null), [userId]);
250
+ return null;
251
+ }
252
+
253
+ const block: ObservabilityBlock<E> = {
254
+ ...declaration,
255
+ sinks,
256
+ events: options.events,
257
+ captureError,
258
+ breadcrumb,
259
+ setUser,
260
+ track: track as ObservabilityBlock<E>["track"],
261
+ timing,
262
+ recent,
263
+ wrap(root) {
264
+ return sinks.reduce((wrapped, sink) => (sink.wrap ? sink.wrap(wrapped) : wrapped), root);
265
+ },
266
+ onError: (error, report) => captureError(error, { fatal: report.fatal, source: report.source, componentStack: report.componentStack }),
267
+ Root: function ObservabilityRoot({ children }: NativeBlockRootProps) {
268
+ return (
269
+ <ObservabilityContext.Provider value={block as ObservabilityBlock}>
270
+ <UserTracker />
271
+ {useSegments ? <RouteTracker /> : null}
272
+ {children}
273
+ </ObservabilityContext.Provider>
274
+ );
275
+ },
276
+ };
277
+ return block;
278
+ }
279
+
280
+ const ObservabilityContext = createContext<ObservabilityBlock | null>(null);
281
+
282
+ /** Provides a block to the hooks without its root (rung 4). @stability Beta */
283
+ export function ObservabilityProvider({ block, children }: { block: ObservabilityBlock; children?: ReactNode }) {
284
+ return <ObservabilityContext.Provider value={block}>{children}</ObservabilityContext.Provider>;
285
+ }
286
+
287
+ /** The block, or `null` when it is off (so a screen can call it whether or not the block is on). @stability Beta */
288
+ export function useObservability(): ObservabilityBlock | null {
289
+ return useContext(ObservabilityContext);
290
+ }
291
+
292
+ /** The last breadcrumbs and errors, scrubbed; empty when the block is off. @stability Beta */
293
+ export function useRecentActivity(block?: ObservabilityBlock | null): RecentActivity {
294
+ const fromContext = useObservability();
295
+ const store = (block ?? fromContext)?.recent ?? EMPTY_STORE;
296
+ return useSyncExternalStore(store.subscribe, store.getState, store.getState);
297
+ }
298
+
299
+ const EMPTY: RecentActivity = { breadcrumbs: [], errors: [] };
300
+ const EMPTY_STORE: RecentStore = { getState: () => EMPTY, subscribe: () => () => {}, clear: () => {} };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Typed analytics events: the app lists every event and the props each may
3
+ * carry once, and `track(name, props)` accepts nothing else. A prop is a
4
+ * closed list of strings, a number or a boolean, never free text, so an
5
+ * event can't carry a name, an email or a message someone typed.
6
+ *
7
+ * ```ts
8
+ * export const events = defineEvents({
9
+ * "auth.signed_in": { method: ["email", "apple"] },
10
+ * "settings.opened": {},
11
+ * "checkin.saved": { mood: "number", first: "boolean" },
12
+ * });
13
+ * ```
14
+ *
15
+ * At run time an unknown event, an unknown prop or a value outside its list
16
+ * is dropped and reported (a `console.warn` in development, `onInvalid`
17
+ * always), so a bad call is loud and still sends nothing.
18
+ */
19
+
20
+ /** A closed list of strings, `"number"` (finite) or `"boolean"`. */
21
+ export type EventPropSpec = readonly string[] | "number" | "boolean";
22
+
23
+ /** Every event, by name, with the props it may carry. */
24
+ export type EventSchema = Readonly<Record<string, Readonly<Record<string, EventPropSpec>>>>;
25
+
26
+ type PropValue<S> = S extends readonly (infer L)[] ? L : S extends "number" ? number : S extends "boolean" ? boolean : never;
27
+
28
+ /** The props of one event: every prop optional, each its spec's type. */
29
+ export type EventProps<S extends Readonly<Record<string, EventPropSpec>>> = { readonly [K in keyof S]?: PropValue<S[K]> };
30
+
31
+ /** What a sink receives: the checked props. */
32
+ export type EventValues = Readonly<Record<string, string | number | boolean>>;
33
+
34
+ // Lower-case words joined by `.` or `_`: `auth.signed_in`, `checkin.saved`.
35
+ const EVENT_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
36
+ const PROP_NAME = /^[a-z][a-z0-9_]*$/;
37
+ const MAX_PROPS = 25;
38
+ const MAX_LITERAL = 64;
39
+
40
+ /**
41
+ * Declare the app's events. Checks names (lower-case, dot-separated), prop
42
+ * names, and that each list has at least one short value, and throws listing
43
+ * every problem, so a bad schema fails where it is written.
44
+ *
45
+ * @stability Beta
46
+ */
47
+ export function defineEvents<const E extends EventSchema>(schema: E): E {
48
+ const problems: string[] = [];
49
+ for (const [name, props] of Object.entries(schema)) {
50
+ if (!EVENT_NAME.test(name)) problems.push(`"${name}": an event name is lower-case words joined by "." (auth.signed_in)`);
51
+ const keys = Object.keys(props);
52
+ if (keys.length > MAX_PROPS) problems.push(`"${name}": at most ${MAX_PROPS} props`);
53
+ for (const key of keys) {
54
+ const spec = props[key];
55
+ if (!PROP_NAME.test(key)) problems.push(`"${name}".${key}: a prop name is lower-case letters, digits and "_"`);
56
+ if (Array.isArray(spec)) {
57
+ if (spec.length === 0) problems.push(`"${name}".${key}: an empty list allows nothing`);
58
+ for (const v of spec) if (typeof v !== "string" || v.length === 0 || v.length > MAX_LITERAL) problems.push(`"${name}".${key}: "${String(v)}" must be 1-${MAX_LITERAL} characters`);
59
+ } else if (spec !== "number" && spec !== "boolean") {
60
+ problems.push(`"${name}".${key}: a prop is a list of strings, "number" or "boolean" (free text is not allowed)`);
61
+ }
62
+ }
63
+ }
64
+ if (problems.length) throw new Error(`Invalid events:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
65
+ return Object.freeze(schema);
66
+ }
67
+
68
+ export type EventCheck = { ok: true; props: EventValues } | { ok: false; problem: string };
69
+
70
+ /** Check one call against the schema: the props to send, or why nothing is sent. */
71
+ export function checkEvent(schema: EventSchema, name: string, props: Readonly<Record<string, unknown>> | undefined): EventCheck {
72
+ if (!Object.prototype.hasOwnProperty.call(schema, name)) return { ok: false, problem: `"${name}" is not in the event schema` };
73
+ const specs = schema[name] ?? {};
74
+ const out: Record<string, string | number | boolean> = {};
75
+ for (const [key, value] of Object.entries(props ?? {})) {
76
+ if (value === undefined) continue;
77
+ if (!Object.prototype.hasOwnProperty.call(specs, key)) return { ok: false, problem: `"${name}" has no prop "${key}"` };
78
+ const spec = specs[key];
79
+ const fits =
80
+ spec === "number"
81
+ ? typeof value === "number" && Number.isFinite(value)
82
+ : spec === "boolean"
83
+ ? typeof value === "boolean"
84
+ : typeof value === "string" && (spec as readonly string[]).includes(value);
85
+ if (!fits) return { ok: false, problem: `"${name}".${key}: ${JSON.stringify(value)} is not allowed` };
86
+ out[key] = value as string | number | boolean;
87
+ }
88
+ return { ok: true, props: out };
89
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `@marktiderman/genesis-native/observability`: crash and error reporting,
3
+ * breadcrumbs, the user id, typed analytics events and timings, through
4
+ * vendor-neutral sinks, with PII scrubbed on the device first.
5
+ *
6
+ * Rung 1: `<GenesisNativeApp blocks={[createObservability({ sinks })]}>`.
7
+ * Rung 3: the sinks (`sentrySink`, `observeSink`, `consoleSink`), the
8
+ * scrubber (`scrubEvent`, `scrubString`, …) and `defineEvents`.
9
+ * Rung 4: `useObservability`, `useRecentActivity`.
10
+ *
11
+ * Optional peers, passed in rather than imported: `@sentry/react-native`
12
+ * (`sentrySink`) and `expo-observe` (`observeSink`).
13
+ *
14
+ * @stability Beta
15
+ */
16
+ export {
17
+ createObservability,
18
+ OBSERVABILITY_BLOCK_ID,
19
+ ObservabilityProvider,
20
+ useObservability,
21
+ useRecentActivity,
22
+ type ObservabilityBlock,
23
+ type ObservabilityOptions,
24
+ type RecentActivity,
25
+ type RecentError,
26
+ type RecentStore,
27
+ } from "./block";
28
+ export {
29
+ consoleSink,
30
+ observeSink,
31
+ scrubbedError,
32
+ scrubBreadcrumb,
33
+ type Breadcrumb,
34
+ type BreadcrumbLevel,
35
+ type ConsoleSinkOptions,
36
+ type ErrorContext,
37
+ type ObservabilitySink,
38
+ type ObserveLike,
39
+ type ObserveSinkOptions,
40
+ type TimingTags,
41
+ } from "./sink";
42
+ export { sentrySink, sentryRelease, signatureTags, type SentryLike, type SentrySinkOptions } from "./sentry";
43
+ export { defineEvents, checkEvent, type EventCheck, type EventPropSpec, type EventProps, type EventSchema, type EventValues } from "./events";
44
+ export { FILTERED, isSecretKey, scrubEvent, scrubRecord, scrubString, scrubUrl, scrubValue, type ScrubbableEvent } from "./scrub";
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The PII scrubber every sink's input goes through by default: text is
3
+ * rewritten by pattern (an email, a phone number, a JWT, a Supabase key, a
4
+ * bearer token, a token in a query string, row values in a database error),
5
+ * values under a key that names a secret or a contact detail are replaced
6
+ * whole, and URLs lose their query and fragment (PostgREST filters carry
7
+ * record ids, auth callbacks carry codes). Strings are cut at 4 KB first, so
8
+ * a huge log line can't stall the JS thread.
9
+ *
10
+ * What it can't know: a person's name in free text. Keep names out of error
11
+ * messages and breadcrumbs; touch breadcrumbs lose their labels in
12
+ * `sentrySink` for this reason.
13
+ *
14
+ * Pattern-based on values, not an allowlist of keys: a key allowlist misses
15
+ * `refresh failed: eyJhbGciOi…`, which is the shape that actually occurs.
16
+ *
17
+ * Fail closed: `scrubEvent` and `scrubRecord` return `null` (drop it) when
18
+ * scrubbing itself throws, never the unscrubbed input.
19
+ */
20
+
21
+ /** What replaces a value under a secret-looking key. */
22
+ export const FILTERED = "[Filtered]";
23
+
24
+ // Keys whose value is a secret (or a contact detail) whatever it looks like.
25
+ // Matched case-insensitively with `-`, `_`, `.` and spaces removed: exactly
26
+ // for the short, ambiguous ones, as a part of the key for the rest
27
+ // (`x-supabase-api-key`, `user.email`, `refresh_token_hash`).
28
+ const SECRET_KEY_EXACT = /^(session|sessionid|dsn|otp|code|verifier|codeverifier|privatekey|refresh|pwd|key)$/;
29
+ const SECRET_KEY_PART = /(authoriz|cookie|passw|secret|token|apikey|credential|jwt|email|phone|servicerole)/;
30
+
31
+ /** True when a value under `key` is a secret (or contact detail) whatever it looks like. */
32
+ export function isSecretKey(key: string): boolean {
33
+ const k = key.replace(/[-_.\s]/g, "").toLowerCase();
34
+ return SECRET_KEY_EXACT.test(k) || SECRET_KEY_PART.test(k);
35
+ }
36
+
37
+ // Sentry's own ids and envelope fields: 32-hex values the hex rule would break, never PII.
38
+ const ID_KEY = /^(event_id|trace_id|span_id|parent_span_id|profile_id|replay_id|debug_id|code_id|public_key|segment_id|sampled|release|dist|environment)$/;
39
+
40
+ // Fields dropped whole: query strings travel as their own fields too (Sentry's http breadcrumbs,
41
+ // spans and requests), and bodies and PostgREST `details` carry row values.
42
+ const DROPPED_KEY = /^(query_string|http\.query|http\.fragment|url\.query|url\.fragment|request_body|response_body|body|cookies|details)$/i;
43
+
44
+ /** Longest string scrubbed in full; the rest is cut off (the patterns stay fast on huge logs). */
45
+ export const MAX_STRING = 4096;
46
+
47
+ const PATTERNS: readonly [RegExp, string][] = [
48
+ // The query string and fragment of a URL with a scheme anywhere in text (a span's
49
+ // "GET https://…?id=eq.42", a deep link "myapp://reset?token_hash=…").
50
+ [/\b([a-z][a-z0-9+.-]{0,31}:\/\/[^\s?#"'<>]{0,2048})[?#][^\s"'<>]*/gi, "$1"],
51
+ // …and of a path ("POST /rest/v1/rpc/find?email=eq.…").
52
+ [/(^|[\s"'(=])(\/[^\s?#"'<>]{0,2048})\?[^\s"'<>]*/g, "$1$2"],
53
+ // JWTs: Supabase access tokens and the legacy anon / service_role keys.
54
+ [/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, "[jwt]"],
55
+ // Supabase's API keys (sb_secret_…, sb_publishable_…).
56
+ [/\bsb_(?:secret|publishable)_[A-Za-z0-9_-]{8,}/g, "[supabase-key]"],
57
+ // `Authorization: Bearer …` copied into a message.
58
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [token]"],
59
+ // Tokens in a query string or a fragment left without a scheme.
60
+ [
61
+ /([?&#](?:access_token|refresh_token|id_token|provider_token|provider_refresh_token|token|token_hash|code|apikey|api_key|key|password|secret|email|phone)=)[^&#\s"']+/gi,
62
+ "$1[Filtered]",
63
+ ],
64
+ // Row values in PostgreSQL errors: `Failing row contains (…)`, `Key (email)=(…)`.
65
+ [/(Failing row contains )\([^)]*\)/g, "$1(…)"],
66
+ [/(Key \([^)]{0,256}\)=)\([^)]*\)/g, "$1(…)"],
67
+ // Email addresses, also percent-encoded (`a%40b.co`). Bounded, so a long run of letters can't backtrack.
68
+ [/[A-Za-z0-9._%+-]{1,64}(?:@|%40)(?:[A-Za-z0-9-]{1,63}\.){1,8}[A-Za-z]{2,24}/g, "[email]"],
69
+ // Phone numbers: international (`+1 555 555 0101`) and North American (`555-555-0101`).
70
+ [/\+\d[\d\s().-]{6,18}\d/g, "[phone]"],
71
+ [/\b\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/g, "[phone]"],
72
+ // Long opaque hex runs: API keys, refresh tokens, DSN secrets. Linear: a run starts only after a non-hex character.
73
+ [/(^|[^A-Fa-f0-9])[A-Fa-f0-9]{32,}(?![A-Fa-f0-9])/g, "$1[hex]"],
74
+ ];
75
+
76
+ /** A string with every URL query, email, phone number, JWT, Supabase key, bearer token and query-string token replaced. */
77
+ export function scrubString(value: string): string {
78
+ let out = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…[truncated]` : value;
79
+ for (const [pattern, replacement] of PATTERNS) out = out.replace(pattern, replacement);
80
+ return out;
81
+ }
82
+
83
+ /** A URL without its query string and fragment, then scrubbed (an email in a path). */
84
+ export function scrubUrl(value: string): string {
85
+ const cut = value.search(/[?#]/);
86
+ return scrubString(cut === -1 ? value : value.slice(0, cut));
87
+ }
88
+
89
+ // Keys whose string value is cut at `?` when it is a URL.
90
+ const URL_KEY = /^(url|href|from|to|uri|description|http\.url|url\.full)$/i;
91
+ const MAX_DEPTH = 8;
92
+
93
+ /**
94
+ * A deep copy of `value` with every string scrubbed and every value under a
95
+ * secret key replaced. Cycles become `"[Circular]"`; past 8 levels,
96
+ * `"[Truncated]"`. Errors become `{ name, message }`. Header lists
97
+ * (`[["authorization", "…"]]`) are treated like objects.
98
+ */
99
+ export function scrubValue(value: unknown, key = "", depth = 0, seen: WeakSet<object> = new WeakSet()): unknown {
100
+ if (key && isSecretKey(key)) return value === undefined || value === null || value === "" ? value : FILTERED;
101
+ if (typeof value === "string") {
102
+ if (key && ID_KEY.test(key)) return value;
103
+ return URL_KEY.test(key) ? scrubUrl(value) : scrubString(value);
104
+ }
105
+ if (value === null || typeof value !== "object") return value;
106
+ if (seen.has(value)) return "[Circular]";
107
+ if (depth >= MAX_DEPTH) return "[Truncated]";
108
+ seen.add(value);
109
+ if (value instanceof Error) return { name: value.name, message: scrubString(value.message) };
110
+ if (Array.isArray(value)) {
111
+ // A `[name, value]` pair (fetch's header lists): the name decides, as for an object key.
112
+ if (value.length === 2 && typeof value[0] === "string" && isSecretKey(value[0])) return [value[0], FILTERED];
113
+ return value.map((item) => scrubValue(item, "", depth + 1, seen));
114
+ }
115
+ const out: Record<string, unknown> = {};
116
+ for (const [k, v] of Object.entries(value)) {
117
+ if (DROPPED_KEY.test(k)) continue;
118
+ out[k] = scrubValue(v, k, depth + 1, seen);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** The fields of a Sentry-shaped event `scrubEvent` treats specially. Anything else is scrubbed by value. */
124
+ export interface ScrubbableEvent {
125
+ user?: { id?: unknown; [key: string]: unknown } | null;
126
+ request?: { url?: unknown; method?: unknown; headers?: unknown; [key: string]: unknown };
127
+ [key: string]: unknown;
128
+ }
129
+
130
+ /**
131
+ * The event with the user reduced to its id, the request reduced to its
132
+ * method, URL (cut at `?`) and scrubbed headers, and every other value
133
+ * scrubbed. `null` when scrubbing threw: fail closed, send nothing rather
134
+ * than PII.
135
+ */
136
+ export function scrubEvent<E extends ScrubbableEvent>(event: E): E | null {
137
+ try {
138
+ // Sentry's processing metadata (scopes, the sampling context its envelope header is built
139
+ // from) is never sent as part of the event: pass it through, or the envelope breaks.
140
+ const { user, request, sdkProcessingMetadata, ...rest } = event;
141
+ const out = scrubValue(rest) as E;
142
+ if (sdkProcessingMetadata !== undefined) (out as ScrubbableEvent).sdkProcessingMetadata = sdkProcessingMetadata;
143
+ if (user !== undefined) {
144
+ (out as ScrubbableEvent).user = user && user.id !== undefined && user.id !== null ? { id: scrubString(String(user.id)) } : null;
145
+ }
146
+ if (request && typeof request === "object") {
147
+ const safe: Record<string, unknown> = {};
148
+ if (typeof request.url === "string") safe.url = scrubUrl(request.url);
149
+ if (typeof request.method === "string") safe.method = request.method;
150
+ if (request.headers && typeof request.headers === "object") safe.headers = scrubValue(request.headers);
151
+ (out as ScrubbableEvent).request = safe;
152
+ }
153
+ return out;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /** A breadcrumb, span or any record, scrubbed by value. `null` when scrubbing threw (fail closed). */
160
+ export function scrubRecord<R extends object>(record: R): R | null {
161
+ try {
162
+ return scrubValue(record) as R;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }