@usegraft/content-api 0.0.0-canary-20260901211203 → 0.2.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/dist/index.d.ts CHANGED
@@ -1,12 +1,6 @@
1
- import { ContentIndexReader } from '@usegraft/contracts';
1
+ import { ContentIndexReader } from '@usegraft/db';
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
- }
10
4
  interface ContentApiHandlerOptions {
11
5
  /** Collection names this endpoint may expose. */
12
6
  collections: readonly string[];
@@ -14,50 +8,18 @@ interface ContentApiHandlerOptions {
14
8
  branch: string;
15
9
  /** Reader owned by the caller. The handler never closes it. */
16
10
  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[] | "*";
46
11
  }
47
12
  interface ContentApiReaderOptions {
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
- */
13
+ /** Full API base URL, for example https://cms.example.com/api/content/v1. */
53
14
  endpoint: string | URL;
54
15
  /** Fetch implementation for non-browser runtimes, tests, or instrumentation. */
55
16
  fetch?: typeof globalThis.fetch;
56
17
  /** Static headers sent with every request, such as Authorization. */
57
18
  headers?: Record<string, string>;
58
19
  }
20
+ /** Create the read-only Web-standard handler mounted at /api/content/v1. */
59
21
  declare function createContentApiHandler(options: ContentApiHandlerOptions): ContentApiHandler;
60
22
  /** Create a ContentIndexReader that reads a remote /api/content/v1 endpoint. */
61
23
  declare function createContentApiReader(options: ContentApiReaderOptions): ContentIndexReader;
62
24
 
63
- export { type ContentApiHandlerOptions, type ContentApiRateLimit, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
25
+ export { type ContentApiHandlerOptions, type ContentApiReaderOptions, createContentApiHandler, createContentApiReader };
package/dist/index.js CHANGED
@@ -1,9 +1,5 @@
1
1
  // src/index.ts
2
- import {
3
- ErrorCodes,
4
- GraftError,
5
- rateIdentity
6
- } from "@usegraft/contracts";
2
+ import { ErrorCodes, GraftError } from "@usegraft/contracts";
7
3
  var CONTENT_API_BASE = "/api/content/v1";
8
4
  var MAX_QUERY_LIMIT = 500;
9
5
  var JSON_CONTENT_TYPE = "application/json; charset=utf-8";
@@ -143,14 +139,6 @@ function json(value, status = 200, extra) {
143
139
  headers.set("content-type", JSON_CONTENT_TYPE);
144
140
  return new Response(JSON.stringify(value), { status, headers });
145
141
  }
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
- }
154
142
  function statusFor(error) {
155
143
  switch (error.code) {
156
144
  case "COLLECTION_NOT_FOUND":
@@ -160,8 +148,6 @@ function statusFor(error) {
160
148
  return 405;
161
149
  case "INPUT_VALIDATION_FAILED":
162
150
  return 400;
163
- case "RATE_LIMITED":
164
- return 429;
165
151
  default:
166
152
  return 500;
167
153
  }
@@ -213,54 +199,10 @@ function assertNoBranchOverride(url) {
213
199
  );
214
200
  }
215
201
  }
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
- }
246
202
  function createContentApiHandler(options) {
247
203
  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;
250
204
  return async (request) => {
251
205
  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
- }
264
206
  try {
265
207
  const route = url.pathname === `${CONTENT_API_BASE}/documents` ? "documents" : url.pathname === `${CONTENT_API_BASE}/search` ? "search" : void 0;
266
208
  if (route === void 0) {
@@ -279,21 +221,6 @@ function createContentApiHandler(options) {
279
221
  details: { method: request.method }
280
222
  });
281
223
  }
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
- }
297
224
  assertNoBranchOverride(url);
298
225
  const collection = requiredParam(url, "collection");
299
226
  if (!collections.has(collection)) {
@@ -322,7 +249,7 @@ function createContentApiHandler(options) {
322
249
  offset: parseOffset(url),
323
250
  branch: options.branch
324
251
  });
325
- return json({ rows: rows.map(toWireRow) }, 200, cors);
252
+ return json({ rows: rows.map(toWireRow) });
326
253
  }
327
254
  const query = requiredParam(url, "query");
328
255
  const hits = await options.index.searchContent({
@@ -331,52 +258,31 @@ function createContentApiHandler(options) {
331
258
  limit,
332
259
  branch: options.branch
333
260
  });
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
- );
261
+ return json({
262
+ hits: hits.map(
263
+ ({ row, rank, snippet }) => ({
264
+ row: toWireRow(row),
265
+ rank,
266
+ snippet
267
+ })
268
+ )
269
+ });
347
270
  } catch (error) {
348
271
  const graftError = error instanceof GraftError ? error : protocolError(
349
272
  `Content API failed to read its index: ${error instanceof Error ? error.message : String(error)}`
350
273
  );
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
- );
274
+ const extra = graftError.code === "METHOD_NOT_ALLOWED" ? { allow: "GET" } : void 0;
275
+ return json(graftError.toJSON(), statusFor(graftError), extra);
357
276
  }
358
277
  };
359
278
  }
360
279
  function normalizeEndpoint(endpoint) {
361
- const url = endpoint instanceof URL ? new URL(endpoint) : resolveEndpointUrl(endpoint);
280
+ const url = new URL(endpoint.toString());
362
281
  url.pathname = url.pathname.replace(/\/+$/, "");
363
282
  url.search = "";
364
283
  url.hash = "";
365
284
  return url;
366
285
  }
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
- }
380
286
  function createContentApiReader(options) {
381
287
  const endpoint = normalizeEndpoint(options.endpoint);
382
288
  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.0.0-canary-20260901211203",
3
+ "version": "0.2.0",
4
4
  "description": "A versioned read-only HTTP transport for Graft's authored content index.",
5
5
  "keywords": [
6
6
  "agent",
@@ -37,10 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@usegraft/contracts": "0.0.0-canary-20260901211203"
41
- },
42
- "devDependencies": {
43
- "@usegraft/db": "0.0.0-canary-20260901211203"
40
+ "@usegraft/contracts": "0.2.0",
41
+ "@usegraft/db": "0.2.0"
44
42
  },
45
43
  "engines": {
46
44
  "node": ">=22.16"