@pylonsync/sync 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/dist/index.d.ts CHANGED
@@ -265,6 +265,8 @@ export declare class SyncEngine {
265
265
  * joiner whose orchestrator never fires onPromote (because it was
266
266
  * never leader) silently run as a leader. */
267
267
  private isMultiTabLeader;
268
+ /** One-shot guard for the cross-app keyspace-collision warning. */
269
+ private warnedForeignToken;
268
270
  /**
269
271
  * Serialized apply queue. Every change-event apply — from WS onmessage,
270
272
  * pull(), or session-changed catchup — chains onto this promise so
@@ -725,6 +727,22 @@ export declare class SyncEngine {
725
727
  * will retry. Errors must not abort bootstrap.
726
728
  */
727
729
  private fetchSessionBootstrap;
730
+ /**
731
+ * A stored token that /api/auth/me resolves to no user is either expired
732
+ * or — the common footgun — a FOREIGN token: two Pylon apps sharing an
733
+ * origin (every app on localhost:4321 in dev) both read the default,
734
+ * unprefixed `pylon_token`, so one app sends the other's token and the
735
+ * server rejects it. The failure surfaces far away as "anonymous session"
736
+ * from createOrg, which names neither the token nor the keyspace.
737
+ *
738
+ * Heal it here instead of "on next login": clear the app's OWN namespaced
739
+ * token so the invalid value stops being sent and the app drops cleanly to
740
+ * signed-out (a real sign-in then writes a valid token into this namespace).
741
+ * Only the namespaced key — never the shared default `pylon_token`, which may
742
+ * belong to a different app on this origin. Warn once so the cause is named.
743
+ * A genuinely signed-out user has no stored token, so this stays quiet.
744
+ */
745
+ private warnForeignToken;
728
746
  /**
729
747
  * Apply a freshly-observed session through the resolver and act on
730
748
  * the verdict. Serialized via `sessionChain` so concurrent triggers
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",
package/src/index.ts CHANGED
@@ -389,6 +389,8 @@ export class SyncEngine {
389
389
  * joiner whose orchestrator never fires onPromote (because it was
390
390
  * never leader) silently run as a leader. */
391
391
  private isMultiTabLeader = false;
392
+ /** One-shot guard for the cross-app keyspace-collision warning. */
393
+ private warnedForeignToken = false;
392
394
 
393
395
  /**
394
396
  * Serialized apply queue. Every change-event apply — from WS onmessage,
@@ -1634,7 +1636,22 @@ export class SyncEngine {
1634
1636
  /** Current auth token from config or the storage adapter. Null when neither has one. */
1635
1637
  private currentToken(): string | null {
1636
1638
  if (this.config.token) return this.config.token;
1637
- return this.storage.get(this.tokenStorageKey());
1639
+ const key = this.tokenStorageKey();
1640
+ const token = this.storage.get(key);
1641
+ if (token) return token;
1642
+ // Non-destructive legacy adoption, mirroring React's configureClient:
1643
+ // an app that shipped on the default `pylon_token` keeps its session
1644
+ // when it adopts a namespace. On web this is a no-op (React already
1645
+ // copied it in shared localStorage); it matters for async/native
1646
+ // adapters the React layer doesn't touch. COPY, never delete.
1647
+ if (key !== "pylon_token") {
1648
+ const legacy = this.storage.get("pylon_token");
1649
+ if (legacy) {
1650
+ this.storage.set(key, legacy);
1651
+ return legacy;
1652
+ }
1653
+ }
1654
+ return null;
1638
1655
  }
1639
1656
 
1640
1657
  /**
@@ -2358,6 +2375,7 @@ export class SyncEngine {
2358
2375
  roles?: string[];
2359
2376
  avatar_url?: string | null;
2360
2377
  };
2378
+ if ((raw.user_id ?? null) === null) this.warnForeignToken();
2361
2379
  return {
2362
2380
  userId: raw.user_id ?? null,
2363
2381
  tenantId: raw.tenant_id ?? null,
@@ -2372,6 +2390,42 @@ export class SyncEngine {
2372
2390
  }
2373
2391
  }
2374
2392
 
2393
+ /**
2394
+ * A stored token that /api/auth/me resolves to no user is either expired
2395
+ * or — the common footgun — a FOREIGN token: two Pylon apps sharing an
2396
+ * origin (every app on localhost:4321 in dev) both read the default,
2397
+ * unprefixed `pylon_token`, so one app sends the other's token and the
2398
+ * server rejects it. The failure surfaces far away as "anonymous session"
2399
+ * from createOrg, which names neither the token nor the keyspace.
2400
+ *
2401
+ * Heal it here instead of "on next login": clear the app's OWN namespaced
2402
+ * token so the invalid value stops being sent and the app drops cleanly to
2403
+ * signed-out (a real sign-in then writes a valid token into this namespace).
2404
+ * Only the namespaced key — never the shared default `pylon_token`, which may
2405
+ * belong to a different app on this origin. Warn once so the cause is named.
2406
+ * A genuinely signed-out user has no stored token, so this stays quiet.
2407
+ */
2408
+ private warnForeignToken(): void {
2409
+ if (this.warnedForeignToken) return;
2410
+ if (this.config.token) return; // explicit token, not a keyspace artifact
2411
+ const key = this.tokenStorageKey();
2412
+ if (!this.storage.get(key)) return; // no token → normal signed-out
2413
+ this.warnedForeignToken = true;
2414
+ if (key !== "pylon_token") {
2415
+ // Self-heal: drop the rejected token from this app's namespace so the
2416
+ // next request doesn't resend it. Leaves the ambiguous default key alone.
2417
+ this.storage.remove(key);
2418
+ this.storage.remove(key.replace(/:token$/, ":userId"));
2419
+ }
2420
+ console.warn(
2421
+ '[pylon] a stored auth token was rejected (/api/auth/me resolved to an ' +
2422
+ 'anonymous identity) and has been cleared from this app. If more than ' +
2423
+ 'one Pylon app runs on this origin, they share the default ' +
2424
+ '"pylon_token" keyspace and clobber each other\'s sessions. Give each ' +
2425
+ "app a name: init({ appName }) / configureClient({ appName }).",
2426
+ );
2427
+ }
2428
+
2375
2429
  /**
2376
2430
  * Apply a freshly-observed session through the resolver and act on
2377
2431
  * the verdict. Serialized via `sessionChain` so concurrent triggers