@pylonsync/sync 0.22.14 → 0.22.17

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
@@ -5,7 +5,7 @@ import { SessionResolver } from "./session-resolver";
5
5
  export { RoomSubscriptions, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
6
6
  export { IndexedDBPersistence, persistChange } from "./persistence";
7
7
  export type { ReplicaPersistence } from "./persistence";
8
- export { buildRequest, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
8
+ export { BEARER_REJECTED_HEADER, bearerRejected, buildRequest, clearRejectedToken, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
9
9
  export type { PylonRequestInit, TransportConfig } from "./transport";
10
10
  export { LocalStore } from "./local-store";
11
11
  export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
@@ -805,6 +805,16 @@ export declare class SyncEngine {
805
805
  * A genuinely signed-out user has no stored token, so this stays quiet.
806
806
  */
807
807
  private warnForeignToken;
808
+ /**
809
+ * The server answered with `X-Pylon-Bearer-Rejected`: the token the request
810
+ * sent resolved to no one (expired, revoked, or not a session of this app).
811
+ * Clear it from the app's namespace so later requests stop sending it and
812
+ * the session cookie, when there is one, applies alone. A token passed in
813
+ * `config.token` belongs to the caller and stays.
814
+ */
815
+ private dropRejectedToken;
816
+ /** `dropRejectedToken` for a response to a request that sent `token`. */
817
+ private observeBearerVerdict;
808
818
  /**
809
819
  * Apply a freshly-observed session through the resolver and act on
810
820
  * the verdict. Serialized via `sessionChain` so concurrent triggers
@@ -16,7 +16,31 @@ export interface TransportConfig {
16
16
  * header when the server sets one. Returning a Promise pauses no
17
17
  * one — the transport doesn't await it. */
18
18
  onChangeSeq?: (seq: number) => void;
19
+ /** Invoked with the bearer token a request sent when the server answers
20
+ * with `X-Pylon-Bearer-Rejected: 1`: the server resolved the token to no
21
+ * one (unknown, expired, or revoked). The code that stored the token
22
+ * clears it here, so later requests stop sending it. */
23
+ onBearerRejected?: (token: string) => void;
19
24
  }
25
+ /** Response header the server sets when the request's bearer token resolved
26
+ * to no one. Lower case, as `Headers.get` matches without case. */
27
+ export declare const BEARER_REJECTED_HEADER = "x-pylon-bearer-rejected";
28
+ /** True when the server marked the bearer token the request sent as rejected. */
29
+ export declare function bearerRejected(res: Response): boolean;
30
+ /**
31
+ * Remove a token the server rejected from `storage`, together with its
32
+ * `:userId` sibling. Only removes it when `key` still holds that exact token,
33
+ * so a sign-in that wrote a new token while the request was in flight keeps
34
+ * its token.
35
+ *
36
+ * The default unprefixed key `pylon_token` is left alone. Every app on an
37
+ * origin that has no `appName` shares it, so the token can belong to another
38
+ * app. Returns true when the token was removed.
39
+ */
40
+ export declare function clearRejectedToken(storage: {
41
+ get(key: string): string | null;
42
+ remove(key: string): void;
43
+ }, key: string, token: string): boolean;
20
44
  /**
21
45
  * Per-call init shape. Mirrors the standard `RequestInit` but
22
46
  * normalizes the two body shapes (JSON-encode-this vs raw-pass-
@@ -56,6 +80,7 @@ export declare function resolveBaseUrl(config: TransportConfig): string;
56
80
  export declare function buildRequest(config: TransportConfig, path: string, init?: PylonRequestInit): {
57
81
  url: string;
58
82
  init: RequestInit;
83
+ token?: string;
59
84
  };
60
85
  /** Error thrown by `pylonFetch` for non-2xx responses. Carries the
61
86
  * status + structured error code + the parsed JSON body (if any). */
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.14",
6
+ "version": "0.22.17",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -0,0 +1,146 @@
1
+ // A stored token the server rejects is cleared. The server answers a request
2
+ // whose bearer token resolved to no one (expired, revoked, or not a session
3
+ // of this app) with `X-Pylon-Bearer-Rejected: 1`, and falls back to the
4
+ // session cookie when it can. Without the client clearing the token, every
5
+ // later request keeps sending it.
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+
9
+ import {
10
+ BEARER_REJECTED_HEADER,
11
+ clearRejectedToken,
12
+ pylonFetch,
13
+ pylonFetchRaw,
14
+ SyncEngine,
15
+ } from "./index";
16
+ import type { Storage } from "./storage";
17
+
18
+ function memoryStorage(initial: Record<string, string> = {}): Storage & {
19
+ data: Map<string, string>;
20
+ } {
21
+ const data = new Map(Object.entries(initial));
22
+ return {
23
+ data,
24
+ get: (k) => data.get(k) ?? null,
25
+ set: (k, v) => void data.set(k, v),
26
+ remove: (k) => void data.delete(k),
27
+ };
28
+ }
29
+
30
+ const realFetch = globalThis.fetch;
31
+ afterEach(() => {
32
+ globalThis.fetch = realFetch;
33
+ });
34
+
35
+ /** Stub fetch: records each request's Authorization header and answers
36
+ * with `X-Pylon-Bearer-Rejected: 1` while `rejected(token)` is true. */
37
+ function stubFetch(rejected: (token: string | undefined) => boolean, body: unknown = {}) {
38
+ const auth: Array<string | undefined> = [];
39
+ globalThis.fetch = (async (_input: RequestInfo | URL, init: RequestInit = {}) => {
40
+ const h = (init.headers ?? {}) as Record<string, string>;
41
+ const header = h.Authorization;
42
+ auth.push(header);
43
+ const token = header?.replace(/^Bearer /, "");
44
+ const headers: Record<string, string> = { "content-type": "application/json" };
45
+ if (rejected(token)) headers[BEARER_REJECTED_HEADER] = "1";
46
+ return new Response(JSON.stringify(body), { status: 200, headers });
47
+ }) as typeof fetch;
48
+ return auth;
49
+ }
50
+
51
+ describe("transport: X-Pylon-Bearer-Rejected", () => {
52
+ test("pylonFetch reports the token it sent when the server rejects it", async () => {
53
+ stubFetch(() => true);
54
+ const seen: string[] = [];
55
+ await pylonFetch(
56
+ { baseUrl: "http://t.invalid", token: "stale", onBearerRejected: (t) => seen.push(t) },
57
+ "/api/fn/x",
58
+ { method: "POST", json: {} },
59
+ );
60
+ expect(seen).toEqual(["stale"]);
61
+ });
62
+
63
+ test("pylonFetchRaw reports it too", async () => {
64
+ stubFetch(() => true);
65
+ const seen: string[] = [];
66
+ await pylonFetchRaw(
67
+ { baseUrl: "http://t.invalid", token: "stale", onBearerRejected: (t) => seen.push(t) },
68
+ "/api/fn/x",
69
+ { method: "POST", json: {} },
70
+ );
71
+ expect(seen).toEqual(["stale"]);
72
+ });
73
+
74
+ test("nothing is reported without the header, or when no token was sent", async () => {
75
+ const seen: string[] = [];
76
+ stubFetch(() => false);
77
+ await pylonFetch(
78
+ { baseUrl: "http://t.invalid", token: "good", onBearerRejected: (t) => seen.push(t) },
79
+ "/api/auth/me",
80
+ );
81
+ stubFetch(() => true);
82
+ await pylonFetch(
83
+ { baseUrl: "http://t.invalid", onBearerRejected: (t) => seen.push(t) },
84
+ "/api/auth/me",
85
+ );
86
+ expect(seen).toEqual([]);
87
+ });
88
+ });
89
+
90
+ describe("clearRejectedToken", () => {
91
+ test("removes the app's token and user id when the key still holds that token", () => {
92
+ const s = memoryStorage({ "pylon:miles:token": "stale", "pylon:miles:userId": "u1" });
93
+ expect(clearRejectedToken(s, "pylon:miles:token", "stale")).toBe(true);
94
+ expect(s.data.size).toBe(0);
95
+ });
96
+
97
+ test("keeps a token that a sign-in wrote while the request was in flight", () => {
98
+ const s = memoryStorage({ "pylon:miles:token": "fresh" });
99
+ expect(clearRejectedToken(s, "pylon:miles:token", "stale")).toBe(false);
100
+ expect(s.get("pylon:miles:token")).toBe("fresh");
101
+ });
102
+
103
+ test("leaves the shared default key alone", () => {
104
+ const s = memoryStorage({ pylon_token: "stale" });
105
+ expect(clearRejectedToken(s, "pylon_token", "stale")).toBe(false);
106
+ expect(s.get("pylon_token")).toBe("stale");
107
+ });
108
+ });
109
+
110
+ describe("SyncEngine clears a stored token the server rejects", () => {
111
+ function engine(storage: Storage, extra: Record<string, unknown> = {}) {
112
+ return new SyncEngine({
113
+ baseUrl: "http://t.invalid",
114
+ appName: "miles",
115
+ storage,
116
+ persist: false,
117
+ multiTab: false,
118
+ transport: "poll",
119
+ ...extra,
120
+ });
121
+ }
122
+
123
+ test("fn(): the next call sends no Authorization header", async () => {
124
+ const storage = memoryStorage({ "pylon:miles:token": "stale" });
125
+ const auth = stubFetch((t) => t === "stale", { ok: true });
126
+ const e = engine(storage);
127
+ await e.fn("resetDemoDealership", {});
128
+ expect(storage.get("pylon:miles:token")).toBeNull();
129
+ await e.fn("resetDemoDealership", {});
130
+ expect(auth).toEqual(["Bearer stale", undefined]);
131
+ });
132
+
133
+ test("a token the server accepts stays", async () => {
134
+ const storage = memoryStorage({ "pylon:miles:token": "good" });
135
+ stubFetch(() => false, { ok: true });
136
+ await engine(storage).fn("resetDemoDealership", {});
137
+ expect(storage.get("pylon:miles:token")).toBe("good");
138
+ });
139
+
140
+ test("an explicitly configured token is the caller's and stays", async () => {
141
+ const storage = memoryStorage({ "pylon:miles:token": "stale" });
142
+ stubFetch(() => true, { ok: true });
143
+ await engine(storage, { token: "stale" }).fn("resetDemoDealership", {});
144
+ expect(storage.get("pylon:miles:token")).toBe("stale");
145
+ });
146
+ });
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  import {
12
+ bearerRejected,
13
+ clearRejectedToken,
12
14
  pylonFetch,
13
15
  PylonHttpError,
14
16
  type TransportConfig,
@@ -45,7 +47,10 @@ export {
45
47
  export { IndexedDBPersistence, persistChange } from "./persistence";
46
48
  export type { ReplicaPersistence } from "./persistence";
47
49
  export {
50
+ BEARER_REJECTED_HEADER,
51
+ bearerRejected,
48
52
  buildRequest,
53
+ clearRejectedToken,
49
54
  pylonFetch,
50
55
  pylonFetchRaw,
51
56
  PylonHttpError,
@@ -1922,6 +1927,7 @@ export class SyncEngine {
1922
1927
  void this.pull();
1923
1928
  }
1924
1929
  },
1930
+ onBearerRejected: (token) => this.dropRejectedToken(token),
1925
1931
  },
1926
1932
  path,
1927
1933
  {
@@ -2700,6 +2706,29 @@ export class SyncEngine {
2700
2706
  );
2701
2707
  }
2702
2708
 
2709
+ /**
2710
+ * The server answered with `X-Pylon-Bearer-Rejected`: the token the request
2711
+ * sent resolved to no one (expired, revoked, or not a session of this app).
2712
+ * Clear it from the app's namespace so later requests stop sending it and
2713
+ * the session cookie, when there is one, applies alone. A token passed in
2714
+ * `config.token` belongs to the caller and stays.
2715
+ */
2716
+ private dropRejectedToken(token: string): void {
2717
+ if (this.config.token) return;
2718
+ if (clearRejectedToken(this.storage, this.tokenStorageKey(), token)) {
2719
+ console.warn(
2720
+ "[pylon] the server rejected the stored auth token (expired, revoked, " +
2721
+ "or issued by another app) and it has been cleared. Requests now " +
2722
+ "authenticate with the session cookie when there is one.",
2723
+ );
2724
+ }
2725
+ }
2726
+
2727
+ /** `dropRejectedToken` for a response to a request that sent `token`. */
2728
+ private observeBearerVerdict(res: Response, token: string | null | undefined): void {
2729
+ if (token && bearerRejected(res)) this.dropRejectedToken(token);
2730
+ }
2731
+
2703
2732
  /**
2704
2733
  * Apply a freshly-observed session through the resolver and act on
2705
2734
  * the verdict. Serialized via `sessionChain` so concurrent triggers
@@ -2899,10 +2928,12 @@ export class SyncEngine {
2899
2928
  // same → `resetReplica` never fires on /api/auth/select-org
2900
2929
  // → the local store keeps every previous tenant's rows in
2901
2930
  // cache and `db.useQuery` returns stale data after a switch.
2902
- return fetch(`${this.config.baseUrl}${path}`, {
2931
+ const res = await fetch(`${this.config.baseUrl}${path}`, {
2903
2932
  headers,
2904
2933
  credentials: "include",
2905
2934
  });
2935
+ this.observeBearerVerdict(res, token);
2936
+ return res;
2906
2937
  }
2907
2938
 
2908
2939
  /**
@@ -3040,6 +3071,7 @@ export class SyncEngine {
3040
3071
  headers,
3041
3072
  credentials: "include",
3042
3073
  });
3074
+ this.observeBearerVerdict(res, token);
3043
3075
  if (!res.ok) return null;
3044
3076
  const body = (await res.json()) as { token?: string; url?: string };
3045
3077
  if (!body.token || !body.url) return null;
@@ -3076,6 +3108,7 @@ export class SyncEngine {
3076
3108
  credentials: "include",
3077
3109
  body: body !== undefined ? JSON.stringify(body) : undefined,
3078
3110
  });
3111
+ this.observeBearerVerdict(res, token);
3079
3112
  const text = await res.text();
3080
3113
  let parsed: unknown = null;
3081
3114
  if (text) {
@@ -4220,6 +4253,7 @@ export class SyncEngine {
4220
4253
  credentials: "include",
4221
4254
  body: body ? JSON.stringify(body) : undefined,
4222
4255
  });
4256
+ this.observeBearerVerdict(res, token);
4223
4257
 
4224
4258
  if (!res.ok) {
4225
4259
  // Surface the status so the caller can distinguish transient
package/src/transport.ts CHANGED
@@ -16,6 +16,9 @@
16
16
  // - X-Pylon-Change-Seq response header surfaced via the optional
17
17
  // `onChangeSeq` callback (so callers can trigger catch-up pulls
18
18
  // when a mutation's seq exceeds their local cursor)
19
+ // - X-Pylon-Bearer-Rejected response header surfaced via the optional
20
+ // `onBearerRejected` callback (so the owner of a stored token can
21
+ // clear a token the server no longer accepts)
19
22
  //
20
23
  // Streaming + raw-binary callers (file uploads, SSE) bypass the
21
24
  // JSON-parse step but reuse the URL/auth/credentials logic via
@@ -40,6 +43,48 @@ export interface TransportConfig {
40
43
  * header when the server sets one. Returning a Promise pauses no
41
44
  * one — the transport doesn't await it. */
42
45
  onChangeSeq?: (seq: number) => void;
46
+ /** Invoked with the bearer token a request sent when the server answers
47
+ * with `X-Pylon-Bearer-Rejected: 1`: the server resolved the token to no
48
+ * one (unknown, expired, or revoked). The code that stored the token
49
+ * clears it here, so later requests stop sending it. */
50
+ onBearerRejected?: (token: string) => void;
51
+ }
52
+
53
+ /** Response header the server sets when the request's bearer token resolved
54
+ * to no one. Lower case, as `Headers.get` matches without case. */
55
+ export const BEARER_REJECTED_HEADER = "x-pylon-bearer-rejected";
56
+
57
+ /** True when the server marked the bearer token the request sent as rejected. */
58
+ export function bearerRejected(res: Response): boolean {
59
+ return res.headers?.get?.(BEARER_REJECTED_HEADER) === "1";
60
+ }
61
+
62
+ /**
63
+ * Remove a token the server rejected from `storage`, together with its
64
+ * `:userId` sibling. Only removes it when `key` still holds that exact token,
65
+ * so a sign-in that wrote a new token while the request was in flight keeps
66
+ * its token.
67
+ *
68
+ * The default unprefixed key `pylon_token` is left alone. Every app on an
69
+ * origin that has no `appName` shares it, so the token can belong to another
70
+ * app. Returns true when the token was removed.
71
+ */
72
+ export function clearRejectedToken(
73
+ storage: { get(key: string): string | null; remove(key: string): void },
74
+ key: string,
75
+ token: string,
76
+ ): boolean {
77
+ if (key === "pylon_token") return false;
78
+ try {
79
+ if (storage.get(key) !== token) return false;
80
+ storage.remove(key);
81
+ if (key.endsWith(":token")) storage.remove(key.replace(/:token$/, ":userId"));
82
+ return true;
83
+ } catch {
84
+ // Storage can throw (Safari private mode). The token stays; the server
85
+ // keeps answering with the session cookie.
86
+ return false;
87
+ }
43
88
  }
44
89
 
45
90
  /**
@@ -90,13 +135,13 @@ export function buildRequest(
90
135
  config: TransportConfig,
91
136
  path: string,
92
137
  init: PylonRequestInit = {},
93
- ): { url: string; init: RequestInit } {
138
+ ): { url: string; init: RequestInit; token?: string } {
94
139
  const base = resolveBaseUrl(config);
95
140
  const url = `${base}${path}`;
96
141
  const headers: Record<string, string> = { ...(init.headers ?? {}) };
97
142
  // Auth: explicit getToken wins over static token. Either may be
98
143
  // null/undefined — cookie-auth apps rely solely on credentials.
99
- const token = config.getToken?.() ?? config.token;
144
+ const token = config.getToken?.() ?? config.token ?? undefined;
100
145
  if (token) headers["Authorization"] = `Bearer ${token}`;
101
146
  if (init.accept) headers["Accept"] = init.accept;
102
147
  let body: BodyInit | undefined = init.body;
@@ -106,6 +151,7 @@ export function buildRequest(
106
151
  }
107
152
  return {
108
153
  url,
154
+ token: token || undefined,
109
155
  init: {
110
156
  method: init.method ?? (body !== undefined ? "POST" : "GET"),
111
157
  headers,
@@ -153,8 +199,9 @@ export async function pylonFetch<T = unknown>(
153
199
  path: string,
154
200
  init: PylonRequestInit = {},
155
201
  ): Promise<T> {
156
- const { url, init: req } = buildRequest(config, path, init);
202
+ const { url, init: req, token } = buildRequest(config, path, init);
157
203
  const res = await fetch(url, req);
204
+ reportBearerVerdict(config, res, token);
158
205
  const text = await res.text();
159
206
  let parsed: unknown = null;
160
207
  if (text) {
@@ -200,6 +247,19 @@ export async function pylonFetchRaw(
200
247
  path: string,
201
248
  init: PylonRequestInit = {},
202
249
  ): Promise<Response> {
203
- const { url, init: req } = buildRequest(config, path, init);
204
- return fetch(url, req);
250
+ const { url, init: req, token } = buildRequest(config, path, init);
251
+ const res = await fetch(url, req);
252
+ reportBearerVerdict(config, res, token);
253
+ return res;
254
+ }
255
+
256
+ /** Call `config.onBearerRejected` when the server rejected the token sent. */
257
+ function reportBearerVerdict(
258
+ config: TransportConfig,
259
+ res: Response,
260
+ token: string | undefined,
261
+ ): void {
262
+ if (token && config.onBearerRejected && bearerRejected(res)) {
263
+ config.onBearerRejected(token);
264
+ }
205
265
  }