@findlane/server 0.28.0 → 0.29.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/README.md +17 -0
- package/dist/index.cjs +40 -4
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +40 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,23 @@ if (!failing.length) await products.replaceSettings({ ...(await products.getSett
|
|
|
88
88
|
|
|
89
89
|
Only upload fields you intend to make public if browser search is enabled. `getPublicSearchAccess()` shows the current mode. `setPublicSearchPolicy({ returnFields, searchFields, filterFields, facetFields, sortIds, defaultSort })` restricts the index at the API, and `usePublicIndexDefaults()` removes the restrictions. Only a workspace owner can change allowed browser origins, in the dashboard.
|
|
90
90
|
|
|
91
|
+
### Signed keys
|
|
92
|
+
|
|
93
|
+
For a logged-in person's browser searches, sign a short-lived key with a signing key from the dashboard's API keys page. It makes no request, and its restrictions replace the index's public settings:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { signSearchKey } from "@findlane/server"
|
|
97
|
+
|
|
98
|
+
const signedKey = await signSearchKey(process.env.FINDLANE_SIGNING_KEY!, {
|
|
99
|
+
validUntil: new Date(Date.now() + 60 * 60 * 1000),
|
|
100
|
+
indexes: ["products"],
|
|
101
|
+
filters: { customerGroup: user.group },
|
|
102
|
+
userToken: user.id,
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The browser SDK takes it as `signedKey`. See [Logged-in visitors](https://findlane.dev/docs/security#logged-in-visitors).
|
|
107
|
+
|
|
91
108
|
## Errors
|
|
92
109
|
|
|
93
110
|
Failed requests throw a `FindlaneError` with the HTTP `status` and the API's message. A 402 means a plan limit, such as records or monthly searches. `replaceAllStaged` throws a `StagedReplacementError` with the `stageId` to retry. Every method takes an optional `AbortSignal`.
|
package/dist/index.cjs
CHANGED
|
@@ -42,7 +42,8 @@ __export(exports_src, {
|
|
|
42
42
|
FindlaneError: () => FindlaneError,
|
|
43
43
|
SearchServerClient: () => SearchServerClient,
|
|
44
44
|
StagedReplacementError: () => StagedReplacementError,
|
|
45
|
-
defaultBaseUrl: () => defaultBaseUrl
|
|
45
|
+
defaultBaseUrl: () => defaultBaseUrl,
|
|
46
|
+
signSearchKey: () => signSearchKey
|
|
46
47
|
});
|
|
47
48
|
module.exports = __toCommonJS(exports_src);
|
|
48
49
|
function pause(ms, signal) {
|
|
@@ -104,6 +105,41 @@ class FindlaneError extends Error {
|
|
|
104
105
|
this.name = "FindlaneError";
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
var signingKeyPattern = /^ssk_[A-Za-z0-9_-]{43}$/;
|
|
109
|
+
async function signSearchKey(signingKey, restrictions) {
|
|
110
|
+
if (!signingKeyPattern.test(signingKey))
|
|
111
|
+
throw new Error("A signing key (ssk_…) is required");
|
|
112
|
+
const { validUntil: until, indexes, filters, returnFields, userToken, restrictSources, referers } = restrictions;
|
|
113
|
+
const validUntil = until instanceof Date ? Math.floor(until.getTime() / 1000) : until;
|
|
114
|
+
if (!Number.isSafeInteger(validUntil))
|
|
115
|
+
throw new Error("validUntil must be a Date or a Unix time in seconds");
|
|
116
|
+
if (validUntil > Date.now() / 1000 + 7 * 86400 + 60)
|
|
117
|
+
throw new Error("A signed key lasts 7 days at most");
|
|
118
|
+
if (userToken !== undefined && (!userToken || userToken.length > 128))
|
|
119
|
+
throw new Error("userToken must be 1 to 128 characters");
|
|
120
|
+
for (const [name, list] of Object.entries({ indexes, returnFields, restrictSources, referers }))
|
|
121
|
+
if (list !== undefined && (!list.length || list.some((item) => typeof item != "string" || !item || item.includes(","))))
|
|
122
|
+
throw new Error(`${name} must list one or more names without commas`);
|
|
123
|
+
const values = {
|
|
124
|
+
validUntil,
|
|
125
|
+
restrictIndices: indexes,
|
|
126
|
+
filters: filters && JSON.stringify(filters),
|
|
127
|
+
returnFields,
|
|
128
|
+
userToken,
|
|
129
|
+
restrictSources,
|
|
130
|
+
referers
|
|
131
|
+
};
|
|
132
|
+
const params = Object.keys(values).filter((key) => values[key] !== undefined).sort().map((key) => {
|
|
133
|
+
const value = values[key];
|
|
134
|
+
return `${key}=${encodeURIComponent(Array.isArray(value) ? value.join(",") : String(value)).replace(/\+/g, "%20")}`;
|
|
135
|
+
}).join("&");
|
|
136
|
+
const nodeCrypto = "node:crypto";
|
|
137
|
+
const subtle = globalThis.crypto?.subtle ?? (await import(nodeCrypto)).webcrypto.subtle;
|
|
138
|
+
const encoder = new TextEncoder;
|
|
139
|
+
const key = await subtle.importKey("raw", encoder.encode(signingKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
140
|
+
const mac = new Uint8Array(await subtle.sign("HMAC", key, encoder.encode(params)));
|
|
141
|
+
return btoa([...mac].map((byte) => byte.toString(16).padStart(2, "0")).join("") + params);
|
|
142
|
+
}
|
|
107
143
|
var defaultBaseUrl = "https://api.findlane.dev";
|
|
108
144
|
|
|
109
145
|
class SearchServerClient {
|
|
@@ -372,10 +408,10 @@ class SearchServerClient {
|
|
|
372
408
|
replaceAllStaged: async (records, options = {}) => {
|
|
373
409
|
const stageId = options.stageId ?? globalThis.crypto.randomUUID();
|
|
374
410
|
const batchSize = options.batchSize ?? 500;
|
|
375
|
-
const maxBatchBytes = options.maxBatchBytes ??
|
|
411
|
+
const maxBatchBytes = options.maxBatchBytes ?? 8388608;
|
|
376
412
|
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 1000)
|
|
377
413
|
throw new RangeError("batchSize must be between 1 and 1,000");
|
|
378
|
-
if (!Number.isInteger(maxBatchBytes) || maxBatchBytes < 32 || maxBatchBytes >
|
|
414
|
+
if (!Number.isInteger(maxBatchBytes) || maxBatchBytes < 32 || maxBatchBytes > 33554432)
|
|
379
415
|
throw new RangeError("maxBatchBytes must be between 32 bytes and 32 MiB");
|
|
380
416
|
try {
|
|
381
417
|
const stage = await beginReplacement(stageId, options.signal);
|
|
@@ -431,7 +467,7 @@ class SearchServerClient {
|
|
|
431
467
|
getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
|
|
432
468
|
setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
|
|
433
469
|
waitForSettings: async (options = {}) => {
|
|
434
|
-
const deadline = Date.now() + (options.timeoutMs ??
|
|
470
|
+
const deadline = Date.now() + (options.timeoutMs ?? 1800000);
|
|
435
471
|
while (true) {
|
|
436
472
|
options.signal?.throwIfAborted();
|
|
437
473
|
const settings = await request("/config", "GET", undefined, options.signal);
|
package/dist/index.d.cts
CHANGED
|
@@ -1232,6 +1232,33 @@ export declare class FindlaneError extends Error {
|
|
|
1232
1232
|
status: number;
|
|
1233
1233
|
constructor(status: number, message: string);
|
|
1234
1234
|
}
|
|
1235
|
+
/** What a signed key allows (signSearchKey). Every restriction but the expiry is optional. */
|
|
1236
|
+
export type SignedKeyRestrictions = {
|
|
1237
|
+
/** When it stops working: a Date, or a Unix time in seconds. At most 7 days ahead. */
|
|
1238
|
+
validUntil: Date | number;
|
|
1239
|
+
/** The indexes it searches, by name, `*` matching any characters; every index its signing key signs for when
|
|
1240
|
+
* left out. */
|
|
1241
|
+
indexes?: string[];
|
|
1242
|
+
/** Filters forced onto every search it makes, in the search API's form; the page's own filters apply as well. */
|
|
1243
|
+
filters?: Record<string, unknown>;
|
|
1244
|
+
/** The only fields its results return. */
|
|
1245
|
+
returnFields?: string[];
|
|
1246
|
+
/** The person it's for, such as a hash of your user ID (up to 128 characters): limits and the heavy-visitors
|
|
1247
|
+
* list count them rather than their address. */
|
|
1248
|
+
userToken?: string;
|
|
1249
|
+
/** IP addresses or ranges (CIDR, such as 192.0.2.0/24) it works from. */
|
|
1250
|
+
restrictSources?: string[];
|
|
1251
|
+
/** The sites it works from: origins or host names, `*` matching any characters (`https://shop.example`,
|
|
1252
|
+
* `*.shop.example`). */
|
|
1253
|
+
referers?: string[];
|
|
1254
|
+
};
|
|
1255
|
+
/**
|
|
1256
|
+
* A short-lived key for one person's browser searches (0.29.0), signed on your server with a signing key (ssk_…)
|
|
1257
|
+
* from the API keys page; pass it to the browser SDK's `signedKey`. Its restrictions replace the index's public
|
|
1258
|
+
* policy and allowed sites, so a catalog behind a login needs no browser search settings. Makes no request: sign
|
|
1259
|
+
* one per person when they log in, or when their page asks. See findlane.dev/docs/security#logged-in-visitors.
|
|
1260
|
+
*/
|
|
1261
|
+
export declare function signSearchKey(signingKey: string, restrictions: SignedKeyRestrictions): Promise<string>;
|
|
1235
1262
|
/** The hosted Findlane API, used unless `baseUrl` says otherwise. */
|
|
1236
1263
|
export declare const defaultBaseUrl = "https://api.findlane.dev";
|
|
1237
1264
|
export type ServerClientOptions = {
|
package/dist/index.d.ts
CHANGED
|
@@ -1232,6 +1232,33 @@ export declare class FindlaneError extends Error {
|
|
|
1232
1232
|
status: number;
|
|
1233
1233
|
constructor(status: number, message: string);
|
|
1234
1234
|
}
|
|
1235
|
+
/** What a signed key allows (signSearchKey). Every restriction but the expiry is optional. */
|
|
1236
|
+
export type SignedKeyRestrictions = {
|
|
1237
|
+
/** When it stops working: a Date, or a Unix time in seconds. At most 7 days ahead. */
|
|
1238
|
+
validUntil: Date | number;
|
|
1239
|
+
/** The indexes it searches, by name, `*` matching any characters; every index its signing key signs for when
|
|
1240
|
+
* left out. */
|
|
1241
|
+
indexes?: string[];
|
|
1242
|
+
/** Filters forced onto every search it makes, in the search API's form; the page's own filters apply as well. */
|
|
1243
|
+
filters?: Record<string, unknown>;
|
|
1244
|
+
/** The only fields its results return. */
|
|
1245
|
+
returnFields?: string[];
|
|
1246
|
+
/** The person it's for, such as a hash of your user ID (up to 128 characters): limits and the heavy-visitors
|
|
1247
|
+
* list count them rather than their address. */
|
|
1248
|
+
userToken?: string;
|
|
1249
|
+
/** IP addresses or ranges (CIDR, such as 192.0.2.0/24) it works from. */
|
|
1250
|
+
restrictSources?: string[];
|
|
1251
|
+
/** The sites it works from: origins or host names, `*` matching any characters (`https://shop.example`,
|
|
1252
|
+
* `*.shop.example`). */
|
|
1253
|
+
referers?: string[];
|
|
1254
|
+
};
|
|
1255
|
+
/**
|
|
1256
|
+
* A short-lived key for one person's browser searches (0.29.0), signed on your server with a signing key (ssk_…)
|
|
1257
|
+
* from the API keys page; pass it to the browser SDK's `signedKey`. Its restrictions replace the index's public
|
|
1258
|
+
* policy and allowed sites, so a catalog behind a login needs no browser search settings. Makes no request: sign
|
|
1259
|
+
* one per person when they log in, or when their page asks. See findlane.dev/docs/security#logged-in-visitors.
|
|
1260
|
+
*/
|
|
1261
|
+
export declare function signSearchKey(signingKey: string, restrictions: SignedKeyRestrictions): Promise<string>;
|
|
1235
1262
|
/** The hosted Findlane API, used unless `baseUrl` says otherwise. */
|
|
1236
1263
|
export declare const defaultBaseUrl = "https://api.findlane.dev";
|
|
1237
1264
|
export type ServerClientOptions = {
|
package/dist/index.js
CHANGED
|
@@ -58,6 +58,41 @@ class FindlaneError extends Error {
|
|
|
58
58
|
this.name = "FindlaneError";
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
+
var signingKeyPattern = /^ssk_[A-Za-z0-9_-]{43}$/;
|
|
62
|
+
async function signSearchKey(signingKey, restrictions) {
|
|
63
|
+
if (!signingKeyPattern.test(signingKey))
|
|
64
|
+
throw new Error("A signing key (ssk_…) is required");
|
|
65
|
+
const { validUntil: until, indexes, filters, returnFields, userToken, restrictSources, referers } = restrictions;
|
|
66
|
+
const validUntil = until instanceof Date ? Math.floor(until.getTime() / 1000) : until;
|
|
67
|
+
if (!Number.isSafeInteger(validUntil))
|
|
68
|
+
throw new Error("validUntil must be a Date or a Unix time in seconds");
|
|
69
|
+
if (validUntil > Date.now() / 1000 + 7 * 86400 + 60)
|
|
70
|
+
throw new Error("A signed key lasts 7 days at most");
|
|
71
|
+
if (userToken !== undefined && (!userToken || userToken.length > 128))
|
|
72
|
+
throw new Error("userToken must be 1 to 128 characters");
|
|
73
|
+
for (const [name, list] of Object.entries({ indexes, returnFields, restrictSources, referers }))
|
|
74
|
+
if (list !== undefined && (!list.length || list.some((item) => typeof item != "string" || !item || item.includes(","))))
|
|
75
|
+
throw new Error(`${name} must list one or more names without commas`);
|
|
76
|
+
const values = {
|
|
77
|
+
validUntil,
|
|
78
|
+
restrictIndices: indexes,
|
|
79
|
+
filters: filters && JSON.stringify(filters),
|
|
80
|
+
returnFields,
|
|
81
|
+
userToken,
|
|
82
|
+
restrictSources,
|
|
83
|
+
referers
|
|
84
|
+
};
|
|
85
|
+
const params = Object.keys(values).filter((key) => values[key] !== undefined).sort().map((key) => {
|
|
86
|
+
const value = values[key];
|
|
87
|
+
return `${key}=${encodeURIComponent(Array.isArray(value) ? value.join(",") : String(value)).replace(/\+/g, "%20")}`;
|
|
88
|
+
}).join("&");
|
|
89
|
+
const nodeCrypto = "node:crypto";
|
|
90
|
+
const subtle = globalThis.crypto?.subtle ?? (await import(nodeCrypto)).webcrypto.subtle;
|
|
91
|
+
const encoder = new TextEncoder;
|
|
92
|
+
const key = await subtle.importKey("raw", encoder.encode(signingKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
93
|
+
const mac = new Uint8Array(await subtle.sign("HMAC", key, encoder.encode(params)));
|
|
94
|
+
return btoa([...mac].map((byte) => byte.toString(16).padStart(2, "0")).join("") + params);
|
|
95
|
+
}
|
|
61
96
|
var defaultBaseUrl = "https://api.findlane.dev";
|
|
62
97
|
|
|
63
98
|
class SearchServerClient {
|
|
@@ -326,10 +361,10 @@ class SearchServerClient {
|
|
|
326
361
|
replaceAllStaged: async (records, options = {}) => {
|
|
327
362
|
const stageId = options.stageId ?? globalThis.crypto.randomUUID();
|
|
328
363
|
const batchSize = options.batchSize ?? 500;
|
|
329
|
-
const maxBatchBytes = options.maxBatchBytes ??
|
|
364
|
+
const maxBatchBytes = options.maxBatchBytes ?? 8388608;
|
|
330
365
|
if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 1000)
|
|
331
366
|
throw new RangeError("batchSize must be between 1 and 1,000");
|
|
332
|
-
if (!Number.isInteger(maxBatchBytes) || maxBatchBytes < 32 || maxBatchBytes >
|
|
367
|
+
if (!Number.isInteger(maxBatchBytes) || maxBatchBytes < 32 || maxBatchBytes > 33554432)
|
|
333
368
|
throw new RangeError("maxBatchBytes must be between 32 bytes and 32 MiB");
|
|
334
369
|
try {
|
|
335
370
|
const stage = await beginReplacement(stageId, options.signal);
|
|
@@ -385,7 +420,7 @@ class SearchServerClient {
|
|
|
385
420
|
getSearchTests: async (signal) => (await request("/tests", "GET", undefined, signal)).tests,
|
|
386
421
|
setSearchTests: async (tests, signal) => (await request("/tests", "PUT", { tests }, signal)).tests,
|
|
387
422
|
waitForSettings: async (options = {}) => {
|
|
388
|
-
const deadline = Date.now() + (options.timeoutMs ??
|
|
423
|
+
const deadline = Date.now() + (options.timeoutMs ?? 1800000);
|
|
389
424
|
while (true) {
|
|
390
425
|
options.signal?.throwIfAborted();
|
|
391
426
|
const settings = await request("/config", "GET", undefined, options.signal);
|
|
@@ -426,5 +461,6 @@ export {
|
|
|
426
461
|
FindlaneError,
|
|
427
462
|
SearchServerClient,
|
|
428
463
|
StagedReplacementError,
|
|
429
|
-
defaultBaseUrl
|
|
464
|
+
defaultBaseUrl,
|
|
465
|
+
signSearchKey
|
|
430
466
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@findlane/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Findlane server SDK: upload catalogs, manage index settings, and search with a secret workspace API key.",
|
|
5
5
|
"keywords": ["findlane", "search", "full-text search", "typo tolerance", "ecommerce search", "search api", "sdk"],
|
|
6
6
|
"license": "MIT",
|