@rebasepro/client 0.11.0 → 0.11.1-canary.g16c8254
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-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.11.
|
|
4
|
+
"version": "0.11.1-canary.g16c8254",
|
|
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.
|
|
33
|
-
"@rebasepro/types": "0.11.
|
|
34
|
-
"@rebasepro/utils": "0.11.
|
|
32
|
+
"@rebasepro/common": "0.11.1-canary.g16c8254",
|
|
33
|
+
"@rebasepro/types": "0.11.1-canary.g16c8254",
|
|
34
|
+
"@rebasepro/utils": "0.11.1-canary.g16c8254"
|
|
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);
|
|
@@ -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
|