@findlane/server 0.28.0 → 0.30.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 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 ?? 8 * 1024 * 1024;
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 > 32 * 1024 * 1024)
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 ?? 30 * 60000);
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
@@ -59,6 +59,11 @@ export type IndexSettings = {
59
59
  rules?: QueryRule[];
60
60
  /** Languages whose common words queries drop, and whose plurals and alternative spellings match. */
61
61
  languages?: Array<"da" | "de" | "en" | "is" | "no" | "sv">;
62
+ /** Icelandic word forms (0.30.0), with "is" among the languages: a query word of 4+ letters that no word
63
+ * of the index is or begins with also matches the other forms of its word from the BÍN dictionary
64
+ * ("kjólar" finds "Kjóll", "jokerinn" finds "Joker"), and a search that finds nothing tries every word's
65
+ * forms. Searches that find results with the words as typed don't change. Off by default. */
66
+ inflections?: boolean;
62
67
  /** Searches say which words typo tolerance corrected, and `suggest()` offers a corrected query for a
63
68
  * search that finds nothing. Off by default. */
64
69
  didYouMean?: boolean;
@@ -437,7 +442,8 @@ export type GeoBox = {
437
442
  west: number;
438
443
  };
439
444
  /** How a query word matched: as itself, as the start of a longer word, within its typos, through a synonym,
440
- * as another form of the word in the index's languages (a plural or another spelling), or, with the
445
+ * as another form of the word in the index's languages (a plural or another spelling, or with `inflections`
446
+ * an Icelandic case or plural), or, with the
441
447
  * `compoundWords` setting, as a compound: its parts written apart, or the word it makes with a neighbouring
442
448
  * query word. */
443
449
  export type MatchKind = "exact" | "prefix" | "typo" | "synonym" | "form" | "compound";
@@ -1232,6 +1238,33 @@ export declare class FindlaneError extends Error {
1232
1238
  status: number;
1233
1239
  constructor(status: number, message: string);
1234
1240
  }
1241
+ /** What a signed key allows (signSearchKey). Every restriction but the expiry is optional. */
1242
+ export type SignedKeyRestrictions = {
1243
+ /** When it stops working: a Date, or a Unix time in seconds. At most 7 days ahead. */
1244
+ validUntil: Date | number;
1245
+ /** The indexes it searches, by name, `*` matching any characters; every index its signing key signs for when
1246
+ * left out. */
1247
+ indexes?: string[];
1248
+ /** Filters forced onto every search it makes, in the search API's form; the page's own filters apply as well. */
1249
+ filters?: Record<string, unknown>;
1250
+ /** The only fields its results return. */
1251
+ returnFields?: string[];
1252
+ /** The person it's for, such as a hash of your user ID (up to 128 characters): limits and the heavy-visitors
1253
+ * list count them rather than their address. */
1254
+ userToken?: string;
1255
+ /** IP addresses or ranges (CIDR, such as 192.0.2.0/24) it works from. */
1256
+ restrictSources?: string[];
1257
+ /** The sites it works from: origins or host names, `*` matching any characters (`https://shop.example`,
1258
+ * `*.shop.example`). */
1259
+ referers?: string[];
1260
+ };
1261
+ /**
1262
+ * A short-lived key for one person's browser searches (0.29.0), signed on your server with a signing key (ssk_…)
1263
+ * from the API keys page; pass it to the browser SDK's `signedKey`. Its restrictions replace the index's public
1264
+ * policy and allowed sites, so a catalog behind a login needs no browser search settings. Makes no request: sign
1265
+ * one per person when they log in, or when their page asks. See findlane.dev/docs/security#logged-in-visitors.
1266
+ */
1267
+ export declare function signSearchKey(signingKey: string, restrictions: SignedKeyRestrictions): Promise<string>;
1235
1268
  /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
1236
1269
  export declare const defaultBaseUrl = "https://api.findlane.dev";
1237
1270
  export type ServerClientOptions = {
package/dist/index.d.ts CHANGED
@@ -59,6 +59,11 @@ export type IndexSettings = {
59
59
  rules?: QueryRule[];
60
60
  /** Languages whose common words queries drop, and whose plurals and alternative spellings match. */
61
61
  languages?: Array<"da" | "de" | "en" | "is" | "no" | "sv">;
62
+ /** Icelandic word forms (0.30.0), with "is" among the languages: a query word of 4+ letters that no word
63
+ * of the index is or begins with also matches the other forms of its word from the BÍN dictionary
64
+ * ("kjólar" finds "Kjóll", "jokerinn" finds "Joker"), and a search that finds nothing tries every word's
65
+ * forms. Searches that find results with the words as typed don't change. Off by default. */
66
+ inflections?: boolean;
62
67
  /** Searches say which words typo tolerance corrected, and `suggest()` offers a corrected query for a
63
68
  * search that finds nothing. Off by default. */
64
69
  didYouMean?: boolean;
@@ -437,7 +442,8 @@ export type GeoBox = {
437
442
  west: number;
438
443
  };
439
444
  /** How a query word matched: as itself, as the start of a longer word, within its typos, through a synonym,
440
- * as another form of the word in the index's languages (a plural or another spelling), or, with the
445
+ * as another form of the word in the index's languages (a plural or another spelling, or with `inflections`
446
+ * an Icelandic case or plural), or, with the
441
447
  * `compoundWords` setting, as a compound: its parts written apart, or the word it makes with a neighbouring
442
448
  * query word. */
443
449
  export type MatchKind = "exact" | "prefix" | "typo" | "synonym" | "form" | "compound";
@@ -1232,6 +1238,33 @@ export declare class FindlaneError extends Error {
1232
1238
  status: number;
1233
1239
  constructor(status: number, message: string);
1234
1240
  }
1241
+ /** What a signed key allows (signSearchKey). Every restriction but the expiry is optional. */
1242
+ export type SignedKeyRestrictions = {
1243
+ /** When it stops working: a Date, or a Unix time in seconds. At most 7 days ahead. */
1244
+ validUntil: Date | number;
1245
+ /** The indexes it searches, by name, `*` matching any characters; every index its signing key signs for when
1246
+ * left out. */
1247
+ indexes?: string[];
1248
+ /** Filters forced onto every search it makes, in the search API's form; the page's own filters apply as well. */
1249
+ filters?: Record<string, unknown>;
1250
+ /** The only fields its results return. */
1251
+ returnFields?: string[];
1252
+ /** The person it's for, such as a hash of your user ID (up to 128 characters): limits and the heavy-visitors
1253
+ * list count them rather than their address. */
1254
+ userToken?: string;
1255
+ /** IP addresses or ranges (CIDR, such as 192.0.2.0/24) it works from. */
1256
+ restrictSources?: string[];
1257
+ /** The sites it works from: origins or host names, `*` matching any characters (`https://shop.example`,
1258
+ * `*.shop.example`). */
1259
+ referers?: string[];
1260
+ };
1261
+ /**
1262
+ * A short-lived key for one person's browser searches (0.29.0), signed on your server with a signing key (ssk_…)
1263
+ * from the API keys page; pass it to the browser SDK's `signedKey`. Its restrictions replace the index's public
1264
+ * policy and allowed sites, so a catalog behind a login needs no browser search settings. Makes no request: sign
1265
+ * one per person when they log in, or when their page asks. See findlane.dev/docs/security#logged-in-visitors.
1266
+ */
1267
+ export declare function signSearchKey(signingKey: string, restrictions: SignedKeyRestrictions): Promise<string>;
1235
1268
  /** The hosted Findlane API, used unless `baseUrl` says otherwise. */
1236
1269
  export declare const defaultBaseUrl = "https://api.findlane.dev";
1237
1270
  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 ?? 8 * 1024 * 1024;
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 > 32 * 1024 * 1024)
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 ?? 30 * 60000);
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.28.0",
3
+ "version": "0.30.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",