@buildinternet/releases-api-types 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/releases-api-types",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Wire protocol — request/response types for the Releases registry API. Shared by web, workers, and clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,10 @@
16
16
  "src",
17
17
  "README.md"
18
18
  ],
19
+ "dependencies": {
20
+ "@buildinternet/releases-core": "workspace:*",
21
+ "zod": "^4.3.6"
22
+ },
19
23
  "publishConfig": {
20
24
  "access": "public"
21
25
  }
package/src/api-types.ts CHANGED
@@ -1,44 +1,132 @@
1
1
  /**
2
- * Shared API response types.
3
- *
4
- * This is the single source of truth for all API response shapes.
5
- * Consumed by: web frontend, CLI client, and (optionally) API worker routes.
2
+ * Shared API response types — single source of truth for the wire protocol.
3
+ * Consumed by: web frontend, MCP worker, OSS CLI, and the API worker.
6
4
  */
7
5
 
6
+ import type { z } from "zod";
7
+
8
+ export type {
9
+ SourceType,
10
+ SourceDiscovery,
11
+ SourceFetchPriority,
12
+ } from "@buildinternet/releases-core/source-enums";
13
+ import type {
14
+ MediaItemSchema,
15
+ PaginationSchema,
16
+ StatsSchema,
17
+ ErrorResponseSchema,
18
+ ReleaseTypeSchema,
19
+ ReleaseItemSchema,
20
+ ReleaseSummaryItemSchema,
21
+ OverviewPageItemSchema,
22
+ } from "./schemas/shared.js";
23
+ import type {
24
+ OrgListItemSchema,
25
+ OrgListResponseSchema,
26
+ OrgAccountItemSchema,
27
+ OrgAccountsResponseSchema,
28
+ OrgTagsResponseSchema,
29
+ CreateOrgBodySchema,
30
+ UpdateOrgBodySchema,
31
+ OrgDetailSchema,
32
+ } from "./schemas/orgs.js";
33
+ import type {
34
+ SourceListItemSchema,
35
+ SourceWithOrgSchema,
36
+ SourceListResponseSchema,
37
+ SourceListResultSchema,
38
+ SourceDetailSchema,
39
+ SourceMutationResponseSchema,
40
+ SourceOrgRefSchema,
41
+ SourcePatchInputSchema,
42
+ CreateSourceBodySchema,
43
+ ChangelogFileSummarySchema,
44
+ SourceChangelogResponseSchema,
45
+ } from "./schemas/sources.js";
46
+ import type {
47
+ ProductRowSchema,
48
+ ProductListItemSchema,
49
+ ProductListResponseSchema,
50
+ ProductDetailSourceSchema,
51
+ ProductDetailSchema,
52
+ CreateProductBodySchema,
53
+ UpdateProductBodySchema,
54
+ AdoptProductBodySchema,
55
+ ProductAdoptResultSchema,
56
+ ProductAdoptDryRunSchema,
57
+ ProductAdoptResponseSchema,
58
+ ProductDeleteResponseSchema,
59
+ } from "./schemas/products.js";
60
+
61
+ export {
62
+ MediaItemSchema,
63
+ PaginationSchema,
64
+ ListResponseSchema,
65
+ StatsSchema,
66
+ ErrorResponseSchema,
67
+ ReleaseTypeSchema,
68
+ ReleaseItemSchema,
69
+ ReleaseSummaryItemSchema,
70
+ OverviewPageItemSchema,
71
+ CategorySchema,
72
+ } from "./schemas/shared.js";
73
+ export {
74
+ OrgListItemSchema,
75
+ OrgListResponseSchema,
76
+ OrgAccountItemSchema,
77
+ OrgAccountsResponseSchema,
78
+ OrgTagsResponseSchema,
79
+ CreateOrgBodySchema,
80
+ UpdateOrgBodySchema,
81
+ OrgDetailSchema,
82
+ } from "./schemas/orgs.js";
83
+ export {
84
+ SourceListItemSchema,
85
+ SourceWithOrgSchema,
86
+ SourceListResponseSchema,
87
+ SourceListResultSchema,
88
+ SourceDetailSchema,
89
+ SourceMutationResponseSchema,
90
+ SourceOrgRefSchema,
91
+ SourcePatchInputSchema,
92
+ CreateSourceBodySchema,
93
+ ChangelogFileSummarySchema,
94
+ SourceChangelogResponseSchema,
95
+ } from "./schemas/sources.js";
96
+ export {
97
+ ProductRowSchema,
98
+ ProductListItemSchema,
99
+ ProductListResponseSchema,
100
+ ProductDetailSourceSchema,
101
+ ProductDetailSchema,
102
+ CreateProductBodySchema,
103
+ UpdateProductBodySchema,
104
+ AdoptProductBodySchema,
105
+ ProductAdoptResultSchema,
106
+ ProductAdoptDryRunSchema,
107
+ ProductAdoptResponseSchema,
108
+ ProductDeleteResponseSchema,
109
+ } from "./schemas/products.js";
110
+
8
111
  // ── Media ──
9
112
 
10
- export interface MediaItem {
11
- type: "image" | "video" | "gif";
12
- url: string;
13
- alt?: string;
14
- r2Url?: string;
15
- }
113
+ export type MediaItem = z.infer<typeof MediaItemSchema>;
16
114
 
17
115
  // ── Stats ──
18
116
 
19
- export interface Stats {
20
- orgs: number;
21
- sources: number;
22
- releases: number;
23
- products: number;
24
- }
117
+ export type Stats = z.infer<typeof StatsSchema>;
25
118
 
26
119
  // ── Pagination ──
27
120
 
28
- export interface Pagination {
29
- page: number;
30
- pageSize: number;
31
- returned: number;
32
- totalItems?: number;
33
- totalPages?: number;
34
- hasMore: boolean;
35
- }
121
+ export type Pagination = z.infer<typeof PaginationSchema>;
36
122
 
37
123
  export interface ListResponse<T> {
38
124
  items: T[];
39
125
  pagination: Pagination;
40
126
  }
41
127
 
128
+ export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
129
+
42
130
  // ── Sitemap (bulk URL emission) ──
43
131
 
44
132
  export interface SitemapPayload {
@@ -49,227 +137,29 @@ export interface SitemapPayload {
49
137
 
50
138
  // ── Organizations ──
51
139
 
52
- export interface OrgListItem {
53
- slug: string;
54
- name: string;
55
- domain: string | null;
56
- avatarUrl: string | null;
57
- githubHandle: string | null;
58
- sourceCount: number;
59
- releaseCount: number;
60
- recentReleaseCount: number;
61
- lastActivity: string | null;
62
- topProducts: string[];
63
- sparkline: number[];
64
- }
140
+ export type OrgListItem = z.infer<typeof OrgListItemSchema>;
141
+ export type OrgListResponse = z.infer<typeof OrgListResponseSchema>;
142
+ export type OrgAccountItem = z.infer<typeof OrgAccountItemSchema>;
143
+ export type OrgAccountsResponse = z.infer<typeof OrgAccountsResponseSchema>;
144
+ export type OrgTagsResponse = z.infer<typeof OrgTagsResponseSchema>;
145
+ export type CreateOrgBody = z.infer<typeof CreateOrgBodySchema>;
146
+ export type UpdateOrgBody = z.infer<typeof UpdateOrgBodySchema>;
65
147
 
66
- export type OrgListResponse = ListResponse<OrgListItem>;
67
-
68
- export interface OrgAccountItem {
69
- platform: string;
70
- handle: string;
71
- }
72
-
73
- export type OrgAccountsResponse = ListResponse<OrgAccountItem>;
74
- export type OrgTagsResponse = ListResponse<string>;
75
-
76
- export interface OrgDetail {
77
- id?: string;
78
- slug: string;
79
- name: string;
80
- domain: string | null;
81
- description?: string | null;
82
- category?: string | null;
83
- avatarUrl: string | null;
84
- tags?: string[];
85
- sourceCount: number;
86
- releaseCount: number;
87
- releasesLast30Days: number;
88
- avgReleasesPerWeek: number;
89
- lastFetchedAt: string | null;
90
- lastPolledAt: string | null;
91
- trackingSince: string;
92
- aliases?: string[];
93
- accounts: OrgAccountItem[];
94
- products: Array<{
95
- id: string;
96
- slug: string;
97
- name: string;
98
- url: string | null;
99
- description: string | null;
100
- sourceCount: number;
101
- }>;
102
- sources: SourceListItem[];
103
- overview?: OverviewPageItem | null;
104
- playbook?: { scope: "playbook"; content: string; updatedAt: string } | null;
105
- }
148
+ export type OrgDetail = z.infer<typeof OrgDetailSchema>;
106
149
 
107
150
  // ── Sources ──
108
151
 
109
- export interface SourceListItem {
110
- slug: string;
111
- name: string;
112
- type: string;
113
- url?: string;
114
- orgSlug?: string | null;
115
- releaseCount: number;
116
- latestVersion: string | null;
117
- latestDate: string | null;
118
- latestAddedAt?: string | null;
119
- isPrimary?: boolean;
120
- isHidden?: boolean;
121
- /**
122
- * How the row was created. Optional on the wire so older API responses
123
- * (mid-deploy or pinned old workers) degrade gracefully — consumers that
124
- * see `undefined` should treat it as `"curated"`.
125
- */
126
- discovery?: "curated" | "agent" | "on_demand";
127
- fetchPriority?: "normal" | "low" | "paused" | null;
128
- lastFetchedAt?: string | null;
129
- lastPolledAt?: string | null;
130
- changeDetectedAt?: string | null;
131
- consecutiveNoChange?: number | null;
132
- consecutiveErrors?: number | null;
133
- nextFetchAfter?: string | null;
134
- medianGapDays?: number | null;
135
- lastRetieredAt?: string | null;
136
- metadata?: string | null;
137
- productName?: string | null;
138
- productSlug?: string | null;
139
- }
140
-
141
- /**
142
- * Canonical shape returned by GET /v1/sources (list) and used by both
143
- * local-mode queries and the remote API client. Superset of SourceListItem
144
- * with required fields (id, orgName, orgSlug) that the CLI `list` command needs.
145
- */
146
- export interface SourceWithOrg {
147
- id: string;
148
- name: string;
149
- slug: string;
150
- type: string;
151
- url: string;
152
- orgName: string | null;
153
- orgSlug: string | null;
154
- productName: string | null;
155
- productSlug: string | null;
156
- isPrimary: boolean;
157
- isHidden: boolean | null;
158
- discovery?: "curated" | "agent" | "on_demand";
159
- metadata: string | null;
160
- releaseCount: number;
161
- latestVersion: string | null;
162
- latestDate: string | null;
163
- lastFetchedAt: string | null;
164
- lastPolledAt: string | null;
165
- fetchPriority: string | null;
166
- changeDetectedAt: string | null;
167
- consecutiveNoChange: number | null;
168
- consecutiveErrors: number | null;
169
- nextFetchAfter: string | null;
170
- medianGapDays: number | null;
171
- lastRetieredAt: string | null;
172
- }
173
-
174
- /** Fields accepted by PATCH /v1/sources/:slug. */
175
- export interface SourcePatchInput {
176
- name?: string;
177
- url?: string;
178
- type?: string;
179
- slug?: string;
180
- metadata?: string;
181
- orgId?: string | null;
182
- productId?: string | null;
183
- lastFetchedAt?: string | null;
184
- lastContentHash?: string | null;
185
- fetchPriority?: string;
186
- consecutiveNoChange?: number;
187
- consecutiveErrors?: number;
188
- nextFetchAfter?: string | null;
189
- isPrimary?: boolean;
190
- isHidden?: boolean;
191
- changeDetectedAt?: string | null;
192
- lastPolledAt?: string | null;
193
- }
194
-
195
- /** Lightweight summary of a changelog file — used for the file index. */
196
- export interface ChangelogFileSummary {
197
- path: string;
198
- filename: string;
199
- url: string;
200
- bytes: number;
201
- fetchedAt: string;
202
- }
203
-
204
- export interface SourceChangelogResponse {
205
- path: string;
206
- filename: string;
207
- url: string;
208
- rawUrl: string;
209
- content: string;
210
- bytes: number;
211
- fetchedAt: string;
212
- /** Character offset of the first character in `content` within the full file. */
213
- offset: number;
214
- /** The limit (in chars) that was applied to produce this slice. */
215
- limit: number;
216
- /** Next offset to request for the next slice, or null if `content` is the tail. */
217
- nextOffset: number | null;
218
- /** Total length of the full file in characters. */
219
- totalChars: number;
220
- /** Requested token budget when in token mode (cl100k_base). */
221
- tokens?: number;
222
- /** Encoded token count of the returned `content`. Set in token mode. */
223
- sliceTokens?: number;
224
- /** Full-file token count (cl100k_base). Always populated. */
225
- totalTokens: number;
226
- /** True when the upstream file exceeded the 1MB cap and content was sliced. */
227
- truncated: boolean;
228
- /** Byte offset where the file was truncated, or null when not truncated. */
229
- truncatedAt: number | null;
230
- /**
231
- * Index of every changelog file tracked for this source (root plus any
232
- * discovered per-package files). Always present even for single-file
233
- * sources so clients can lazily render a file picker.
234
- */
235
- files: ChangelogFileSummary[];
236
- }
237
-
238
- export interface SourceDetail {
239
- slug: string;
240
- name: string;
241
- type: string;
242
- url: string;
243
- /**
244
- * Hidden sources are reachable by direct URL but excluded from listings,
245
- * sitemap, and AI features. Set on on-demand lookups and admin-suppressed
246
- * rows; absent on canonical curated/agent sources.
247
- */
248
- isHidden?: boolean;
249
- /**
250
- * Pairs with `isHidden` to distinguish admin-suppressed rows (`curated` /
251
- * `agent` + hidden) from rows materialized by `/v1/lookups` (`on_demand`).
252
- * Optional for graceful degradation against older API responses.
253
- */
254
- discovery?: "curated" | "agent" | "on_demand";
255
- changelogUrl?: string | null;
256
- hasChangelogFile?: boolean;
257
- org: { slug: string; name: string } | null;
258
- releaseCount: number;
259
- releasesLast30Days: number;
260
- avgReleasesPerWeek: number;
261
- latestVersion: string | null;
262
- latestDate: string | null;
263
- lastFetchedAt: string | null;
264
- lastPolledAt: string | null;
265
- trackingSince: string;
266
- releases: ReleaseItem[];
267
- pagination: Pagination;
268
- summaries: {
269
- rolling: ReleaseSummaryItem | null;
270
- monthly: ReleaseSummaryItem[];
271
- };
272
- }
152
+ export type SourceListItem = z.infer<typeof SourceListItemSchema>;
153
+ export type SourceWithOrg = z.infer<typeof SourceWithOrgSchema>;
154
+ export type SourceListResponse = z.infer<typeof SourceListResponseSchema>;
155
+ export type SourceListResult = z.infer<typeof SourceListResultSchema>;
156
+ export type SourcePatchInput = z.infer<typeof SourcePatchInputSchema>;
157
+ export type CreateSourceBody = z.infer<typeof CreateSourceBodySchema>;
158
+ export type ChangelogFileSummary = z.infer<typeof ChangelogFileSummarySchema>;
159
+ export type SourceChangelogResponse = z.infer<typeof SourceChangelogResponseSchema>;
160
+ export type SourceDetail = z.infer<typeof SourceDetailSchema>;
161
+ export type SourceMutationResponse = z.infer<typeof SourceMutationResponseSchema>;
162
+ export type SourceOrgRef = z.infer<typeof SourceOrgRefSchema>;
273
163
 
274
164
  // ── Admin telemetry: orgs rollup ──
275
165
 
@@ -305,20 +195,9 @@ export interface OrgsRollupResponse extends ListResponse<OrgsRollupRow> {
305
195
  * Optional on the wire so older API responses (mid-deploy or pinned old workers)
306
196
  * degrade gracefully — consumers that see `undefined` should treat it as `"feature"`.
307
197
  */
308
- export type ReleaseType = "feature" | "rollup";
198
+ export type ReleaseType = z.infer<typeof ReleaseTypeSchema>;
309
199
 
310
- export interface ReleaseItem {
311
- id?: string;
312
- version: string | null;
313
- title: string;
314
- summary: string;
315
- content?: string;
316
- publishedAt: string | null;
317
- url: string | null;
318
- media?: MediaItem[];
319
- /** Release type. See {@link ReleaseType}. */
320
- type?: ReleaseType;
321
- }
200
+ export type ReleaseItem = z.infer<typeof ReleaseItemSchema>;
322
201
 
323
202
  export interface ReleaseDetail {
324
203
  id: string;
@@ -352,14 +231,7 @@ export type ReleaseCoverageResponse =
352
231
  | { role: "coverage"; canonical: ReleaseCoverageRow; covers: [] }
353
232
  | { role: "canonical"; canonical: null; covers: ReleaseCoverageRow[] };
354
233
 
355
- export interface ReleaseSummaryItem {
356
- year?: number | null;
357
- month?: number | null;
358
- windowDays?: number | null;
359
- summary: string;
360
- releaseCount: number;
361
- generatedAt: string;
362
- }
234
+ export type ReleaseSummaryItem = z.infer<typeof ReleaseSummaryItemSchema>;
363
235
 
364
236
  // ── Search ──
365
237
 
@@ -497,6 +369,50 @@ export interface SearchChunkHit {
497
369
  score: number;
498
370
  }
499
371
 
372
+ // ── Lookup by domain (entity resolution) ──
373
+
374
+ /**
375
+ * Returned by GET /v1/lookups/by-domain. Pure resolution: the server
376
+ * normalizes the input domain, exact-matches against
377
+ * `organizations.domain` (primary) and `domain_aliases.domain` (alias for
378
+ * either an org or a product), and returns whatever it finds. Unknown
379
+ * domains 404 — there is no on-demand probing for domains, unlike the
380
+ * GitHub coordinate path.
381
+ */
382
+ export interface DomainLookupResponse {
383
+ /** Normalized domain that was matched (lowercased, no scheme/path/www). */
384
+ domain: string;
385
+ /**
386
+ * Owning organization, if one exists. `matchedVia` distinguishes a hit
387
+ * on `organizations.domain` (`"primary"`) from an alias hit
388
+ * (`"alias"`) so the UI can render the difference.
389
+ */
390
+ org: {
391
+ id: string;
392
+ slug: string;
393
+ name: string;
394
+ domain: string | null;
395
+ description: string | null;
396
+ category: string | null;
397
+ avatarUrl: string | null;
398
+ matchedVia: "primary" | "alias";
399
+ } | null;
400
+ /**
401
+ * Products whose alias points at this domain. Multiple are possible
402
+ * when an org owns several products that all set the same alias —
403
+ * unusual, but allowed.
404
+ */
405
+ products: Array<{
406
+ id: string;
407
+ slug: string;
408
+ name: string;
409
+ orgId: string;
410
+ orgSlug: string;
411
+ orgName: string;
412
+ category: string | null;
413
+ }>;
414
+ }
415
+
500
416
  // ── Lookup (on-demand GitHub index) ──
501
417
 
502
418
  export type LookupStatus = "indexed" | "existing" | "empty" | "not_found" | "deferred";
@@ -535,6 +451,21 @@ export interface LookupResultPayload {
535
451
 
536
452
  export interface UnifiedSearchResponse {
537
453
  query: string;
454
+ /**
455
+ * Normalized form of `?domain=` when the caller passed it. The server
456
+ * does the normalization once and echoes the canonical form back so
457
+ * clients don't have to re-normalize for analytics or display. Absent
458
+ * when no domain filter was applied.
459
+ */
460
+ domain?: string;
461
+ /**
462
+ * Outcome of the `?domain=` resolution step. `"matched"` means the
463
+ * domain resolved to a known org and results below are scoped to it.
464
+ * `"not_found"` means the domain didn't match anything; in that case
465
+ * orgs/catalog/releases will all be empty arrays — the caller can
466
+ * distinguish "no hits in scope" from "scope didn't exist."
467
+ */
468
+ domainStatus?: "matched" | "not_found";
538
469
  orgs: SearchOrgHit[];
539
470
  /** Products and standalone sources folded into a single list. */
540
471
  catalog: SearchCatalogHit[];
@@ -559,16 +490,7 @@ export interface UnifiedSearchResponse {
559
490
 
560
491
  // ── Overview Pages ──
561
492
 
562
- export interface OverviewPageItem {
563
- scope: "org" | "product";
564
- orgSlug?: string | null;
565
- productSlug?: string | null;
566
- content: string;
567
- releaseCount: number;
568
- lastContributingReleaseAt: string | null;
569
- generatedAt: string;
570
- updatedAt: string;
571
- }
493
+ export type OverviewPageItem = z.infer<typeof OverviewPageItemSchema>;
572
494
 
573
495
  /** @deprecated Use OverviewPageItem */
574
496
  export type KnowledgePageItem = OverviewPageItem;
@@ -700,39 +622,20 @@ export interface OrgReleasesResponse {
700
622
 
701
623
  // ── Products ──
702
624
 
703
- export interface ProductListItem {
704
- id: string;
705
- name: string;
706
- slug: string;
707
- orgId: string;
708
- url: string | null;
709
- description: string | null;
710
- category: string | null;
711
- createdAt: string;
712
- sourceCount: number;
713
- }
714
-
715
- export type ProductListResponse = ListResponse<ProductListItem>;
716
-
717
- export interface ProductDetail {
718
- id: string;
719
- name: string;
720
- slug: string;
721
- orgId: string;
722
- url: string | null;
723
- description: string | null;
724
- category: string | null;
725
- createdAt: string;
726
- sources: Array<{ id: string; slug: string; name: string; type: string; url: string }>;
727
- tags: string[];
728
- }
729
-
730
- export interface ProductAdoptResult {
731
- product: ProductDetail;
732
- sourcesMoved: number;
733
- accountsMoved: number;
734
- sourceOrgDeleted: string;
735
- }
625
+ // `Category` lives in @buildinternet/releases-core/categories — import it from
626
+ // there. CategorySchema is re-exported here for OpenAPI / Zod consumers only.
627
+ export type ProductRow = z.infer<typeof ProductRowSchema>;
628
+ export type ProductListItem = z.infer<typeof ProductListItemSchema>;
629
+ export type ProductListResponse = z.infer<typeof ProductListResponseSchema>;
630
+ export type ProductDetailSource = z.infer<typeof ProductDetailSourceSchema>;
631
+ export type ProductDetail = z.infer<typeof ProductDetailSchema>;
632
+ export type CreateProductBody = z.infer<typeof CreateProductBodySchema>;
633
+ export type UpdateProductBody = z.infer<typeof UpdateProductBodySchema>;
634
+ export type AdoptProductBody = z.infer<typeof AdoptProductBodySchema>;
635
+ export type ProductAdoptResult = z.infer<typeof ProductAdoptResultSchema>;
636
+ export type ProductAdoptDryRun = z.infer<typeof ProductAdoptDryRunSchema>;
637
+ export type ProductAdoptResponse = z.infer<typeof ProductAdoptResponseSchema>;
638
+ export type ProductDeleteResponse = z.infer<typeof ProductDeleteResponseSchema>;
736
639
 
737
640
  // ── Taxonomy (categories + tags) ──
738
641
 
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ import { CategorySchema, ListResponseSchema, OverviewPageItemSchema } from "./shared.js";
3
+ import { SourceListItemSchema } from "./sources.js";
4
+ import { ProductListItemSchema } from "./products.js";
5
+
6
+ export const OrgListItemSchema = z.object({
7
+ id: z.string(),
8
+ slug: z.string(),
9
+ name: z.string(),
10
+ domain: z.string().nullable(),
11
+ description: z.string().nullable(),
12
+ category: CategorySchema.nullable(),
13
+ avatarUrl: z.string().nullable(),
14
+ sourceCount: z.number().int().min(0),
15
+ releaseCount: z.number().int().min(0),
16
+ recentReleaseCount: z.number().int().min(0),
17
+ lastActivity: z.string().nullable(),
18
+ topProducts: z.array(z.string()),
19
+ sparkline: z.array(z.number().int().min(0)).length(30),
20
+ });
21
+
22
+ export const OrgListResponseSchema = ListResponseSchema(OrgListItemSchema);
23
+
24
+ export const OrgAccountItemSchema = z.object({
25
+ platform: z.string(),
26
+ handle: z.string(),
27
+ });
28
+
29
+ export const OrgAccountsResponseSchema = ListResponseSchema(OrgAccountItemSchema);
30
+ export const OrgTagsResponseSchema = ListResponseSchema(z.string());
31
+
32
+ export const CreateOrgBodySchema = z.object({
33
+ name: z.string().min(1),
34
+ slug: z.string().optional(),
35
+ domain: z.string().optional(),
36
+ description: z.string().optional(),
37
+ category: CategorySchema.optional(),
38
+ tags: z.array(z.string()).optional(),
39
+ });
40
+
41
+ export const UpdateOrgBodySchema = z.object({
42
+ name: z.string().optional(),
43
+ slug: z.string().optional(),
44
+ domain: z.string().nullable().optional(),
45
+ description: z.string().nullable().optional(),
46
+ category: CategorySchema.nullable().optional(),
47
+ tags: z.array(z.string()).optional(),
48
+ aliases: z.array(z.string()).optional(),
49
+ });
50
+
51
+ // Org detail's products query selects a strict subset of `ProductListItem` —
52
+ // no category/orgId/createdAt because the parent already names the org and
53
+ // the org detail UI doesn't surface those columns.
54
+ const OrgDetailProductSchema = ProductListItemSchema.pick({
55
+ id: true,
56
+ slug: true,
57
+ name: true,
58
+ url: true,
59
+ description: true,
60
+ sourceCount: true,
61
+ });
62
+
63
+ const OrgDetailPlaybookSchema = z.object({
64
+ scope: z.literal("playbook"),
65
+ content: z.string(),
66
+ updatedAt: z.string(),
67
+ });
68
+
69
+ export const OrgDetailSchema = z.object({
70
+ id: z.string().optional(),
71
+ slug: z.string(),
72
+ name: z.string(),
73
+ domain: z.string().nullable(),
74
+ description: z.string().nullable().optional(),
75
+ category: CategorySchema.nullable().optional(),
76
+ avatarUrl: z.string().nullable(),
77
+ tags: z.array(z.string()).optional(),
78
+ sourceCount: z.number().int().min(0),
79
+ releaseCount: z.number().int().min(0),
80
+ releasesLast30Days: z.number().int().min(0),
81
+ avgReleasesPerWeek: z.number(),
82
+ lastFetchedAt: z.string().nullable(),
83
+ lastPolledAt: z.string().nullable(),
84
+ trackingSince: z.string(),
85
+ aliases: z.array(z.string()).optional(),
86
+ accounts: z.array(OrgAccountItemSchema),
87
+ products: z.array(OrgDetailProductSchema),
88
+ sources: z.array(SourceListItemSchema),
89
+ overview: OverviewPageItemSchema.nullable().optional(),
90
+ playbook: OrgDetailPlaybookSchema.nullable().optional(),
91
+ });
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { CategorySchema, ListResponseSchema } from "./shared.js";
3
+ import { SourceTypeSchema } from "./sources.js";
4
+
5
+ /**
6
+ * Raw `products` table row, returned by `POST /v1/products` and
7
+ * `PATCH /v1/products/:slug`. The OSS CLI types these responses against
8
+ * `Product` from `@buildinternet/releases-core/schema` (the drizzle row
9
+ * type), so `embeddedAt` and `deletedAt` stay on the wire even though
10
+ * they're internal columns. `deletedAt` is always `null` on these paths
11
+ * (live rows only); `embeddedAt` reflects when the product was last
12
+ * indexed for semantic search.
13
+ */
14
+ export const ProductRowSchema = z.object({
15
+ id: z.string(),
16
+ name: z.string(),
17
+ slug: z.string(),
18
+ orgId: z.string(),
19
+ url: z.string().nullable(),
20
+ description: z.string().nullable(),
21
+ category: CategorySchema.nullable(),
22
+ createdAt: z.string(),
23
+ embeddedAt: z.string().nullable(),
24
+ deletedAt: z.string().nullable(),
25
+ });
26
+
27
+ /**
28
+ * Per-product row returned by `GET /v1/products`. Adds `sourceCount` to
29
+ * the row shape but omits the internal `embeddedAt` / `deletedAt` columns
30
+ * (the list handler explicitly selects only the user-facing fields).
31
+ */
32
+ export const ProductListItemSchema = z.object({
33
+ id: z.string(),
34
+ name: z.string(),
35
+ slug: z.string(),
36
+ orgId: z.string(),
37
+ url: z.string().nullable(),
38
+ description: z.string().nullable(),
39
+ category: CategorySchema.nullable(),
40
+ createdAt: z.string(),
41
+ sourceCount: z.number().int().min(0),
42
+ });
43
+
44
+ export const ProductListResponseSchema = ListResponseSchema(ProductListItemSchema);
45
+
46
+ /**
47
+ * Embedded source row returned in `ProductDetail.sources`. The detail
48
+ * handler explicitly selects only this small subset; reuses the shared
49
+ * `SourceTypeSchema` enum so source types stay in one place.
50
+ */
51
+ export const ProductDetailSourceSchema = z.object({
52
+ id: z.string(),
53
+ slug: z.string(),
54
+ name: z.string(),
55
+ type: SourceTypeSchema,
56
+ url: z.string(),
57
+ });
58
+
59
+ /**
60
+ * Returned by `GET /v1/products/:identifier` (and the org-scoped twin).
61
+ * Spreads the raw product row and adds `sources`, `tags`, `aliases`.
62
+ */
63
+ export const ProductDetailSchema = ProductRowSchema.extend({
64
+ sources: z.array(ProductDetailSourceSchema),
65
+ tags: z.array(z.string()),
66
+ aliases: z.array(z.string()),
67
+ });
68
+
69
+ /** Body accepted by `POST /v1/products`. */
70
+ export const CreateProductBodySchema = z.object({
71
+ name: z.string().min(1),
72
+ orgId: z.string().optional(),
73
+ orgSlug: z.string().optional(),
74
+ slug: z.string().optional(),
75
+ url: z.string().optional(),
76
+ description: z.string().optional(),
77
+ category: CategorySchema.optional(),
78
+ tags: z.array(z.string()).optional(),
79
+ });
80
+
81
+ /** Body accepted by `PATCH /v1/products/:slug`. */
82
+ export const UpdateProductBodySchema = z.object({
83
+ name: z.string().optional(),
84
+ url: z.string().nullable().optional(),
85
+ description: z.string().nullable().optional(),
86
+ category: CategorySchema.nullable().optional(),
87
+ tags: z.array(z.string()).optional(),
88
+ aliases: z.array(z.string()).optional(),
89
+ });
90
+
91
+ /** Body accepted by `POST /v1/products/adopt`. */
92
+ export const AdoptProductBodySchema = z.object({
93
+ sourceOrgSlug: z.string().min(1),
94
+ targetOrgSlug: z.string().min(1),
95
+ slug: z.string().optional(),
96
+ url: z.string().optional(),
97
+ dryRun: z.boolean().optional(),
98
+ });
99
+
100
+ /** Live (non-dryRun) result from `POST /v1/products/adopt`. */
101
+ export const ProductAdoptResultSchema = z.object({
102
+ product: ProductRowSchema,
103
+ sourcesMoved: z.number().int().min(0),
104
+ accountsMoved: z.number().int().min(0),
105
+ sourceOrgDeleted: z.string(),
106
+ });
107
+
108
+ /** Dry-run preview from `POST /v1/products/adopt` with `dryRun: true`. */
109
+ export const ProductAdoptDryRunSchema = z.object({
110
+ dryRun: z.literal(true),
111
+ product: z.object({
112
+ name: z.string(),
113
+ slug: z.string(),
114
+ url: z.string().nullable(),
115
+ orgSlug: z.string(),
116
+ }),
117
+ sourcesToMove: z.array(z.string()),
118
+ sourceOrgToDelete: z.string(),
119
+ });
120
+
121
+ /** Response shape returned by `POST /v1/products/adopt` (union of live + dry-run). */
122
+ export const ProductAdoptResponseSchema = z.union([
123
+ ProductAdoptResultSchema,
124
+ ProductAdoptDryRunSchema,
125
+ ]);
126
+
127
+ /** Response shape returned by `DELETE /v1/products/:identifier`. */
128
+ export const ProductDeleteResponseSchema = z.object({
129
+ deleted: z.literal(true),
130
+ hard: z.literal(true).optional(),
131
+ deletedAt: z.string().optional(),
132
+ });
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ import { RELEASE_TYPES } from "@buildinternet/releases-core/schema";
3
+ import { CATEGORIES } from "@buildinternet/releases-core/categories";
4
+
5
+ export const CategorySchema = z.enum(CATEGORIES);
6
+
7
+ export const MediaItemSchema = z.object({
8
+ type: z.enum(["image", "video", "gif"]),
9
+ url: z.string(),
10
+ alt: z.string().optional(),
11
+ r2Url: z.string().optional(),
12
+ });
13
+
14
+ export const PaginationSchema = z.object({
15
+ page: z.number().int().min(1),
16
+ pageSize: z.number().int().min(1),
17
+ returned: z.number().int().min(0),
18
+ totalItems: z.number().int().min(0).optional(),
19
+ totalPages: z.number().int().min(0).optional(),
20
+ hasMore: z.boolean(),
21
+ });
22
+
23
+ export const ListResponseSchema = <T extends z.ZodTypeAny>(item: T) =>
24
+ z.object({
25
+ items: z.array(item),
26
+ pagination: PaginationSchema,
27
+ });
28
+
29
+ export const ErrorResponseSchema = z.object({
30
+ error: z.string(),
31
+ message: z.string(),
32
+ });
33
+
34
+ export const StatsSchema = z.object({
35
+ orgs: z.number().int().min(0),
36
+ sources: z.number().int().min(0),
37
+ releases: z.number().int().min(0),
38
+ products: z.number().int().min(0),
39
+ });
40
+
41
+ export const ReleaseTypeSchema = z.enum(RELEASE_TYPES);
42
+
43
+ export const ReleaseItemSchema = z.object({
44
+ id: z.string().optional(),
45
+ version: z.string().nullable(),
46
+ title: z.string(),
47
+ summary: z.string(),
48
+ content: z.string().optional(),
49
+ publishedAt: z.string().nullable(),
50
+ url: z.string().nullable(),
51
+ media: z.array(MediaItemSchema).optional(),
52
+ // `.optional()` — older API responses (mid-deploy or pinned old workers)
53
+ // may omit `type`; consumers should treat `undefined` as `"feature"`.
54
+ type: ReleaseTypeSchema.optional(),
55
+ // Pre-release flag (alpha/beta/rc/preview/nightly). Computed at ingest —
56
+ // GitHub uses the API's authoritative `prerelease` field; other adapters
57
+ // fall back to a SemVer-prerelease regex. `.optional()` for the same
58
+ // older-response degrade-gracefully reason as `type`.
59
+ prerelease: z.boolean().optional(),
60
+ });
61
+
62
+ export const ReleaseSummaryItemSchema = z.object({
63
+ year: z.number().int().nullable().optional(),
64
+ month: z.number().int().nullable().optional(),
65
+ windowDays: z.number().int().nullable().optional(),
66
+ summary: z.string(),
67
+ releaseCount: z.number().int().min(0),
68
+ generatedAt: z.string(),
69
+ });
70
+
71
+ export const OverviewPageItemSchema = z.object({
72
+ scope: z.enum(["org", "product"]),
73
+ orgSlug: z.string().nullable().optional(),
74
+ productSlug: z.string().nullable().optional(),
75
+ content: z.string(),
76
+ releaseCount: z.number().int().min(0),
77
+ lastContributingReleaseAt: z.string().nullable(),
78
+ generatedAt: z.string(),
79
+ updatedAt: z.string(),
80
+ });
@@ -0,0 +1,267 @@
1
+ import { z } from "zod";
2
+ import {
3
+ SOURCE_TYPES,
4
+ SOURCE_DISCOVERY,
5
+ SOURCE_FETCH_PRIORITIES,
6
+ } from "@buildinternet/releases-core/source-enums";
7
+ import {
8
+ ListResponseSchema,
9
+ PaginationSchema,
10
+ ReleaseItemSchema,
11
+ ReleaseSummaryItemSchema,
12
+ } from "./shared.js";
13
+
14
+ export const SourceTypeSchema = z.enum(SOURCE_TYPES);
15
+ export const SourceDiscoverySchema = z.enum(SOURCE_DISCOVERY);
16
+ export const SourceFetchPrioritySchema = z.enum(SOURCE_FETCH_PRIORITIES);
17
+
18
+ /**
19
+ * Per-source row used in list/embedded contexts (e.g. nested inside OrgDetail).
20
+ * Most fields are optional — embedded shapes (org detail) only carry the
21
+ * subset needed for that view.
22
+ */
23
+ export const SourceListItemSchema = z.object({
24
+ id: z.string().optional(),
25
+ slug: z.string(),
26
+ name: z.string(),
27
+ type: SourceTypeSchema,
28
+ url: z.string().optional(),
29
+ // `orgSlug` is populated on the standalone `/v1/sources` list path; the
30
+ // `OrgDetail.sources` embedded path omits it (the parent already names the org).
31
+ orgSlug: z.string().nullable().optional(),
32
+ releaseCount: z.number().int().min(0),
33
+ latestVersion: z.string().nullable(),
34
+ latestDate: z.string().nullable(),
35
+ latestAddedAt: z.string().nullable().optional(),
36
+ isPrimary: z.boolean().optional(),
37
+ isHidden: z.boolean().optional(),
38
+ /**
39
+ * How the row was created. Optional on the wire so older API responses
40
+ * (mid-deploy or pinned old workers) degrade gracefully — consumers that
41
+ * see `undefined` should treat it as `"curated"`.
42
+ */
43
+ discovery: SourceDiscoverySchema.optional(),
44
+ fetchPriority: SourceFetchPrioritySchema.nullable().optional(),
45
+ lastFetchedAt: z.string().nullable().optional(),
46
+ lastPolledAt: z.string().nullable().optional(),
47
+ changeDetectedAt: z.string().nullable().optional(),
48
+ consecutiveNoChange: z.number().int().nullable().optional(),
49
+ consecutiveErrors: z.number().int().nullable().optional(),
50
+ nextFetchAfter: z.string().nullable().optional(),
51
+ medianGapDays: z.number().nullable().optional(),
52
+ lastRetieredAt: z.string().nullable().optional(),
53
+ metadata: z.string().nullable().optional(),
54
+ productName: z.string().nullable().optional(),
55
+ productSlug: z.string().nullable().optional(),
56
+ });
57
+
58
+ /**
59
+ * Canonical row returned by `GET /v1/sources` (list). Superset of
60
+ * `SourceListItem` with required `id` / `orgName` / `orgSlug` fields used by
61
+ * the CLI `list` command.
62
+ */
63
+ export const SourceWithOrgSchema = z.object({
64
+ id: z.string(),
65
+ name: z.string(),
66
+ slug: z.string(),
67
+ type: SourceTypeSchema,
68
+ url: z.string(),
69
+ orgName: z.string().nullable(),
70
+ orgSlug: z.string().nullable(),
71
+ productName: z.string().nullable(),
72
+ productSlug: z.string().nullable(),
73
+ isPrimary: z.boolean(),
74
+ isHidden: z.boolean().nullable(),
75
+ discovery: SourceDiscoverySchema.optional(),
76
+ metadata: z.string().nullable(),
77
+ releaseCount: z.number().int().min(0),
78
+ latestVersion: z.string().nullable(),
79
+ latestDate: z.string().nullable(),
80
+ lastFetchedAt: z.string().nullable(),
81
+ lastPolledAt: z.string().nullable(),
82
+ fetchPriority: SourceFetchPrioritySchema.nullable(),
83
+ changeDetectedAt: z.string().nullable(),
84
+ consecutiveNoChange: z.number().int().nullable(),
85
+ consecutiveErrors: z.number().int().nullable(),
86
+ nextFetchAfter: z.string().nullable(),
87
+ medianGapDays: z.number().nullable(),
88
+ lastRetieredAt: z.string().nullable(),
89
+ });
90
+
91
+ export const SourceListResponseSchema = ListResponseSchema(SourceWithOrgSchema);
92
+
93
+ /**
94
+ * `GET /v1/sources` returns a bare array by default and an envelope when
95
+ * `?envelope=true` is passed. The OpenAPI spec advertises the union so
96
+ * either client expectation is documented.
97
+ */
98
+ export const SourceListResultSchema = z.union([
99
+ z.array(SourceWithOrgSchema),
100
+ SourceListResponseSchema,
101
+ ]);
102
+
103
+ const SourceDetailSummariesSchema = z.object({
104
+ rolling: ReleaseSummaryItemSchema.nullable(),
105
+ monthly: z.array(ReleaseSummaryItemSchema),
106
+ });
107
+
108
+ /**
109
+ * Resolved attribution block returned alongside a source row. `id` is
110
+ * additive vs the legacy `{ slug, name }` shape — older clients that ignored
111
+ * it still parse cleanly. `productId` / `productSlug` are sibling top-level
112
+ * fields on the source response (kept flat to mirror `orgId`), not nested
113
+ * inside this block.
114
+ */
115
+ export const SourceOrgRefSchema = z.object({
116
+ id: z.string(),
117
+ slug: z.string(),
118
+ name: z.string(),
119
+ });
120
+
121
+ export const SourceDetailSchema = z.object({
122
+ id: z.string(),
123
+ slug: z.string(),
124
+ name: z.string(),
125
+ type: SourceTypeSchema,
126
+ url: z.string(),
127
+ orgId: z.string().nullable(),
128
+ productId: z.string().nullable(),
129
+ productSlug: z.string().nullable(),
130
+ // Hidden sources are reachable by direct URL but excluded from listings,
131
+ // sitemap, and AI features. On-demand lookups and admin-suppressed rows
132
+ // set this true; canonical curated/agent sources leave it false.
133
+ isHidden: z.boolean(),
134
+ // Pairs with `isHidden` to distinguish admin-suppressed rows (`curated` /
135
+ // `agent` + hidden) from rows materialized by `/v1/lookups` (`on_demand`).
136
+ // Optional for graceful degradation against older API responses.
137
+ discovery: SourceDiscoverySchema.optional(),
138
+ isPrimary: z.boolean(),
139
+ metadata: z.string(),
140
+ changelogUrl: z.string().nullable().optional(),
141
+ hasChangelogFile: z.boolean().optional(),
142
+ org: SourceOrgRefSchema.nullable(),
143
+ releaseCount: z.number().int().min(0),
144
+ releasesLast30Days: z.number().int().min(0),
145
+ avgReleasesPerWeek: z.number(),
146
+ latestVersion: z.string().nullable(),
147
+ latestDate: z.string().nullable(),
148
+ lastFetchedAt: z.string().nullable(),
149
+ lastPolledAt: z.string().nullable(),
150
+ trackingSince: z.string(),
151
+ releases: z.array(ReleaseItemSchema),
152
+ pagination: PaginationSchema,
153
+ summaries: SourceDetailSummariesSchema,
154
+ });
155
+
156
+ /**
157
+ * Response returned by `POST /v1/sources` and `PATCH /v1/sources/:slug`.
158
+ *
159
+ * Pre-#794 these endpoints returned the bare drizzle row, leaving callers to
160
+ * round-trip a separate `GET` to confirm the resolved org/product attribution.
161
+ * Now they return the same source row plus a resolved `org { id, slug, name }`
162
+ * block and `productSlug`, so an agent can answer "did the write take?" from
163
+ * the response alone.
164
+ *
165
+ * Wire fields are loosely-typed (`z.unknown()`) for the timestamps/counters
166
+ * that aren't load-bearing for callers — the row is large and we don't want
167
+ * to hand-mirror every column. Stable, agent-relevant fields are typed.
168
+ */
169
+ export const SourceMutationResponseSchema = z.looseObject({
170
+ id: z.string(),
171
+ slug: z.string(),
172
+ name: z.string(),
173
+ type: SourceTypeSchema,
174
+ url: z.string(),
175
+ orgId: z.string().nullable(),
176
+ productId: z.string().nullable(),
177
+ productSlug: z.string().nullable(),
178
+ org: SourceOrgRefSchema.nullable(),
179
+ metadata: z.string().nullable(),
180
+ isPrimary: z.boolean().nullable().optional(),
181
+ isHidden: z.boolean().nullable().optional(),
182
+ discovery: SourceDiscoverySchema.optional(),
183
+ createdAt: z.string().optional(),
184
+ });
185
+
186
+ /** Body accepted by `PATCH /v1/sources/:slug`. */
187
+ export const SourcePatchInputSchema = z.object({
188
+ name: z.string().optional(),
189
+ url: z.string().optional(),
190
+ type: SourceTypeSchema.optional(),
191
+ slug: z.string().optional(),
192
+ metadata: z.string().optional(),
193
+ orgId: z.string().nullable().optional(),
194
+ productId: z.string().nullable().optional(),
195
+ lastFetchedAt: z.string().nullable().optional(),
196
+ lastContentHash: z.string().nullable().optional(),
197
+ fetchPriority: SourceFetchPrioritySchema.optional(),
198
+ consecutiveNoChange: z.number().int().optional(),
199
+ consecutiveErrors: z.number().int().optional(),
200
+ nextFetchAfter: z.string().nullable().optional(),
201
+ isPrimary: z.boolean().optional(),
202
+ isHidden: z.boolean().optional(),
203
+ changeDetectedAt: z.string().nullable().optional(),
204
+ lastPolledAt: z.string().nullable().optional(),
205
+ });
206
+
207
+ /** Body accepted by `POST /v1/sources`. */
208
+ export const CreateSourceBodySchema = z.object({
209
+ name: z.string().min(1),
210
+ url: z.string().min(1),
211
+ type: SourceTypeSchema.optional(),
212
+ slug: z.string().optional(),
213
+ orgId: z.string().optional(),
214
+ orgSlug: z.string().optional(),
215
+ /**
216
+ * Either accepted; `productId` wins when both are supplied. `productSlug`
217
+ * resolves within the requested org (slug uniqueness is per-org). The
218
+ * legacy `productId` ingestion path is preserved for callers that already
219
+ * resolve products client-side; new agent flows should pass `productSlug`.
220
+ */
221
+ productId: z.string().optional(),
222
+ productSlug: z.string().optional(),
223
+ metadata: z.string().optional(),
224
+ isPrimary: z.boolean().optional(),
225
+ });
226
+
227
+ export const ChangelogFileSummarySchema = z.object({
228
+ path: z.string(),
229
+ filename: z.string(),
230
+ url: z.string(),
231
+ bytes: z.number().int().min(0),
232
+ fetchedAt: z.string(),
233
+ });
234
+
235
+ export const SourceChangelogResponseSchema = z.object({
236
+ path: z.string(),
237
+ filename: z.string(),
238
+ url: z.string(),
239
+ rawUrl: z.string(),
240
+ content: z.string(),
241
+ bytes: z.number().int().min(0),
242
+ fetchedAt: z.string(),
243
+ /** Character offset of the first character in `content` within the full file. */
244
+ offset: z.number().int().min(0),
245
+ /** The limit (in chars) that was applied to produce this slice. */
246
+ limit: z.number().int().min(0),
247
+ /** Next offset to request for the next slice, or null if `content` is the tail. */
248
+ nextOffset: z.number().int().nullable(),
249
+ /** Total length of the full file in characters. */
250
+ totalChars: z.number().int().min(0),
251
+ /** Requested token budget when in token mode (cl100k_base). */
252
+ tokens: z.number().int().optional(),
253
+ /** Encoded token count of the returned `content`. Set in token mode. */
254
+ sliceTokens: z.number().int().optional(),
255
+ /** Full-file token count (cl100k_base). Always populated. */
256
+ totalTokens: z.number().int().min(0),
257
+ /** True when the upstream file exceeded the 1MB cap and content was sliced. */
258
+ truncated: z.boolean(),
259
+ /** Byte offset where the file was truncated, or null when not truncated. */
260
+ truncatedAt: z.number().int().nullable(),
261
+ /**
262
+ * Index of every changelog file tracked for this source (root plus any
263
+ * discovered per-package files). Always present even for single-file
264
+ * sources so clients can lazily render a file picker.
265
+ */
266
+ files: z.array(ChangelogFileSummarySchema),
267
+ });