@rebasepro/client 0.11.1-canary.gfd39654 → 0.12.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.
@@ -49,14 +49,14 @@ export interface BroadcastEvent {
49
49
  */
50
50
  replayed?: boolean;
51
51
  }
52
- /** One retained message, as returned by {@link RebaseRealtimeChannel.history}. */
53
- export interface ChannelHistoryEntry {
54
- seq: number;
55
- event: string;
56
- payload: unknown;
57
- senderId?: string;
58
- at?: string;
59
- }
52
+ /**
53
+ * One retained message, as returned by {@link RebaseRealtimeChannel.history}.
54
+ *
55
+ * Re-exported rather than re-declared: the copy that used to live here had
56
+ * drifted `at` to optional, while the server always sends it.
57
+ */
58
+ export type { ChannelHistoryEntry } from "@rebasepro/types";
59
+ import type { ChannelHistoryEntry } from "@rebasepro/types";
60
60
  /** The answer to a catch-up request. */
61
61
  export interface ChannelHistoryResult {
62
62
  messages: ChannelHistoryEntry[];
@@ -56,7 +56,41 @@ export interface RebaseClientConfig {
56
56
  * `client.close()` when shutting down.
57
57
  */
58
58
  realtime?: boolean;
59
+ /**
60
+ * "Yes, I meant to be anonymous."
61
+ *
62
+ * Off-browser, a client with no credential can only ever call as an
63
+ * anonymous user, and row-level security answers it with whatever is
64
+ * public — usually nothing. That is almost always a mistake in a script or
65
+ * cron job, so the SDK warns once on the first request (see
66
+ * {@link ANONYMOUS_SERVER_CLIENT_WARNING}). Anonymous is a legitimate
67
+ * choice for public reads, though; set this to `true` to say so and
68
+ * silence the warning.
69
+ *
70
+ * Has no effect in the browser, where anonymous-before-sign-in is normal
71
+ * and nothing is ever warned about.
72
+ */
73
+ anonymous?: boolean;
59
74
  }
75
+ /**
76
+ * Facts about the surrounding client that the transport cannot read off its own
77
+ * config, but needs in order to decide whether a request is *meaningfully*
78
+ * credential-less.
79
+ */
80
+ export interface TransportEnvironment {
81
+ /**
82
+ * The credential reaches the server without an `Authorization` header —
83
+ * i.e. `auth.authFlowMode: "cookie"`, where the refresh token lives in an
84
+ * httpOnly cookie. Such a client looks tokenless to the transport but is
85
+ * not anonymous, so it must never trip the guard.
86
+ */
87
+ credentialOutOfBand?: boolean;
88
+ }
89
+ /**
90
+ * Emitted once per client. Kept as a constant so the wording is testable and
91
+ * greppable — this is the string a user will paste into a search.
92
+ */
93
+ export declare const ANONYMOUS_SERVER_CLIENT_WARNING: string;
60
94
  /**
61
95
  * Re-export from `@rebasepro/types` for backward compatibility.
62
96
  *
@@ -81,4 +115,4 @@ export interface Transport {
81
115
  getHeaders: (init?: RequestInit) => Record<string, string>;
82
116
  resolveToken: () => Promise<string | null>;
83
117
  }
84
- export declare function createTransport(config: RebaseClientConfig): Transport;
118
+ export declare function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/client",
3
3
  "type": "module",
4
- "version": "0.11.1-canary.gfd39654",
4
+ "version": "0.12.0",
5
5
  "description": "HTTP SDK client for the Rebase custom backend",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,9 +29,9 @@
29
29
  "./package.json": "./package.json"
30
30
  },
31
31
  "dependencies": {
32
- "@rebasepro/common": "0.11.1-canary.gfd39654",
33
- "@rebasepro/types": "0.11.1-canary.gfd39654",
34
- "@rebasepro/utils": "0.11.1-canary.gfd39654"
32
+ "@rebasepro/common": "0.12.0",
33
+ "@rebasepro/types": "0.12.0",
34
+ "@rebasepro/utils": "0.12.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jest/globals": "^30.4.1",
@@ -0,0 +1,190 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
2
+ import { ANONYMOUS_SERVER_CLIENT_WARNING, createTransport } from "./transport";
3
+ import { createRebaseClient } from "./index";
4
+
5
+ /**
6
+ * A client built off-browser with no credential is silently anonymous: RLS
7
+ * answers it with whatever is public, which is usually nothing. A `scrape-jobs`
8
+ * script hit this twice. These pin the *narrowness* of the guard as much as the
9
+ * guard itself — a warning that fires on legitimate anonymous clients (browser
10
+ * before sign-in, public reads) is noise that teaches people to ignore warnings.
11
+ */
12
+
13
+ type MockFetch = jest.Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise<Partial<Response>>>;
14
+
15
+ const okFetch = (): MockFetch => {
16
+ const fetchMock = jest.fn() as MockFetch;
17
+ fetchMock.mockResolvedValue({
18
+ ok: true,
19
+ status: 200,
20
+ text: async () => JSON.stringify({ data: [] })
21
+ });
22
+ return fetchMock;
23
+ };
24
+
25
+ const setWindow = (defined: boolean) => {
26
+ if (!defined) { delete (globalThis as never as { window?: unknown }).window; return; }
27
+ (globalThis as never as { window: unknown }).window = { location: { origin: "https://app.example.com", href: "https://app.example.com/" } };
28
+ };
29
+
30
+ const setDocument = (defined: boolean) => {
31
+ if (!defined) { delete (globalThis as never as { document?: unknown }).document; return; }
32
+ (globalThis as never as { document: unknown }).document = { cookie: "" };
33
+ };
34
+
35
+ let warn: jest.SpiedFunction<typeof console.warn>;
36
+
37
+ beforeEach(() => {
38
+ warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
39
+ });
40
+
41
+ afterEach(() => {
42
+ warn.mockRestore();
43
+ setWindow(false);
44
+ setDocument(false);
45
+ });
46
+
47
+ const anonymousWarnings = () => warn.mock.calls.filter(([first]) => first === ANONYMOUS_SERVER_CLIENT_WARNING);
48
+
49
+ describe("anonymous server client guard", () => {
50
+ it("warns when a Node-side client has no credential at all", async () => {
51
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
52
+
53
+ await transport.request("/data/jobs");
54
+
55
+ expect(anonymousWarnings()).toHaveLength(1);
56
+ expect(anonymousWarnings()[0][0]).toContain("anonymous: true");
57
+ });
58
+
59
+ it("warns once per client, not once per request", async () => {
60
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
61
+
62
+ await transport.request("/data/jobs");
63
+ await transport.request("/data/jobs");
64
+ await transport.request("/data/other");
65
+
66
+ expect(anonymousWarnings()).toHaveLength(1);
67
+ });
68
+
69
+ it("warns per client, so a second offending client is still reported", async () => {
70
+ const a = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
71
+ const b = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
72
+
73
+ await a.request("/data/jobs");
74
+ await b.request("/data/jobs");
75
+
76
+ expect(anonymousWarnings()).toHaveLength(2);
77
+ });
78
+
79
+ it("stays silent in a browser, where anonymous-before-sign-in is normal", async () => {
80
+ setWindow(true);
81
+ const transport = createTransport({ fetch: okFetch() as typeof globalThis.fetch });
82
+
83
+ await transport.request("/data/posts");
84
+
85
+ expect(anonymousWarnings()).toHaveLength(0);
86
+ });
87
+
88
+ it("stays silent when only `document` is defined, so an SSR/test shim is not mistaken for Node", async () => {
89
+ setDocument(true);
90
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
91
+
92
+ await transport.request("/data/posts");
93
+
94
+ expect(anonymousWarnings()).toHaveLength(0);
95
+ });
96
+
97
+ it("stays silent when a token was passed", async () => {
98
+ const transport = createTransport({ baseUrl: "http://localhost:3001", token: "service-key", fetch: okFetch() as typeof globalThis.fetch });
99
+
100
+ await transport.request("/data/jobs");
101
+
102
+ expect(anonymousWarnings()).toHaveLength(0);
103
+ });
104
+
105
+ it("stays silent when the token arrives after construction (the reason this is checked at first request)", async () => {
106
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
107
+ transport.setToken("service-key");
108
+
109
+ await transport.request("/data/jobs");
110
+
111
+ expect(anonymousWarnings()).toHaveLength(0);
112
+ });
113
+
114
+ it("stays silent when an auth token getter is installed", async () => {
115
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
116
+ transport.setAuthTokenGetter(async () => "fetched-token");
117
+
118
+ await transport.request("/data/jobs");
119
+
120
+ expect(anonymousWarnings()).toHaveLength(0);
121
+ });
122
+
123
+ it("stays silent for a token getter that has nothing yet — the credential path exists", async () => {
124
+ const transport = createTransport({ baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch });
125
+ transport.setAuthTokenGetter(async () => null);
126
+
127
+ await transport.request("/data/jobs");
128
+
129
+ expect(anonymousWarnings()).toHaveLength(0);
130
+ });
131
+
132
+ it("stays silent for the cookie auth flow, where the credential is not a header", async () => {
133
+ const transport = createTransport(
134
+ { baseUrl: "http://localhost:3001", fetch: okFetch() as typeof globalThis.fetch },
135
+ { credentialOutOfBand: true }
136
+ );
137
+
138
+ await transport.request("/data/jobs");
139
+
140
+ expect(anonymousWarnings()).toHaveLength(0);
141
+ });
142
+
143
+ it("stays silent when the caller opted in with `anonymous: true`", async () => {
144
+ const transport = createTransport({ baseUrl: "http://localhost:3001", anonymous: true, fetch: okFetch() as typeof globalThis.fetch });
145
+
146
+ await transport.request("/data/jobs");
147
+
148
+ expect(anonymousWarnings()).toHaveLength(0);
149
+ });
150
+ });
151
+
152
+ describe("anonymous server client guard, through createRebaseClient", () => {
153
+ it("warns for a credential-less client built in a script", async () => {
154
+ const client = createRebaseClient({
155
+ baseUrl: "http://localhost:3001",
156
+ realtime: false,
157
+ fetch: okFetch() as typeof globalThis.fetch
158
+ });
159
+
160
+ await client.collection("jobs").find();
161
+
162
+ expect(anonymousWarnings()).toHaveLength(1);
163
+ });
164
+
165
+ it("stays silent when `auth.authFlowMode` is cookie", async () => {
166
+ const client = createRebaseClient({
167
+ baseUrl: "http://localhost:3001",
168
+ realtime: false,
169
+ fetch: okFetch() as typeof globalThis.fetch,
170
+ auth: { authFlowMode: "cookie", persistSession: false }
171
+ });
172
+
173
+ await client.collection("jobs").find();
174
+
175
+ expect(anonymousWarnings()).toHaveLength(0);
176
+ });
177
+
178
+ it("stays silent when the caller opted in with `anonymous: true`", async () => {
179
+ const client = createRebaseClient({
180
+ baseUrl: "http://localhost:3001",
181
+ realtime: false,
182
+ anonymous: true,
183
+ fetch: okFetch() as typeof globalThis.fetch
184
+ });
185
+
186
+ await client.collection("jobs").find();
187
+
188
+ expect(anonymousWarnings()).toHaveLength(0);
189
+ });
190
+ });
package/src/api-keys.ts CHANGED
@@ -1,50 +1,27 @@
1
1
  import type { Transport } from "./transport";
2
2
 
3
- // Re-define the types locally since they live in server, not in @rebasepro/types.
4
- // These match the server-side types exactly.
5
-
6
- /** A single permission entry scoping an API key to a collection and its allowed operations. */
7
- export interface ApiKeyPermission {
8
- collection: string;
9
- operations: ("read" | "write" | "delete")[];
10
- }
11
-
12
- /** An API key with the secret portion masked (returned by list / get / update). */
13
- export interface ApiKeyMasked {
14
- id: string;
15
- name: string;
16
- key_prefix: string;
17
- permissions: ApiKeyPermission[];
18
- admin: boolean;
19
- rate_limit: number | null;
20
- created_by: string;
21
- created_at: string;
22
- updated_at: string;
23
- last_used_at: string | null;
24
- expires_at: string | null;
25
- revoked_at: string | null;
26
- }
27
-
28
- /** An API key including the full secret (returned only on creation). */
29
- export interface ApiKeyWithSecret extends ApiKeyMasked {
30
- key: string;
31
- }
32
-
33
- /** Payload for creating a new API key. */
34
- export interface CreateApiKeyRequest {
35
- name: string;
36
- permissions: ApiKeyPermission[];
37
- rate_limit?: number | null;
38
- expires_at?: string | null;
39
- }
3
+ /**
4
+ * These were re-declared here, under a comment saying they lived in the server
5
+ * package rather than in `@rebasepro/types`. That stopped being true, and the
6
+ * copy drifted: it never gained `admin`, the flag that grants a key the `admin`
7
+ * role — admin routes plus the RLS `default_admin` policies — so the SDK could
8
+ * describe every kind of key except a privileged one, and
9
+ * `createKey({ …, admin: true })` was an excess-property error.
10
+ */
11
+ export type {
12
+ ApiKeyPermission,
13
+ ApiKeyMasked,
14
+ ApiKeyWithSecret,
15
+ CreateApiKeyRequest,
16
+ UpdateApiKeyRequest
17
+ } from "@rebasepro/types";
40
18
 
41
- /** Payload for updating an existing API key. */
42
- export interface UpdateApiKeyRequest {
43
- name?: string;
44
- permissions?: ApiKeyPermission[];
45
- rate_limit?: number | null;
46
- expires_at?: string | null;
47
- }
19
+ import type {
20
+ ApiKeyMasked,
21
+ ApiKeyWithSecret,
22
+ CreateApiKeyRequest,
23
+ UpdateApiKeyRequest
24
+ } from "@rebasepro/types";
48
25
 
49
26
  /** Options for the `createApiKeys` factory. */
50
27
  export interface CreateApiKeysOptions {
@@ -144,7 +144,7 @@ hasMore: false }
144
144
  // Flat row access — no .values wrapper
145
145
  expect(result.data[0].id).toBe("1");
146
146
  expect((result.data[0] as Record<string, unknown>).name).toBe("Product A");
147
- // No path field — that's CMS leakage
147
+ // No path field — that's admin leakage
148
148
  expect((result.data[0] as Record<string, unknown>).path).toBeUndefined();
149
149
  expect(result.meta.total).toBe(1);
150
150
  });
package/src/collection.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { buildQueryString, FindParams, RebaseApiError, Transport } from "./transport";
2
2
  import { RebaseWebSocketClient } from "./websocket";
3
3
  import {
4
+ FindAllParams,
4
5
  FindResult,
6
+ IterateParams,
5
7
  LogicalCondition,
6
8
  SDKCollectionClient,
7
9
  SDKQueryBuilderInterface,
@@ -9,6 +11,7 @@ import {
9
11
  WhereValue,
10
12
  WriteOptions
11
13
  } from "@rebasepro/types";
14
+ import { collectAllPages, paginateFind } from "@rebasepro/common";
12
15
 
13
16
  import { SDKQueryBuilder } from "./sdk_query_builder";
14
17
 
@@ -113,6 +116,17 @@ export function createCollectionClient<M extends Record<string, unknown> = Recor
113
116
  };
114
117
  },
115
118
 
119
+ // The pagination engine lives in `@rebasepro/common`, shared with the
120
+ // in-process accessor: `iterate()` has to mean the same thing whichever
121
+ // transport the caller happens to be holding.
122
+ iterate(params?: IterateParams<M>) {
123
+ return paginateFind<M>((p) => client.find(p), params, slug);
124
+ },
125
+
126
+ findAll(params?: FindAllParams<M>) {
127
+ return collectAllPages<M>((p) => client.find(p), params, slug);
128
+ },
129
+
116
130
  async findById(id: string | number) {
117
131
  try {
118
132
  const raw = await transport.request<Record<string, unknown>>(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
package/src/functions.ts CHANGED
@@ -33,14 +33,8 @@ export interface FunctionsClient {
33
33
  ): Promise<T>;
34
34
  }
35
35
 
36
- export interface FunctionInvokeOptions {
37
- /** HTTP method defaults to `"POST"`. */
38
- method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
39
- /** Sub-path appended after the function name, e.g. `"status/123"`. */
40
- path?: string;
41
- /** Extra headers merged into the request (auth is still injected automatically). */
42
- headers?: Record<string, string>;
43
- }
36
+ export type { FunctionInvokeOptions } from "@rebasepro/types";
37
+ import type { FunctionInvokeOptions } from "@rebasepro/types";
44
38
 
45
39
  /**
46
40
  * Create a `FunctionsClient` backed by the given transport.
package/src/index.ts CHANGED
@@ -48,6 +48,12 @@ export type { RebaseClientConfig, FindParams, FindResponse } from "./transport";
48
48
  export type { CollectionClient } from "./collection";
49
49
  export type { FindResult, SDKCollectionClient, SDKQueryBuilderInterface, PaginationMeta } from "@rebasepro/types";
50
50
 
51
+ // Pagination: `iterate()` / `findAll()` parameter types and the error a walk
52
+ // throws instead of quietly returning a truncated answer.
53
+ export type { IterateParams, FindAllParams, PageWalkOptions, CursorSpec } from "@rebasepro/types";
54
+ export { RebasePaginationError } from "@rebasepro/common";
55
+ export type { PaginationErrorCode } from "@rebasepro/common";
56
+
51
57
  // Logical-condition helpers for `.where(or(...), and(...))`.
52
58
  export { QueryBuilder, or, and, cond } from "@rebasepro/common";
53
59
 
@@ -257,7 +263,10 @@ function deriveWebSocketUrl(baseUrl?: string): string {
257
263
  }
258
264
 
259
265
  export function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB> {
260
- const transport = createTransport(options);
266
+ // `credentialOutOfBand`: in cookie auth mode the credential is an httpOnly
267
+ // cookie, so a tokenless transport is not an anonymous client and must not
268
+ // trip the server-side anonymous guard (see `RebaseClientConfig.anonymous`).
269
+ const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
261
270
  const auth = createAuth(transport, options.auth);
262
271
  const admin = createAdmin(transport, options.admin);
263
272
  const cron = createCron(transport, options.cron);
@@ -155,7 +155,40 @@ describe("row codec round-trip", () => {
155
155
  });
156
156
  });
157
157
 
158
+ /**
159
+ * Empty the shared queue between the manager tests.
160
+ *
161
+ * `IDB_NAME` is a hardcoded `"rebase-offline"`, so every
162
+ * `new IndexedDBOfflineStore()` in this file opens the *same* database. The
163
+ * `IndexedDBOfflineStore` block above shares it deliberately — each of its
164
+ * tests uses its own key prefix, and one of them exists to prove that
165
+ * isolation holds. The two `OfflineManager` blocks below do not: a manager
166
+ * queues under no prefix, so without this they inherit each other's pending
167
+ * writes.
168
+ *
169
+ * CI failed both manager tests on 2026-07-28 with exactly the symptoms this
170
+ * coupling would produce — "replays them from a fresh one" asserted
171
+ * `flushed: 1` and got `2`, and the cross-tab read came back empty. It has not
172
+ * been reproduced locally (14 runs, including randomized order), so treat this
173
+ * as removing a real coupling rather than as a confirmed diagnosis: if CI fails
174
+ * here again, the shared queue is no longer the explanation and the next place
175
+ * to look is timing inside the manager itself.
176
+ */
177
+ async function clearOfflineQueue(): Promise<void> {
178
+ await new Promise<void>((resolve, reject) => {
179
+ const request = indexedDB.deleteDatabase("rebase-offline");
180
+ request.onsuccess = () => resolve();
181
+ request.onerror = () => reject(request.error);
182
+ // A still-open connection blocks the delete; the manager tests each
183
+ // build their own stores, so resolving here keeps a stray one from
184
+ // hanging the suite rather than failing it.
185
+ request.onblocked = () => resolve();
186
+ });
187
+ }
188
+
158
189
  describe("two tabs over one database", () => {
190
+ beforeEach(clearOfflineQueue);
191
+
159
192
  /** BroadcastChannel delivery is asynchronous; give it a macrotask or two. */
160
193
  const settle = async () => {
161
194
  for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 0));
@@ -240,6 +273,8 @@ describe("two tabs over one database", () => {
240
273
  });
241
274
 
242
275
  describe("OfflineManager over IndexedDB", () => {
276
+ beforeEach(clearOfflineQueue);
277
+
243
278
  it("queues offline writes in one manager and replays them from a fresh one", async () => {
244
279
  const table = new Map<string, Record<string, unknown>>();
245
280
  const state = { online: true };
@@ -8,6 +8,11 @@ import { MemoryOfflineStore } from "./offline-store";
8
8
  * offline.test.ts covers the manager against fakes, not this plumbing.
9
9
  */
10
10
  describe("createRebaseClient({ offline })", () => {
11
+ // Tokenless clients in a Node environment, on purpose — that is what the
12
+ // anonymous-server-client guard warns about (covered by
13
+ // src/anonymous-client-guard.test.ts). Here it is only noise.
14
+ beforeEach(() => { jest.spyOn(console, "warn").mockImplementation(() => undefined); });
15
+
11
16
  function jsonResponse(body: unknown): Response {
12
17
  return new Response(JSON.stringify(body), {
13
18
  status: 200,
package/src/offline.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { buildQueryString, FindParams, RebaseApiError } from "./transport";
2
- import { FindResult, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
2
+ import { FindAllParams, FindResult, IterateParams, LogicalCondition, SDKCollectionClient, WhereFilterOp, WhereValue } from "@rebasepro/types";
3
+ import { collectAllPages, paginateFind } from "@rebasepro/common";
3
4
  import { CollectionClient, LiveResult, ObserveOptions, RowSnapshotMeta } from "./collection";
4
5
  import { SDKQueryBuilder } from "./sdk_query_builder";
5
6
  import { dehydrateRow, hydrateRow } from "./offline-codec";
@@ -437,6 +438,13 @@ export class OfflineManager {
437
438
  return { data: answer.data, meta: answer.meta };
438
439
  },
439
440
 
441
+ // Paginates the *wrapped* find, so a walk started offline is served
442
+ // page by page out of the local database exactly as it would be
443
+ // from the server, and rejoins the network mid-walk if it returns.
444
+ iterate: (params?: IterateParams<M>) => paginateFind<M>((p) => wrapped.find(p), params, slug),
445
+
446
+ findAll: (params?: FindAllParams<M>) => collectAllPages<M>((p) => wrapped.find(p), params, slug),
447
+
440
448
  findById: async (id: string | number) => {
441
449
  await this.ensureCollection(slug);
442
450
  if (this.connectivity.shouldAttempt()) {
@@ -53,14 +53,14 @@ export interface BroadcastEvent {
53
53
  replayed?: boolean;
54
54
  }
55
55
 
56
- /** One retained message, as returned by {@link RebaseRealtimeChannel.history}. */
57
- export interface ChannelHistoryEntry {
58
- seq: number;
59
- event: string;
60
- payload: unknown;
61
- senderId?: string;
62
- at?: string;
63
- }
56
+ /**
57
+ * One retained message, as returned by {@link RebaseRealtimeChannel.history}.
58
+ *
59
+ * Re-exported rather than re-declared: the copy that used to live here had
60
+ * drifted `at` to optional, while the server always sends it.
61
+ */
62
+ export type { ChannelHistoryEntry } from "@rebasepro/types";
63
+ import type { ChannelHistoryEntry } from "@rebasepro/types";
64
64
 
65
65
  /** The answer to a catch-up request. */
66
66
  export interface ChannelHistoryResult {
package/src/transport.ts CHANGED
@@ -63,8 +63,65 @@ export interface RebaseClientConfig {
63
63
  * `client.close()` when shutting down.
64
64
  */
65
65
  realtime?: boolean;
66
+ /**
67
+ * "Yes, I meant to be anonymous."
68
+ *
69
+ * Off-browser, a client with no credential can only ever call as an
70
+ * anonymous user, and row-level security answers it with whatever is
71
+ * public — usually nothing. That is almost always a mistake in a script or
72
+ * cron job, so the SDK warns once on the first request (see
73
+ * {@link ANONYMOUS_SERVER_CLIENT_WARNING}). Anonymous is a legitimate
74
+ * choice for public reads, though; set this to `true` to say so and
75
+ * silence the warning.
76
+ *
77
+ * Has no effect in the browser, where anonymous-before-sign-in is normal
78
+ * and nothing is ever warned about.
79
+ */
80
+ anonymous?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Facts about the surrounding client that the transport cannot read off its own
85
+ * config, but needs in order to decide whether a request is *meaningfully*
86
+ * credential-less.
87
+ */
88
+ export interface TransportEnvironment {
89
+ /**
90
+ * The credential reaches the server without an `Authorization` header —
91
+ * i.e. `auth.authFlowMode: "cookie"`, where the refresh token lives in an
92
+ * httpOnly cookie. Such a client looks tokenless to the transport but is
93
+ * not anonymous, so it must never trip the guard.
94
+ */
95
+ credentialOutOfBand?: boolean;
66
96
  }
67
97
 
98
+ /**
99
+ * True when there is no browser to have signed a user in — a Node script, a
100
+ * cron job, an edge worker.
101
+ *
102
+ * Anonymous is an ordinary, correct state in a browser: before sign-in, on a
103
+ * marketing page, for public reads. Warning there would be noise that teaches
104
+ * people to ignore warnings, so the guard is off entirely. This uses the same
105
+ * `typeof window` test as {@link resolveBaseUrl}, and additionally treats a
106
+ * defined `document` as a browser so an SSR shim or test harness that installs
107
+ * only one of the two is still excluded.
108
+ */
109
+ function isServerLikeEnvironment(): boolean {
110
+ return typeof window === "undefined" && typeof document === "undefined";
111
+ }
112
+
113
+ /**
114
+ * Emitted once per client. Kept as a constant so the wording is testable and
115
+ * greppable — this is the string a user will paste into a search.
116
+ */
117
+ export const ANONYMOUS_SERVER_CLIENT_WARNING =
118
+ "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, "
119
+ + "and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only "
120
+ + "publicly readable rows, which is usually nothing and occasionally the wrong thing. "
121
+ + "Inside a cron or function handler, use the `client` you were handed instead of building a new one: its data "
122
+ + "plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. "
123
+ + "If you really do want anonymous access, pass `anonymous: true` to silence this.";
124
+
68
125
  /**
69
126
  * Re-export from `@rebasepro/types` for backward compatibility.
70
127
  *
@@ -156,12 +213,32 @@ function resolveBaseUrl(configured?: string): string {
156
213
  return "";
157
214
  }
158
215
 
159
- export function createTransport(config: RebaseClientConfig): Transport {
216
+ export function createTransport(config: RebaseClientConfig, environment?: TransportEnvironment): Transport {
160
217
  const fetchFn = config.fetch || globalThis.fetch;
161
218
  const apiPath = config.apiPath || "/api";
162
219
  let token = config.token;
163
220
  let tokenGetter: (() => Promise<string | null>) | undefined;
164
221
  let onUnauthorizedHandler = config.onUnauthorized;
222
+ /** Once per client, never per request — log spam is its own bug. */
223
+ let anonymousWarningIssued = false;
224
+
225
+ /**
226
+ * Warn a server-side caller that it built a client that can only ever be
227
+ * anonymous. Deliberately checked at the *first request* rather than at
228
+ * construction: `setToken()` / `setAuthTokenGetter()` and a server-side
229
+ * `auth.signIn…()` (which calls `transport.setToken`) all land after the
230
+ * constructor, and warning at construction would fire on every one of them.
231
+ */
232
+ function warnIfAnonymousServerClient(activeToken: string | undefined): void {
233
+ if (anonymousWarningIssued) return;
234
+ if (activeToken) return; // a credential is being sent
235
+ if (tokenGetter) return; // a credential is being fetched per request
236
+ if (config.anonymous) return; // "yes, I meant this"
237
+ if (environment?.credentialOutOfBand) return; // cookie auth flow — credential is not a header
238
+ if (!isServerLikeEnvironment()) return; // browsers are legitimately anonymous
239
+ anonymousWarningIssued = true;
240
+ console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
241
+ }
165
242
 
166
243
  function getHeaders(activeToken: string | undefined, init?: RequestInit) {
167
244
  return {
@@ -186,6 +263,8 @@ export function createTransport(config: RebaseClientConfig): Transport {
186
263
  }
187
264
  }
188
265
 
266
+ warnIfAnonymousServerClient(activeToken);
267
+
189
268
  const headers = getHeaders(activeToken, init);
190
269
 
191
270
  // If passing FormData, we MUST let fetch set the boundary, so remove Content-Type