@abinnovision/payloadcms-mcpx 2.0.0 → 2.1.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.
@@ -1,9 +1,9 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
- import { grantContext, grantKvSlug, rowHandle } from "../grants/grant.mjs";
2
+ import { leaseContext, leaseHandle, leaseKvSlug } from "../leases/lease.mjs";
3
3
  import { respond } from "./http.mjs";
4
4
  import { CONFIRMATIONS_PATH, DECISIONS } from "../api-keys/confirmation-view.mjs";
5
5
  import { relationId } from "../auth/resolve.mjs";
6
- import { decideConfirmations, issueConfirmation, listConfirmations } from "../grants/confirmation.mjs";
6
+ import { decideConfirmations, issueConfirmation, listConfirmations } from "../leases/confirmation.mjs";
7
7
  import { publicOrigin } from "../request.mjs";
8
8
  import { jsonResult } from "../result.mjs";
9
9
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
@@ -25,7 +25,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
25
25
  confirmed: false,
26
26
  request: async (args) => {
27
27
  const { req } = scope;
28
- const { slug, apiKeyId } = grantContext(req);
28
+ const { slug, apiKeyId } = leaseContext(req);
29
29
  const view = keyViewUrl(req, options, apiKeyId);
30
30
  const { id, exp } = await issueConfirmation(req.payload, slug, {
31
31
  apiKeyId,
@@ -42,7 +42,7 @@ const keyViewUrl = (req, options, apiKeyId) => {
42
42
  }
43
43
  });
44
44
  const authorize = async (req, options, keyId) => {
45
- const slug = grantKvSlug(req.payload.config);
45
+ const slug = leaseKvSlug(req.payload.config);
46
46
  if (slug === void 0) return respond(404, { error: "Confirmations are not available." });
47
47
  if (req.user?.collection !== options.userCollection) return respond(401, { error: "Sign in as the user of this key." });
48
48
  const key = typeof keyId === "string" || typeof keyId === "number" ? await req.payload.findByID({
@@ -88,7 +88,7 @@ const viewOf = async (req, entry, highlight) => {
88
88
  if (allowed instanceof Response) return allowed;
89
89
  const { payload } = req;
90
90
  const highlighted = req.searchParams.get("confirmation");
91
- const highlight = highlighted === null ? void 0 : rowHandle(payload, highlighted);
91
+ const highlight = highlighted === null ? void 0 : leaseHandle(payload, highlighted);
92
92
  const entries = (await listConfirmations(payload, allowed.slug, allowed.apiKeyId)).filter((entry) => entry.confirmation.state === "pending");
93
93
  const confirmations = [];
94
94
  for (const entry of entries) confirmations.push(await viewOf(req, entry, highlight));
@@ -1,5 +1,5 @@
1
1
  import { resolveCapabilities, scopeSlugs } from "../capabilities.mjs";
2
- import { grantsAvailable, readCapped } from "./http.mjs";
2
+ import { leasesAvailable, readCapped } from "./http.mjs";
3
3
  import { isValidAuthResult, resolveApiKeyAuth } from "../auth/resolve.mjs";
4
4
  import { jsonRpcError } from "./errors.mjs";
5
5
  import { createMcpServer } from "./server.mjs";
@@ -22,7 +22,7 @@ const BATCH_MESSAGES_LIMIT = 10;
22
22
  defaultLocale: localization.defaultLocale
23
23
  } : null,
24
24
  limits: options.limits,
25
- uploads: grantsAvailable(req, options) !== void 0,
25
+ uploads: leasesAvailable(req, options) !== void 0,
26
26
  diagnostics: options.diagnostics ? options.serverInfo : null,
27
27
  exposure: {
28
28
  collections: options.collections,
@@ -1,4 +1,4 @@
1
- import { grantKvSlug } from "../grants/grant.mjs";
1
+ import { leaseKvSlug } from "../leases/lease.mjs";
2
2
  //#region src/endpoint/http.ts
3
3
  const respond = (status, body) => Response.json(body, { status });
4
4
  /**
@@ -19,9 +19,9 @@ const respond = (status, body) => Response.json(body, { status });
19
19
  return Buffer.concat(chunks);
20
20
  };
21
21
  /**
22
- * The grant KV slug where uploads, downloads and confirmations are possible:
22
+ * The lease KV slug where uploads, downloads and confirmations are possible:
23
23
  * the KV is database-backed and no custom `auth.resolve` is set, since the
24
- * grant endpoints cannot replay what such a resolver authenticated by.
25
- */ const grantsAvailable = (req, options) => options.auth?.resolve === void 0 ? grantKvSlug(req.payload.config) : void 0;
24
+ * lease endpoints cannot replay what such a resolver authenticated by.
25
+ */ const leasesAvailable = (req, options) => options.auth?.resolve === void 0 ? leaseKvSlug(req.payload.config) : void 0;
26
26
  //#endregion
27
- export { grantsAvailable, readCapped, respond };
27
+ export { leasesAvailable, readCapped, respond };
@@ -1,8 +1,8 @@
1
1
  import { isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { claimGrant } from "../grants/grant.mjs";
3
- import { grantsAvailable, readCapped, respond } from "./http.mjs";
2
+ import { leasesAvailable, readCapped, respond } from "./http.mjs";
4
3
  import { resolveApiKeyById } from "../auth/resolve.mjs";
5
4
  import { parseResult } from "../result.mjs";
5
+ import { claimGrant } from "../leases/grant.mjs";
6
6
  import { downloadSlugs, setUploadedFile, uploadMaxBytes } from "../upload/file.mjs";
7
7
  import { BUILTIN_TOOLS } from "../tools/builtin.mjs";
8
8
  import { runTool } from "./server.mjs";
@@ -23,7 +23,7 @@ const NO_SESSION = {
23
23
  sendRequest: () => Promise.reject(/* @__PURE__ */ new Error("No MCP session."))
24
24
  };
25
25
  const claimAs = async (req, options, kind) => {
26
- const slug = grantsAvailable(req, options);
26
+ const slug = leasesAvailable(req, options);
27
27
  const grantId = req.headers.get("x-mcpx-grant");
28
28
  if (slug === void 0 || grantId === null) return;
29
29
  const grant = await claimGrant(req.payload, slug, grantId);
@@ -1,8 +1,7 @@
1
- import { NO_REQ, ROW_ID, claimRow, expOf, issueRow, rowHandle, rowPrefixes, rowsWith } from "./grant.mjs";
2
- //#region src/grants/confirmation.ts
1
+ import { LEASE_ID, NO_REQ, claimLease, issueLease, leaseHandle, leasePrefixes, leasesWith, unexpired } from "./lease.mjs";
2
+ //#region src/leases/confirmation.ts
3
3
  const CONFIRMATION_TTL_MS = 9e5;
4
4
  const HANDLE = /^[\da-f]{64}$/;
5
- const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
6
5
  /**
7
6
  * Stores a pending call and returns its id. A key may hold at most
8
7
  * {@link CONFIRMATIONS_PER_KEY} calls.
@@ -11,8 +10,8 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
11
10
  ...call,
12
11
  state: "pending"
13
12
  };
14
- return await issueRow(payload, slug, {
15
- ...rowPrefixes("confirmation", call.apiKeyId),
13
+ return await issueLease(payload, slug, {
14
+ ...leasePrefixes("confirmation", call.apiKeyId),
16
15
  cap: 50,
17
16
  capMessage: `This key already has ${String(50)} calls waiting for approval. Run or abandon them first; each expires 15 minutes after it was requested.`,
18
17
  ttlMs: CONFIRMATION_TTL_MS,
@@ -23,22 +22,22 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
23
22
  * The unexpired call of `apiKeyId` with `id`, without claiming it. A call of
24
23
  * another key is `undefined`, as is a malformed id, before any database read.
25
24
  */ const readConfirmation = async (payload, slug, apiKeyId, id) => {
26
- if (!ROW_ID.test(id)) return;
27
- const key = `${rowPrefixes("confirmation", apiKeyId).prefix}${rowHandle(payload, id)}`;
28
- const rows = await rowsWith(payload, slug, [key]);
29
- return asConfirmation(rows.find((row) => row.key === key), Date.now());
25
+ if (!LEASE_ID.test(id)) return;
26
+ const key = `${leasePrefixes("confirmation", apiKeyId).prefix}${leaseHandle(payload, id)}`;
27
+ const rows = await leasesWith(payload, slug, [key]);
28
+ return unexpired(rows.find((row) => row.key === key), Date.now());
30
29
  };
31
30
  /**
32
31
  * Marks the call used and removes it. Of two concurrent claims exactly one
33
32
  * returns `true`.
34
- */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimRow(payload, slug, rowPrefixes("confirmation", apiKeyId), rowHandle(payload, id), exp);
33
+ */ const claimConfirmation = async (payload, slug, apiKeyId, id, exp) => await claimLease(payload, slug, leasePrefixes("confirmation", apiKeyId), leaseHandle(payload, id), exp);
35
34
  /**
36
35
  * Every unexpired call of `apiKeyId` with its handle, oldest first.
37
36
  */ const listConfirmations = async (payload, slug, apiKeyId) => {
38
- const { prefix } = rowPrefixes("confirmation", apiKeyId);
37
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
39
38
  const now = Date.now();
40
- return (await rowsWith(payload, slug, [prefix])).flatMap((row) => {
41
- const confirmation = asConfirmation(row, now);
39
+ return (await leasesWith(payload, slug, [prefix])).flatMap((row) => {
40
+ const confirmation = unexpired(row, now);
42
41
  return confirmation ? [{
43
42
  handle: row.key.slice(prefix.length),
44
43
  confirmation
@@ -50,13 +49,13 @@ const asConfirmation = (row, now) => row && expOf(row.data) > now ? row.data : v
50
49
  * returns the handles it changed. A call that was decided, run or expired is
51
50
  * left alone and not returned.
52
51
  */ const decideConfirmations = async (payload, slug, apiKeyId, handles, state) => {
53
- const { prefix } = rowPrefixes("confirmation", apiKeyId);
52
+ const { prefix } = leasePrefixes("confirmation", apiKeyId);
54
53
  const keys = new Set(handles.filter((handle) => HANDLE.test(handle)).map((handle) => `${prefix}${handle}`));
55
54
  if (keys.size === 0) return [];
56
55
  const now = Date.now();
57
56
  const decided = [];
58
- for (const row of await rowsWith(payload, slug, [prefix])) {
59
- const confirmation = asConfirmation(row, now);
57
+ for (const row of await leasesWith(payload, slug, [prefix])) {
58
+ const confirmation = unexpired(row, now);
60
59
  if (!keys.has(row.key) || confirmation?.state !== "pending") continue;
61
60
  const updated = await payload.db.updateMany({
62
61
  collection: slug,
@@ -0,0 +1,29 @@
1
+ import { LEASE_ID, claimLease, issueLease, leaseHandle, leasePrefixes, leasesMatching, unexpired } from "./lease.mjs";
2
+ //#region src/leases/grant.ts
3
+ const GRANT_TTL_MS = 3e5;
4
+ const GRANTS_PER_KEY = 10;
5
+ /**
6
+ * Stores a grant and returns its id. A key may hold at most
7
+ * {@link GRANTS_PER_KEY} grants.
8
+ */ const issueGrant = async (payload, slug, grant) => await issueLease(payload, slug, {
9
+ ...leasePrefixes("grant", grant.apiKeyId),
10
+ cap: GRANTS_PER_KEY,
11
+ capMessage: `This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`,
12
+ ttlMs: GRANT_TTL_MS,
13
+ data: grant
14
+ });
15
+ /**
16
+ * Loads the grant for `grantId` and marks it used, or returns `undefined` for
17
+ * a malformed, unknown, expired or used one. A malformed id is refused before
18
+ * any database read.
19
+ */ const claimGrant = async (payload, slug, grantId) => {
20
+ if (!LEASE_ID.test(grantId)) return;
21
+ const handle = leaseHandle(payload, grantId);
22
+ const row = (await leasesMatching(payload, slug, [handle])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${handle}`));
23
+ const grant = unexpired(row, Date.now());
24
+ if (!row || !grant) return;
25
+ const prefixes = leasePrefixes("grant", grant.apiKeyId);
26
+ return row.key === `${prefixes.prefix}${handle}` && await claimLease(payload, slug, prefixes, handle, grant.exp) ? grant : void 0;
27
+ };
28
+ //#endregion
29
+ export { claimGrant, issueGrant };
@@ -1,33 +1,31 @@
1
1
  import { isPlainObject } from "../guards.mjs";
2
2
  import { generateApiKey, hashApiKey } from "../api-keys/key.mjs";
3
3
  import { APIError, ValidationError } from "payload";
4
- //#region src/grants/grant.ts
5
- const GRANT_TTL_MS = 3e5;
6
- const GRANTS_PER_KEY = 10;
7
- const ROW_ID = /^[\w-]{43}$/;
4
+ //#region src/leases/lease.ts
5
+ const LEASE_ID = /^[\w-]{43}$/;
8
6
  const NO_REQ = {};
9
7
  /**
10
- * The key prefixes of a kind of row: waiting rows and the markers of used
8
+ * The key prefixes of a kind of lease: waiting leases and the markers of used
11
9
  * ones.
12
- */ const rowPrefixes = (kind, apiKeyId) => ({
10
+ */ const leasePrefixes = (kind, apiKeyId) => ({
13
11
  prefix: `mcpx-${kind}:${String(apiKeyId)}:`,
14
12
  usedPrefix: `mcpx-${kind}-used:${String(apiKeyId)}:`
15
13
  });
16
14
  /**
17
- * What a row is addressed by: the HMAC of its id, which the row key ends in.
18
- */ const rowHandle = (payload, id) => hashApiKey(payload.secret, id);
15
+ * What a lease is addressed by: the HMAC of its id, which the row key ends in.
16
+ */ const leaseHandle = (payload, id) => hashApiKey(payload.secret, id);
19
17
  const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ? data["exp"] : 0;
20
18
  /**
21
19
  * The KV collection slug, when Payload's database KV adapter backs it with a
22
20
  * unique `key`, which the single-use claim relies on. `undefined` otherwise.
23
- */ const grantKvSlug = (config) => {
21
+ */ const leaseKvSlug = (config) => {
24
22
  const kv = config.kv;
25
23
  const key = kv?.kvCollection?.fields.find((field) => "name" in field && field.name === "key");
26
24
  return key && "unique" in key && key.unique ? kv?.kvCollection?.slug : void 0;
27
25
  };
28
26
  /**
29
27
  * KV rows whose key contains one of `fragments`.
30
- */ const findRows = async (payload, slug, fragments) => {
28
+ */ const leasesMatching = async (payload, slug, fragments) => {
31
29
  const { docs } = await payload.db.find({
32
30
  collection: slug,
33
31
  where: { or: fragments.map((fragment) => ({ key: { like: fragment } })) },
@@ -40,15 +38,15 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
40
38
  /**
41
39
  * KV rows whose key starts with one of `prefixes`. `like` matches a
42
40
  * substring, so the prefix is checked again here.
43
- */ const rowsWith = async (payload, slug, prefixes) => (await findRows(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
41
+ */ const leasesWith = async (payload, slug, prefixes) => (await leasesMatching(payload, slug, prefixes)).filter((row) => prefixes.some((prefix) => row.key.startsWith(prefix)));
44
42
  /**
45
43
  * Stores `data` under `prefix` and the HMAC of a fresh id, and returns the id,
46
44
  * the only copy of which goes to the client. Expired rows under `prefix` and
47
45
  * `usedPrefix` are purged first, and at most `cap` unexpired rows may wait
48
46
  * under `prefix`; past that, `capMessage` is thrown with 429.
49
- */ const issueRow = async (payload, slug, args) => {
47
+ */ const issueLease = async (payload, slug, args) => {
50
48
  const now = Date.now();
51
- const rows = await rowsWith(payload, slug, [args.prefix, args.usedPrefix]);
49
+ const rows = await leasesWith(payload, slug, [args.prefix, args.usedPrefix]);
52
50
  const expired = rows.filter((row) => expOf(row.data) <= now);
53
51
  if (expired.length > 0) await payload.db.deleteMany({
54
52
  collection: slug,
@@ -61,7 +59,7 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
61
59
  await payload.db.create({
62
60
  collection: slug,
63
61
  data: {
64
- key: `${args.prefix}${rowHandle(payload, id)}`,
62
+ key: `${args.prefix}${leaseHandle(payload, id)}`,
65
63
  data: {
66
64
  ...args.data,
67
65
  exp
@@ -77,7 +75,7 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
77
75
  /**
78
76
  * Marks the row under `handle` used and removes it. The marker is a
79
77
  * unique-key insert, so of two concurrent claims exactly one returns `true`.
80
- */ const claimRow = async (payload, slug, prefixes, handle, exp) => {
78
+ */ const claimLease = async (payload, slug, prefixes, handle, exp) => {
81
79
  try {
82
80
  await payload.db.create({
83
81
  collection: slug,
@@ -99,39 +97,19 @@ const expOf = (data) => isPlainObject(data) && typeof data["exp"] === "number" ?
99
97
  return true;
100
98
  };
101
99
  /**
102
- * The KV slug and key id of the request, which a call that stores a grant or
103
- * a confirmation needs. Throws where the endpoint would not offer one.
104
- */ const grantContext = (req) => {
105
- const slug = grantKvSlug(req.payload.config);
100
+ * The KV slug and key id of the request, which a call that stores a lease
101
+ * needs. Throws where the endpoint would not offer one.
102
+ */ const leaseContext = (req) => {
103
+ const slug = leaseKvSlug(req.payload.config);
106
104
  const apiKeyId = req.context.mcpx?.apiKeyId;
107
- if (slug === void 0 || apiKeyId === void 0) throw new Error("A grant or confirmation needs a database KV and a key.");
105
+ if (slug === void 0 || apiKeyId === void 0) throw new Error("A lease needs a database KV and a key.");
108
106
  return {
109
107
  slug,
110
108
  apiKeyId
111
109
  };
112
110
  };
113
111
  /**
114
- * Stores a grant and returns its id. A key may hold at most
115
- * {@link GRANTS_PER_KEY} grants.
116
- */ const issueGrant = async (payload, slug, grant) => await issueRow(payload, slug, {
117
- ...rowPrefixes("grant", grant.apiKeyId),
118
- cap: GRANTS_PER_KEY,
119
- capMessage: `This key already has ${String(GRANTS_PER_KEY)} uploads or downloads waiting. Use or abandon them first; each expires 5 minutes after it was issued.`,
120
- ttlMs: GRANT_TTL_MS,
121
- data: grant
122
- });
123
- /**
124
- * Loads the grant for `grantId` and marks it used, or returns `undefined` for
125
- * a malformed, unknown, expired or used one. A malformed id is refused before
126
- * any database read.
127
- */ const claimGrant = async (payload, slug, grantId) => {
128
- if (!ROW_ID.test(grantId)) return;
129
- const handle = rowHandle(payload, grantId);
130
- const row = (await findRows(payload, slug, [handle])).find((candidate) => candidate.key.startsWith("mcpx-grant:") && candidate.key.endsWith(`:${handle}`));
131
- const grant = row?.data;
132
- if (!row || !isPlainObject(grant) || expOf(grant) <= Date.now()) return;
133
- const prefixes = rowPrefixes("grant", grant.apiKeyId);
134
- return row.key === `${prefixes.prefix}${handle}` && await claimRow(payload, slug, prefixes, handle, grant.exp) ? grant : void 0;
135
- };
112
+ * The data of `row` when it has not expired at `now`, otherwise `undefined`.
113
+ */ const unexpired = (row, now) => row && expOf(row.data) > now ? row.data : void 0;
136
114
  //#endregion
137
- export { NO_REQ, ROW_ID, claimGrant, claimRow, expOf, grantContext, grantKvSlug, issueGrant, issueRow, rowHandle, rowPrefixes, rowsWith };
115
+ export { LEASE_ID, NO_REQ, claimLease, issueLease, leaseContext, leaseHandle, leaseKvSlug, leasePrefixes, leasesMatching, leasesWith, unexpired };
@@ -1,6 +1,6 @@
1
1
  import { defineMcpxTool, isToolEnabled, toolInputSchema } from "../define-tool.mjs";
2
- import { grantContext } from "../grants/grant.mjs";
3
- import { claimConfirmation, readConfirmation } from "../grants/confirmation.mjs";
2
+ import { leaseContext } from "../leases/lease.mjs";
3
+ import { claimConfirmation, readConfirmation } from "../leases/confirmation.mjs";
4
4
  import { jsonResult, parseResult } from "../result.mjs";
5
5
  import { withRequestLocale } from "./document.mjs";
6
6
  import { z } from "zod";
@@ -86,7 +86,7 @@ const claimApproved = async (req, slug, apiKeyId, id) => {
86
86
  inputSchema: { ids: z.array(z.string().max(64)).min(1).max(50).describe("\"id\" of each \"confirmation\".") },
87
87
  handler: async ({ args, scope, extra }) => {
88
88
  const { req } = scope;
89
- const { slug, apiKeyId } = grantContext(req);
89
+ const { slug, apiKeyId } = leaseContext(req);
90
90
  const outcomes = [];
91
91
  for (const id of args.ids) {
92
92
  const confirmation = await claimApproved(req, slug, apiKeyId, id);
@@ -1,6 +1,7 @@
1
- import { grantContext, issueGrant } from "../grants/grant.mjs";
1
+ import { leaseContext } from "../leases/lease.mjs";
2
2
  import { publicOrigin } from "../request.mjs";
3
3
  import { errorResult, jsonResult } from "../result.mjs";
4
+ import { issueGrant } from "../leases/grant.mjs";
4
5
  import { SchemaError } from "../schema/errors.mjs";
5
6
  import { resolveDataPointer } from "../schema/pointer.mjs";
6
7
  import "../schema/index.mjs";
@@ -126,7 +127,7 @@ const handoffUrl = (req, path) => {
126
127
  */ const uploadHandoff = async (scope, call) => {
127
128
  const { req } = scope;
128
129
  const { payload } = req;
129
- const { slug, apiKeyId } = grantContext(req);
130
+ const { slug, apiKeyId } = leaseContext(req);
130
131
  const url = handoffUrl(req, "upload");
131
132
  const { id, exp } = await issueGrant(payload, slug, {
132
133
  kind: "upload",
@@ -158,7 +159,7 @@ const handoffUrl = (req, path) => {
158
159
  * GET it.
159
160
  */ const downloadHandoff = async (scope, target) => {
160
161
  const { req } = scope;
161
- const { slug, apiKeyId } = grantContext(req);
162
+ const { slug, apiKeyId } = leaseContext(req);
162
163
  const url = handoffUrl(req, "file");
163
164
  const { id, exp } = await issueGrant(req.payload, slug, {
164
165
  kind: "download",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "2.0.0",
4
+ "version": "2.1.0-beta.1",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",