@usegraft/content-api 0.2.0 → 1.0.0-beta.1

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/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
- import { ContentIndexReader } from '@usegraft/db';
1
+ import { ContentIndexReader } from '@usegraft/contracts';
2
2
 
3
3
  type ContentApiHandler = (request: Request) => Promise<Response>;
4
+ interface ContentApiRateLimit {
5
+ /** Requests allowed per identity per window. */
6
+ limit: number;
7
+ /** Window length in seconds. Also the `Retry-After` value on a refusal. */
8
+ windowSeconds: number;
9
+ }
4
10
  interface ContentApiHandlerOptions {
5
11
  /** Collection names this endpoint may expose. */
6
12
  collections: readonly string[];
@@ -8,18 +14,50 @@ interface ContentApiHandlerOptions {
8
14
  branch: string;
9
15
  /** Reader owned by the caller. The handler never closes it. */
10
16
  index: ContentIndexReader;
17
+ /**
18
+ * Per-identity backstop. Omitted means unlimited, which is the right default
19
+ * for a handler mounted behind something that already has a limiter — but a
20
+ * mount facing the open internet wants one, because these routes run database
21
+ * listings and full-text searches for callers this handler never
22
+ * authenticates.
23
+ */
24
+ rateLimit?: ContentApiRateLimit;
25
+ /**
26
+ * How many proxies in front of this handler are ours. Forwarded to
27
+ * `rateIdentity`; zero (the default) means `x-forwarded-for` is never read
28
+ * and every unidentified caller shares one bucket.
29
+ */
30
+ trustedProxyHops?: number;
31
+ /**
32
+ * Origins allowed to read responses from a browser.
33
+ *
34
+ * Omitted means no CORS headers at all, which is same-origin only — the
35
+ * correct default, because publishing an endpoint to other origins is a
36
+ * decision for whoever deploys it and not one a library should make on their
37
+ * behalf. `@usegraft/sdk-react` needs this set whenever the app and the
38
+ * content API are on different origins, which is the ordinary case.
39
+ *
40
+ * An explicit list is echoed back per request (with `Vary: Origin`, so a
41
+ * cache cannot serve one origin's response to another). `"*"` allows any
42
+ * origin and is reasonable for content that is public anyway — which this
43
+ * handler's content is, since it authenticates nobody.
44
+ */
45
+ allowedOrigins?: readonly string[] | "*";
11
46
  }
12
47
  interface ContentApiReaderOptions {
13
- /** Full API base URL, for example https://cms.example.com/api/content/v1. */
48
+ /**
49
+ * API base URL: absolute (https://cms.example.com/api/content/v1), or a
50
+ * same-origin path (/api/content/v1) when running in a browser, where it
51
+ * resolves against the page origin.
52
+ */
14
53
  endpoint: string | URL;
15
54
  /** Fetch implementation for non-browser runtimes, tests, or instrumentation. */
16
55
  fetch?: typeof globalThis.fetch;
17
56
  /** Static headers sent with every request, such as Authorization. */
18
57
  headers?: Record<string, string>;
19
58
  }
20
- /** Create the read-only Web-standard handler mounted at /api/content/v1. */
21
59
  declare function createContentApiHandler(options: ContentApiHandlerOptions): ContentApiHandler;
22
60
  /** Create a ContentIndexReader that reads a remote /api/content/v1 endpoint. */
23
61
  declare function createContentApiReader(options: ContentApiReaderOptions): ContentIndexReader;
24
62
 
25
- export { type ContentApiHandlerOptions, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
63
+ export { type ContentApiHandlerOptions, type ContentApiRateLimit, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // src/index.ts
2
- import { ErrorCodes, GraftError } from "@usegraft/contracts";
2
+ import {
3
+ ErrorCodes,
4
+ GraftError,
5
+ rateIdentity
6
+ } from "@usegraft/contracts";
3
7
  var CONTENT_API_BASE = "/api/content/v1";
4
8
  var MAX_QUERY_LIMIT = 500;
5
9
  var JSON_CONTENT_TYPE = "application/json; charset=utf-8";
@@ -139,6 +143,14 @@ function json(value, status = 200, extra) {
139
143
  headers.set("content-type", JSON_CONTENT_TYPE);
140
144
  return new Response(JSON.stringify(value), { status, headers });
141
145
  }
146
+ function responseHeadersFor(error) {
147
+ if (error.code === "METHOD_NOT_ALLOWED") return { allow: "GET" };
148
+ if (error.code === "RATE_LIMITED") {
149
+ const retryAfter = error.details?.retryAfter;
150
+ if (typeof retryAfter === "number") return { "retry-after": String(retryAfter) };
151
+ }
152
+ return void 0;
153
+ }
142
154
  function statusFor(error) {
143
155
  switch (error.code) {
144
156
  case "COLLECTION_NOT_FOUND":
@@ -148,6 +160,8 @@ function statusFor(error) {
148
160
  return 405;
149
161
  case "INPUT_VALIDATION_FAILED":
150
162
  return 400;
163
+ case "RATE_LIMITED":
164
+ return 429;
151
165
  default:
152
166
  return 500;
153
167
  }
@@ -199,10 +213,54 @@ function assertNoBranchOverride(url) {
199
213
  );
200
214
  }
201
215
  }
216
+ function createRateLimiter(limit, windowSeconds) {
217
+ const windowMs = windowSeconds * 1e3;
218
+ const buckets = /* @__PURE__ */ new Map();
219
+ const SWEEP_THRESHOLD = 4096;
220
+ return (identity, now) => {
221
+ if (buckets.size >= SWEEP_THRESHOLD) {
222
+ for (const [key, bucket2] of buckets) {
223
+ if (bucket2.resetAt <= now) buckets.delete(key);
224
+ }
225
+ }
226
+ const bucket = buckets.get(identity);
227
+ if (bucket === void 0 || bucket.resetAt <= now) {
228
+ buckets.set(identity, { count: 1, resetAt: now + windowMs });
229
+ return void 0;
230
+ }
231
+ bucket.count += 1;
232
+ if (bucket.count <= limit) return void 0;
233
+ return Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
234
+ };
235
+ }
236
+ function corsHeaders(request, allowed) {
237
+ if (allowed === void 0) return void 0;
238
+ const origin = request.headers.get("origin");
239
+ if (origin === null) return void 0;
240
+ if (allowed === "*") {
241
+ return { "access-control-allow-origin": "*" };
242
+ }
243
+ if (!allowed.includes(origin)) return void 0;
244
+ return { "access-control-allow-origin": origin, vary: "Origin" };
245
+ }
202
246
  function createContentApiHandler(options) {
203
247
  const collections = new Set(options.collections);
248
+ const consume = options.rateLimit === void 0 ? void 0 : createRateLimiter(options.rateLimit.limit, options.rateLimit.windowSeconds);
249
+ const trustedProxyHops = options.trustedProxyHops ?? 0;
204
250
  return async (request) => {
205
251
  const url = new URL(request.url);
252
+ const cors = corsHeaders(request, options.allowedOrigins);
253
+ if (request.method === "OPTIONS" && cors !== void 0) {
254
+ return new Response(null, {
255
+ status: 204,
256
+ headers: {
257
+ ...cors,
258
+ "access-control-allow-methods": "GET, OPTIONS",
259
+ "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "authorization,content-type",
260
+ "access-control-max-age": "86400"
261
+ }
262
+ });
263
+ }
206
264
  try {
207
265
  const route = url.pathname === `${CONTENT_API_BASE}/documents` ? "documents" : url.pathname === `${CONTENT_API_BASE}/search` ? "search" : void 0;
208
266
  if (route === void 0) {
@@ -221,6 +279,21 @@ function createContentApiHandler(options) {
221
279
  details: { method: request.method }
222
280
  });
223
281
  }
282
+ if (consume !== void 0 && options.rateLimit !== void 0) {
283
+ const retryAfter = consume(rateIdentity(request, trustedProxyHops), Date.now());
284
+ if (retryAfter !== void 0) {
285
+ throw new GraftError({
286
+ code: "RATE_LIMITED",
287
+ message: `This content endpoint allows ${options.rateLimit.limit} requests per ${options.rateLimit.windowSeconds}s per caller.`,
288
+ fix: `Wait ${retryAfter}s \u2014 the Retry-After header says how long \u2014 then retry. Cache reads at your CDN if you need a higher sustained rate.`,
289
+ details: {
290
+ limit: options.rateLimit.limit,
291
+ windowSeconds: options.rateLimit.windowSeconds,
292
+ retryAfter
293
+ }
294
+ });
295
+ }
296
+ }
224
297
  assertNoBranchOverride(url);
225
298
  const collection = requiredParam(url, "collection");
226
299
  if (!collections.has(collection)) {
@@ -249,7 +322,7 @@ function createContentApiHandler(options) {
249
322
  offset: parseOffset(url),
250
323
  branch: options.branch
251
324
  });
252
- return json({ rows: rows.map(toWireRow) });
325
+ return json({ rows: rows.map(toWireRow) }, 200, cors);
253
326
  }
254
327
  const query = requiredParam(url, "query");
255
328
  const hits = await options.index.searchContent({
@@ -258,31 +331,52 @@ function createContentApiHandler(options) {
258
331
  limit,
259
332
  branch: options.branch
260
333
  });
261
- return json({
262
- hits: hits.map(
263
- ({ row, rank, snippet }) => ({
264
- row: toWireRow(row),
265
- rank,
266
- snippet
267
- })
268
- )
269
- });
334
+ return json(
335
+ {
336
+ hits: hits.map(
337
+ ({ row, rank, snippet }) => ({
338
+ row: toWireRow(row),
339
+ rank,
340
+ snippet
341
+ })
342
+ )
343
+ },
344
+ 200,
345
+ cors
346
+ );
270
347
  } catch (error) {
271
348
  const graftError = error instanceof GraftError ? error : protocolError(
272
349
  `Content API failed to read its index: ${error instanceof Error ? error.message : String(error)}`
273
350
  );
274
- const extra = graftError.code === "METHOD_NOT_ALLOWED" ? { allow: "GET" } : void 0;
275
- return json(graftError.toJSON(), statusFor(graftError), extra);
351
+ const extra = { ...responseHeadersFor(graftError), ...cors };
352
+ return json(
353
+ graftError.toJSON(),
354
+ statusFor(graftError),
355
+ Object.keys(extra).length > 0 ? extra : void 0
356
+ );
276
357
  }
277
358
  };
278
359
  }
279
360
  function normalizeEndpoint(endpoint) {
280
- const url = new URL(endpoint.toString());
361
+ const url = endpoint instanceof URL ? new URL(endpoint) : resolveEndpointUrl(endpoint);
281
362
  url.pathname = url.pathname.replace(/\/+$/, "");
282
363
  url.search = "";
283
364
  url.hash = "";
284
365
  return url;
285
366
  }
367
+ function resolveEndpointUrl(endpoint) {
368
+ const base = globalThis.location?.href;
369
+ try {
370
+ return base === void 0 ? new URL(endpoint) : new URL(endpoint, base);
371
+ } catch {
372
+ throw new GraftError({
373
+ code: "CONFIG_INVALID",
374
+ message: `\`endpoint\` is not a valid URL: ${endpoint}`,
375
+ fix: base === void 0 ? "Outside a browser there is no page origin to resolve a relative path against. Pass an absolute endpoint, e.g. https://cms.example.com/api/content/v1." : "Pass an absolute endpoint (https://cms.example.com/api/content/v1) or a same-origin path (/api/content/v1).",
376
+ details: { endpoint, base }
377
+ });
378
+ }
379
+ }
286
380
  function createContentApiReader(options) {
287
381
  const endpoint = normalizeEndpoint(options.endpoint);
288
382
  const fetchImpl = options.fetch ?? globalThis.fetch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usegraft/content-api",
3
- "version": "0.2.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "description": "A versioned read-only HTTP transport for Graft's authored content index.",
5
5
  "keywords": [
6
6
  "agent",
@@ -37,8 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@usegraft/contracts": "0.2.0",
41
- "@usegraft/db": "0.2.0"
40
+ "@usegraft/contracts": "1.0.0-beta.1"
41
+ },
42
+ "devDependencies": {
43
+ "@usegraft/db": "1.0.0-beta.1"
42
44
  },
43
45
  "engines": {
44
46
  "node": ">=22.16"