@ggui-ai/registry-core 0.7.0 → 0.8.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/impls/memory-bundle-storage.d.ts.map +1 -1
- package/dist/impls/memory-bundle-storage.js +25 -22
- package/dist/impls/memory-registry-storage.d.ts.map +1 -1
- package/dist/impls/memory-registry-storage.js +3 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/interfaces/bundle-storage.d.ts +55 -31
- package/dist/interfaces/bundle-storage.d.ts.map +1 -1
- package/dist/interfaces/registry-storage.d.ts +11 -0
- package/dist/interfaces/registry-storage.d.ts.map +1 -1
- package/dist/mcp-tool-filters.d.ts +23 -0
- package/dist/mcp-tool-filters.d.ts.map +1 -0
- package/dist/mcp-tool-filters.js +14 -0
- package/dist/ops/list-versions.d.ts +19 -10
- package/dist/ops/list-versions.d.ts.map +1 -1
- package/dist/ops/list-versions.js +43 -8
- package/dist/ops/private-read-authz.d.ts +88 -0
- package/dist/ops/private-read-authz.d.ts.map +1 -0
- package/dist/ops/private-read-authz.js +58 -0
- package/dist/ops/publish.d.ts +30 -0
- package/dist/ops/publish.d.ts.map +1 -1
- package/dist/ops/publish.js +162 -28
- package/dist/ops/read.d.ts +28 -6
- package/dist/ops/read.d.ts.map +1 -1
- package/dist/ops/read.js +47 -7
- package/dist/ops/search.d.ts +13 -1
- package/dist/ops/search.d.ts.map +1 -1
- package/dist/ops/search.js +54 -2
- package/dist/testing/bundle-storage-contract.d.ts.map +1 -1
- package/dist/testing/bundle-storage-contract.js +61 -27
- package/dist/testing/registry-storage-contract.d.ts.map +1 -1
- package/dist/testing/registry-storage-contract.js +122 -1
- package/dist/types.d.ts +115 -16
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +30 -14
- package/package.json +11 -5
package/dist/ops/search.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { SEARCH_SORT_OPTIONS } from '../types.js';
|
|
2
|
+
import { artifactScope } from './private-read-authz.js';
|
|
3
|
+
import { MCP_TOOL_BINDING_NAME_RE } from '@ggui-ai/artifact-manifest';
|
|
2
4
|
const DEFAULT_LIMIT = 50;
|
|
3
5
|
const MAX_LIMIT = 200;
|
|
4
6
|
export async function searchArtifacts(input, deps) {
|
|
@@ -45,12 +47,36 @@ export async function searchArtifacts(input, deps) {
|
|
|
45
47
|
}
|
|
46
48
|
sort = input.sort;
|
|
47
49
|
}
|
|
50
|
+
const tool = nonEmpty(input.tool);
|
|
51
|
+
if (tool !== undefined && !MCP_TOOL_BINDING_NAME_RE.test(tool)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 400,
|
|
55
|
+
body: {
|
|
56
|
+
error: 'invalid_request',
|
|
57
|
+
message: '`tool` must match ^[A-Za-z0-9_.-]{1,128}$',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const server = nonEmpty(input.server);
|
|
62
|
+
if (server !== undefined && !MCP_TOOL_BINDING_NAME_RE.test(server)) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
status: 400,
|
|
66
|
+
body: {
|
|
67
|
+
error: 'invalid_request',
|
|
68
|
+
message: '`server` must match ^[A-Za-z0-9_.-]{1,128}$',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
48
72
|
const filter = {
|
|
49
73
|
q: nonEmpty(input.q),
|
|
50
74
|
kind,
|
|
51
75
|
hook: nonEmpty(input.hook),
|
|
52
76
|
tag: nonEmpty(input.tag),
|
|
53
77
|
author: nonEmpty(input.author),
|
|
78
|
+
tool,
|
|
79
|
+
server,
|
|
54
80
|
limit,
|
|
55
81
|
cursor: nonEmpty(input.cursor),
|
|
56
82
|
order: sort,
|
|
@@ -72,11 +98,16 @@ export async function searchArtifacts(input, deps) {
|
|
|
72
98
|
continue;
|
|
73
99
|
visibleRows.push(row);
|
|
74
100
|
}
|
|
101
|
+
// Scope-verification surfacing — ONE owner read per unique scope on
|
|
102
|
+
// the page, batched. A failed or empty read leaves the fields ABSENT
|
|
103
|
+
// (state unknown is not 'unverified'): the wire only carries a label
|
|
104
|
+
// the ownership row actually asserted.
|
|
105
|
+
const ownersByScope = await readScopeOwners(deps.storage, visibleRows);
|
|
75
106
|
// `sort=recent` ordering is served by the storage layer (the filter
|
|
76
107
|
// carries `order: 'recent'`); rows arrive newest-first and are
|
|
77
108
|
// emitted verbatim — no re-sort here, so page order and cursor order
|
|
78
109
|
// cannot diverge.
|
|
79
|
-
const results = visibleRows.map(rowToEntry);
|
|
110
|
+
const results = visibleRows.map((row) => rowToEntry(row, ownersByScope.get(artifactScope(row.artifactId)) ?? null));
|
|
80
111
|
return {
|
|
81
112
|
ok: true,
|
|
82
113
|
status: 200,
|
|
@@ -86,7 +117,24 @@ export async function searchArtifacts(input, deps) {
|
|
|
86
117
|
function isSearchSort(value) {
|
|
87
118
|
return SEARCH_SORT_OPTIONS.includes(value);
|
|
88
119
|
}
|
|
89
|
-
function
|
|
120
|
+
async function readScopeOwners(storage, rows) {
|
|
121
|
+
const scopes = [...new Set(rows.map((row) => artifactScope(row.artifactId)))];
|
|
122
|
+
const entries = await Promise.all(scopes.map(async (scope) => {
|
|
123
|
+
try {
|
|
124
|
+
return [scope, await storage.getScopeOwner(scope)];
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
// eslint-disable-next-line no-console -- server-side operator signal; the wire simply omits the fields
|
|
128
|
+
console.error('registry search: scope-owner lookup failed; omitting verification fields', {
|
|
129
|
+
scope,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
return [scope, null];
|
|
133
|
+
}
|
|
134
|
+
}));
|
|
135
|
+
return new Map(entries);
|
|
136
|
+
}
|
|
137
|
+
function rowToEntry(row, scopeOwner) {
|
|
90
138
|
return {
|
|
91
139
|
artifactId: row.artifactId,
|
|
92
140
|
latestVersion: row.latestVersion,
|
|
@@ -94,6 +142,10 @@ function rowToEntry(row) {
|
|
|
94
142
|
description: row.description,
|
|
95
143
|
tags: row.tags,
|
|
96
144
|
publishedAt: row.publishedAt,
|
|
145
|
+
mcpTools: row.mcpTools,
|
|
146
|
+
mcpToolsSource: row.mcpToolsSource,
|
|
147
|
+
scopeVerification: scopeOwner?.verification,
|
|
148
|
+
verifiedDomain: scopeOwner?.verification === 'verified' ? scopeOwner.verifiedDomain : undefined,
|
|
97
149
|
};
|
|
98
150
|
}
|
|
99
151
|
function nonEmpty(s) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bundle-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/bundle-storage-contract.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AA0DrE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,aAAa,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"bundle-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/bundle-storage-contract.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AA0DrE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,aAAa,GAAG,IAAI,CA8K5E"}
|
|
@@ -69,35 +69,69 @@ export function bundleStorageContract(makeStorage) {
|
|
|
69
69
|
describe('bundle', () => {
|
|
70
70
|
it('returns null on miss', async () => {
|
|
71
71
|
const s = makeStorage();
|
|
72
|
-
expect(await s.getBundle('@test', 'foo', '0.1.0')).toBe(null);
|
|
72
|
+
expect(await s.getBundle('@test', 'foo', '0.1.0', 'public')).toBe(null);
|
|
73
73
|
});
|
|
74
74
|
it('round-trips bundle bytes', async () => {
|
|
75
75
|
const s = makeStorage();
|
|
76
76
|
const bytes = new Uint8Array([0xff, 0x00, 0x10, 0x20]);
|
|
77
|
-
await s.putBundle('@test', 'foo', '0.1.0', bytes);
|
|
78
|
-
const fetched = await s.getBundle('@test', 'foo', '0.1.0');
|
|
77
|
+
await s.putBundle('@test', 'foo', '0.1.0', 'public', bytes);
|
|
78
|
+
const fetched = await s.getBundle('@test', 'foo', '0.1.0', 'public');
|
|
79
79
|
expect(fetched).toEqual(bytes);
|
|
80
80
|
});
|
|
81
81
|
it('isolates bundles by (scope, name, version)', async () => {
|
|
82
82
|
const s = makeStorage();
|
|
83
|
-
await s.putBundle('@a', 'pkg', '0.1.0', new Uint8Array([1]));
|
|
84
|
-
await s.putBundle('@a', 'pkg', '0.2.0', new Uint8Array([2]));
|
|
85
|
-
await s.putBundle('@b', 'pkg', '0.1.0', new Uint8Array([3]));
|
|
86
|
-
expect((await s.getBundle('@a', 'pkg', '0.1.0'))?.[0]).toBe(1);
|
|
87
|
-
expect((await s.getBundle('@a', 'pkg', '0.2.0'))?.[0]).toBe(2);
|
|
88
|
-
expect((await s.getBundle('@b', 'pkg', '0.1.0'))?.[0]).toBe(3);
|
|
83
|
+
await s.putBundle('@a', 'pkg', '0.1.0', 'public', new Uint8Array([1]));
|
|
84
|
+
await s.putBundle('@a', 'pkg', '0.2.0', 'public', new Uint8Array([2]));
|
|
85
|
+
await s.putBundle('@b', 'pkg', '0.1.0', 'public', new Uint8Array([3]));
|
|
86
|
+
expect((await s.getBundle('@a', 'pkg', '0.1.0', 'public'))?.[0]).toBe(1);
|
|
87
|
+
expect((await s.getBundle('@a', 'pkg', '0.2.0', 'public'))?.[0]).toBe(2);
|
|
88
|
+
expect((await s.getBundle('@b', 'pkg', '0.1.0', 'public'))?.[0]).toBe(3);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('visibility key split (H1)', () => {
|
|
92
|
+
it('a private-written bundle is invisible to a public read of the same triple', async () => {
|
|
93
|
+
const s = makeStorage();
|
|
94
|
+
await s.putBundle('@acme', 'secret', '1.0.0', 'private', new Uint8Array([7]));
|
|
95
|
+
expect(await s.getBundle('@acme', 'secret', '1.0.0', 'public')).toBe(null);
|
|
96
|
+
expect((await s.getBundle('@acme', 'secret', '1.0.0', 'private'))?.[0]).toBe(7);
|
|
97
|
+
});
|
|
98
|
+
it('a public-written bundle is invisible to a private read of the same triple', async () => {
|
|
99
|
+
const s = makeStorage();
|
|
100
|
+
await s.putBundle('@acme', 'open', '1.0.0', 'public', new Uint8Array([9]));
|
|
101
|
+
expect(await s.getBundle('@acme', 'open', '1.0.0', 'private')).toBe(null);
|
|
102
|
+
expect((await s.getBundle('@acme', 'open', '1.0.0', 'public'))?.[0]).toBe(9);
|
|
103
|
+
});
|
|
104
|
+
it('splits signatures and manifests by visibility too', async () => {
|
|
105
|
+
const s = makeStorage();
|
|
106
|
+
await s.putSignature('@acme', 'secret', '1.0.0', 'private', stubSignature());
|
|
107
|
+
await s.putManifest('@acme', 'secret', '1.0.0', 'private', stubManifest());
|
|
108
|
+
expect(await s.getSignature('@acme', 'secret', '1.0.0', 'public')).toBe(null);
|
|
109
|
+
expect(await s.getManifest('@acme', 'secret', '1.0.0', 'public')).toBe(null);
|
|
110
|
+
expect(await s.getSignature('@acme', 'secret', '1.0.0', 'private')).not.toBe(null);
|
|
111
|
+
expect(await s.getManifest('@acme', 'secret', '1.0.0', 'private')).not.toBe(null);
|
|
112
|
+
});
|
|
113
|
+
it('embeds the visibility segment in every composed URL', () => {
|
|
114
|
+
const s = makeStorage();
|
|
115
|
+
for (const compose of [
|
|
116
|
+
s.bundleUrl.bind(s),
|
|
117
|
+
s.signatureUrl.bind(s),
|
|
118
|
+
s.manifestUrl.bind(s),
|
|
119
|
+
]) {
|
|
120
|
+
expect(compose('@test', 'foo', '0.1.0', 'public')).toContain('/bundles/public/');
|
|
121
|
+
expect(compose('@test', 'foo', '0.1.0', 'private')).toContain('/bundles/private/');
|
|
122
|
+
}
|
|
89
123
|
});
|
|
90
124
|
});
|
|
91
125
|
describe('signature', () => {
|
|
92
126
|
it('returns null on miss', async () => {
|
|
93
127
|
const s = makeStorage();
|
|
94
|
-
expect(await s.getSignature('@test', 'foo', '0.1.0')).toBe(null);
|
|
128
|
+
expect(await s.getSignature('@test', 'foo', '0.1.0', 'public')).toBe(null);
|
|
95
129
|
});
|
|
96
130
|
it('round-trips an Ed25519 signature envelope', async () => {
|
|
97
131
|
const s = makeStorage();
|
|
98
132
|
const sig = stubSignature();
|
|
99
|
-
await s.putSignature('@test', 'foo', '0.1.0', sig);
|
|
100
|
-
const fetched = await s.getSignature('@test', 'foo', '0.1.0');
|
|
133
|
+
await s.putSignature('@test', 'foo', '0.1.0', 'private', sig);
|
|
134
|
+
const fetched = await s.getSignature('@test', 'foo', '0.1.0', 'private');
|
|
101
135
|
expect(fetched).toEqual(sig);
|
|
102
136
|
});
|
|
103
137
|
// Pin the sigstore round-trip through the storage layer. Guards
|
|
@@ -110,8 +144,8 @@ export function bundleStorageContract(makeStorage) {
|
|
|
110
144
|
it('round-trips a sigstore signature envelope including the embedded cosign bundle', async () => {
|
|
111
145
|
const s = makeStorage();
|
|
112
146
|
const sig = stubSigstoreSignature();
|
|
113
|
-
await s.putSignature('@test', 'foo', '0.1.0', sig);
|
|
114
|
-
const fetched = await s.getSignature('@test', 'foo', '0.1.0');
|
|
147
|
+
await s.putSignature('@test', 'foo', '0.1.0', 'public', sig);
|
|
148
|
+
const fetched = await s.getSignature('@test', 'foo', '0.1.0', 'public');
|
|
115
149
|
expect(fetched).toEqual(sig);
|
|
116
150
|
// Specifically pin the `algorithm` discriminator + the
|
|
117
151
|
// `bundle` field — these are sigstore-only fields the prior
|
|
@@ -135,41 +169,41 @@ export function bundleStorageContract(makeStorage) {
|
|
|
135
169
|
// case is covered by the round-trip tests; this test pins the
|
|
136
170
|
// null-on-miss contract specifically — without it, a regression
|
|
137
171
|
// that silently returns a stub object would slip through.
|
|
138
|
-
const fetched = await s.getSignature('@no-such-scope', 'no-such-name', '0.0.0');
|
|
172
|
+
const fetched = await s.getSignature('@no-such-scope', 'no-such-name', '0.0.0', 'public');
|
|
139
173
|
expect(fetched).toBe(null);
|
|
140
174
|
});
|
|
141
175
|
});
|
|
142
176
|
describe('manifest', () => {
|
|
143
177
|
it('returns null on miss', async () => {
|
|
144
178
|
const s = makeStorage();
|
|
145
|
-
expect(await s.getManifest('@test', 'foo', '0.1.0')).toBe(null);
|
|
179
|
+
expect(await s.getManifest('@test', 'foo', '0.1.0', 'public')).toBe(null);
|
|
146
180
|
});
|
|
147
181
|
it('round-trips the manifest', async () => {
|
|
148
182
|
const s = makeStorage();
|
|
149
183
|
const m = stubManifest();
|
|
150
|
-
await s.putManifest('@test', 'foo', '0.1.0', m);
|
|
151
|
-
const fetched = await s.getManifest('@test', 'foo', '0.1.0');
|
|
184
|
+
await s.putManifest('@test', 'foo', '0.1.0', 'public', m);
|
|
185
|
+
const fetched = await s.getManifest('@test', 'foo', '0.1.0', 'public');
|
|
152
186
|
expect(fetched).toEqual(m);
|
|
153
187
|
});
|
|
154
188
|
});
|
|
155
189
|
describe('URL composition', () => {
|
|
156
190
|
it('produces distinct URLs for bundle / sig / manifest', async () => {
|
|
157
191
|
const s = makeStorage();
|
|
158
|
-
const b = s.bundleUrl('@test', 'foo', '0.1.0');
|
|
159
|
-
const sig = s.signatureUrl('@test', 'foo', '0.1.0');
|
|
160
|
-
const man = s.manifestUrl('@test', 'foo', '0.1.0');
|
|
192
|
+
const b = s.bundleUrl('@test', 'foo', '0.1.0', 'public');
|
|
193
|
+
const sig = s.signatureUrl('@test', 'foo', '0.1.0', 'public');
|
|
194
|
+
const man = s.manifestUrl('@test', 'foo', '0.1.0', 'public');
|
|
161
195
|
expect(b).not.toBe(sig);
|
|
162
196
|
expect(b).not.toBe(man);
|
|
163
197
|
expect(sig).not.toBe(man);
|
|
164
198
|
});
|
|
165
199
|
it('matches the URL returned from put*', async () => {
|
|
166
200
|
const s = makeStorage();
|
|
167
|
-
const putBundleUrl = await s.putBundle('@test', 'foo', '0.1.0', new Uint8Array([1]));
|
|
168
|
-
expect(putBundleUrl).toBe(s.bundleUrl('@test', 'foo', '0.1.0'));
|
|
169
|
-
const putSigUrl = await s.putSignature('@test', 'foo', '0.1.0', stubSignature());
|
|
170
|
-
expect(putSigUrl).toBe(s.signatureUrl('@test', 'foo', '0.1.0'));
|
|
171
|
-
const putManUrl = await s.putManifest('@test', 'foo', '0.1.0', stubManifest());
|
|
172
|
-
expect(putManUrl).toBe(s.manifestUrl('@test', 'foo', '0.1.0'));
|
|
201
|
+
const putBundleUrl = await s.putBundle('@test', 'foo', '0.1.0', 'private', new Uint8Array([1]));
|
|
202
|
+
expect(putBundleUrl).toBe(s.bundleUrl('@test', 'foo', '0.1.0', 'private'));
|
|
203
|
+
const putSigUrl = await s.putSignature('@test', 'foo', '0.1.0', 'private', stubSignature());
|
|
204
|
+
expect(putSigUrl).toBe(s.signatureUrl('@test', 'foo', '0.1.0', 'private'));
|
|
205
|
+
const putManUrl = await s.putManifest('@test', 'foo', '0.1.0', 'private', stubManifest());
|
|
206
|
+
expect(putManUrl).toBe(s.manifestUrl('@test', 'foo', '0.1.0', 'private'));
|
|
173
207
|
});
|
|
174
208
|
});
|
|
175
209
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/registry-storage-contract.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAqI3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"registry-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/registry-storage-contract.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAqI3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,CAgzBhF"}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { describe, expect, it } from 'vitest';
|
|
19
19
|
import { AuthorKeyAlreadyExistsError, } from '../interfaces/registry-storage.js';
|
|
20
|
-
import { ARTIFACTS_METADATA_SK } from '../types.js';
|
|
20
|
+
import { ARTIFACTS_METADATA_SK, SAN_ALLOWLIST_INVALID } from '../types.js';
|
|
21
21
|
function makeMetadata(overrides = {}) {
|
|
22
22
|
return {
|
|
23
23
|
artifactId: '@test/foo',
|
|
@@ -265,6 +265,68 @@ export function registryStorageContract(makeStorage) {
|
|
|
265
265
|
const byTag = await storage.scanArtifacts({ q: 'geo' });
|
|
266
266
|
expect(byTag.rows.map((r) => r.artifactId)).toEqual(['@a/leaflet']);
|
|
267
267
|
});
|
|
268
|
+
// #259 delta-2 — MCP tool-binding filters share `matchesMcpToolFilters`
|
|
269
|
+
// semantics with the search op; every impl must honor them identically
|
|
270
|
+
// (the byMcpTool GSI path in the DDB adapter is the load-bearing case).
|
|
271
|
+
it('filters by tool (matches any entry with that tool name, with or without a server)', async () => {
|
|
272
|
+
const storage = makeStorage();
|
|
273
|
+
await storage.putArtifactMetadata(makeMetadata({
|
|
274
|
+
artifactId: '@a/stripe-ui',
|
|
275
|
+
mcpTools: [{ server: 'stripe-mcp', tool: 'create_payment' }],
|
|
276
|
+
mcpToolsSource: 'declared',
|
|
277
|
+
}));
|
|
278
|
+
await storage.putArtifactMetadata(makeMetadata({
|
|
279
|
+
artifactId: '@a/bare-ui',
|
|
280
|
+
mcpTools: [{ tool: 'refund_payment' }],
|
|
281
|
+
mcpToolsSource: 'derived',
|
|
282
|
+
}));
|
|
283
|
+
await storage.putArtifactMetadata(makeMetadata({ artifactId: '@a/unbound', mcpTools: undefined }));
|
|
284
|
+
const byServerBound = await storage.scanArtifacts({ tool: 'create_payment' });
|
|
285
|
+
expect(byServerBound.rows.map((r) => r.artifactId)).toEqual(['@a/stripe-ui']);
|
|
286
|
+
const byBare = await storage.scanArtifacts({ tool: 'refund_payment' });
|
|
287
|
+
expect(byBare.rows.map((r) => r.artifactId)).toEqual(['@a/bare-ui']);
|
|
288
|
+
const byMiss = await storage.scanArtifacts({ tool: 'unknown_tool' });
|
|
289
|
+
expect(byMiss.rows).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
it('filters by server; bare-tool entries never match', async () => {
|
|
292
|
+
const storage = makeStorage();
|
|
293
|
+
await storage.putArtifactMetadata(makeMetadata({
|
|
294
|
+
artifactId: '@a/bound',
|
|
295
|
+
mcpTools: [{ server: 'stripe-mcp', tool: 'create_payment' }],
|
|
296
|
+
mcpToolsSource: 'declared',
|
|
297
|
+
}));
|
|
298
|
+
await storage.putArtifactMetadata(makeMetadata({
|
|
299
|
+
artifactId: '@a/bare',
|
|
300
|
+
mcpTools: [{ tool: 'create_payment' }],
|
|
301
|
+
mcpToolsSource: 'derived',
|
|
302
|
+
}));
|
|
303
|
+
const page = await storage.scanArtifacts({ server: 'stripe-mcp' });
|
|
304
|
+
expect(page.rows.map((r) => r.artifactId)).toEqual(['@a/bound']);
|
|
305
|
+
});
|
|
306
|
+
it('tool + server composes into the exact-pair filter — cross-product is not enough', async () => {
|
|
307
|
+
const storage = makeStorage();
|
|
308
|
+
// A single artifact declaring TWO bindings — `stripe-mcp` and
|
|
309
|
+
// `refund_payment` each independently exist on this row, but
|
|
310
|
+
// never paired on the same binding entry.
|
|
311
|
+
await storage.putArtifactMetadata(makeMetadata({
|
|
312
|
+
artifactId: '@a/multi-bound',
|
|
313
|
+
mcpTools: [
|
|
314
|
+
{ server: 'stripe-mcp', tool: 'create_payment' },
|
|
315
|
+
{ server: 'other-mcp', tool: 'refund_payment' },
|
|
316
|
+
],
|
|
317
|
+
mcpToolsSource: 'declared',
|
|
318
|
+
}));
|
|
319
|
+
const exactPair = await storage.scanArtifacts({
|
|
320
|
+
server: 'stripe-mcp',
|
|
321
|
+
tool: 'create_payment',
|
|
322
|
+
});
|
|
323
|
+
expect(exactPair.rows.map((r) => r.artifactId)).toEqual(['@a/multi-bound']);
|
|
324
|
+
const crossProduct = await storage.scanArtifacts({
|
|
325
|
+
server: 'stripe-mcp',
|
|
326
|
+
tool: 'refund_payment',
|
|
327
|
+
});
|
|
328
|
+
expect(crossProduct.rows).toEqual([]);
|
|
329
|
+
});
|
|
268
330
|
});
|
|
269
331
|
describe('plugin versions', () => {
|
|
270
332
|
it('returns null for missing version', async () => {
|
|
@@ -582,6 +644,65 @@ export function registryStorageContract(makeStorage) {
|
|
|
582
644
|
await storage.claimScope(makeScopeOwner({ ownerSubject: 'winner' }));
|
|
583
645
|
expect((await storage.getScopeOwner('@test'))?.ownerSubject).toBe('winner');
|
|
584
646
|
});
|
|
647
|
+
// F4 — `sanAllowlist` is the per-scope publisher-identity
|
|
648
|
+
// allowlist the publish gate enforces on sigstore-signed
|
|
649
|
+
// publishes. It's an OPTIONAL array column: a row without it
|
|
650
|
+
// falls back to the deployment's default identity rule, so a
|
|
651
|
+
// storage impl that silently drops (or fabricates) the field
|
|
652
|
+
// would flip a scope's identity policy without any write.
|
|
653
|
+
it('claimScope round-trips a sanAllowlist (F4 identity binding)', async () => {
|
|
654
|
+
const storage = makeStorage();
|
|
655
|
+
const row = makeScopeOwner({
|
|
656
|
+
sanAllowlist: [
|
|
657
|
+
'ci@test.example',
|
|
658
|
+
'https://ci.example.com/workflows/publish.yml@refs/heads/main',
|
|
659
|
+
],
|
|
660
|
+
});
|
|
661
|
+
await storage.claimScope(row);
|
|
662
|
+
expect(await storage.getScopeOwner(row.scope)).toEqual(row);
|
|
663
|
+
});
|
|
664
|
+
it('updateScopeOwner sets a sanAllowlist on an existing row (operator path)', async () => {
|
|
665
|
+
const storage = makeStorage();
|
|
666
|
+
await storage.claimScope(makeScopeOwner());
|
|
667
|
+
const withAllowlist = makeScopeOwner({ sanAllowlist: ['ops@test.example'] });
|
|
668
|
+
const result = await storage.updateScopeOwner(withAllowlist, {
|
|
669
|
+
ownerSubject: 'user-1',
|
|
670
|
+
verification: 'unverified',
|
|
671
|
+
});
|
|
672
|
+
expect(result).toEqual({ ok: true });
|
|
673
|
+
expect(await storage.getScopeOwner('@test')).toEqual(withAllowlist);
|
|
674
|
+
});
|
|
675
|
+
it('the SAN_ALLOWLIST_INVALID marker round-trips as invalid — corruption never launders into a valid policy', async () => {
|
|
676
|
+
// Fail-closed obligation: an adapter that reads malformed
|
|
677
|
+
// allowlist data projects SAN_ALLOWLIST_INVALID (see
|
|
678
|
+
// ScopeOwnerRow.sanAllowlist). Writing the marker itself back
|
|
679
|
+
// (e.g. a verification flip preserving a corrupt column) MUST
|
|
680
|
+
// read back as invalid in every impl — the marker is a bare
|
|
681
|
+
// string, itself a malformed column value, so the round-trip
|
|
682
|
+
// cannot turn corruption into "no allowlist" or a real list.
|
|
683
|
+
const storage = makeStorage();
|
|
684
|
+
const row = makeScopeOwner({ sanAllowlist: SAN_ALLOWLIST_INVALID });
|
|
685
|
+
await storage.claimScope(row);
|
|
686
|
+
expect((await storage.getScopeOwner('@test'))?.sanAllowlist).toBe(SAN_ALLOWLIST_INVALID);
|
|
687
|
+
});
|
|
688
|
+
it('updateScopeOwner clears a sanAllowlist — the field is ABSENT after, not empty', async () => {
|
|
689
|
+
// Absent vs empty is load-bearing: the publish gate treats a
|
|
690
|
+
// missing allowlist as "no per-scope rule" (default identity
|
|
691
|
+
// rule applies). A cleared row that came back `[]` instead of
|
|
692
|
+
// absent would be indistinguishable in JS truthiness tests but
|
|
693
|
+
// MUST round-trip as absent so every impl agrees on the shape.
|
|
694
|
+
const storage = makeStorage();
|
|
695
|
+
await storage.claimScope(makeScopeOwner({ sanAllowlist: ['ops@test.example'] }));
|
|
696
|
+
const cleared = makeScopeOwner();
|
|
697
|
+
const result = await storage.updateScopeOwner(cleared, {
|
|
698
|
+
ownerSubject: 'user-1',
|
|
699
|
+
verification: 'unverified',
|
|
700
|
+
});
|
|
701
|
+
expect(result).toEqual({ ok: true });
|
|
702
|
+
const fetched = await storage.getScopeOwner('@test');
|
|
703
|
+
expect(fetched).toEqual(cleared);
|
|
704
|
+
expect(fetched?.sanAllowlist).toBeUndefined();
|
|
705
|
+
});
|
|
585
706
|
});
|
|
586
707
|
describe('author keys', () => {
|
|
587
708
|
it('returns null on miss', async () => {
|
package/dist/types.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* 'blueprint'` as the discriminator. The field name `artifactId`
|
|
15
15
|
* matches what the field actually contains.
|
|
16
16
|
*/
|
|
17
|
-
import type { ArtifactManifest } from '@ggui-ai/artifact-manifest';
|
|
17
|
+
import type { ArtifactManifest, McpToolBinding, McpToolBindingSource } from '@ggui-ai/artifact-manifest';
|
|
18
18
|
import type { GadgetSignature } from '@ggui-ai/gadget-signing';
|
|
19
19
|
import type { ConformanceFailureCode } from './ops/conformance.js';
|
|
20
20
|
/**
|
|
@@ -54,6 +54,15 @@ export interface ArtifactsMetadataRow {
|
|
|
54
54
|
readonly tags?: readonly string[];
|
|
55
55
|
readonly visibility: Visibility;
|
|
56
56
|
readonly hook?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Denormalized MCP tool bindings of the latest version — stamped at
|
|
59
|
+
* publish from the manifest's declared `mcpTools`, or derived from a
|
|
60
|
+
* blueprint contract's tool names, so registry search can filter by
|
|
61
|
+
* tool without reading version rows.
|
|
62
|
+
*/
|
|
63
|
+
readonly mcpTools?: ReadonlyArray<McpToolBinding>;
|
|
64
|
+
/** How the denormalized bindings were produced: declared on the manifest, or derived from the contract. */
|
|
65
|
+
readonly mcpToolsSource?: McpToolBindingSource;
|
|
57
66
|
readonly authorName?: string;
|
|
58
67
|
readonly publishedAt: string;
|
|
59
68
|
readonly publishedBy: string;
|
|
@@ -239,7 +248,55 @@ export interface ScopeOwnerRow {
|
|
|
239
248
|
readonly verifiedDomain?: string;
|
|
240
249
|
/** ISO timestamp of the verification, present when `verification: 'verified'`. */
|
|
241
250
|
readonly verifiedAt?: string;
|
|
251
|
+
/**
|
|
252
|
+
* F4 identity binding — per-scope allowlist of sigstore certificate
|
|
253
|
+
* identities (the Fulcio cert's SubjectAlternativeName) permitted to
|
|
254
|
+
* publish under this scope. Entries are EXACT literal identity
|
|
255
|
+
* strings — an email (`release@acme.com`) or a CI workflow identity
|
|
256
|
+
* URI (`https://github.com/acme/app/.github/workflows/publish.yml@refs/heads/main`).
|
|
257
|
+
* Regex/glob patterns are deliberately NOT stored server-side: an
|
|
258
|
+
* allowlist is a finite set of named signers, and pattern semantics
|
|
259
|
+
* would make the authorization decision unauditable.
|
|
260
|
+
*
|
|
261
|
+
* Case rule: ONE rule for every identity comparison — the publish
|
|
262
|
+
* gate matches entries against certificate SANs CASE-INSENSITIVELY
|
|
263
|
+
* (mirroring the verified-email rule), and the operator tooling
|
|
264
|
+
* lowercase-normalizes entries at write time.
|
|
265
|
+
*
|
|
266
|
+
* Enforcement (publish gate, sigstore-signed publishes only): when
|
|
267
|
+
* present and non-empty, one of the bundle certificate's SANs MUST
|
|
268
|
+
* match one of these entries. When absent, the deployment's default
|
|
269
|
+
* identity rule applies — a SAN must equal the publishing account's
|
|
270
|
+
* verified email where the deployment provides a resolver;
|
|
271
|
+
* deployments without one enforce identity only through this
|
|
272
|
+
* allowlist.
|
|
273
|
+
*
|
|
274
|
+
* Fail-closed corruption handling: a storage adapter that finds a
|
|
275
|
+
* MALFORMED value in this column (anything but an array of strings)
|
|
276
|
+
* MUST project it as {@link SAN_ALLOWLIST_INVALID}, never as absent
|
|
277
|
+
* — projecting corrupt policy data as "no allowlist" would silently
|
|
278
|
+
* downgrade the scope to the weaker default rule. The publish gate
|
|
279
|
+
* rejects sigstore publishes into such a scope with a
|
|
280
|
+
* storage-inconsistency error until an operator rewrites the column
|
|
281
|
+
* (set or clear). Writers MUST only ever write string arrays.
|
|
282
|
+
*
|
|
283
|
+
* Managed by the registry operator (never by publish); a first-
|
|
284
|
+
* publish claim always lands without one, and an ownership transfer
|
|
285
|
+
* drops it (the previous owner's signer identities must not survive
|
|
286
|
+
* into the new owner's scope).
|
|
287
|
+
*/
|
|
288
|
+
readonly sanAllowlist?: readonly string[] | SanAllowlistInvalid;
|
|
242
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Fail-closed marker a storage adapter projects when the
|
|
292
|
+
* {@link ScopeOwnerRow.sanAllowlist} column holds malformed data (see
|
|
293
|
+
* that field's docstring). Chosen as a bare string precisely because a
|
|
294
|
+
* string is itself a malformed value for the column — the marker
|
|
295
|
+
* round-trips as invalid through every storage impl, so corruption can
|
|
296
|
+
* never launder itself back into a valid policy.
|
|
297
|
+
*/
|
|
298
|
+
export declare const SAN_ALLOWLIST_INVALID: "invalid";
|
|
299
|
+
export type SanAllowlistInvalid = typeof SAN_ALLOWLIST_INVALID;
|
|
243
300
|
/**
|
|
244
301
|
* `GET /pkg/{scope}/{name}/{version}` body.
|
|
245
302
|
*
|
|
@@ -266,6 +323,14 @@ export interface ReadPkgResponse {
|
|
|
266
323
|
readonly authorPublicKey?: string;
|
|
267
324
|
readonly publishedAt: string;
|
|
268
325
|
readonly publishedBy: string;
|
|
326
|
+
/**
|
|
327
|
+
* Verification state of the package's scope. Present only when the
|
|
328
|
+
* scope-ownership row was read; ABSENT when the scope state is
|
|
329
|
+
* unknown — absence is not `'unverified'`.
|
|
330
|
+
*/
|
|
331
|
+
readonly scopeVerification?: ScopeVerification;
|
|
332
|
+
/** Domain whose control was proven — present only when `scopeVerification: 'verified'`. */
|
|
333
|
+
readonly verifiedDomain?: string;
|
|
269
334
|
}
|
|
270
335
|
/**
|
|
271
336
|
* One row of `GET /search?…` results — a lightweight per-artifactId
|
|
@@ -279,6 +344,19 @@ export interface SearchResultEntry {
|
|
|
279
344
|
readonly description?: string;
|
|
280
345
|
readonly tags?: readonly string[];
|
|
281
346
|
readonly publishedAt: string;
|
|
347
|
+
/** MCP tool bindings of the latest version, when the artifact declares or derives any. */
|
|
348
|
+
readonly mcpTools?: ReadonlyArray<McpToolBinding>;
|
|
349
|
+
/** How the bindings were produced: declared on the manifest, or derived from a blueprint contract. */
|
|
350
|
+
readonly mcpToolsSource?: McpToolBindingSource;
|
|
351
|
+
/**
|
|
352
|
+
* Verification state of the artifact's scope. Present only when the
|
|
353
|
+
* scope-ownership row was read; ABSENT when the scope state is
|
|
354
|
+
* unknown (unclaimed scope or a failed lookup) — absence is not
|
|
355
|
+
* `'unverified'`.
|
|
356
|
+
*/
|
|
357
|
+
readonly scopeVerification?: ScopeVerification;
|
|
358
|
+
/** Domain whose control was proven — present only when `scopeVerification: 'verified'`. */
|
|
359
|
+
readonly verifiedDomain?: string;
|
|
282
360
|
}
|
|
283
361
|
/**
|
|
284
362
|
* `GET /search?…` body. `nextCursor` is opaque (impl-defined: a
|
|
@@ -290,8 +368,8 @@ export interface SearchResponse {
|
|
|
290
368
|
readonly nextCursor?: string;
|
|
291
369
|
}
|
|
292
370
|
/**
|
|
293
|
-
* `GET /search` sort options. Supports
|
|
294
|
-
*
|
|
371
|
+
* `GET /search` sort options. Supports registry browse UIs that need
|
|
372
|
+
* a "Recent" view with `publishedAt`-DESC ordering.
|
|
295
373
|
*
|
|
296
374
|
* - `recent` — order by {@link ArtifactsMetadataRow.publishedAt} DESC.
|
|
297
375
|
*
|
|
@@ -382,7 +460,7 @@ export interface PublishResponseBody {
|
|
|
382
460
|
* propagates to both the wire and the runtime membership check used
|
|
383
461
|
* by downstream guards.
|
|
384
462
|
*/
|
|
385
|
-
export declare const PUBLISH_ERROR_CODES: readonly ["unauthorized", "manifest_invalid", "bundle_required", "bundle_too_large", "conformance_failed", "bundle_hash_mismatch", "visibility_algorithm_mismatch", "scope_forbidden", "unknown_key", "signature_invalid", "version_exists", "internal"];
|
|
463
|
+
export declare const PUBLISH_ERROR_CODES: readonly ["unauthorized", "manifest_invalid", "bundle_required", "bundle_too_large", "conformance_failed", "bundle_hash_mismatch", "visibility_algorithm_mismatch", "scope_forbidden", "identity_mismatch", "unknown_key", "signature_invalid", "version_exists", "internal"];
|
|
386
464
|
export type PublishErrorCode = (typeof PUBLISH_ERROR_CODES)[number];
|
|
387
465
|
/**
|
|
388
466
|
* Closed enum for `GET /pkg/:scope/:name[/version]` read responses.
|
|
@@ -390,16 +468,21 @@ export type PublishErrorCode = (typeof PUBLISH_ERROR_CODES)[number];
|
|
|
390
468
|
* `yanked` is the 410-Gone path — the version exists in metadata but
|
|
391
469
|
* the publisher revoked it. Clients SHOULD treat this as a hard
|
|
392
470
|
* failure (don't fall back to a different version automatically).
|
|
471
|
+
*
|
|
472
|
+
* There is deliberately NO `forbidden` code: an unauthorized read of
|
|
473
|
+
* a private row answers with the SAME `not_found` shape as a true
|
|
474
|
+
* miss, so the wire never confirms a private artifact exists.
|
|
393
475
|
*/
|
|
394
|
-
export declare const READ_ERROR_CODES: readonly ["not_found", "
|
|
476
|
+
export declare const READ_ERROR_CODES: readonly ["not_found", "invalid_request", "yanked", "server_error"];
|
|
395
477
|
export type ReadErrorCode = (typeof READ_ERROR_CODES)[number];
|
|
396
478
|
/**
|
|
397
|
-
* Closed enum for `GET /search` responses.
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
479
|
+
* Closed enum for `GET /search` responses. Search serves only public
|
|
480
|
+
* metadata rows and takes no caller identity, so the surface has no
|
|
481
|
+
* authorization failure at all — just request validation and adapter
|
|
482
|
+
* faults. (`yanked` likewise never surfaces: search filters yanked
|
|
483
|
+
* rows out of the result set.)
|
|
401
484
|
*/
|
|
402
|
-
export declare const SEARCH_ERROR_CODES: readonly ["
|
|
485
|
+
export declare const SEARCH_ERROR_CODES: readonly ["invalid_request", "server_error"];
|
|
403
486
|
export type SearchErrorCode = (typeof SEARCH_ERROR_CODES)[number];
|
|
404
487
|
/**
|
|
405
488
|
* Closed enum for `POST /author-keys` register responses. An operator
|
|
@@ -595,6 +678,19 @@ export interface ArtifactScanFilter {
|
|
|
595
678
|
readonly hook?: string;
|
|
596
679
|
readonly tag?: string;
|
|
597
680
|
readonly author?: string;
|
|
681
|
+
/**
|
|
682
|
+
* Exact MCP tool-name filter. Matches rows whose binding list has an
|
|
683
|
+
* entry with this tool name — with or without a server. Case-sensitive.
|
|
684
|
+
* Shared semantics: `matchesMcpToolFilters` in this package.
|
|
685
|
+
*/
|
|
686
|
+
readonly tool?: string;
|
|
687
|
+
/**
|
|
688
|
+
* Exact MCP server-name filter. Matches rows whose binding list has an
|
|
689
|
+
* entry declaring this server; bare (server-less) entries never match.
|
|
690
|
+
* Combined with `tool`, requires a single entry carrying exactly that
|
|
691
|
+
* (server, tool) pair. Case-sensitive.
|
|
692
|
+
*/
|
|
693
|
+
readonly server?: string;
|
|
598
694
|
readonly limit?: number;
|
|
599
695
|
readonly cursor?: string;
|
|
600
696
|
/**
|
|
@@ -611,12 +707,15 @@ export interface ArtifactScanFilter {
|
|
|
611
707
|
* Closed enum for `GET /pkg/{scope}/{name}` list-versions responses.
|
|
612
708
|
*
|
|
613
709
|
* - `invalid_request` — missing or malformed `artifactId`.
|
|
614
|
-
* - `not_found` — no metadata row for `artifactId
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
710
|
+
* - `not_found` — no metadata row for `artifactId`, OR no
|
|
711
|
+
* version of it is visible to the caller.
|
|
712
|
+
* The two are DELIBERATELY indistinguishable:
|
|
713
|
+
* any difference between "does not exist" and
|
|
714
|
+
* "exists but you may not see it" is an
|
|
715
|
+
* existence oracle for private artifacts.
|
|
716
|
+
* - `server_error` — unexpected adapter failure. The message is
|
|
717
|
+
* generic by contract — raw storage error text
|
|
718
|
+
* never reaches the wire.
|
|
620
719
|
*/
|
|
621
720
|
export declare const LIST_VERSIONS_ERROR_CODES: readonly ["invalid_request", "not_found", "server_error"];
|
|
622
721
|
export type ListVersionsErrorCode = (typeof LIST_VERSIONS_ERROR_CODES)[number];
|