@pylonsync/react 0.5.0 → 0.5.3

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.5.0",
6
+ "version": "0.5.3",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/sdk": "0.5.0",
18
- "@pylonsync/sync": "0.5.0"
17
+ "@pylonsync/sdk": "0.5.3",
18
+ "@pylonsync/sync": "0.5.3"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=19.0.0"
package/src/index.ts CHANGED
@@ -236,7 +236,58 @@ export function configureClient(config: AgentDBClientConfig): void {
236
236
  maybeWarnDowngrade(config.baseUrl);
237
237
  }
238
238
  if (config.appName) {
239
+ const prev = _appName;
239
240
  _appName = config.appName;
241
+ // An app moving off the default (unprefixed `pylon_token`) keyspace onto
242
+ // its own `pylon:<app>:token` namespace keeps its session: copy the legacy
243
+ // keys up once. COPY, never delete — a framework rollback still finds the
244
+ // old keys.
245
+ if (config.appName !== "default" && prev === "default") {
246
+ adoptLegacyKeys();
247
+ }
248
+ }
249
+ }
250
+
251
+ /** Session slots whose legacy (default-keyspace) value is adopted into the
252
+ * app namespace on first `configureClient({ appName })`. */
253
+ const MIGRATED_SLOTS = ["token", "userId"] as const;
254
+
255
+ /**
256
+ * A `pylon:<other>:token` key already on this origin means another Pylon app
257
+ * has run here — so the unprefixed `pylon_token` cannot be assumed to be OURS.
258
+ * The legacy keyspace records no owner, and on a shared origin (every app on
259
+ * localhost:4321 in dev) that stranger's token is usually invalid for us.
260
+ * Adopt only when this looks like the origin's sole app; otherwise leave the
261
+ * slot empty and let a real sign-in fill it. Web-only — RN's per-app async
262
+ * adapters are already isolated, so the shared-origin hazard doesn't exist.
263
+ */
264
+ function anotherAppNamespaced(): boolean {
265
+ if (typeof localStorage === "undefined") return false;
266
+ const mine = storageKey("token");
267
+ for (let i = 0; i < localStorage.length; i++) {
268
+ const k = localStorage.key(i);
269
+ if (k && k.startsWith("pylon:") && k.endsWith(":token") && k !== mine) {
270
+ return true;
271
+ }
272
+ }
273
+ return false;
274
+ }
275
+
276
+ function adoptLegacyKeys(): void {
277
+ try {
278
+ if (anotherAppNamespaced()) return; // legacy token is likely foreign — skip
279
+ for (const slot of MIGRATED_SLOTS) {
280
+ const nsKey = storageKey(slot); // pylon:<app>:<slot>
281
+ const legacyKey = `pylon_${slot}`;
282
+ if (nsKey === legacyKey) continue;
283
+ const existing = _storage.get(nsKey);
284
+ if (existing != null && existing !== "") continue; // already namespaced
285
+ const legacy = _storage.get(legacyKey);
286
+ if (legacy != null && legacy !== "") _storage.set(nsKey, legacy);
287
+ }
288
+ } catch {
289
+ // Storage can be absent (SSR/node) or throw (Safari private mode) —
290
+ // never let a migration attempt break client configuration.
240
291
  }
241
292
  }
242
293
 
@@ -0,0 +1,76 @@
1
+ // A fresh app defaulted `_appName` to "default", so `storageKey("token")`
2
+ // returned the unprefixed `pylon_token`. Two apps on one origin (every app on
3
+ // localhost:4321 in dev) then shared one token keyspace and clobbered each
4
+ // other's sessions. When an app adopts a namespace, `configureClient` copies a
5
+ // legacy default-keyspace session up so a single-app upgrade doesn't log anyone
6
+ // out — but ONLY when the legacy key is plausibly ours (no other app has
7
+ // namespaced here). These pin adopt / no-clobber / don't-import-a-stranger.
8
+ //
9
+ // `_appName` is a module global that only transitions off "default" once per
10
+ // process, so `beforeEach` resets it (configureClient({ appName: "default" }))
11
+ // and clears the origin's localStorage that the shared-origin guard reads.
12
+
13
+ import { beforeEach, expect, test } from "bun:test";
14
+
15
+ import { configureClient, setReactStorage, storageKey } from "./index";
16
+ import type { Storage as PylonStorage } from "@pylonsync/sync";
17
+
18
+ function memStorage(): PylonStorage & { map: Map<string, string> } {
19
+ const map = new Map<string, string>();
20
+ return {
21
+ map,
22
+ get: (k: string) => map.get(k) ?? null,
23
+ set: (k: string, v: string) => void map.set(k, v),
24
+ remove: (k: string) => void map.delete(k),
25
+ };
26
+ }
27
+
28
+ beforeEach(() => {
29
+ configureClient({ appName: "default" }); // reset the module-global _appName
30
+ if (typeof localStorage !== "undefined") localStorage.clear();
31
+ });
32
+
33
+ test("adopts a legacy pylon_token into the app namespace, non-destructively", () => {
34
+ const store = memStorage();
35
+ setReactStorage(store);
36
+ store.set("pylon_token", "tok_legacy");
37
+ store.set("pylon_userId", "user_1");
38
+
39
+ configureClient({ appName: "revtrail" });
40
+
41
+ // Session preserved under the new namespaced keys…
42
+ expect(store.get("pylon:revtrail:token")).toBe("tok_legacy");
43
+ expect(store.get("pylon:revtrail:userId")).toBe("user_1");
44
+ // …and COPIED, not moved — a framework rollback still finds the old key.
45
+ expect(store.get("pylon_token")).toBe("tok_legacy");
46
+ // storageKey now resolves to the namespace.
47
+ expect(storageKey("token")).toBe("pylon:revtrail:token");
48
+ });
49
+
50
+ test("does not clobber a token already in the app namespace", () => {
51
+ const store = memStorage();
52
+ setReactStorage(store);
53
+ store.set("pylon_token", "tok_legacy");
54
+ store.set("pylon:revtrail:token", "tok_current");
55
+
56
+ configureClient({ appName: "revtrail" });
57
+
58
+ expect(store.get("pylon:revtrail:token")).toBe("tok_current");
59
+ });
60
+
61
+ test("does not adopt a foreign pylon_token when another app is namespaced here", () => {
62
+ // Another Pylon app has already run on this origin (shared-origin repro from
63
+ // a 0.5.2 field report). The ownerless legacy key can't be assumed ours.
64
+ localStorage.setItem("pylon:otherapp:token", "tok_other_app");
65
+
66
+ const store = memStorage();
67
+ setReactStorage(store);
68
+ store.set("pylon_token", "tok_FOREIGN_from_another_app");
69
+
70
+ configureClient({ appName: "mastagents" });
71
+
72
+ // The stranger's token is NOT copied into our namespace…
73
+ expect(store.get("pylon:mastagents:token")).toBeNull();
74
+ // …and the legacy key is untouched (we never delete it either).
75
+ expect(store.get("pylon_token")).toBe("tok_FOREIGN_from_another_app");
76
+ });