@buildinternet/releases-api-types 0.34.0 → 0.35.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.34.0",
3
+ "version": "0.35.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",
@@ -17,7 +17,7 @@
17
17
  "README.md"
18
18
  ],
19
19
  "dependencies": {
20
- "@buildinternet/releases-core": "^0.23.0",
20
+ "@buildinternet/releases-core": "^0.25.0",
21
21
  "zod": "^4.4.3"
22
22
  },
23
23
  "publishConfig": {
package/src/api-types.ts CHANGED
@@ -18,7 +18,6 @@ import type {
18
18
  MediaItemSchema,
19
19
  PaginationSchema,
20
20
  StatsSchema,
21
- ErrorResponseSchema,
22
21
  ReleaseTypeSchema,
23
22
  ReleaseCompositionSchema,
24
23
  ReleaseItemSchema,
@@ -258,7 +257,6 @@ export {
258
257
  PaginationSchema,
259
258
  ListResponseSchema,
260
259
  StatsSchema,
261
- ErrorResponseSchema,
262
260
  ReleaseTypeSchema,
263
261
  ReleaseCompositionSchema,
264
262
  ReleaseItemSchema,
@@ -453,6 +451,8 @@ export {
453
451
  FeedbackDeleteResponseSchema,
454
452
  } from "./schemas/feedback.js";
455
453
  export { SitemapSourceSchema, SitemapPayloadSchema } from "./schemas/sitemap.js";
454
+ export { errorEnvelopeSchema, decodeApiError, isApiError } from "./schemas/errors.js";
455
+ export type { ErrorEnvelope, DecodedApiError } from "./schemas/errors.js";
456
456
  export {
457
457
  RelatedScopeSchema,
458
458
  RelatedReleaseThumbnailSchema,
@@ -554,8 +554,6 @@ export interface CreateUserApiKeyBody {
554
554
  expiresInDays?: number;
555
555
  }
556
556
 
557
- export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
558
-
559
557
  // ── Sitemap (bulk URL emission) ──
560
558
 
561
559
  export type SitemapSource = z.infer<typeof SitemapSourceSchema>;
@@ -1236,7 +1234,13 @@ export type UpdatePlaybookNotesResponse = z.infer<typeof UpdatePlaybookNotesResp
1236
1234
  // ── Overview Manifest (admin planning) ──
1237
1235
 
1238
1236
  export type OverviewStaleness = "missing" | "behind" | "fresh";
1239
- export type OverviewPlanAction = "missing" | "refresh" | "skip";
1237
+ /**
1238
+ * `opted_out` — the org has `autoGenerateContent=false`, so the batch-overview
1239
+ * eligibility filter skips it regardless of staleness. Surfaced so an
1240
+ * orchestrator planning a sweep doesn't queue a regen that will never run
1241
+ * (#1795).
1242
+ */
1243
+ export type OverviewPlanAction = "missing" | "refresh" | "skip" | "opted_out";
1240
1244
 
1241
1245
  /**
1242
1246
  * Per-org row returned by GET /v1/admin/overviews. Designed for orchestrators
@@ -1255,6 +1259,12 @@ export interface OverviewManifestRow {
1255
1259
  releasesSinceOverview: number;
1256
1260
  recentReleaseCount: number;
1257
1261
  staleness: OverviewStaleness;
1262
+ /**
1263
+ * Whether the org is opted into automatic AI content. When `false`, the
1264
+ * batch-overview cron/manual regen skips it — the staleness signal alone is
1265
+ * misleading without this (#1795). Older workers may omit it mid-deploy.
1266
+ */
1267
+ autoGenerateContent?: boolean;
1258
1268
  /** Only populated when ?format=plan. */
1259
1269
  action?: OverviewPlanAction;
1260
1270
  /** Only populated when ?format=plan. */
@@ -1582,14 +1592,16 @@ export interface Session {
1582
1592
  * Sub-agent **role** label that ran this session — `"coordinator"` is the
1583
1593
  * parent orchestrator (typically Sonnet) when a multi-agent session
1584
1594
  * delegates, `"sonnet"` / `"haiku"` are direct single-agent runs.
1585
- * Surfaces on the detail GET.
1595
+ * `"deterministic"` marks the #1878/#1881 update path that skips the
1596
+ * Managed-Agents (Haiku) session entirely — no Anthropic session runs, so
1597
+ * it must never be reported as `"haiku"`. Surfaces on the detail GET.
1586
1598
  *
1587
1599
  * This is a logical role label, not the runtime model identifier. The
1588
- * resolved Anthropic model string (e.g. `claude-sonnet-4-6`,
1600
+ * resolved Anthropic model string (e.g. `claude-sonnet-5`,
1589
1601
  * `claude-haiku-4-5`) lives on `usage.model` when the session reported
1590
1602
  * one — consult that field for the concrete model.
1591
1603
  */
1592
- agent?: "sonnet" | "haiku" | "coordinator";
1604
+ agent?: "sonnet" | "haiku" | "coordinator" | "deterministic";
1593
1605
  /** Identifies the client that started this session (e.g. hostname). */
1594
1606
  runner?: string;
1595
1607
  /** Correlation ID for end-to-end tracing across CLI → API → managed agent. */
@@ -0,0 +1,55 @@
1
+ import { expect, test } from "bun:test";
2
+ import { ERROR_TYPES } from "@buildinternet/releases-core/errors";
3
+ import { errorEnvelopeSchema, decodeApiError, isApiError } from "./errors";
4
+
5
+ test("a valid envelope parses", () => {
6
+ const ok = errorEnvelopeSchema.safeParse({
7
+ error: { code: "not_found", type: "not_found", message: "Nope" },
8
+ });
9
+ expect(ok.success).toBe(true);
10
+ });
11
+
12
+ test("malformed envelopes reject", () => {
13
+ expect(errorEnvelopeSchema.safeParse({}).success).toBe(false);
14
+ expect(
15
+ errorEnvelopeSchema.safeParse({
16
+ error: { code: 1, type: "not_found", message: "x" },
17
+ }).success,
18
+ ).toBe(false);
19
+ });
20
+
21
+ test("schema type enum matches the core union (anti-drift invariant 2)", () => {
22
+ const options = errorEnvelopeSchema.shape.error.shape.type.options;
23
+ expect([...options].sort()).toEqual([...ERROR_TYPES].sort());
24
+ });
25
+
26
+ test("decodeApiError normalizes an unknown type to internal, preserves unknown code", () => {
27
+ const decoded = decodeApiError({
28
+ error: { code: "brand_new_code", type: "teapot", message: "?" },
29
+ });
30
+ expect(decoded.type).toBe("internal");
31
+ expect(decoded.code).toBe("brand_new_code");
32
+ });
33
+
34
+ test("decodeApiError degrades a malformed body without throwing", () => {
35
+ expect(decodeApiError(null)).toEqual({
36
+ code: "internal_error",
37
+ type: "internal",
38
+ message: "Unknown error",
39
+ });
40
+ });
41
+
42
+ test("decodeApiError carries details through when present", () => {
43
+ const decoded = decodeApiError({
44
+ error: { code: "not_found", type: "not_found", message: "x", details: { id: "src_1" } },
45
+ });
46
+ expect(decoded.details).toEqual({ id: "src_1" });
47
+ });
48
+
49
+ test("isApiError matches shape and optional code", () => {
50
+ const body = { error: { code: "not_found", type: "not_found", message: "x" } };
51
+ expect(isApiError(body)).toBe(true);
52
+ expect(isApiError(body, "not_found")).toBe(true);
53
+ expect(isApiError(body, "conflict")).toBe(false);
54
+ expect(isApiError({ nope: true })).toBe(false);
55
+ });
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { ERROR_TYPES, type ErrorType, type ErrorCode } from "@buildinternet/releases-core/errors";
3
+
4
+ /**
5
+ * The one on-the-wire error shape. `type` is constrained (documents the
6
+ * contract, powers OpenAPI); `code` is an open string so a new server code an
7
+ * older client doesn't recognize still parses. The throwable hierarchy in
8
+ * `@releases/lib/releases-error` serializes to this via `toWire()`.
9
+ */
10
+ export const errorEnvelopeSchema = z
11
+ .object({
12
+ error: z.object({
13
+ code: z
14
+ .string()
15
+ .describe(
16
+ "Stable, specific machine code (e.g. `not_found`, `rate_limited`). Never reworded once shipped, but open-ended — branch defensively on codes you don't recognize.",
17
+ ),
18
+ type: z
19
+ .enum(ERROR_TYPES)
20
+ .describe(
21
+ "Coarse category from a fixed set; determines the HTTP status. Safe to switch on.",
22
+ ),
23
+ message: z.string().describe("Human-readable explanation. May change; do not parse."),
24
+ details: z
25
+ .unknown()
26
+ .optional()
27
+ .describe(
28
+ "Optional structured context carried by select codes (e.g. `database_not_initialized`).",
29
+ ),
30
+ }),
31
+ })
32
+ .describe(
33
+ "Standardized error response — every non-2xx response uses this shape. Switch on `error.type` (fixed set → HTTP status) or match `error.code` (specific, stable). See https://releases.sh/docs/api/errors.",
34
+ );
35
+
36
+ export type ErrorEnvelope = z.infer<typeof errorEnvelopeSchema>;
37
+
38
+ /**
39
+ * Lenient variant used ONLY for decode: `type` is left open so an unknown
40
+ * category (older client, newer server) does not reject.
41
+ */
42
+ const lenientEnvelopeSchema = z.object({
43
+ error: z.object({
44
+ code: z.string(),
45
+ type: z.string(),
46
+ message: z.string(),
47
+ details: z.unknown().optional(),
48
+ }),
49
+ });
50
+
51
+ export interface DecodedApiError {
52
+ code: string;
53
+ type: ErrorType;
54
+ message: string;
55
+ details?: unknown;
56
+ }
57
+
58
+ /**
59
+ * Parse an API error body into a typed, normalized shape. Forward-compatible: a
60
+ * malformed body or an unknown `type` degrades to `internal` and never throws.
61
+ */
62
+ export function decodeApiError(body: unknown): DecodedApiError {
63
+ const parsed = lenientEnvelopeSchema.safeParse(body);
64
+ if (!parsed.success) {
65
+ return { code: "internal_error", type: "internal", message: "Unknown error" };
66
+ }
67
+ const { code, type, message, details } = parsed.data.error;
68
+ const normalizedType: ErrorType = (ERROR_TYPES as readonly string[]).includes(type)
69
+ ? (type as ErrorType)
70
+ : "internal";
71
+ return details === undefined
72
+ ? { code, type: normalizedType, message }
73
+ : { code, type: normalizedType, message, details };
74
+ }
75
+
76
+ /** True if `body` is an API error envelope; if `code` is given, also matches it. */
77
+ export function isApiError(body: unknown, code?: ErrorCode): boolean {
78
+ const parsed = lenientEnvelopeSchema.safeParse(body);
79
+ if (!parsed.success) return false;
80
+ return code === undefined || parsed.data.error.code === code;
81
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { KIND_VALUES } from "@buildinternet/releases-core/kinds";
3
+ import { BREAKING_LEVELS } from "@buildinternet/releases-core/breaking";
3
4
  import {
4
5
  AppStoreSourceInfoSchema,
5
6
  CategorySchema,
@@ -89,6 +90,14 @@ export const CreateOrgBodySchema = z.object({
89
90
  description: z.string().optional(),
90
91
  category: z.string().min(1).optional(),
91
92
  tags: z.array(z.string()).optional(),
93
+ /**
94
+ * Admin-only: opt the new org into automatic AI content (overviews +
95
+ * per-release summaries). Omitted → defaults to `true` server-side, since
96
+ * `POST /v1/orgs` only ever mints `curated` orgs and onboarding into the
97
+ * registry should mean it gets overviews. Pass `false` for a deliberate
98
+ * opt-out (#1795).
99
+ */
100
+ autoGenerateContent: z.boolean().optional(),
92
101
  });
93
102
 
94
103
  /**
@@ -416,6 +425,12 @@ export const OrgRecentReleaseItemSchema = z.object({
416
425
  summary: z.string().nullable(),
417
426
  titleGenerated: z.string().nullable().optional(),
418
427
  titleShort: z.string().nullable().optional(),
428
+ /**
429
+ * Machine-readable breaking-change level (#1696, surfaced on read paths in
430
+ * #1710). `.optional()` for mid-deploy tolerance; absent or `"unknown"`
431
+ * means not classified. List paths carry `breaking` only.
432
+ */
433
+ breaking: z.enum(BREAKING_LEVELS).optional(),
419
434
  url: z.string().nullable(),
420
435
  contentHash: z.string().nullable(),
421
436
  metadata: z.string().nullable(),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { SOURCE_TYPES } from "@buildinternet/releases-core/source-enums";
3
+ import { BREAKING_LEVELS } from "@buildinternet/releases-core/breaking";
3
4
  import {
4
5
  AppStoreSourceInfoSchema,
5
6
  MediaItemSchema,
@@ -51,6 +52,14 @@ export const ReleaseLatestItemSchema = z.object({
51
52
  summary: z.string().nullable(),
52
53
  titleGenerated: z.string().nullable(),
53
54
  titleShort: z.string().nullable(),
55
+ /**
56
+ * Machine-readable breaking-change level (#1696, surfaced on read paths in
57
+ * #1710). `.optional()` for mid-deploy / pinned-worker tolerance and because
58
+ * rows predating the column omit it; absent or `"unknown"` means not
59
+ * classified (the fail-open default). List paths carry `breaking` only —
60
+ * `migrationNotes` stays detail-route-only to keep list payloads slim.
61
+ */
62
+ breaking: z.enum(BREAKING_LEVELS).optional(),
54
63
  publishedAt: z.string().nullable(),
55
64
  url: z.string().nullable(),
56
65
  media: z.array(MediaItemSchema),
@@ -215,6 +224,18 @@ export const ReleaseDetailResponseSchema = z.object({
215
224
  summary: z.string().nullable(),
216
225
  titleGenerated: z.string().nullable(),
217
226
  titleShort: z.string().nullable(),
227
+ /**
228
+ * Machine-readable breaking-change level (#1696, surfaced on read paths in
229
+ * #1710). `.optional()` — rows predating the column omit it; absent or
230
+ * `"unknown"` means not classified (the fail-open default). Mirrors
231
+ * `ReleaseDetail.breaking` in api-types.ts.
232
+ */
233
+ breaking: z.enum(BREAKING_LEVELS).optional(),
234
+ /**
235
+ * Explicit upgrade/migration steps lifted from the body (#1696); omitted
236
+ * when the body gives none. Detail-route-only — list paths never carry it.
237
+ */
238
+ migrationNotes: z.string().nullable().optional(),
218
239
  url: z.string().nullable(),
219
240
  contentHash: z.string().nullable(),
220
241
  media: z.array(MediaItemSchema),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { RELEASE_TYPES } from "@buildinternet/releases-core/schema";
3
+ import { BREAKING_LEVELS } from "@buildinternet/releases-core/breaking";
3
4
  import { CATEGORIES } from "@buildinternet/releases-core/categories";
4
5
  import { isValidNoticeCoordinate } from "@buildinternet/releases-core/notice";
5
6
 
@@ -58,17 +59,6 @@ export const ListResponseSchema = <T extends z.ZodTypeAny>(item: T) =>
58
59
  pagination: PaginationSchema,
59
60
  });
60
61
 
61
- export const ErrorResponseSchema = z.object({
62
- error: z.string(),
63
- message: z.string(),
64
- // Stable D1 error classification from `classifyDbError()` in
65
- // `@releases/lib/db-errors`. Set on 5xx responses that originate from a
66
- // classified D1 failure so callers can branch on a stable code instead of
67
- // substring-matching the upstream message. Absent on non-D1 errors and on
68
- // routes that have not yet been wired. See DbErrorCode in packages/lib.
69
- errorCode: z.string().optional(),
70
- });
71
-
72
62
  export const StatsSchema = z.object({
73
63
  orgs: z.number().int().min(0),
74
64
  sources: z.number().int().min(0),
@@ -120,6 +110,12 @@ export const ReleaseItemSchema = z.object({
120
110
  // `.optional()` for the same mid-deploy / pinned-worker reason as `type`.
121
111
  titleGenerated: z.string().nullable().optional(),
122
112
  titleShort: z.string().nullable().optional(),
113
+ // Machine-readable breaking-change level (#1696, surfaced on read paths in
114
+ // #1710). `.optional()` for mid-deploy / pinned-worker tolerance and because
115
+ // rows predating the column omit it; absent or `"unknown"` means not
116
+ // classified (the fail-open default). List paths carry `breaking` only —
117
+ // `migrationNotes` stays detail-route-only to keep list payloads slim.
118
+ breaking: z.enum(BREAKING_LEVELS).optional(),
123
119
  // Count of demoted siblings rolling up into this row via `release_coverage`
124
120
  // (0 when standalone). Lets list views show a cluster indicator without a
125
121
  // detail-page round trip. `.optional()` — older API responses and pinned
@@ -9,6 +9,7 @@ describe("ReleasesJsonConfigSchema", () => {
9
9
  it("accepts a full org-scope file", () => {
10
10
  const r = ReleasesJsonConfigSchema.safeParse({
11
11
  $schema: "https://releases.sh/schemas/releases.json",
12
+ version: 1,
12
13
  name: "Acme",
13
14
  description: "CI for teams.",
14
15
  category: "developer-tools",
@@ -48,6 +49,16 @@ describe("ReleasesJsonConfigSchema", () => {
48
49
  expect(r.success).toBe(false);
49
50
  });
50
51
 
52
+ it("accepts an integer version", () => {
53
+ expect(ReleasesJsonConfigSchema.safeParse({ version: 1, name: "Acme" }).success).toBe(true);
54
+ });
55
+
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);
60
+ });
61
+
51
62
  it("strips unknown top-level keys", () => {
52
63
  const r = ReleasesJsonConfigSchema.parse({ name: "Acme", bogus: 1 });
53
64
  expect("bogus" in r).toBe(false);
@@ -26,6 +26,11 @@ export const ReleasesJsonProductSchema = z
26
26
  export const ReleasesJsonConfigSchema = z
27
27
  .object({
28
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(),
29
34
  // Org scope
30
35
  name: z.string().min(1).max(120).optional(),
31
36
  description: z.string().max(2000).optional(),