@buildinternet/releases-api-types 0.36.0 → 0.38.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.36.0",
3
+ "version": "0.38.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",
package/src/api-types.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
24
  ReleaseSummaryItemSchema,
25
25
  OverviewPageItemSchema,
26
26
  OverviewCitationSchema,
27
+ OrgStatusSchema,
28
+ ReleaseLocationItemSchema,
27
29
  } from "./schemas/shared.js";
28
30
  import type {
29
31
  OrgListItemSchema,
@@ -263,6 +265,8 @@ export {
263
265
  ReleaseSummaryItemSchema,
264
266
  OverviewPageItemSchema,
265
267
  OverviewCitationSchema,
268
+ OrgStatusSchema,
269
+ ReleaseLocationItemSchema,
266
270
  CategorySchema,
267
271
  NoticeSchema,
268
272
  } from "./schemas/shared.js";
@@ -308,11 +312,28 @@ export {
308
312
  WorkspaceProfileResponseSchema,
309
313
  } from "./schemas/account-profile.js";
310
314
  export {
315
+ CreateStubOrgBodySchema,
316
+ StubFromDomainBodySchema,
311
317
  ReleasesJsonConfigSchema,
318
+ ReleasesJsonDomainReleaseSchema,
319
+ ReleasesJsonDomainSchema,
312
320
  ReleasesJsonProductSchema,
321
+ ReleasesJsonRegistriesSchema,
322
+ ReleasesJsonRepoProductSchema,
323
+ ReleasesJsonRepoReleaseSchema,
324
+ ReleasesJsonRepoSchema,
313
325
  SyncWellKnownResponseSchema,
314
326
  } from "./schemas/well-known.js";
315
- export type { ReleasesJsonConfig, ReleasesJsonProduct } from "./schemas/well-known.js";
327
+ export type {
328
+ CreateStubOrgBody,
329
+ StubFromDomainBody,
330
+ ReleasesJsonConfig,
331
+ ReleasesJsonDomain,
332
+ ReleasesJsonDomainRelease,
333
+ ReleasesJsonProduct,
334
+ ReleasesJsonRepo,
335
+ ReleasesJsonRepoRelease,
336
+ } from "./schemas/well-known.js";
316
337
  export {
317
338
  SourceListItemSchema,
318
339
  SourceWithOrgSchema,
@@ -509,6 +530,11 @@ export type { ResolveResponse } from "./schemas/resolve.js";
509
530
 
510
531
  export type MediaItem = z.infer<typeof MediaItemSchema>;
511
532
 
533
+ // ── Stub tier (#1947) ──
534
+
535
+ export type OrgStatus = z.infer<typeof OrgStatusSchema>;
536
+ export type ReleaseLocationItem = z.infer<typeof ReleaseLocationItemSchema>;
537
+
512
538
  // ── Stats ──
513
539
 
514
540
  export type Stats = z.infer<typeof StatsSchema>;
@@ -766,6 +792,12 @@ export interface WhatsChangedEntry {
766
792
  breaking: BreakingLevel;
767
793
  migrationNotes: string | null;
768
794
  url: string | null;
795
+ /**
796
+ * Absolute canonical web URL — the slugged `/release/<id>-<slug>` form
797
+ * (#1906), built from `WEB_BASE_URL`. Distinct from `url` (the upstream
798
+ * source URL); lets an upgrade-planning agent link the release detail page.
799
+ */
800
+ webUrl: string | null;
769
801
  }
770
802
 
771
803
  /**
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { KIND_VALUES } from "@buildinternet/releases-core/kinds";
3
- import { CategorySchema } from "./shared.js";
3
+ import { CategorySchema, OrgStatusSchema, ReleaseLocationItemSchema } from "./shared.js";
4
4
 
5
5
  /**
6
6
  * Status flags returned by `POST /v1/lookups`:
@@ -104,6 +104,14 @@ export const DomainLookupOrgSchema = z.object({
104
104
  category: z.string().nullable(),
105
105
  avatarUrl: z.string().nullable(),
106
106
  matchedVia: z.enum(["primary", "alias"]),
107
+ /** Org tier (#1947). Optional for mid-deploy tolerance; absent ⇒ `"tracked"`. */
108
+ status: OrgStatusSchema.optional(),
109
+ /**
110
+ * Declared release locations (#1947) for a stub org — the "check these
111
+ * locations" answer when the domain resolves to an org with no sources yet.
112
+ * Omitted/empty for a tracked org.
113
+ */
114
+ locations: z.array(ReleaseLocationItemSchema).optional(),
107
115
  });
108
116
 
109
117
  export const DomainLookupProductSchema = z.object({
@@ -6,9 +6,11 @@ import {
6
6
  CategorySchema,
7
7
  ListResponseSchema,
8
8
  NoticeSchema,
9
+ OrgStatusSchema,
9
10
  OverviewPageItemSchema,
10
11
  PaginationSchema,
11
12
  ReleaseItemSchema,
13
+ ReleaseLocationItemSchema,
12
14
  VideoSourceInfoSchema,
13
15
  } from "./shared.js";
14
16
  import { SourceListItemSchema } from "./sources.js";
@@ -24,6 +26,12 @@ export const OrgListItemSchema = z.object({
24
26
  category: CategorySchema.nullable(),
25
27
  avatarUrl: z.string().nullable(),
26
28
  featured: z.boolean(),
29
+ /**
30
+ * Org tier (#1947). Optional for mid-deploy tolerance; absent ⇒ `"tracked"`.
31
+ * A `"stub"` org has no processed sources yet — the UI renders a badge and a
32
+ * stub is excluded from release-ranked surfaces.
33
+ */
34
+ status: OrgStatusSchema.optional(),
27
35
  sourceCount: z.number().int().min(0),
28
36
  releaseCount: z.number().int().min(0),
29
37
  recentReleaseCount: z.number().int().min(0),
@@ -480,6 +488,18 @@ export const OrgDetailSchema = z.object({
480
488
  fetchPaused: z.boolean().optional(),
481
489
  /** How the org row was created. `on_demand` orgs are excluded from overview generation regardless of `autoGenerateContent`. */
482
490
  discovery: z.enum(["curated", "agent", "on_demand"]).optional(),
491
+ /**
492
+ * Org tier (#1947). Optional for mid-deploy tolerance; absent ⇒ `"tracked"`.
493
+ * A `"stub"` org's `sources` array is empty — `locations` carries the declared
494
+ * release locations instead.
495
+ */
496
+ status: OrgStatusSchema.optional(),
497
+ /**
498
+ * Declared release locations (#1947), present (and non-empty) for stub orgs.
499
+ * Same shape as a releases.json `releases[]` entry plus `basis`/`sourceId`.
500
+ * Omitted or empty for a tracked org with no locators.
501
+ */
502
+ locations: z.array(ReleaseLocationItemSchema).optional(),
483
503
  tags: z.array(z.string()).optional(),
484
504
  sourceCount: z.number().int().min(0),
485
505
  releaseCount: z.number().int().min(0),
@@ -11,23 +11,19 @@ export const OrgOverviewResponseSchema = OverviewPageItemSchema.extend({
11
11
  }).nullable();
12
12
 
13
13
  /**
14
- * Incoming citation row attached to `POST /v1/orgs/:slug/overview`. Slimmer
15
- * than the read-side `OverviewCitationSchema` because the wire shape doesn't
16
- * carry `releaseId` — that's resolved server-side from `sourceUrl`. Spans
17
- * must be in-range (`endIndex > startIndex`); the cross-field
18
- * `endIndex <= content.length` check stays in the handler.
14
+ * Incoming citation row attached to `POST /v1/orgs/:slug/overview`. A source the
15
+ * overview drew on — just `sourceUrl` (+ optional display `title`); `releaseId`
16
+ * is resolved server-side from `sourceUrl`. The legacy body-offset fields
17
+ * (`startIndex`/`endIndex`/`citedText`) are accepted-but-ignored for wire
18
+ * back-compat with older clients (#1934); they're no longer stored.
19
19
  */
20
- export const IncomingOverviewCitationSchema = z
21
- .object({
22
- startIndex: z.number().int().min(0),
23
- endIndex: z.number().int().min(1),
24
- sourceUrl: z.string().min(1),
25
- title: z.string().nullable().optional(),
26
- citedText: z.string().min(1),
27
- })
28
- .refine((c) => c.endIndex > c.startIndex, {
29
- message: "endIndex must be > startIndex",
30
- });
20
+ export const IncomingOverviewCitationSchema = z.object({
21
+ sourceUrl: z.string().min(1),
22
+ title: z.string().nullable().optional(),
23
+ startIndex: z.number().int().min(0).optional(),
24
+ endIndex: z.number().int().min(0).optional(),
25
+ citedText: z.string().optional(),
26
+ });
31
27
 
32
28
  /**
33
29
  * Body accepted by `POST /v1/orgs/:slug/overview` — the agent-authored
@@ -44,6 +44,37 @@ export const VideoSourceInfoSchema = z.object({
44
44
  provider: z.enum(["youtube", "vimeo", "wistia"]),
45
45
  });
46
46
 
47
+ /**
48
+ * Org tier surfaced on read paths (#1947). `stub` = known only by declared
49
+ * release locations (no processed sources yet); `tracked` = a normal org whose
50
+ * sources fetch. The column is `organizations.tier`; the wire name is `status`.
51
+ * Optional on the wire so a pinned client mid-deploy tolerates its absence —
52
+ * treat a missing value as `"tracked"`.
53
+ */
54
+ export const OrgStatusSchema = z.enum(["stub", "tracked"]);
55
+
56
+ /**
57
+ * A declared release location (#1947) — one `release_locations` row on the
58
+ * wire, the same shape a releases.json `releases[]` entry carries plus its
59
+ * provenance `basis` and, once promoted, the `sourceId` it materialized into.
60
+ * Surfaced on stub orgs so an agent gets "release info is published at these
61
+ * locations" instead of an empty sources list.
62
+ */
63
+ export const ReleaseLocationItemSchema = z.object({
64
+ url: z.string().optional(),
65
+ feed: z.string().optional(),
66
+ github: z.string().optional(),
67
+ appstore: z.string().optional(),
68
+ file: z.string().optional(),
69
+ title: z.string().nullable().optional(),
70
+ canonical: z.boolean(),
71
+ basis: z.enum(["curator", "declared", "detected", "generated"]),
72
+ /** Owning product id when the locator is product-scoped, else null. */
73
+ productId: z.string().nullable(),
74
+ /** Set once the locator has been promoted into a source, else null. */
75
+ sourceId: z.string().nullable(),
76
+ });
77
+
47
78
  export const PaginationSchema = z.object({
48
79
  page: z.number().int().min(1),
49
80
  pageSize: z.number().int().min(1),
@@ -146,19 +177,32 @@ export const ReleaseSummaryItemSchema = z.object({
146
177
  });
147
178
 
148
179
  /**
149
- * Inline citation attached to an overview page (#846). Each row maps a
150
- * character span in `OverviewPageItem.content` back to a release URL the
151
- * model cited via Anthropic's search_result blocks. Optional everywhere it
152
- * appears — pages generated before #846 lands have none, and consumers
153
- * should degrade gracefully.
180
+ * A source attached to an overview page (#846, reshaped #1934). Each row names
181
+ * a source the overview drew on — `sourceUrl` plus, when it resolved to an
182
+ * on-registry release, `releaseId` + a canonical `releaseWebUrl`. This is a
183
+ * source *list*, not span-anchored provenance: the old body char offsets +
184
+ * verbatim quote are removed. Optional everywhere it appears — consumers should
185
+ * degrade gracefully.
154
186
  */
155
187
  export const OverviewCitationSchema = z.object({
156
- startIndex: z.number().int().min(0),
157
- endIndex: z.number().int().min(0),
158
188
  sourceUrl: z.string(),
159
189
  title: z.string().nullable().optional(),
160
- citedText: z.string(),
190
+ /** Best-effort link to the on-registry release this source maps to (resolved server-side). */
161
191
  releaseId: z.string().nullable().optional(),
192
+ /**
193
+ * Canonical web URL of the on-registry release page for this source, when
194
+ * `releaseId` resolved (#1934). The Sources footer links here (internal,
195
+ * crawlable) in preference to the external `sourceUrl`.
196
+ */
197
+ releaseWebUrl: z.string().nullable().optional(),
198
+ /**
199
+ * @deprecated Body char offsets / verbatim quote — removed with span-anchored
200
+ * citations (#1934). Kept optional for wire back-compat; no producer emits
201
+ * them and no consumer should read them.
202
+ */
203
+ startIndex: z.number().int().min(0).optional(),
204
+ endIndex: z.number().int().min(0).optional(),
205
+ citedText: z.string().optional(),
162
206
  });
163
207
 
164
208
  /**
@@ -1,66 +1,163 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { ReleasesJsonConfigSchema } from "./well-known.js";
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ ReleasesJsonConfigSchema,
4
+ ReleasesJsonDomainSchema,
5
+ ReleasesJsonRepoSchema,
6
+ } from "./well-known.js";
3
7
 
4
- describe("ReleasesJsonConfigSchema", () => {
5
- it("accepts an empty object (no-op file)", () => {
6
- expect(ReleasesJsonConfigSchema.safeParse({}).success).toBe(true);
8
+ const location = (n: number) => ({ url: `https://acme.com/releases/${n}` });
9
+
10
+ describe("releases.json v2 schemas", () => {
11
+ it("accepts the minimal domain manifest", () => {
12
+ expect(
13
+ ReleasesJsonDomainSchema.parse({
14
+ version: 2,
15
+ releases: [{ url: "https://acme.com/updates", feed: "https://acme.com/updates.xml" }],
16
+ }),
17
+ ).toEqual({
18
+ version: 2,
19
+ releases: [{ url: "https://acme.com/updates", feed: "https://acme.com/updates.xml" }],
20
+ });
7
21
  });
8
22
 
9
- it("accepts a full org-scope file", () => {
10
- const r = ReleasesJsonConfigSchema.safeParse({
23
+ it("accepts the full domain shape with advisory taxonomy", () => {
24
+ const parsed = ReleasesJsonDomainSchema.parse({
11
25
  $schema: "https://releases.sh/schemas/releases.json",
12
- version: 1,
26
+ version: 2,
13
27
  name: "Acme",
14
28
  description: "CI for teams.",
15
- category: "developer-tools",
29
+ category: "future-category",
16
30
  avatar: "https://acme.com/logo.png",
17
- tags: ["ci", "observability"],
31
+ tags: ["future-tag"],
18
32
  social: { twitter: "acmehq", github: "acme" },
19
- notice: { message: "Docs moved", href: "https://acme.com/docs" },
33
+ products: [
34
+ {
35
+ name: "Acme Cloud",
36
+ slug: "acme-cloud",
37
+ kind: "future-kind",
38
+ category: "future-category",
39
+ description: "Managed CI runners.",
40
+ website: "https://acme.com/cloud",
41
+ docs: "https://docs.acme.com/cloud",
42
+ support: "https://acme.com/support",
43
+ social: { twitter: "acmecloud" },
44
+ archived: true,
45
+ releases: [{ github: "acme/cloud", canonical: true }],
46
+ },
47
+ ],
48
+ registries: {
49
+ "releases.sh": {
50
+ org: "org_abc123",
51
+ verification: "verification-token",
52
+ futureKey: true,
53
+ },
54
+ "example.com": { anything: "goes" },
55
+ },
20
56
  });
21
- expect(r.success).toBe(true);
57
+
58
+ expect(parsed.products?.[0]?.kind).toBe("future-kind");
59
+ expect(parsed.registries?.["example.com"]).toEqual({ anything: "goes" });
22
60
  });
23
61
 
24
- it("accepts a product-scope file", () => {
25
- const r = ReleasesJsonConfigSchema.safeParse({
26
- product: { name: "Acme Cloud", slug: "acme-cloud", category: "cloud", kind: "platform" },
27
- });
28
- expect(r.success).toBe(true);
62
+ it("accepts the repo variant including github self", () => {
63
+ expect(
64
+ ReleasesJsonRepoSchema.safeParse({
65
+ version: 2,
66
+ product: { name: "Acme Cloud", slug: "acme-cloud" },
67
+ releases: [{ github: "self", canonical: true }],
68
+ registries: { "releases.sh": { product: "prd_abc123" } },
69
+ }).success,
70
+ ).toBe(true);
29
71
  });
30
72
 
31
- it("rejects a non-https avatar", () => {
32
- expect(ReleasesJsonConfigSchema.safeParse({ avatar: "http://acme.com/x.png" }).success).toBe(
33
- false,
34
- );
73
+ it("requires the version 2 literal with no v1 compatibility", () => {
74
+ expect(ReleasesJsonConfigSchema.safeParse({ releases: [location(1)] }).success).toBe(false);
75
+ expect(ReleasesJsonConfigSchema.safeParse({ version: 1 }).success).toBe(false);
76
+ expect(ReleasesJsonConfigSchema.safeParse({ version: 3 }).success).toBe(false);
35
77
  });
36
78
 
37
- it("rejects a product with no name", () => {
38
- expect(ReleasesJsonConfigSchema.safeParse({ product: { slug: "x" } }).success).toBe(false);
79
+ it("requires at least one locator per release location", () => {
80
+ expect(
81
+ ReleasesJsonDomainSchema.safeParse({ version: 2, releases: [{ title: "Updates" }] }).success,
82
+ ).toBe(false);
39
83
  });
40
84
 
41
- it("rejects an unknown product kind", () => {
85
+ it("allows github self only in repo files", () => {
42
86
  expect(
43
- ReleasesJsonConfigSchema.safeParse({ product: { name: "Acme Cloud", kind: "saas" } }).success,
87
+ ReleasesJsonDomainSchema.safeParse({ version: 2, releases: [{ github: "self" }] }).success,
44
88
  ).toBe(false);
89
+ expect(
90
+ ReleasesJsonRepoSchema.safeParse({ version: 2, releases: [{ github: "self" }] }).success,
91
+ ).toBe(true);
45
92
  });
46
93
 
47
- it("rejects an over-long notice message", () => {
48
- const r = ReleasesJsonConfigSchema.safeParse({ notice: { message: "x".repeat(281) } });
49
- expect(r.success).toBe(false);
94
+ it("enforces product and release-location caps", () => {
95
+ expect(
96
+ ReleasesJsonDomainSchema.safeParse({
97
+ version: 2,
98
+ products: Array.from({ length: 25 }, (_, i) => ({ name: `Product ${i}` })),
99
+ }).success,
100
+ ).toBe(false);
101
+ expect(
102
+ ReleasesJsonDomainSchema.safeParse({
103
+ version: 2,
104
+ products: [{ name: "Cloud", releases: Array.from({ length: 9 }, (_, i) => location(i)) }],
105
+ }).success,
106
+ ).toBe(false);
107
+ expect(
108
+ ReleasesJsonDomainSchema.safeParse({
109
+ version: 2,
110
+ releases: Array.from({ length: 33 }, (_, i) => location(i)),
111
+ }).success,
112
+ ).toBe(false);
113
+ expect(
114
+ ReleasesJsonDomainSchema.safeParse({
115
+ version: 2,
116
+ releases: Array.from({ length: 25 }, (_, i) => location(i)),
117
+ products: [
118
+ { name: "Cloud", releases: Array.from({ length: 8 }, (_, i) => location(i + 25)) },
119
+ ],
120
+ }).success,
121
+ ).toBe(false);
50
122
  });
51
123
 
52
- it("accepts an integer version", () => {
53
- expect(ReleasesJsonConfigSchema.safeParse({ version: 1, name: "Acme" }).success).toBe(true);
124
+ it("allows at most one canonical location per scope", () => {
125
+ const canonical = (n: number) => ({ ...location(n), canonical: true });
126
+ expect(
127
+ ReleasesJsonDomainSchema.safeParse({
128
+ version: 2,
129
+ releases: [canonical(1), canonical(2)],
130
+ }).success,
131
+ ).toBe(false);
132
+ expect(
133
+ ReleasesJsonDomainSchema.safeParse({
134
+ version: 2,
135
+ products: [{ name: "Cloud", releases: [canonical(1), canonical(2)] }],
136
+ }).success,
137
+ ).toBe(false);
54
138
  });
55
139
 
56
- it("rejects a zero, negative, or non-integer version", () => {
57
- expect(ReleasesJsonConfigSchema.safeParse({ version: 0 }).success).toBe(false);
58
- expect(ReleasesJsonConfigSchema.safeParse({ version: -1 }).success).toBe(false);
59
- expect(ReleasesJsonConfigSchema.safeParse({ version: 1.5 }).success).toBe(false);
140
+ it("rejects removed v1 identity fields", () => {
141
+ expect(
142
+ ReleasesJsonDomainSchema.safeParse({ version: 2, website: "https://acme.com" }).success,
143
+ ).toBe(false);
144
+ expect(
145
+ ReleasesJsonDomainSchema.safeParse({ version: 2, notice: { message: "Moved" } }).success,
146
+ ).toBe(false);
60
147
  });
61
148
 
62
- it("strips unknown top-level keys", () => {
63
- const r = ReleasesJsonConfigSchema.parse({ name: "Acme", bogus: 1 });
64
- expect("bogus" in r).toBe(false);
149
+ it("requires typed stable ids for the releases.sh registry", () => {
150
+ expect(
151
+ ReleasesJsonDomainSchema.safeParse({
152
+ version: 2,
153
+ registries: { "releases.sh": { org: "prd_wrong" } },
154
+ }).success,
155
+ ).toBe(false);
156
+ expect(
157
+ ReleasesJsonRepoSchema.safeParse({
158
+ version: 2,
159
+ registries: { "releases.sh": { product: "org_wrong" } },
160
+ }).success,
161
+ ).toBe(false);
65
162
  });
66
163
  });
@@ -1,54 +1,207 @@
1
1
  import { z } from "zod";
2
- import { KIND_VALUES } from "@buildinternet/releases-core/kinds";
3
- import { NoticeSchema } from "./shared.js";
4
2
 
5
- /** A single social handle/URL. Bare handles allowed; URLs must be https. */
3
+ const MAX_PRODUCTS = 24;
4
+ const MAX_PRODUCT_RELEASES = 8;
5
+ const MAX_FILE_RELEASES = 32;
6
+
7
+ /** A single social handle/URL. Bare handles are intentionally accepted. */
6
8
  const SocialValueSchema = z.string().min(1).max(200);
9
+ const SocialSchema = z.record(z.string().min(1).max(40), SocialValueSchema);
7
10
 
8
- /** Product-scope block: declares the hosting repo's source belongs to this product. */
9
- export const ReleasesJsonProductSchema = z
10
- .object({
11
- name: z.string().min(1).max(120),
12
- slug: z.string().min(1).max(120).optional(),
13
- description: z.string().max(2000).optional(),
14
- // Accepted leniently as a string; resolved/validated against CATEGORIES at apply time.
15
- category: z.string().min(1).max(120).optional(),
16
- kind: z.enum(KIND_VALUES).optional(),
11
+ const HttpsUrlSchema = z
12
+ .url()
13
+ .refine((url) => url.startsWith("https://"), "locator must be an https URL");
14
+
15
+ const ReleaseLocationFields = {
16
+ url: HttpsUrlSchema.optional(),
17
+ feed: HttpsUrlSchema.optional(),
18
+ appstore: HttpsUrlSchema.optional(),
19
+ file: HttpsUrlSchema.optional(),
20
+ title: z.string().min(1).max(200).optional(),
21
+ canonical: z.boolean().optional(),
22
+ };
23
+
24
+ function hasLocator(value: {
25
+ url?: string;
26
+ feed?: string;
27
+ github?: string;
28
+ appstore?: string;
29
+ file?: string;
30
+ }): boolean {
31
+ return Boolean(value.url || value.feed || value.github || value.appstore || value.file);
32
+ }
33
+
34
+ /** A domain manifest can name a concrete GitHub repository, but never `self`. */
35
+ export const ReleasesJsonDomainReleaseSchema = z
36
+ .strictObject({
37
+ ...ReleaseLocationFields,
38
+ github: z
39
+ .string()
40
+ .regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, "github must be owner/repo")
41
+ .optional(),
42
+ })
43
+ .refine(hasLocator, "at least one release locator is required");
44
+
45
+ /** A repo manifest additionally accepts `github: "self"`. */
46
+ export const ReleasesJsonRepoReleaseSchema = z
47
+ .strictObject({
48
+ ...ReleaseLocationFields,
49
+ github: z
50
+ .union([
51
+ z.literal("self"),
52
+ z.string().regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, "github must be owner/repo"),
53
+ ])
54
+ .optional(),
17
55
  })
18
- .strict();
56
+ .refine(hasLocator, "at least one release locator is required");
57
+
58
+ function releasesArray<T extends z.ZodType>(item: T, max: number) {
59
+ return z
60
+ .array(item)
61
+ .max(max)
62
+ .refine(
63
+ (items) =>
64
+ items.filter((entry) => Boolean((entry as { canonical?: boolean }).canonical)).length <= 1,
65
+ "at most one canonical release location is allowed per scope",
66
+ );
67
+ }
68
+
69
+ const DomainProductReleasesSchema = releasesArray(
70
+ ReleasesJsonDomainReleaseSchema,
71
+ MAX_PRODUCT_RELEASES,
72
+ );
73
+ const RepoReleasesSchema = releasesArray(ReleasesJsonRepoReleaseSchema, MAX_FILE_RELEASES);
74
+ const DomainReleasesSchema = releasesArray(ReleasesJsonDomainReleaseSchema, MAX_FILE_RELEASES);
19
75
 
20
76
  /**
21
- * One file name, two hosting scopes. Org-identity keys are honored only from a
22
- * domain `.well-known/releases.json`; `product` is honored only from a repo-root
23
- * file. The server enforces which keys it honors based on the host the file came
24
- * from — this schema only validates shape.
77
+ * Product declaration in a domain manifest. Taxonomy strings are deliberately
78
+ * lenient here; the reconciler resolves known values and ignores the rest.
25
79
  */
26
- export const ReleasesJsonConfigSchema = z
80
+ export const ReleasesJsonProductSchema = z.strictObject({
81
+ name: z.string().min(1).max(120),
82
+ slug: z.string().min(1).max(120).optional(),
83
+ kind: z.string().min(1).max(120).optional(),
84
+ category: z.string().min(1).max(120).optional(),
85
+ description: z.string().max(2000).optional(),
86
+ website: z.url().optional(),
87
+ docs: z.url().optional(),
88
+ support: z.url().optional(),
89
+ social: SocialSchema.optional(),
90
+ archived: z.boolean().optional(),
91
+ releases: DomainProductReleasesSchema.optional(),
92
+ });
93
+
94
+ /** Repo-scope product binding. Slug is only a creation suggestion. */
95
+ export const ReleasesJsonRepoProductSchema = z.strictObject({
96
+ name: z.string().min(1).max(120),
97
+ slug: z.string().min(1).max(120).optional(),
98
+ });
99
+
100
+ const ReleasesRegistrySchema = z
101
+ .object({
102
+ org: z
103
+ .string()
104
+ .regex(/^org_[A-Za-z0-9]+$/)
105
+ .optional(),
106
+ product: z
107
+ .string()
108
+ .regex(/^prd_[A-Za-z0-9]+$/)
109
+ .optional(),
110
+ verification: z.string().min(1).max(500).optional(),
111
+ })
112
+ .catchall(z.unknown());
113
+
114
+ /** Registry-specific extensions are forward-compatible by design. */
115
+ export const ReleasesJsonRegistriesSchema = z
27
116
  .object({
28
- $schema: z.url().optional(),
29
- // Manifest format version. Currently 1; optional — a missing value is read as
30
- // v1. Bumped only on a breaking change to this file's shape, so a consumer can
31
- // branch on it without fetching the JSON Schema. Lenient/fail-open: the
32
- // reconciler ignores it entirely, so an unexpected value never fails the sync.
33
- version: z.number().int().positive().max(1000).optional(),
34
- // Org scope
117
+ "releases.sh": ReleasesRegistrySchema.optional(),
118
+ })
119
+ .catchall(z.unknown());
120
+
121
+ /** Top-level fields shared by every manifest scope. */
122
+ const BaseFields = {
123
+ $schema: z.url().optional(),
124
+ version: z.literal(2),
125
+ registries: ReleasesJsonRegistriesSchema.optional(),
126
+ };
127
+
128
+ const CommonFields = {
129
+ ...BaseFields,
130
+ releases: DomainReleasesSchema.optional(),
131
+ };
132
+
133
+ /** Domain-hosted variant: flat org identity plus product and release declarations. */
134
+ export const ReleasesJsonDomainSchema = z
135
+ .strictObject({
136
+ ...CommonFields,
35
137
  name: z.string().min(1).max(120).optional(),
36
138
  description: z.string().max(2000).optional(),
37
139
  category: z.string().min(1).max(120).optional(),
38
- avatar: z
39
- .url()
40
- .refine((u) => u.startsWith("https://"), "avatar must be an https URL")
41
- .optional(),
140
+ avatar: HttpsUrlSchema.optional(),
42
141
  tags: z.array(z.string().min(1).max(60)).max(50).optional(),
43
- social: z.record(z.string().min(1).max(40), SocialValueSchema).optional(),
44
- notice: NoticeSchema.optional(),
45
- // Source/product scope
46
- product: ReleasesJsonProductSchema.optional(),
142
+ social: SocialSchema.optional(),
143
+ products: z.array(ReleasesJsonProductSchema).max(MAX_PRODUCTS).optional(),
47
144
  })
48
- .strip();
145
+ .refine(
146
+ (manifest) =>
147
+ (manifest.releases?.length ?? 0) +
148
+ (manifest.products?.reduce((sum, product) => sum + (product.releases?.length ?? 0), 0) ??
149
+ 0) <=
150
+ MAX_FILE_RELEASES,
151
+ `at most ${MAX_FILE_RELEASES} release locations are allowed per file`,
152
+ );
153
+
154
+ /** Repo-root variant: product binding plus repo-scoped release declarations. */
155
+ export const ReleasesJsonRepoSchema = z.strictObject({
156
+ ...BaseFields,
157
+ product: ReleasesJsonRepoProductSchema.optional(),
158
+ releases: RepoReleasesSchema.optional(),
159
+ });
160
+
161
+ /** Public schema accepts either hosting scope; reconcilers use the scoped schema. */
162
+ export const ReleasesJsonConfigSchema = z.union([ReleasesJsonDomainSchema, ReleasesJsonRepoSchema]);
49
163
 
50
164
  export type ReleasesJsonConfig = z.infer<typeof ReleasesJsonConfigSchema>;
165
+ export type ReleasesJsonDomain = z.infer<typeof ReleasesJsonDomainSchema>;
166
+ export type ReleasesJsonRepo = z.infer<typeof ReleasesJsonRepoSchema>;
51
167
  export type ReleasesJsonProduct = z.infer<typeof ReleasesJsonProductSchema>;
168
+ export type ReleasesJsonDomainRelease = z.infer<typeof ReleasesJsonDomainReleaseSchema>;
169
+ export type ReleasesJsonRepoRelease = z.infer<typeof ReleasesJsonRepoReleaseSchema>;
170
+
171
+ /**
172
+ * Body for `POST /v1/orgs/stub` — curator-authored stub org (#1947). Mirrors
173
+ * the domain-manifest identity + product + locator shape (so a curator can hand
174
+ * the same declaration a manifest would carry), minus manifest-transport fields
175
+ * (`version`/`$schema`/`registries`) and plus the registry-identity fields a
176
+ * stub org needs to be resolvable (`slug`/`domain`). Locators are optional — an
177
+ * identity-only stub (locations TBD) is valid. `category`/taxonomy strings stay
178
+ * lenient; the route resolves them and ignores the rest, same as the reconciler.
179
+ */
180
+ export const CreateStubOrgBodySchema = z.strictObject({
181
+ name: z.string().min(1).max(120),
182
+ slug: z.string().min(1).max(120).optional(),
183
+ domain: z.string().min(1).max(255).optional(),
184
+ description: z.string().max(2000).optional(),
185
+ category: z.string().min(1).max(120).optional(),
186
+ avatar: HttpsUrlSchema.optional(),
187
+ tags: z.array(z.string().min(1).max(60)).max(50).optional(),
188
+ social: SocialSchema.optional(),
189
+ products: z.array(ReleasesJsonProductSchema).max(MAX_PRODUCTS).optional(),
190
+ releases: DomainReleasesSchema.optional(),
191
+ });
192
+
193
+ export type CreateStubOrgBody = z.infer<typeof CreateStubOrgBodySchema>;
194
+
195
+ /**
196
+ * Body for `POST /v1/orgs/stub-from-domain` (#1947) — the unlisted-domain path.
197
+ * The worker fetches https://{domain}/.well-known/releases.json and, if valid
198
+ * and the domain has no org yet, creates a stub org + declared locators.
199
+ */
200
+ export const StubFromDomainBodySchema = z.strictObject({
201
+ domain: z.string().min(1).max(255),
202
+ });
203
+
204
+ export type StubFromDomainBody = z.infer<typeof StubFromDomainBodySchema>;
52
205
 
53
206
  /** Response shape of POST /v1/orgs/:slug/sync-well-known. */
54
207
  export const SyncWellKnownResponseSchema = z.object({