@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 +1 -1
- package/src/api-types.ts +33 -1
- package/src/schemas/lookups.ts +9 -1
- package/src/schemas/orgs.ts +20 -0
- package/src/schemas/overviews.ts +12 -16
- package/src/schemas/shared.ts +52 -8
- package/src/schemas/well-known.test.ts +134 -37
- package/src/schemas/well-known.ts +187 -34
package/package.json
CHANGED
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 {
|
|
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
|
/**
|
package/src/schemas/lookups.ts
CHANGED
|
@@ -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({
|
package/src/schemas/orgs.ts
CHANGED
|
@@ -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),
|
package/src/schemas/overviews.ts
CHANGED
|
@@ -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`.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
package/src/schemas/shared.ts
CHANGED
|
@@ -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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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
|
-
|
|
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,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ReleasesJsonConfigSchema,
|
|
4
|
+
ReleasesJsonDomainSchema,
|
|
5
|
+
ReleasesJsonRepoSchema,
|
|
6
|
+
} from "./well-known.js";
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
10
|
-
const
|
|
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:
|
|
26
|
+
version: 2,
|
|
13
27
|
name: "Acme",
|
|
14
28
|
description: "CI for teams.",
|
|
15
|
-
category: "
|
|
29
|
+
category: "future-category",
|
|
16
30
|
avatar: "https://acme.com/logo.png",
|
|
17
|
-
tags: ["
|
|
31
|
+
tags: ["future-tag"],
|
|
18
32
|
social: { twitter: "acmehq", github: "acme" },
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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("
|
|
32
|
-
expect(ReleasesJsonConfigSchema.safeParse({
|
|
33
|
-
|
|
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("
|
|
38
|
-
expect(
|
|
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("
|
|
85
|
+
it("allows github self only in repo files", () => {
|
|
42
86
|
expect(
|
|
43
|
-
|
|
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("
|
|
48
|
-
|
|
49
|
-
|
|
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("
|
|
53
|
-
|
|
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
|
|
57
|
-
expect(
|
|
58
|
-
|
|
59
|
-
|
|
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("
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
.
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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:
|
|
44
|
-
|
|
45
|
-
// Source/product scope
|
|
46
|
-
product: ReleasesJsonProductSchema.optional(),
|
|
142
|
+
social: SocialSchema.optional(),
|
|
143
|
+
products: z.array(ReleasesJsonProductSchema).max(MAX_PRODUCTS).optional(),
|
|
47
144
|
})
|
|
48
|
-
.
|
|
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({
|