@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.
- package/dist/api-keys.d.ts +10 -38
- package/dist/functions.d.ts +2 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.es.js +51 -4
- package/dist/index.es.js.map +1 -1
- package/dist/realtime-channel.d.ts +8 -8
- package/dist/transport.d.ts +35 -1
- package/package.json +4 -4
- package/src/anonymous-client-guard.test.ts +190 -0
- package/src/api-keys.ts +21 -44
- package/src/collection.test.ts +1 -1
- package/src/collection.ts +14 -0
- package/src/functions.ts +2 -8
- package/src/index.ts +10 -1
- package/src/offline-idb-store.test.ts +35 -0
- package/src/offline-integration.test.ts +5 -0
- package/src/offline.ts +9 -1
- package/src/realtime-channel.ts +8 -8
- package/src/transport.ts +80 -1
|
@@ -49,14 +49,14 @@ export interface BroadcastEvent {
|
|
|
49
49
|
*/
|
|
50
50
|
replayed?: boolean;
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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[];
|
package/dist/transport.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
33
|
-
"@rebasepro/types": "0.
|
|
34
|
-
"@rebasepro/utils": "0.
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 {
|
package/src/collection.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
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()) {
|
package/src/realtime-channel.ts
CHANGED
|
@@ -53,14 +53,14 @@ export interface BroadcastEvent {
|
|
|
53
53
|
replayed?: boolean;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|