@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.
Files changed (60) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +76 -0
  3. package/dist/impls/memory-bundle-storage.d.ts +12 -0
  4. package/dist/impls/memory-bundle-storage.d.ts.map +1 -0
  5. package/dist/impls/memory-bundle-storage.js +40 -0
  6. package/dist/impls/memory-registry-storage.d.ts +3 -0
  7. package/dist/impls/memory-registry-storage.d.ts.map +1 -0
  8. package/dist/impls/memory-registry-storage.js +154 -0
  9. package/dist/index.d.ts +26 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +26 -0
  12. package/dist/interfaces/authn.d.ts +51 -0
  13. package/dist/interfaces/authn.d.ts.map +1 -0
  14. package/dist/interfaces/authn.js +1 -0
  15. package/dist/interfaces/bundle-storage.d.ts +79 -0
  16. package/dist/interfaces/bundle-storage.d.ts.map +1 -0
  17. package/dist/interfaces/bundle-storage.js +1 -0
  18. package/dist/interfaces/registry-storage.d.ts +204 -0
  19. package/dist/interfaces/registry-storage.d.ts.map +1 -0
  20. package/dist/interfaces/registry-storage.js +19 -0
  21. package/dist/ops/compile.d.ts +48 -0
  22. package/dist/ops/compile.d.ts.map +1 -0
  23. package/dist/ops/compile.js +163 -0
  24. package/dist/ops/conformance.d.ts +154 -0
  25. package/dist/ops/conformance.d.ts.map +1 -0
  26. package/dist/ops/conformance.js +487 -0
  27. package/dist/ops/list-versions.d.ts +58 -0
  28. package/dist/ops/list-versions.d.ts.map +1 -0
  29. package/dist/ops/list-versions.js +60 -0
  30. package/dist/ops/publish.d.ts +70 -0
  31. package/dist/ops/publish.d.ts.map +1 -0
  32. package/dist/ops/publish.js +417 -0
  33. package/dist/ops/read.d.ts +52 -0
  34. package/dist/ops/read.d.ts.map +1 -0
  35. package/dist/ops/read.js +60 -0
  36. package/dist/ops/register-author-key.d.ts +22 -0
  37. package/dist/ops/register-author-key.d.ts.map +1 -0
  38. package/dist/ops/register-author-key.js +171 -0
  39. package/dist/ops/search.d.ts +50 -0
  40. package/dist/ops/search.d.ts.map +1 -0
  41. package/dist/ops/search.js +106 -0
  42. package/dist/testing/bundle-storage-contract.d.ts +3 -0
  43. package/dist/testing/bundle-storage-contract.d.ts.map +1 -0
  44. package/dist/testing/bundle-storage-contract.js +176 -0
  45. package/dist/testing/index.d.ts +10 -0
  46. package/dist/testing/index.d.ts.map +1 -0
  47. package/dist/testing/index.js +9 -0
  48. package/dist/testing/registry-storage-contract.d.ts +3 -0
  49. package/dist/testing/registry-storage-contract.d.ts.map +1 -0
  50. package/dist/testing/registry-storage-contract.js +392 -0
  51. package/dist/types.d.ts +482 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +110 -0
  54. package/dist/utils/base64.d.ts +15 -0
  55. package/dist/utils/base64.d.ts.map +1 -0
  56. package/dist/utils/base64.js +35 -0
  57. package/dist/utils/semver.d.ts +13 -0
  58. package/dist/utils/semver.d.ts.map +1 -0
  59. package/dist/utils/semver.js +69 -0
  60. package/package.json +85 -0
@@ -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,3 @@
1
+ import type { BundleStorage } from '../interfaces/bundle-storage.js';
2
+ export declare function bundleStorageContract(makeStorage: () => BundleStorage): void;
3
+ //# sourceMappingURL=bundle-storage-contract.d.ts.map
@@ -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,3 @@
1
+ import type { RegistryStorage } from '../interfaces/registry-storage.js';
2
+ export declare function registryStorageContract(makeStorage: () => RegistryStorage): void;
3
+ //# sourceMappingURL=registry-storage-contract.d.ts.map
@@ -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"}