payload-mcp-toolkit 0.7.0 → 0.7.4

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.
Files changed (48) hide show
  1. package/README.md +29 -8
  2. package/dist/api-keys.js +57 -21
  3. package/dist/api-keys.js.map +1 -1
  4. package/dist/auth-strategy.d.ts +18 -7
  5. package/dist/auth-strategy.js +54 -12
  6. package/dist/auth-strategy.js.map +1 -1
  7. package/dist/tools/_helpers.d.ts +34 -0
  8. package/dist/tools/_helpers.js +98 -0
  9. package/dist/tools/_helpers.js.map +1 -1
  10. package/dist/tools/publish-draft.js +33 -1
  11. package/dist/tools/publish-draft.js.map +1 -1
  12. package/dist/tools/publish-global-draft.js +30 -1
  13. package/dist/tools/publish-global-draft.js.map +1 -1
  14. package/package.json +29 -15
  15. package/dist/__tests__/api-keys.test.js +0 -292
  16. package/dist/__tests__/api-keys.test.js.map +0 -1
  17. package/dist/__tests__/auth-strategy.test.js +0 -681
  18. package/dist/__tests__/auth-strategy.test.js.map +0 -1
  19. package/dist/__tests__/conflict-detection.test.js +0 -69
  20. package/dist/__tests__/conflict-detection.test.js.map +0 -1
  21. package/dist/__tests__/delete-document.test.js +0 -70
  22. package/dist/__tests__/delete-document.test.js.map +0 -1
  23. package/dist/__tests__/endpoint.test.js +0 -143
  24. package/dist/__tests__/endpoint.test.js.map +0 -1
  25. package/dist/__tests__/find-document.test.js +0 -178
  26. package/dist/__tests__/find-document.test.js.map +0 -1
  27. package/dist/__tests__/find-global.test.js +0 -173
  28. package/dist/__tests__/find-global.test.js.map +0 -1
  29. package/dist/__tests__/global-versions.test.js +0 -183
  30. package/dist/__tests__/global-versions.test.js.map +0 -1
  31. package/dist/__tests__/hash.test.js +0 -58
  32. package/dist/__tests__/hash.test.js.map +0 -1
  33. package/dist/__tests__/index-integration.test.js +0 -191
  34. package/dist/__tests__/index-integration.test.js.map +0 -1
  35. package/dist/__tests__/introspection.test.js +0 -659
  36. package/dist/__tests__/introspection.test.js.map +0 -1
  37. package/dist/__tests__/patch-global-layout.test.js +0 -474
  38. package/dist/__tests__/patch-global-layout.test.js.map +0 -1
  39. package/dist/__tests__/patch-layout.test.js +0 -171
  40. package/dist/__tests__/patch-layout.test.js.map +0 -1
  41. package/dist/__tests__/registry.test.js +0 -795
  42. package/dist/__tests__/registry.test.js.map +0 -1
  43. package/dist/__tests__/resources.test.js +0 -139
  44. package/dist/__tests__/resources.test.js.map +0 -1
  45. package/dist/__tests__/update-global.test.js +0 -157
  46. package/dist/__tests__/update-global.test.js.map +0 -1
  47. package/dist/__tests__/url-validator.test.js +0 -326
  48. package/dist/__tests__/url-validator.test.js.map +0 -1
@@ -1,681 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { createBearerStrategy, AUTH_STRATEGY_NAME, getApiKeyContext, composeScopes } from '../auth-strategy';
3
- import { hashKey } from '../hash';
4
- const SECRET = 'test-payload-secret';
5
- function buildPayload(opts) {
6
- const findMock = vi.fn(async ()=>{
7
- if (opts.findError) throw opts.findError;
8
- return {
9
- docs: opts.rows,
10
- totalDocs: opts.rows.length
11
- };
12
- });
13
- const updateMock = vi.fn(async ()=>{
14
- if (opts.updateError) throw opts.updateError;
15
- return {};
16
- });
17
- return {
18
- secret: SECRET,
19
- find: findMock,
20
- update: updateMock,
21
- logger: {
22
- info: vi.fn(),
23
- warn: vi.fn(),
24
- error: vi.fn()
25
- },
26
- findMock,
27
- updateMock
28
- };
29
- }
30
- function makeHeaders(token) {
31
- return {
32
- get: (name)=>{
33
- if (name.toLowerCase() === 'authorization') return token === null ? null : `Bearer ${token}`;
34
- return null;
35
- }
36
- };
37
- }
38
- describe('composeScopes', ()=>{
39
- const baseRow = {
40
- id: 'k',
41
- user: {
42
- id: 'u'
43
- }
44
- };
45
- it('returns null when no typed fields are populated (= full access)', ()=>{
46
- expect(composeScopes({
47
- ...baseRow
48
- })).toBeNull();
49
- });
50
- it('builds KeyScopes from typed fields when populated', ()=>{
51
- const out = composeScopes({
52
- ...baseRow,
53
- preset: 'editor',
54
- toolDeny: [
55
- 'safeDelete'
56
- ]
57
- });
58
- expect(out).toEqual({
59
- preset: 'editor',
60
- tools: {
61
- deny: [
62
- 'safeDelete'
63
- ]
64
- }
65
- });
66
- });
67
- it('treats preset === "custom" as a UI sentinel and drops it from KeyScopes', ()=>{
68
- const out = composeScopes({
69
- ...baseRow,
70
- preset: 'custom',
71
- collectionScopes: [
72
- {
73
- slug: 'posts',
74
- actions: [
75
- 'read',
76
- 'create'
77
- ]
78
- }
79
- ]
80
- });
81
- expect(out).toEqual({
82
- collections: {
83
- posts: [
84
- 'read',
85
- 'create'
86
- ]
87
- }
88
- });
89
- });
90
- it('returns a deny-all sentinel when preset === "custom" with no overrides', ()=>{
91
- // Fail-closed contract: a freshly-created Custom key with empty
92
- // collectionScopes / toolAllow / toolDeny denies every dispatch instead
93
- // of falling through to the "no scopes set = full access" guard.
94
- expect(composeScopes({
95
- ...baseRow,
96
- preset: 'custom'
97
- })).toEqual({
98
- collections: {},
99
- globals: {},
100
- tools: {
101
- allow: []
102
- }
103
- });
104
- });
105
- it('honours partial custom overrides (only toolAllow) without injecting deny-all', ()=>{
106
- // Verifies the sentinel only fires when ALL override fields are empty.
107
- const out = composeScopes({
108
- ...baseRow,
109
- preset: 'custom',
110
- toolAllow: [
111
- 'searchContent'
112
- ]
113
- });
114
- expect(out).toEqual({
115
- tools: {
116
- allow: [
117
- 'searchContent'
118
- ]
119
- }
120
- });
121
- });
122
- it('preserves an empty actions array as explicit-deny-all on a listed collection', ()=>{
123
- const out = composeScopes({
124
- ...baseRow,
125
- preset: 'custom',
126
- collectionScopes: [
127
- {
128
- slug: 'posts',
129
- actions: []
130
- }
131
- ]
132
- });
133
- expect(out).toEqual({
134
- collections: {
135
- posts: []
136
- }
137
- });
138
- });
139
- it('filters invalid action values out of collectionScopes', ()=>{
140
- const out = composeScopes({
141
- ...baseRow,
142
- preset: 'custom',
143
- collectionScopes: [
144
- {
145
- slug: 'posts',
146
- actions: [
147
- 'read',
148
- 'bogus',
149
- 1,
150
- 'update'
151
- ]
152
- }
153
- ]
154
- });
155
- expect(out).toEqual({
156
- collections: {
157
- posts: [
158
- 'read',
159
- 'update'
160
- ]
161
- }
162
- });
163
- });
164
- // ─── Globals (U9) ────────────────────────────────────────────────
165
- it('maps globalScopes to KeyScopes.globals', ()=>{
166
- const out = composeScopes({
167
- ...baseRow,
168
- preset: 'custom',
169
- globalScopes: [
170
- {
171
- slug: 'siteSettings',
172
- actions: [
173
- 'read',
174
- 'update'
175
- ]
176
- },
177
- {
178
- slug: 'footer',
179
- actions: [
180
- 'read'
181
- ]
182
- }
183
- ]
184
- });
185
- expect(out).toEqual({
186
- globals: {
187
- siteSettings: [
188
- 'read',
189
- 'update'
190
- ],
191
- footer: [
192
- 'read'
193
- ]
194
- }
195
- });
196
- });
197
- it('filters invalid global action values', ()=>{
198
- const out = composeScopes({
199
- ...baseRow,
200
- preset: 'custom',
201
- globalScopes: [
202
- {
203
- slug: 'siteSettings',
204
- actions: [
205
- 'read',
206
- 'create',
207
- 'delete',
208
- 'update'
209
- ]
210
- }
211
- ]
212
- });
213
- // Only read/update are valid on globals — create/delete dropped.
214
- expect(out).toEqual({
215
- globals: {
216
- siteSettings: [
217
- 'read',
218
- 'update'
219
- ]
220
- }
221
- });
222
- });
223
- it('preset admin + globalScopes: null produces a preset-only KeyScopes (legacy v0.5 row)', ()=>{
224
- const out = composeScopes({
225
- ...baseRow,
226
- preset: 'admin',
227
- globalScopes: null
228
- });
229
- expect(out).toEqual({
230
- preset: 'admin'
231
- });
232
- });
233
- it('preset editor + globalScopes: [] (explicit empty) emits explicit deny-all on the globals axis', ()=>{
234
- // Axis-independent rule: an explicit empty array commits "no globals
235
- // allowed" even when a preset is set. Use globalScopes: null (or omit
236
- // the field) to fall through to the preset default.
237
- const out = composeScopes({
238
- ...baseRow,
239
- preset: 'editor',
240
- globalScopes: []
241
- });
242
- expect(out).toEqual({
243
- preset: 'editor',
244
- globals: {}
245
- });
246
- });
247
- it('preset editor + collectionScopes: [] (explicit empty) emits explicit deny-all on the collections axis', ()=>{
248
- const out = composeScopes({
249
- ...baseRow,
250
- preset: 'editor',
251
- collectionScopes: []
252
- });
253
- expect(out).toEqual({
254
- preset: 'editor',
255
- collections: {}
256
- });
257
- });
258
- it('preset custom + populated collectionScopes + explicit toolAllow: [] honours both axes', ()=>{
259
- // Closes the prior gap where toolAllow: [] (operator intent: "no tools
260
- // allowed") was treated identically to absent and silently dropped, so
261
- // the row authenticated with a collection scope but no tool gate.
262
- const out = composeScopes({
263
- ...baseRow,
264
- preset: 'custom',
265
- collectionScopes: [
266
- {
267
- slug: 'posts',
268
- actions: [
269
- 'read'
270
- ]
271
- }
272
- ],
273
- toolAllow: []
274
- });
275
- expect(out).toEqual({
276
- collections: {
277
- posts: [
278
- 'read'
279
- ]
280
- },
281
- tools: {
282
- allow: []
283
- }
284
- });
285
- });
286
- it('explicit toolDeny: [] carries no entries and emits nothing', ()=>{
287
- // toolDeny is a deny-list — an empty array has nothing to deny, so the
288
- // axis is dropped rather than emitting `tools.deny: []`.
289
- const out = composeScopes({
290
- ...baseRow,
291
- preset: 'editor',
292
- toolDeny: []
293
- });
294
- expect(out).toEqual({
295
- preset: 'editor'
296
- });
297
- });
298
- it('widened deny-all sentinel: empty everywhere produces both maps and tools.allow=[]', ()=>{
299
- expect(composeScopes({
300
- ...baseRow,
301
- preset: 'custom',
302
- collectionScopes: [],
303
- globalScopes: [],
304
- toolAllow: [],
305
- toolDeny: []
306
- })).toEqual({
307
- collections: {},
308
- globals: {},
309
- tools: {
310
- allow: []
311
- }
312
- });
313
- });
314
- it('partial custom override on the globals axis does NOT fire the sentinel', ()=>{
315
- const out = composeScopes({
316
- ...baseRow,
317
- preset: 'custom',
318
- globalScopes: [
319
- {
320
- slug: 'siteSettings',
321
- actions: [
322
- 'read'
323
- ]
324
- }
325
- ]
326
- });
327
- expect(out).toEqual({
328
- globals: {
329
- siteSettings: [
330
- 'read'
331
- ]
332
- }
333
- });
334
- });
335
- it('combines toolAllow and toolDeny into tools.allow / tools.deny', ()=>{
336
- const out = composeScopes({
337
- ...baseRow,
338
- toolAllow: [
339
- 'findDocument',
340
- 'searchContent'
341
- ],
342
- toolDeny: [
343
- 'deleteDocument'
344
- ]
345
- });
346
- expect(out).toEqual({
347
- tools: {
348
- allow: [
349
- 'findDocument',
350
- 'searchContent'
351
- ],
352
- deny: [
353
- 'deleteDocument'
354
- ]
355
- }
356
- });
357
- });
358
- // ─── Legacy row-shape fallback (pre-0.6 → 0.6 transition) ─────────
359
- //
360
- // The {slug, actions} normalization landed mid-0.6 — pre-existing
361
- // {collection, actions} / {global, actions} rows are tolerated for one
362
- // release so locally-tested 0.6 fixtures keep authenticating. The
363
- // fallback is removed in v0.7.
364
- it('falls back to row.collection when row.slug is missing (legacy collectionScopes)', ()=>{
365
- const warn = vi.fn();
366
- const out = composeScopes({
367
- ...baseRow,
368
- preset: 'custom',
369
- collectionScopes: [
370
- {
371
- collection: 'posts',
372
- actions: [
373
- 'read'
374
- ]
375
- }
376
- ]
377
- }, {
378
- warn
379
- });
380
- expect(out).toEqual({
381
- collections: {
382
- posts: [
383
- 'read'
384
- ]
385
- }
386
- });
387
- });
388
- it('falls back to row.global when row.slug is missing (legacy globalScopes)', ()=>{
389
- const warn = vi.fn();
390
- const out = composeScopes({
391
- ...baseRow,
392
- preset: 'custom',
393
- globalScopes: [
394
- {
395
- global: 'siteSettings',
396
- actions: [
397
- 'read'
398
- ]
399
- }
400
- ]
401
- }, {
402
- warn
403
- });
404
- expect(out).toEqual({
405
- globals: {
406
- siteSettings: [
407
- 'read'
408
- ]
409
- }
410
- });
411
- });
412
- it('prefers row.slug over the legacy row.collection key when both are set', ()=>{
413
- const out = composeScopes({
414
- ...baseRow,
415
- preset: 'custom',
416
- collectionScopes: [
417
- {
418
- slug: 'posts',
419
- collection: 'stale-slug',
420
- actions: [
421
- 'read'
422
- ]
423
- }
424
- ]
425
- });
426
- expect(out).toEqual({
427
- collections: {
428
- posts: [
429
- 'read'
430
- ]
431
- }
432
- });
433
- });
434
- });
435
- describe('createBearerStrategy.authenticate', ()=>{
436
- const strategy = createBearerStrategy({
437
- collectionSlug: 'payload-mcp-api-keys',
438
- userCollection: 'users'
439
- });
440
- it('exports the documented strategy name', ()=>{
441
- expect(strategy.name).toBe(AUTH_STRATEGY_NAME);
442
- });
443
- it('returns null user when Authorization header is missing', async ()=>{
444
- const payload = buildPayload({
445
- rows: []
446
- });
447
- const result = await strategy.authenticate({
448
- headers: makeHeaders(null),
449
- payload: payload
450
- });
451
- expect(result).toEqual({
452
- user: null
453
- });
454
- expect(payload.findMock).not.toHaveBeenCalled();
455
- });
456
- it('returns null user when scheme is not Bearer', async ()=>{
457
- const payload = buildPayload({
458
- rows: []
459
- });
460
- const result = await strategy.authenticate({
461
- headers: {
462
- get: ()=>'Basic abc'
463
- },
464
- payload: payload
465
- });
466
- expect(result).toEqual({
467
- user: null
468
- });
469
- });
470
- it('returns null user when no row matches the hashed token', async ()=>{
471
- const payload = buildPayload({
472
- rows: []
473
- });
474
- const result = await strategy.authenticate({
475
- headers: makeHeaders('plaintext-key'),
476
- payload: payload
477
- });
478
- expect(result).toEqual({
479
- user: null
480
- });
481
- expect(payload.findMock).toHaveBeenCalledWith(expect.objectContaining({
482
- collection: 'payload-mcp-api-keys',
483
- where: {
484
- apiKeyIndex: {
485
- equals: hashKey('plaintext-key', SECRET)
486
- }
487
- }
488
- }));
489
- });
490
- it('returns null user when the matched row is revoked', async ()=>{
491
- const payload = buildPayload({
492
- rows: [
493
- {
494
- id: 'k1',
495
- user: {
496
- id: 'u1',
497
- email: 'a@b.com'
498
- },
499
- revokedAt: '2025-01-01T00:00:00Z'
500
- }
501
- ]
502
- });
503
- const result = await strategy.authenticate({
504
- headers: makeHeaders('plaintext-key'),
505
- payload: payload
506
- });
507
- expect(result).toEqual({
508
- user: null
509
- });
510
- });
511
- it('returns null user when the matched row has expired', async ()=>{
512
- const payload = buildPayload({
513
- rows: [
514
- {
515
- id: 'k1',
516
- user: {
517
- id: 'u1'
518
- },
519
- expiresAt: new Date(Date.now() - 60_000).toISOString()
520
- }
521
- ]
522
- });
523
- const result = await strategy.authenticate({
524
- headers: makeHeaders('plaintext-key'),
525
- payload: payload
526
- });
527
- expect(result).toEqual({
528
- user: null
529
- });
530
- });
531
- it('returns null user when the linked user is missing', async ()=>{
532
- const payload = buildPayload({
533
- rows: [
534
- {
535
- id: 'k1',
536
- user: null
537
- }
538
- ]
539
- });
540
- const result = await strategy.authenticate({
541
- headers: makeHeaders('plaintext-key'),
542
- payload: payload
543
- });
544
- expect(result).toEqual({
545
- user: null
546
- });
547
- });
548
- it('hydrates the user, key context, and fires lastUsedAt write on a happy match', async ()=>{
549
- const payload = buildPayload({
550
- rows: [
551
- {
552
- id: 'k1',
553
- user: {
554
- id: 'u1',
555
- email: 'a@b.com'
556
- },
557
- preset: 'admin',
558
- keyPrefix: 'abc12345'
559
- }
560
- ]
561
- });
562
- const result = await strategy.authenticate({
563
- headers: makeHeaders('plaintext-key'),
564
- payload: payload
565
- });
566
- expect(result.user).toMatchObject({
567
- id: 'u1',
568
- email: 'a@b.com',
569
- collection: 'users',
570
- _strategy: AUTH_STRATEGY_NAME,
571
- _mcpKey: {
572
- keyId: 'k1',
573
- keyPrefix: 'abc12345',
574
- scopes: {
575
- preset: 'admin'
576
- }
577
- }
578
- });
579
- // lastUsedAt update is fire-and-forget; allow microtask to schedule.
580
- await new Promise((r)=>setImmediate(r));
581
- expect(payload.updateMock).toHaveBeenCalledWith(expect.objectContaining({
582
- collection: 'payload-mcp-api-keys',
583
- id: 'k1',
584
- data: expect.objectContaining({
585
- lastUsedAt: expect.any(String)
586
- })
587
- }));
588
- });
589
- it('hydrates _mcpKey.scopes from typed fields with collectionScopes + toolDeny', async ()=>{
590
- const payload = buildPayload({
591
- rows: [
592
- {
593
- id: 'k3',
594
- user: {
595
- id: 'u3'
596
- },
597
- keyPrefix: 'deadbeef',
598
- preset: 'custom',
599
- collectionScopes: [
600
- {
601
- slug: 'posts',
602
- actions: [
603
- 'read',
604
- 'update'
605
- ]
606
- }
607
- ],
608
- toolDeny: [
609
- 'safeDelete'
610
- ]
611
- }
612
- ]
613
- });
614
- const result = await strategy.authenticate({
615
- headers: makeHeaders('plaintext-key'),
616
- payload: payload
617
- });
618
- expect(result.user._mcpKey.scopes).toEqual({
619
- collections: {
620
- posts: [
621
- 'read',
622
- 'update'
623
- ]
624
- },
625
- tools: {
626
- deny: [
627
- 'safeDelete'
628
- ]
629
- }
630
- });
631
- });
632
- it('does not block auth if find() throws', async ()=>{
633
- const payload = buildPayload({
634
- rows: [],
635
- findError: new Error('db down')
636
- });
637
- const result = await strategy.authenticate({
638
- headers: makeHeaders('plaintext-key'),
639
- payload: payload
640
- });
641
- expect(result).toEqual({
642
- user: null
643
- });
644
- expect(payload.logger.error).toHaveBeenCalled();
645
- });
646
- });
647
- describe('getApiKeyContext', ()=>{
648
- it('returns null for non-MCP requests', ()=>{
649
- expect(getApiKeyContext({
650
- user: null
651
- })).toBeNull();
652
- expect(getApiKeyContext({
653
- user: {
654
- id: 'cookie-user'
655
- }
656
- })).toBeNull();
657
- });
658
- it('returns the embedded key context when present', ()=>{
659
- const ctx = getApiKeyContext({
660
- user: {
661
- id: 'u1',
662
- _mcpKey: {
663
- keyId: 'k1',
664
- keyPrefix: 'abcd',
665
- scopes: {
666
- preset: 'editor'
667
- }
668
- }
669
- }
670
- });
671
- expect(ctx).toEqual({
672
- keyId: 'k1',
673
- keyPrefix: 'abcd',
674
- scopes: {
675
- preset: 'editor'
676
- }
677
- });
678
- });
679
- });
680
-
681
- //# sourceMappingURL=auth-strategy.test.js.map