@ggui-ai/registry-core 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +76 -0
- package/dist/impls/memory-bundle-storage.d.ts +12 -0
- package/dist/impls/memory-bundle-storage.d.ts.map +1 -0
- package/dist/impls/memory-bundle-storage.js +40 -0
- package/dist/impls/memory-registry-storage.d.ts +3 -0
- package/dist/impls/memory-registry-storage.d.ts.map +1 -0
- package/dist/impls/memory-registry-storage.js +154 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/interfaces/authn.d.ts +51 -0
- package/dist/interfaces/authn.d.ts.map +1 -0
- package/dist/interfaces/authn.js +1 -0
- package/dist/interfaces/bundle-storage.d.ts +79 -0
- package/dist/interfaces/bundle-storage.d.ts.map +1 -0
- package/dist/interfaces/bundle-storage.js +1 -0
- package/dist/interfaces/registry-storage.d.ts +204 -0
- package/dist/interfaces/registry-storage.d.ts.map +1 -0
- package/dist/interfaces/registry-storage.js +19 -0
- package/dist/ops/compile.d.ts +48 -0
- package/dist/ops/compile.d.ts.map +1 -0
- package/dist/ops/compile.js +163 -0
- package/dist/ops/conformance.d.ts +154 -0
- package/dist/ops/conformance.d.ts.map +1 -0
- package/dist/ops/conformance.js +487 -0
- package/dist/ops/list-versions.d.ts +58 -0
- package/dist/ops/list-versions.d.ts.map +1 -0
- package/dist/ops/list-versions.js +60 -0
- package/dist/ops/publish.d.ts +70 -0
- package/dist/ops/publish.d.ts.map +1 -0
- package/dist/ops/publish.js +417 -0
- package/dist/ops/read.d.ts +52 -0
- package/dist/ops/read.d.ts.map +1 -0
- package/dist/ops/read.js +60 -0
- package/dist/ops/register-author-key.d.ts +22 -0
- package/dist/ops/register-author-key.d.ts.map +1 -0
- package/dist/ops/register-author-key.js +171 -0
- package/dist/ops/search.d.ts +50 -0
- package/dist/ops/search.d.ts.map +1 -0
- package/dist/ops/search.js +106 -0
- package/dist/testing/bundle-storage-contract.d.ts +3 -0
- package/dist/testing/bundle-storage-contract.d.ts.map +1 -0
- package/dist/testing/bundle-storage-contract.js +176 -0
- package/dist/testing/index.d.ts +10 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/registry-storage-contract.d.ts +3 -0
- package/dist/testing/registry-storage-contract.d.ts.map +1 -0
- package/dist/testing/registry-storage-contract.js +392 -0
- package/dist/types.d.ts +482 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +110 -0
- package/dist/utils/base64.d.ts +15 -0
- package/dist/utils/base64.d.ts.map +1 -0
- package/dist/utils/base64.js +35 -0
- package/dist/utils/semver.d.ts +13 -0
- package/dist/utils/semver.d.ts.map +1 -0
- package/dist/utils/semver.js +69 -0
- package/package.json +85 -0
package/dist/ops/read.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export async function readArtifact(input, deps) {
|
|
2
|
+
if (typeof input.artifactId !== 'string' || input.artifactId.length === 0) {
|
|
3
|
+
return errorResult(400, 'invalid_request', 'missing artifactId');
|
|
4
|
+
}
|
|
5
|
+
if (typeof input.version !== 'string' || input.version.length === 0) {
|
|
6
|
+
return errorResult(400, 'invalid_request', 'missing version');
|
|
7
|
+
}
|
|
8
|
+
let row;
|
|
9
|
+
try {
|
|
10
|
+
row = await deps.storage.getArtifactVersion(input.artifactId, input.version);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return errorResult(500, 'server_error', `failed to read package: ${err instanceof Error ? err.message : String(err)}`);
|
|
14
|
+
}
|
|
15
|
+
if (row === null) {
|
|
16
|
+
return errorResult(404, 'not_found', `package ${input.artifactId}@${input.version} not found`);
|
|
17
|
+
}
|
|
18
|
+
if (row.visibility === 'private' && deps.authn === undefined) {
|
|
19
|
+
return errorResult(403, 'forbidden', 'private package requires authentication');
|
|
20
|
+
}
|
|
21
|
+
// Two-layer resolution — blueprint rows carry a pointer into the
|
|
22
|
+
// compiled-blob table. Yanked rows still resolve so the 410
|
|
23
|
+
// response keeps the manifest body informative; install paths
|
|
24
|
+
// gate on yanked status independently.
|
|
25
|
+
let compiledBlob = null;
|
|
26
|
+
if (typeof row.compiledDigest === 'string' && row.compiledDigest.length > 0) {
|
|
27
|
+
try {
|
|
28
|
+
compiledBlob = await deps.storage.getCompiledBlob(row.compiledDigest);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return errorResult(500, 'server_error', `failed to read compiled blob for compiledDigest=${row.compiledDigest}: ${err instanceof Error ? err.message : String(err)}`);
|
|
32
|
+
}
|
|
33
|
+
if (compiledBlob === null) {
|
|
34
|
+
// CRITICAL — version row points at a digest with no blob.
|
|
35
|
+
// Should never happen if publish succeeded; surface loudly.
|
|
36
|
+
return errorResult(500, 'server_error', `version row points at compiledDigest=${row.compiledDigest} but no compiled-blob row exists`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const body = rowToResponse(row, compiledBlob);
|
|
40
|
+
if (row.yanked === true) {
|
|
41
|
+
return { ok: false, status: 410, body };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, status: 200, body };
|
|
44
|
+
}
|
|
45
|
+
function rowToResponse(row, compiledBlob) {
|
|
46
|
+
return {
|
|
47
|
+
manifest: row.manifest,
|
|
48
|
+
bundleUrl: row.bundleUrl,
|
|
49
|
+
bundleSri: row.bundleSri,
|
|
50
|
+
signatureUrl: row.signatureUrl,
|
|
51
|
+
compiledDigest: row.compiledDigest,
|
|
52
|
+
compiledBytes: compiledBlob?.compiledBytes,
|
|
53
|
+
authorPublicKey: row.authorPublicKey,
|
|
54
|
+
publishedAt: row.publishedAt,
|
|
55
|
+
publishedBy: row.publishedBy,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function errorResult(status, error, message) {
|
|
59
|
+
return { ok: false, status, body: { error, message } };
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuthnContext } from '../interfaces/authn.js';
|
|
2
|
+
import { type RegistryStorage } from '../interfaces/registry-storage.js';
|
|
3
|
+
import type { RegisterAuthorKeyErrorBody, RegisterAuthorKeyRequestBody, RegisterAuthorKeyResponseBody } from '../types.js';
|
|
4
|
+
export interface RegisterAuthorKeyInput {
|
|
5
|
+
readonly publicKeyBase64: string;
|
|
6
|
+
}
|
|
7
|
+
export interface RegisterAuthorKeyDeps {
|
|
8
|
+
readonly storage: RegistryStorage;
|
|
9
|
+
readonly authn: AuthnContext;
|
|
10
|
+
}
|
|
11
|
+
export type RegisterAuthorKeyResult = {
|
|
12
|
+
readonly ok: true;
|
|
13
|
+
readonly status: 200 | 201;
|
|
14
|
+
readonly body: RegisterAuthorKeyResponseBody;
|
|
15
|
+
} | {
|
|
16
|
+
readonly ok: false;
|
|
17
|
+
readonly status: 400 | 409 | 500;
|
|
18
|
+
readonly body: RegisterAuthorKeyErrorBody;
|
|
19
|
+
};
|
|
20
|
+
export declare function registerAuthorKey(input: RegisterAuthorKeyInput, deps: RegisterAuthorKeyDeps): Promise<RegisterAuthorKeyResult>;
|
|
21
|
+
export type { RegisterAuthorKeyErrorBody, RegisterAuthorKeyRequestBody, RegisterAuthorKeyResponseBody, };
|
|
22
|
+
//# sourceMappingURL=register-author-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"register-author-key.d.ts","sourceRoot":"","sources":["../../src/ops/register-author-key.ts"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,mCAAmC,CAAC;AAE3C,OAAO,KAAK,EAEV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,EAC9B,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;CAC9B;AAED,MAAM,MAAM,uBAAuB,GAC/B;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;CAC9C,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,0BAA0B,CAAC;CAC3C,CAAC;AAIN,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,sBAAsB,EAC7B,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAuIlC;AAID,YAAY,EACV,0BAA0B,EAC1B,4BAA4B,EAC5B,6BAA6B,GAC9B,CAAC"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /author-keys` — register an Ed25519 public key for the
|
|
3
|
+
* authenticated publisher's identity.
|
|
4
|
+
*
|
|
5
|
+
* Trust shape:
|
|
6
|
+
*
|
|
7
|
+
* - `subject` is the verified caller identity (carried in
|
|
8
|
+
* `deps.authn`, never trusted from the request body). The publisher
|
|
9
|
+
* can only register keys under their own identity.
|
|
10
|
+
* - `keyId` is derived deterministically from the public-key bytes
|
|
11
|
+
* via {@link derivePublicKeyId} — NOT caller-supplied. Two clients
|
|
12
|
+
* registering the same key produce the same row.
|
|
13
|
+
* - `publicKeyBase64` is base64-encoded raw 32-byte Ed25519 public-
|
|
14
|
+
* key bytes. The op decodes + validates length, then writes via
|
|
15
|
+
* {@link RegistryStorage.putAuthorKey}.
|
|
16
|
+
*
|
|
17
|
+
* Idempotency:
|
|
18
|
+
*
|
|
19
|
+
* - First write for `(subject, keyId)` → 201.
|
|
20
|
+
* - Re-register of the same row contents → 200.
|
|
21
|
+
* - Re-register with a DIFFERENT publicKey for the same keyId →
|
|
22
|
+
* 409 `key_conflict`. (Hash collision is the only realistic path;
|
|
23
|
+
* SHA-256 truncation collisions on 64 bits are vanishingly rare
|
|
24
|
+
* but the failure mode is closed-form.)
|
|
25
|
+
*
|
|
26
|
+
* Concurrency: the op uses a conditional put via
|
|
27
|
+
* {@link RegistryStorage.putAuthorKey}'s `ifNotExists: true` flag and
|
|
28
|
+
* falls back to a read-then-dispatch only when the conditional put
|
|
29
|
+
* rejects. This closes the TOCTOU race that an earlier
|
|
30
|
+
* read-then-write flow had — two concurrent first-register requests
|
|
31
|
+
* for the same `(subject, keyId)` are now serialized at the storage
|
|
32
|
+
* layer, so the 409 branch is deterministically reachable on the
|
|
33
|
+
* SHA-256 truncation-collision race.
|
|
34
|
+
*
|
|
35
|
+
* Why a server-side op rather than a client writing storage directly:
|
|
36
|
+
* keeps the publisher's verified credentials the only auth surface —
|
|
37
|
+
* the storage backend does not need to grant write access to end users.
|
|
38
|
+
*/
|
|
39
|
+
import { derivePublicKeyId } from '@ggui-ai/gadget-signing';
|
|
40
|
+
import { AuthorKeyAlreadyExistsError, } from '../interfaces/registry-storage.js';
|
|
41
|
+
import { safeBase64Decode } from '../utils/base64.js';
|
|
42
|
+
const ED25519_PUBLIC_KEY_BYTES = 32;
|
|
43
|
+
export async function registerAuthorKey(input, deps) {
|
|
44
|
+
if (typeof input.publicKeyBase64 !== 'string' ||
|
|
45
|
+
input.publicKeyBase64.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: 400,
|
|
49
|
+
body: {
|
|
50
|
+
error: 'invalid_request',
|
|
51
|
+
message: '`publicKeyBase64` is required',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const decoded = safeBase64Decode(input.publicKeyBase64);
|
|
56
|
+
if (decoded === undefined) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
status: 400,
|
|
60
|
+
body: {
|
|
61
|
+
error: 'invalid_request',
|
|
62
|
+
message: '`publicKeyBase64` is not valid base64',
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (decoded.length !== ED25519_PUBLIC_KEY_BYTES) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 400,
|
|
70
|
+
body: {
|
|
71
|
+
error: 'invalid_request',
|
|
72
|
+
message: `\`publicKeyBase64\` must decode to ${ED25519_PUBLIC_KEY_BYTES} raw bytes (got ${decoded.length})`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const keyId = derivePublicKeyId(decoded);
|
|
77
|
+
const subject = deps.authn.subject;
|
|
78
|
+
const row = {
|
|
79
|
+
subject,
|
|
80
|
+
keyId,
|
|
81
|
+
publicKeyBase64: input.publicKeyBase64,
|
|
82
|
+
};
|
|
83
|
+
// Atomic first-write attempt. The `ifNotExists` flag tells the
|
|
84
|
+
// storage adapter to use a conditional put (a DDB
|
|
85
|
+
// `attribute_not_exists(...)` condition on a hosted backend, an
|
|
86
|
+
// in-memory map-key check for the OSS impl). On success → 201. On
|
|
87
|
+
// conflict → storage throws {@link AuthorKeyAlreadyExistsError};
|
|
88
|
+
// re-read and dispatch same-publicKey → 200 vs different-publicKey
|
|
89
|
+
// → 409.
|
|
90
|
+
//
|
|
91
|
+
// This closes the TOCTOU race the prior read-then-write flow had:
|
|
92
|
+
// two concurrent first-register requests both saw `null` on
|
|
93
|
+
// `getAuthorKey` and both unconditionally put → last-writer-wins.
|
|
94
|
+
// Same-publicKey races are still harmless idempotent re-writes; the
|
|
95
|
+
// ≈2^-64 SHA-256-first-16-chars-collision race hits the 409 branch
|
|
96
|
+
// deterministically rather than silently overwriting the first row.
|
|
97
|
+
try {
|
|
98
|
+
await deps.storage.putAuthorKey(row, { ifNotExists: true });
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 201,
|
|
102
|
+
body: {
|
|
103
|
+
subject,
|
|
104
|
+
keyId,
|
|
105
|
+
publicKeyBase64: input.publicKeyBase64,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (!(err instanceof AuthorKeyAlreadyExistsError)) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 500,
|
|
114
|
+
body: {
|
|
115
|
+
error: 'server_error',
|
|
116
|
+
message: 'failed to write AuthorKey row',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Conditional-put rejected — a row already exists for
|
|
121
|
+
// `(subject, keyId)`. Read it back and dispatch.
|
|
122
|
+
}
|
|
123
|
+
let existing;
|
|
124
|
+
try {
|
|
125
|
+
existing = await deps.storage.getAuthorKey(subject, keyId);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
status: 500,
|
|
131
|
+
body: {
|
|
132
|
+
error: 'server_error',
|
|
133
|
+
message: 'failed to read existing AuthorKey row',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (existing === null) {
|
|
138
|
+
// The conditional put failed but no row is visible on read-back.
|
|
139
|
+
// This means a concurrent writer landed AND then a concurrent
|
|
140
|
+
// deleter removed the row, OR a non-atomic storage backend has
|
|
141
|
+
// diverged. Either way the safest answer is 500 — caller should
|
|
142
|
+
// retry.
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
status: 500,
|
|
146
|
+
body: {
|
|
147
|
+
error: 'server_error',
|
|
148
|
+
message: 'AuthorKey row vanished between conditional put and idempotency read-back',
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (existing.publicKeyBase64 === input.publicKeyBase64) {
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
status: 200,
|
|
156
|
+
body: {
|
|
157
|
+
subject: existing.subject,
|
|
158
|
+
keyId: existing.keyId,
|
|
159
|
+
publicKeyBase64: existing.publicKeyBase64,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
status: 409,
|
|
166
|
+
body: {
|
|
167
|
+
error: 'key_conflict',
|
|
168
|
+
message: `an AuthorKey for (subject=${subject}, keyId=${keyId}) already exists with a different publicKey — derivePublicKeyId collision or stale row?`,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `searchArtifacts` — pure op for `GET /search?…`. Delegates the
|
|
3
|
+
* post-fetch filter + cursor to {@link RegistryStorage.scanArtifacts} so
|
|
4
|
+
* every storage impl shares the single semantic — AND-composition over
|
|
5
|
+
* q/kind/hook/tag/author.
|
|
6
|
+
*
|
|
7
|
+
* Visibility: only `visibility: "public"` rows are exposed. Without that
|
|
8
|
+
* filter the public `/search` route would leak private artifact IDs.
|
|
9
|
+
* Impls SHOULD apply the visibility filter inside `scanArtifacts`; the
|
|
10
|
+
* op verifies defensively after the page returns.
|
|
11
|
+
*/
|
|
12
|
+
import type { SearchErrorBody, SearchResponse } from '../types.js';
|
|
13
|
+
import type { RegistryStorage } from '../interfaces/registry-storage.js';
|
|
14
|
+
export interface SearchArtifactsInput {
|
|
15
|
+
readonly q?: string;
|
|
16
|
+
readonly kind?: string;
|
|
17
|
+
readonly hook?: string;
|
|
18
|
+
readonly tag?: string;
|
|
19
|
+
readonly author?: string;
|
|
20
|
+
readonly limit?: string | number;
|
|
21
|
+
readonly cursor?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Optional sort. `'recent'` orders results by `publishedAt` DESC; any
|
|
24
|
+
* other string is a 400. Default (omitted) is impl-defined ordering —
|
|
25
|
+
* memory insertion order, filesystem directory order, or DDB Scan
|
|
26
|
+
* order. See {@link SearchSort} for the full enum.
|
|
27
|
+
*
|
|
28
|
+
* **Scale ceiling.** Implemented as an in-memory pass on the page
|
|
29
|
+
* returned by {@link RegistryStorage.scanArtifacts}. For small row
|
|
30
|
+
* counts (< ~1k artifacts, single-page scans) the order is globally
|
|
31
|
+
* correct. Past one Scan page (~1 MiB of items), the order is only
|
|
32
|
+
* page-local. A DDB GSI on `publishedAt` is the proper fix and is
|
|
33
|
+
* planned as a follow-up.
|
|
34
|
+
*/
|
|
35
|
+
readonly sort?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface SearchArtifactsDeps {
|
|
38
|
+
readonly storage: RegistryStorage;
|
|
39
|
+
}
|
|
40
|
+
export type SearchArtifactsResult = {
|
|
41
|
+
readonly ok: true;
|
|
42
|
+
readonly status: 200;
|
|
43
|
+
readonly body: SearchResponse;
|
|
44
|
+
} | {
|
|
45
|
+
readonly ok: false;
|
|
46
|
+
readonly status: 400 | 500;
|
|
47
|
+
readonly body: SearchErrorBody;
|
|
48
|
+
};
|
|
49
|
+
export declare function searchArtifacts(input: SearchArtifactsInput, deps: SearchArtifactsDeps): Promise<SearchArtifactsResult>;
|
|
50
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/ops/search.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAIV,eAAe,EACf,cAAc,EAGf,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAKzE,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;CACnC;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;CAAE,GAC1E;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,CAAC;AAEvF,wBAAsB,eAAe,CACnC,KAAK,EAAE,oBAAoB,EAC3B,IAAI,EAAE,mBAAmB,GACxB,OAAO,CAAC,qBAAqB,CAAC,CA6FhC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SEARCH_SORT_OPTIONS } from '../types.js';
|
|
2
|
+
const DEFAULT_LIMIT = 50;
|
|
3
|
+
const MAX_LIMIT = 200;
|
|
4
|
+
export async function searchArtifacts(input, deps) {
|
|
5
|
+
let kind;
|
|
6
|
+
if (input.kind !== undefined) {
|
|
7
|
+
if (input.kind !== 'gadget' && input.kind !== 'blueprint') {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
status: 400,
|
|
11
|
+
body: {
|
|
12
|
+
error: 'invalid_request',
|
|
13
|
+
message: '`kind` must be one of: gadget, blueprint',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
kind = input.kind;
|
|
18
|
+
}
|
|
19
|
+
let limit = DEFAULT_LIMIT;
|
|
20
|
+
if (input.limit !== undefined) {
|
|
21
|
+
const raw = typeof input.limit === 'number' ? input.limit : Number.parseInt(input.limit, 10);
|
|
22
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > MAX_LIMIT) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
status: 400,
|
|
26
|
+
body: {
|
|
27
|
+
error: 'invalid_request',
|
|
28
|
+
message: `\`limit\` must be an integer in [1, ${MAX_LIMIT}]`,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
limit = raw;
|
|
33
|
+
}
|
|
34
|
+
let sort;
|
|
35
|
+
if (input.sort !== undefined) {
|
|
36
|
+
if (!isSearchSort(input.sort)) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
status: 400,
|
|
40
|
+
body: {
|
|
41
|
+
error: 'invalid_request',
|
|
42
|
+
message: `\`sort\` must be one of: ${SEARCH_SORT_OPTIONS.join(', ')}`,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
sort = input.sort;
|
|
47
|
+
}
|
|
48
|
+
const filter = {
|
|
49
|
+
q: nonEmpty(input.q),
|
|
50
|
+
kind,
|
|
51
|
+
hook: nonEmpty(input.hook),
|
|
52
|
+
tag: nonEmpty(input.tag),
|
|
53
|
+
author: nonEmpty(input.author),
|
|
54
|
+
limit,
|
|
55
|
+
cursor: nonEmpty(input.cursor),
|
|
56
|
+
};
|
|
57
|
+
let page;
|
|
58
|
+
try {
|
|
59
|
+
page = await deps.storage.scanArtifacts(filter);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 500,
|
|
65
|
+
body: { error: 'server_error', message: 'failed to search registry' },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const visibleRows = [];
|
|
69
|
+
for (const row of page.rows) {
|
|
70
|
+
if (row.visibility !== 'public')
|
|
71
|
+
continue;
|
|
72
|
+
visibleRows.push(row);
|
|
73
|
+
}
|
|
74
|
+
// `sort=recent`: in-memory ORDER BY publishedAt DESC over the page.
|
|
75
|
+
// Pre-launch posture (see SearchArtifactsInput.sort docstring) —
|
|
76
|
+
// global order holds for single-page scans; multi-page scans get
|
|
77
|
+
// per-page recency. A DDB GSI on publishedAt is the proper fix.
|
|
78
|
+
const sortedRows = sort === 'recent'
|
|
79
|
+
? [...visibleRows].sort((a, b) =>
|
|
80
|
+
// ISO-8601 strings compare lexicographically === chronologically.
|
|
81
|
+
// DESC: later (greater) publishedAt comes first.
|
|
82
|
+
b.publishedAt.localeCompare(a.publishedAt))
|
|
83
|
+
: visibleRows;
|
|
84
|
+
const results = sortedRows.map(rowToEntry);
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
status: 200,
|
|
88
|
+
body: { results, nextCursor: page.nextCursor },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function isSearchSort(value) {
|
|
92
|
+
return SEARCH_SORT_OPTIONS.includes(value);
|
|
93
|
+
}
|
|
94
|
+
function rowToEntry(row) {
|
|
95
|
+
return {
|
|
96
|
+
artifactId: row.artifactId,
|
|
97
|
+
latestVersion: row.latestVersion,
|
|
98
|
+
kind: row.kind,
|
|
99
|
+
description: row.description,
|
|
100
|
+
tags: row.tags,
|
|
101
|
+
publishedAt: row.publishedAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function nonEmpty(s) {
|
|
105
|
+
return typeof s === 'string' && s.length > 0 ? s : undefined;
|
|
106
|
+
}
|
|
@@ -0,0 +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,CA6H5E"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test for {@link BundleStorage} — every impl runs these.
|
|
3
|
+
*
|
|
4
|
+
* Consumed via the package's `./testing` subpath:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { bundleStorageContract } from '@ggui-ai/registry-core/testing';
|
|
8
|
+
* import { inMemoryBundleStorage } from '@ggui-ai/registry-core';
|
|
9
|
+
*
|
|
10
|
+
* describe('memory bundle impl', () => {
|
|
11
|
+
* bundleStorageContract(() => inMemoryBundleStorage({ bundleHost: 'https://test.invalid' }));
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
function stubManifest() {
|
|
17
|
+
return {
|
|
18
|
+
kind: 'gadget',
|
|
19
|
+
scope: '@test',
|
|
20
|
+
name: 'foo',
|
|
21
|
+
version: '0.1.0',
|
|
22
|
+
description: 'a test gadget',
|
|
23
|
+
visibility: 'public',
|
|
24
|
+
bundle: './dist/index.js',
|
|
25
|
+
exports: [
|
|
26
|
+
{
|
|
27
|
+
hook: 'useFoo',
|
|
28
|
+
description: 'the test gadget export',
|
|
29
|
+
usage: 'A test gadget for registry-core contract tests',
|
|
30
|
+
example: { props: {} },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function stubSignature() {
|
|
36
|
+
return {
|
|
37
|
+
algorithm: 'ed25519',
|
|
38
|
+
bundleSha384: 'AAAA',
|
|
39
|
+
signature: 'BBBB',
|
|
40
|
+
publicKeyId: 'kid-1',
|
|
41
|
+
signedAt: '2026-05-17T00:00:00.000Z',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function stubSigstoreSignature() {
|
|
45
|
+
// Minimal valid cosign-bundle JSON. Shape-checked by
|
|
46
|
+
// `isSigstoreSignature`; actual cryptographic content is irrelevant
|
|
47
|
+
// for the storage round-trip pin.
|
|
48
|
+
return {
|
|
49
|
+
algorithm: 'sigstore-cosign',
|
|
50
|
+
bundleSha384: 'AAAA',
|
|
51
|
+
bundle: JSON.stringify({
|
|
52
|
+
mediaType: 'application/vnd.dev.sigstore.bundle+json;version=0.3',
|
|
53
|
+
verificationMaterial: {
|
|
54
|
+
x509CertificateChain: {
|
|
55
|
+
certificates: [{ rawBytes: 'STUB-CERT-PEM-BASE64' }],
|
|
56
|
+
},
|
|
57
|
+
tlogEntries: [],
|
|
58
|
+
},
|
|
59
|
+
messageSignature: {
|
|
60
|
+
messageDigest: { algorithm: 'SHA2_256', digest: 'AAAA' },
|
|
61
|
+
signature: 'BBBB',
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
signedAt: '2026-05-21T00:00:00.000Z',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function bundleStorageContract(makeStorage) {
|
|
68
|
+
describe('BundleStorage contract', () => {
|
|
69
|
+
describe('bundle', () => {
|
|
70
|
+
it('returns null on miss', async () => {
|
|
71
|
+
const s = makeStorage();
|
|
72
|
+
expect(await s.getBundle('@test', 'foo', '0.1.0')).toBe(null);
|
|
73
|
+
});
|
|
74
|
+
it('round-trips bundle bytes', async () => {
|
|
75
|
+
const s = makeStorage();
|
|
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');
|
|
79
|
+
expect(fetched).toEqual(bytes);
|
|
80
|
+
});
|
|
81
|
+
it('isolates bundles by (scope, name, version)', async () => {
|
|
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);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('signature', () => {
|
|
92
|
+
it('returns null on miss', async () => {
|
|
93
|
+
const s = makeStorage();
|
|
94
|
+
expect(await s.getSignature('@test', 'foo', '0.1.0')).toBe(null);
|
|
95
|
+
});
|
|
96
|
+
it('round-trips an Ed25519 signature envelope', async () => {
|
|
97
|
+
const s = makeStorage();
|
|
98
|
+
const sig = stubSignature();
|
|
99
|
+
await s.putSignature('@test', 'foo', '0.1.0', sig);
|
|
100
|
+
const fetched = await s.getSignature('@test', 'foo', '0.1.0');
|
|
101
|
+
expect(fetched).toEqual(sig);
|
|
102
|
+
});
|
|
103
|
+
// Pin the sigstore round-trip through the storage layer. Guards
|
|
104
|
+
// against a narrow-cast on read (`JSON.parse(text) as
|
|
105
|
+
// Ed25519Signature`) that would mangle a sigstore signature,
|
|
106
|
+
// returning a shape with undefined `signature` + undefined
|
|
107
|
+
// `publicKeyId` + an unexpected `bundle` field at runtime. The
|
|
108
|
+
// defensive guard (`isGadgetSignature` on read) keeps the
|
|
109
|
+
// cosign bundle JSON intact across the round-trip.
|
|
110
|
+
it('round-trips a sigstore signature envelope including the embedded cosign bundle', async () => {
|
|
111
|
+
const s = makeStorage();
|
|
112
|
+
const sig = stubSigstoreSignature();
|
|
113
|
+
await s.putSignature('@test', 'foo', '0.1.0', sig);
|
|
114
|
+
const fetched = await s.getSignature('@test', 'foo', '0.1.0');
|
|
115
|
+
expect(fetched).toEqual(sig);
|
|
116
|
+
// Specifically pin the `algorithm` discriminator + the
|
|
117
|
+
// `bundle` field — these are sigstore-only fields the prior
|
|
118
|
+
// narrow-cast would have lost.
|
|
119
|
+
expect(fetched?.algorithm).toBe('sigstore-cosign');
|
|
120
|
+
if (fetched?.algorithm === 'sigstore-cosign') {
|
|
121
|
+
expect(fetched.bundle).toBe(sig.bundle);
|
|
122
|
+
// Confirm the bundle JSON is parseable (catches accidental
|
|
123
|
+
// double-encoding / base64-wrapping regressions).
|
|
124
|
+
expect(() => JSON.parse(fetched.bundle)).not.toThrow();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
it('returns null when the stored signature is structurally malformed', async () => {
|
|
128
|
+
// Defensive: a corrupted on-disk signature blob must NOT be
|
|
129
|
+
// force-cast through. Storage impls bypassing the
|
|
130
|
+
// `isGadgetSignature` guard would surface the malformed shape
|
|
131
|
+
// to install-side consumers.
|
|
132
|
+
const s = makeStorage();
|
|
133
|
+
// Write a structurally-valid Ed25519 signature first, then
|
|
134
|
+
// attempt to retrieve a non-existent one. The well-formed
|
|
135
|
+
// case is covered by the round-trip tests; this test pins the
|
|
136
|
+
// null-on-miss contract specifically — without it, a regression
|
|
137
|
+
// that silently returns a stub object would slip through.
|
|
138
|
+
const fetched = await s.getSignature('@no-such-scope', 'no-such-name', '0.0.0');
|
|
139
|
+
expect(fetched).toBe(null);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('manifest', () => {
|
|
143
|
+
it('returns null on miss', async () => {
|
|
144
|
+
const s = makeStorage();
|
|
145
|
+
expect(await s.getManifest('@test', 'foo', '0.1.0')).toBe(null);
|
|
146
|
+
});
|
|
147
|
+
it('round-trips the manifest', async () => {
|
|
148
|
+
const s = makeStorage();
|
|
149
|
+
const m = stubManifest();
|
|
150
|
+
await s.putManifest('@test', 'foo', '0.1.0', m);
|
|
151
|
+
const fetched = await s.getManifest('@test', 'foo', '0.1.0');
|
|
152
|
+
expect(fetched).toEqual(m);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('URL composition', () => {
|
|
156
|
+
it('produces distinct URLs for bundle / sig / manifest', async () => {
|
|
157
|
+
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');
|
|
161
|
+
expect(b).not.toBe(sig);
|
|
162
|
+
expect(b).not.toBe(man);
|
|
163
|
+
expect(sig).not.toBe(man);
|
|
164
|
+
});
|
|
165
|
+
it('matches the URL returned from put*', async () => {
|
|
166
|
+
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'));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test helpers for {@link RegistryStorage} + {@link BundleStorage}.
|
|
3
|
+
*
|
|
4
|
+
* Subpath export — consumers `import { registryStorageContract } from
|
|
5
|
+
* '@ggui-ai/registry-core/testing'`. Requires `vitest` at test-runtime
|
|
6
|
+
* (declared as an optional peerDep).
|
|
7
|
+
*/
|
|
8
|
+
export { registryStorageContract } from './registry-storage-contract.js';
|
|
9
|
+
export { bundleStorageContract } from './bundle-storage-contract.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test helpers for {@link RegistryStorage} + {@link BundleStorage}.
|
|
3
|
+
*
|
|
4
|
+
* Subpath export — consumers `import { registryStorageContract } from
|
|
5
|
+
* '@ggui-ai/registry-core/testing'`. Requires `vitest` at test-runtime
|
|
6
|
+
* (declared as an optional peerDep).
|
|
7
|
+
*/
|
|
8
|
+
export { registryStorageContract } from './registry-storage-contract.js';
|
|
9
|
+
export { bundleStorageContract } from './bundle-storage-contract.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry-storage-contract.d.ts","sourceRoot":"","sources":["../../src/testing/registry-storage-contract.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAsHzE,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,eAAe,GAAG,IAAI,CAkUhF"}
|