@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 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
@@ -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 ?? 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.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",