@honua/react 0.1.4-beta.0 → 0.1.9-beta.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.
Files changed (157) hide show
  1. package/contract/types.d.ts +1 -1
  2. package/contract/types.d.ts.map +1 -1
  3. package/contract/types.js +4 -0
  4. package/control-plane/admin-client.d.ts +87 -0
  5. package/control-plane/admin-client.d.ts.map +1 -0
  6. package/control-plane/admin-client.js +169 -0
  7. package/control-plane/client.d.ts.map +1 -1
  8. package/control-plane/client.js +21 -3
  9. package/control-plane/commands/catalog.d.ts +133 -0
  10. package/control-plane/commands/catalog.d.ts.map +1 -0
  11. package/control-plane/commands/catalog.js +353 -0
  12. package/control-plane/commands/errors.d.ts +96 -0
  13. package/control-plane/commands/errors.d.ts.map +1 -0
  14. package/control-plane/commands/errors.js +130 -0
  15. package/control-plane/commands/identity.d.ts +71 -0
  16. package/control-plane/commands/identity.d.ts.map +1 -0
  17. package/control-plane/commands/identity.js +0 -0
  18. package/control-plane/commands/index.d.ts +39 -0
  19. package/control-plane/commands/index.d.ts.map +1 -0
  20. package/control-plane/commands/index.js +34 -0
  21. package/control-plane/commands/runtime.d.ts +146 -0
  22. package/control-plane/commands/runtime.d.ts.map +1 -0
  23. package/control-plane/commands/runtime.js +318 -0
  24. package/control-plane/commands/types.d.ts +226 -0
  25. package/control-plane/commands/types.d.ts.map +1 -0
  26. package/control-plane/commands/types.js +30 -0
  27. package/control-plane/commands/validate.d.ts +27 -0
  28. package/control-plane/commands/validate.d.ts.map +1 -0
  29. package/control-plane/commands/validate.js +132 -0
  30. package/control-plane/generated/admin-api.d.ts +30263 -0
  31. package/control-plane/generated/admin-api.d.ts.map +1 -0
  32. package/control-plane/generated/admin-api.js +5 -0
  33. package/control-plane/generated/admin-operations.d.ts +4053 -0
  34. package/control-plane/generated/admin-operations.d.ts.map +1 -0
  35. package/control-plane/generated/admin-operations.js +820 -0
  36. package/control-plane/index.d.ts +11 -0
  37. package/control-plane/index.d.ts.map +1 -1
  38. package/control-plane/index.js +8 -0
  39. package/control-plane/types.d.ts +14 -0
  40. package/control-plane/types.d.ts.map +1 -1
  41. package/core/client.d.ts +6 -0
  42. package/core/client.d.ts.map +1 -1
  43. package/core/client.js +12 -10
  44. package/core/credential-redaction.d.ts +163 -0
  45. package/core/credential-redaction.d.ts.map +1 -0
  46. package/core/credential-redaction.js +389 -0
  47. package/core/error-classifications.d.ts +32 -0
  48. package/core/error-classifications.d.ts.map +1 -1
  49. package/core/error-classifications.js +32 -0
  50. package/core/error-code-registry.d.ts +33 -1
  51. package/core/error-code-registry.d.ts.map +1 -1
  52. package/core/error-code-registry.js +32 -0
  53. package/core/errors.d.ts +1 -1
  54. package/core/errors.d.ts.map +1 -1
  55. package/core/geoparquet-sql.d.ts +1 -1
  56. package/core/geoparquet-sql.js +2 -2
  57. package/core/grpc-adapter.d.ts +2 -2
  58. package/core/grpc-adapter.d.ts.map +1 -1
  59. package/core/grpc-adapter.js +30 -6
  60. package/core/job-run-errors.d.ts +10 -0
  61. package/core/job-run-errors.d.ts.map +1 -0
  62. package/core/job-run-errors.js +14 -0
  63. package/core/job-run-lifecycle.d.ts +34 -0
  64. package/core/job-run-lifecycle.d.ts.map +1 -0
  65. package/core/job-run-lifecycle.js +193 -0
  66. package/core/ogc-processes.d.ts +38 -31
  67. package/core/ogc-processes.d.ts.map +1 -1
  68. package/core/ogc-processes.js +123 -173
  69. package/core/process-runner.js +15 -100
  70. package/core/stac.d.ts +2 -1
  71. package/core/stac.d.ts.map +1 -1
  72. package/core/stac.js +29 -11
  73. package/core/surfaces.d.ts +2 -8
  74. package/core/surfaces.d.ts.map +1 -1
  75. package/core/surfaces.js +21 -112
  76. package/core/types.d.ts +22 -8
  77. package/core/types.d.ts.map +1 -1
  78. package/gen/geospatial/v1/common_pb.d.ts +514 -0
  79. package/gen/geospatial/v1/common_pb.d.ts.map +1 -0
  80. package/gen/geospatial/v1/common_pb.js +335 -0
  81. package/gen/geospatial/v1/execution_types_pb.d.ts +1186 -0
  82. package/gen/geospatial/v1/execution_types_pb.d.ts.map +1 -0
  83. package/gen/geospatial/v1/execution_types_pb.js +396 -0
  84. package/gen/geospatial/v1/feature_service_pb.d.ts +403 -0
  85. package/gen/geospatial/v1/feature_service_pb.d.ts.map +1 -0
  86. package/gen/geospatial/v1/feature_service_pb.js +47 -0
  87. package/gen/geospatial/v1/index.d.ts +5 -0
  88. package/gen/geospatial/v1/index.d.ts.map +1 -0
  89. package/gen/geospatial/v1/index.js +9 -0
  90. package/gen/geospatial/v1/spatial_types_pb.d.ts +285 -0
  91. package/gen/geospatial/v1/spatial_types_pb.d.ts.map +1 -0
  92. package/gen/geospatial/v1/spatial_types_pb.js +58 -0
  93. package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts +379 -0
  94. package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts.map +1 -0
  95. package/gen/geospatial/v1/workspace_artifact_types_pb.js +151 -0
  96. package/generated-app/projection.d.ts.map +1 -1
  97. package/generated-app/projection.js +10 -1
  98. package/interactions/declarative-contract.d.ts +149 -0
  99. package/interactions/declarative-contract.d.ts.map +1 -0
  100. package/interactions/declarative-contract.js +293 -0
  101. package/interactions/declarative.d.ts +198 -0
  102. package/interactions/declarative.d.ts.map +1 -0
  103. package/interactions/declarative.js +470 -0
  104. package/interactions/feature-state.d.ts +7 -0
  105. package/interactions/feature-state.d.ts.map +1 -1
  106. package/interactions/feature-state.js +10 -2
  107. package/map/maplibre-target.js +1 -1
  108. package/package.json +2 -2
  109. package/query-planner/grpc.d.ts +4 -4
  110. package/query-planner/grpc.d.ts.map +1 -1
  111. package/query-planner/grpc.js +11 -11
  112. package/query-planner/types.d.ts +4 -4
  113. package/query-planner/types.d.ts.map +1 -1
  114. package/realtime/sse.d.ts.map +1 -1
  115. package/realtime/sse.js +7 -1
  116. package/runtime/errors.d.ts +1 -1
  117. package/runtime/errors.d.ts.map +1 -1
  118. package/runtime/errors.js +2 -0
  119. package/runtime/generated/map-package-schema-meta.d.ts +11 -0
  120. package/runtime/generated/map-package-schema-meta.d.ts.map +1 -0
  121. package/runtime/generated/map-package-schema-meta.js +12 -0
  122. package/runtime/generated/map-package-schema-validator.d.ts +16 -0
  123. package/runtime/generated/map-package-schema-validator.d.ts.map +1 -0
  124. package/runtime/generated/map-package-schema-validator.js +1422 -0
  125. package/runtime/index.d.ts +5 -1
  126. package/runtime/index.d.ts.map +1 -1
  127. package/runtime/index.js +6 -0
  128. package/runtime/map-package-export.d.ts +151 -0
  129. package/runtime/map-package-export.d.ts.map +1 -0
  130. package/runtime/map-package-export.js +636 -0
  131. package/runtime/map-package-validation.d.ts +21 -1
  132. package/runtime/map-package-validation.d.ts.map +1 -1
  133. package/runtime/map-package-validation.js +132 -95
  134. package/runtime/map-package.d.ts +80 -0
  135. package/runtime/map-package.d.ts.map +1 -1
  136. package/runtime/style-compose.d.ts.map +1 -1
  137. package/runtime/style-compose.js +78 -9
  138. package/studio/chart-spec.d.ts +6 -6
  139. package/studio/chart-spec.d.ts.map +1 -1
  140. package/studio/chart-spec.js +6 -6
  141. package/studio/index.d.ts +17 -7
  142. package/studio/index.d.ts.map +1 -1
  143. package/studio/index.js +15 -5
  144. package/studio/lifecycle-client.d.ts +262 -22
  145. package/studio/lifecycle-client.d.ts.map +1 -1
  146. package/studio/lifecycle-client.js +524 -24
  147. package/studio/lifecycle-errors.d.ts +8 -2
  148. package/studio/lifecycle-errors.d.ts.map +1 -1
  149. package/studio/lifecycle-errors.js +6 -1
  150. package/studio/lifecycle-types.d.ts +228 -8
  151. package/studio/lifecycle-types.d.ts.map +1 -1
  152. package/studio/publication-status.d.ts +55 -0
  153. package/studio/publication-status.d.ts.map +1 -0
  154. package/studio/publication-status.js +103 -0
  155. package/gen/honua/v1/feature_service_pb.d.ts +0 -918
  156. package/gen/honua/v1/feature_service_pb.d.ts.map +0 -1
  157. package/gen/honua/v1/feature_service_pb.js +0 -375
@@ -17,30 +17,38 @@
17
17
  * `./lifecycle-errors.js` for the RFC 7807 problem-detail taxonomy and the
18
18
  * `generation-conflict` discriminant.
19
19
  *
20
- * ## Enumeration gap
20
+ * ## Publication proposals
21
21
  *
22
- * The lifecycle API doc is explicit that **there is no endpoint to enumerate
23
- * drafts or content items** — only single-resource reads
24
- * (`GET /package-drafts/{draftId}`, `GET /content-items/{itemId}/versions`,
25
- * `GET /content-items/{itemId}/versions/{versionId}`) and the
26
- * family-discovery list (`GET /package-families`, which already returns every
27
- * family in one response and does need no list method of its own).
28
- * `honua-server#3003` tracks adding a draft/content-item enumeration
29
- * endpoint. Until it ships:
22
+ * {@link HonuaStudioPublicationRequestsClient} submits an already-saved
23
+ * immutable version for publication, reads the resulting proposal, and walks
24
+ * it to a final state with a bounded, cancellable
25
+ * {@link HonuaStudioPublicationRequestsClient.poll}. Only `Active` yields a
26
+ * final publication URL; `Rejected` and `Failed` are terminal without one,
27
+ * and a status this release does not recognize is treated as neither.
28
+ * Approval is deliberately **not** part of this surface — see that class's
29
+ * doc comment.
30
30
  *
31
- * - `contentVersions.list(itemId)` already covers version enumeration *for a
32
- * known item* — the gap is discovering `itemId`/`draftId` values in the
33
- * first place, not paging a known collection.
34
- * - `.raw()` reaches any endpoint honua-server adds before this client grows
35
- * a dedicated method for it, without a breaking-change window.
36
- * - When the enumeration endpoint ships, it is additive: a new
37
- * `drafts.list(options)` (or a new resource group) can be added without
38
- * touching any existing method signature.
31
+ * ## Enumeration
32
+ *
33
+ * `contentItems.list()` (`GET /content-items`) and `drafts.list()`
34
+ * (`GET /package-drafts`) are the content-browser entry points added by
35
+ * `honua-server#3003`: filters (`family`, `workspaceId`, `owner`, `q`, plus
36
+ * `state` for content items), opaque keyset-cursor pagination ordered by
37
+ * `updatedAt` then row id descending, and — for content items — a joined
38
+ * Content Publication Registry lifecycle badge per row, so a lifecycle badge
39
+ * costs no extra request. Both resource groups also expose a bounded
40
+ * `listAll()` async generator and a `collect()` convenience that walk the
41
+ * cursor for you; neither can loop forever (see
42
+ * {@link HONUA_STUDIO_LIST_MAX_PAGES}).
43
+ *
44
+ * Cursors are **opaque**. Pass a `nextCursor` back verbatim; never build,
45
+ * parse, or persist-and-mutate one.
39
46
  *
40
47
  * @module
41
48
  */
42
- import { isHonuaError } from "../core/errors.js";
49
+ import { HonuaTimeoutError, isHonuaError } from "../core/errors.js";
43
50
  import { HonuaStudioError, classifyStudioProblemStatus } from "./lifecycle-errors.js";
51
+ import { isStudioPublicationActive, isStudioPublicationTerminal, normalizeStudioPublicationStatus, studioPublicationUrl, } from "./publication-status.js";
44
52
  /** Default base path for the Studio package lifecycle API. */
45
53
  export const HONUA_STUDIO_LIFECYCLE_BASE_PATH = "/api/v1/studio";
46
54
  /** Construct a {@link HonuaStudioLifecycleClient} from an existing {@link HonuaClient}. */
@@ -57,6 +65,7 @@ export class HonuaStudioLifecycleClient {
57
65
  #basePath;
58
66
  packageFamilies;
59
67
  drafts;
68
+ contentItems;
60
69
  contentVersions;
61
70
  publicationRequests;
62
71
  rollbackRequests;
@@ -65,6 +74,7 @@ export class HonuaStudioLifecycleClient {
65
74
  this.#basePath = normalizeBasePath(options.basePath ?? HONUA_STUDIO_LIFECYCLE_BASE_PATH);
66
75
  this.packageFamilies = new HonuaStudioPackageFamiliesClient(this);
67
76
  this.drafts = new HonuaStudioDraftsClient(this);
77
+ this.contentItems = new HonuaStudioContentItemsClient(this);
68
78
  this.contentVersions = new HonuaStudioContentVersionsClient(this);
69
79
  this.publicationRequests = new HonuaStudioPublicationRequestsClient(this);
70
80
  this.rollbackRequests = new HonuaStudioRollbackRequestsClient(this);
@@ -72,7 +82,7 @@ export class HonuaStudioLifecycleClient {
72
82
  get basePath() {
73
83
  return this.#basePath;
74
84
  }
75
- /** Escape hatch for endpoints without a dedicated method yet (see the module doc's "Enumeration gap"). */
85
+ /** Escape hatch for any endpoint that does not have a dedicated method on this client yet. */
76
86
  raw(request) {
77
87
  return this.request(request.method ?? "GET", request.path, request.body, {
78
88
  signal: request.signal,
@@ -122,6 +132,35 @@ export class HonuaStudioDraftsClient {
122
132
  create(request, options = {}) {
123
133
  return this.#lifecycle.request("POST", "/package-drafts", request, options);
124
134
  }
135
+ /**
136
+ * `GET /package-drafts` — one page of mutable drafts matching `filters`
137
+ * (`family`, `workspaceId`, `owner`, `q`), ordered by `updatedAt` then
138
+ * `draftId` descending.
139
+ *
140
+ * Rows are full {@link StudioPackageDraft} objects. Pass the response's
141
+ * `nextCursor` back as `filters.cursor` for the next page; its absence (or
142
+ * `null`) means this was the last one. Prefer {@link listAll} or
143
+ * {@link collect} over hand-rolling that loop.
144
+ */
145
+ // `async` so the client-side `limit` guard in `withStudioListQuery` surfaces
146
+ // as a rejected promise, exactly like every server-side failure on this
147
+ // client, rather than as a synchronous throw callers would have to catch
148
+ // separately.
149
+ async list(filters = {}, options = {}) {
150
+ return await this.#lifecycle.request("GET", withStudioListQuery("/package-drafts", filters), undefined, options);
151
+ }
152
+ /**
153
+ * Walk `GET /package-drafts` to completion, yielding each page verbatim.
154
+ * Bounded by `options.maxPages` — see {@link HonuaStudioContentItemsClient.listAll}
155
+ * for the shared pagination rules.
156
+ */
157
+ listAll(filters = {}, options = {}) {
158
+ return paginateStudioList((pageFilters, pageOptions) => this.list(pageFilters, pageOptions), filters, options);
159
+ }
160
+ /** Accumulate every draft page into one {@link StudioListCollection}. */
161
+ collect(filters = {}, options = {}) {
162
+ return collectStudioList(this.listAll(filters, options));
163
+ }
125
164
  /** `GET /package-drafts/{draftId}` — retrieve a mutable draft. */
126
165
  get(draftId, options = {}) {
127
166
  return this.#lifecycle.request("GET", `/package-drafts/${encodeURIComponent(draftId)}`, undefined, options);
@@ -157,6 +196,185 @@ export class HonuaStudioDraftsClient {
157
196
  return this.#lifecycle.request("POST", `/package-drafts/${encodeURIComponent(draftId)}/content-versions`, undefined, options);
158
197
  }
159
198
  }
199
+ /** Page size `GET /content-items` and `GET /package-drafts` use when `limit` is omitted. */
200
+ export const HONUA_STUDIO_LIST_DEFAULT_LIMIT = 25;
201
+ /**
202
+ * Largest `limit` the Studio list endpoints accept. The server clamps anything
203
+ * larger down to this value rather than erroring, which would silently hand a
204
+ * caller fewer rows than they asked for, so this client rejects an oversized
205
+ * `limit` up front instead.
206
+ */
207
+ export const HONUA_STUDIO_LIST_MAX_LIMIT = 1_000;
208
+ /**
209
+ * Default hard bound on how many pages {@link HonuaStudioContentItemsClient.listAll}
210
+ * / {@link HonuaStudioDraftsClient.listAll} will fetch. There is no
211
+ * "walk forever" mode, and no option that produces one.
212
+ */
213
+ export const HONUA_STUDIO_LIST_MAX_PAGES = 1_000;
214
+ /**
215
+ * True when a page is the last one — the server sends `nextCursor: null` on
216
+ * the final page, and its source-generated JSON context omits `null`
217
+ * properties, so "absent" and "null" mean the same thing. An empty-string
218
+ * cursor is treated as exhausted too rather than replayed as a filter value.
219
+ */
220
+ export function isStudioListExhausted(page) {
221
+ return typeof page.nextCursor !== "string" || page.nextCursor.length === 0;
222
+ }
223
+ /** `/content-items` — enumerate Studio content items with joined publication badges. */
224
+ export class HonuaStudioContentItemsClient {
225
+ #lifecycle;
226
+ constructor(lifecycle) {
227
+ this.#lifecycle = lifecycle;
228
+ }
229
+ /**
230
+ * `GET /content-items` — one page of content items matching `filters`
231
+ * (`family`, `workspaceId`, `owner`, `state`, `q`), ordered by `updatedAt`
232
+ * then `itemId` descending so pages stay stable while other rows are
233
+ * concurrently created or updated.
234
+ *
235
+ * Each row is a summary projection — no envelope — carrying the derived
236
+ * `state` (`draft`/`current`/`published`) and, when the Content Publication
237
+ * Registry has a publication whose `sourceContentId` is this `itemId`, a
238
+ * joined `publication` badge with the route's *own* lifecycle. That join is
239
+ * batched server-side, so a badge costs no extra request.
240
+ *
241
+ * A non-admin caller under `Studio:EndUserAuthorization:Enabled` always gets
242
+ * their own content regardless of the `owner` filter they pass, and a caller
243
+ * whose id cannot be resolved at all is refused with `403` rather than shown
244
+ * an unscoped list. An unknown `family` or `state` value is `400`.
245
+ */
246
+ // `async` so the client-side `limit` guard in `withStudioListQuery` surfaces
247
+ // as a rejected promise, exactly like every server-side failure on this
248
+ // client, rather than as a synchronous throw callers would have to catch
249
+ // separately.
250
+ async list(filters = {}, options = {}) {
251
+ return await this.#lifecycle.request("GET", withStudioListQuery("/content-items", filters), undefined, options);
252
+ }
253
+ /**
254
+ * Walk `GET /content-items` to completion, yielding each page verbatim.
255
+ *
256
+ * - **Bounded by construction.** At most `options.maxPages` pages
257
+ * ({@link HONUA_STUDIO_LIST_MAX_PAGES} by default) are fetched. Reaching
258
+ * the cap ends the walk; the last yielded page still carries the
259
+ * `nextCursor` to resume from.
260
+ * - **Cursors stay opaque.** Each request replays the previous page's
261
+ * `nextCursor` verbatim; `filters.cursor` seeds the walk, so a walk can be
262
+ * resumed exactly where a previous one stopped.
263
+ * - **Cancellable.** `options.signal` aborts the in-flight page request and
264
+ * ends the iteration.
265
+ * - **A stuck server is not an infinite loop.** A page that repeats the
266
+ * cursor it was fetched with throws {@link HonuaStudioError} rather than
267
+ * paging forever.
268
+ * - **Standard error handling.** Every non-2xx surfaces as
269
+ * {@link HonuaStudioError} with its RFC 7807 problem details.
270
+ */
271
+ listAll(filters = {}, options = {}) {
272
+ return paginateStudioList((pageFilters, pageOptions) => this.list(pageFilters, pageOptions), filters, options);
273
+ }
274
+ /** Accumulate every content-item page into one {@link StudioListCollection}. */
275
+ collect(filters = {}, options = {}) {
276
+ return collectStudioList(this.listAll(filters, options));
277
+ }
278
+ }
279
+ /**
280
+ * The shared bounded cursor walk behind every `listAll`. Generic over the row
281
+ * type so content items and drafts get identical pagination semantics from one
282
+ * implementation.
283
+ */
284
+ async function* paginateStudioList(fetchPage, filters, options) {
285
+ const maxPages = normalizePageBound(options.maxPages);
286
+ const requestOptions = { signal: options.signal, headers: options.headers };
287
+ let cursor = filters.cursor;
288
+ for (let page = 0; page < maxPages; page += 1) {
289
+ throwIfAborted(options.signal);
290
+ const current = await fetchPage(cursor === undefined ? filters : { ...filters, cursor }, requestOptions);
291
+ yield current;
292
+ if (isStudioListExhausted(current))
293
+ return;
294
+ const next = current.nextCursor;
295
+ // A server that hands back the cursor it was just given would otherwise
296
+ // spin until `maxPages`, re-fetching the same page every time.
297
+ if (next === cursor) {
298
+ throw new HonuaStudioError("internal", 500, `Studio list pagination stalled: the server returned the same cursor it was queried with (${next}).`);
299
+ }
300
+ cursor = next;
301
+ }
302
+ }
303
+ /** Drain a bounded page walk into one {@link StudioListCollection}. */
304
+ async function collectStudioList(pages) {
305
+ const items = [];
306
+ let pageCount = 0;
307
+ let total = 0;
308
+ let last;
309
+ for await (const page of pages) {
310
+ pageCount += 1;
311
+ total = page.total;
312
+ last = page;
313
+ items.push(...page.items);
314
+ }
315
+ // The generator stops either because the server said "no more" or because
316
+ // the page cap fired; only the second leaves a usable resume cursor behind.
317
+ const truncated = last !== undefined && !isStudioListExhausted(last);
318
+ return {
319
+ items,
320
+ pages: pageCount,
321
+ total,
322
+ nextCursor: truncated ? (last?.nextCursor ?? undefined) : undefined,
323
+ truncated,
324
+ };
325
+ }
326
+ function normalizePageBound(value) {
327
+ if (value === undefined)
328
+ return HONUA_STUDIO_LIST_MAX_PAGES;
329
+ if (!Number.isInteger(value) || value < 1) {
330
+ throw new TypeError(`maxPages must be an integer >= 1, received ${String(value)}.`);
331
+ }
332
+ return value;
333
+ }
334
+ /**
335
+ * Serialize the shared Studio list filters onto `path`.
336
+ *
337
+ * `family` and `state` accept one value or several and are sent in the
338
+ * server's comma-separated form. Blank strings are dropped rather than sent as
339
+ * an empty filter the server would trim to `null` anyway.
340
+ */
341
+ function withStudioListQuery(path, filters) {
342
+ const params = new URLSearchParams();
343
+ const family = joinListFilter(filters.family);
344
+ if (family)
345
+ params.set("family", family);
346
+ const state = joinListFilter(filters.state);
347
+ if (state)
348
+ params.set("state", state);
349
+ if (filters.workspaceId?.trim())
350
+ params.set("workspaceId", filters.workspaceId);
351
+ if (filters.owner?.trim())
352
+ params.set("owner", filters.owner);
353
+ if (filters.q?.trim())
354
+ params.set("q", filters.q);
355
+ if (filters.cursor)
356
+ params.set("cursor", filters.cursor);
357
+ if (filters.limit !== undefined)
358
+ params.set("limit", String(normalizeListLimit(filters.limit)));
359
+ const query = params.toString();
360
+ return query ? `${path}?${query}` : path;
361
+ }
362
+ function joinListFilter(value) {
363
+ const values = (typeof value === "string" ? [value] : (value ?? [])).map((entry) => entry.trim()).filter(Boolean);
364
+ return values.length > 0 ? values.join(",") : undefined;
365
+ }
366
+ /**
367
+ * Reject a `limit` the server would not honor as asked. Below `1` it would be
368
+ * replaced with the default and above {@link HONUA_STUDIO_LIST_MAX_LIMIT} it
369
+ * would be silently clamped — both hand back a page size the caller did not
370
+ * request, which is worse than a thrown programming error.
371
+ */
372
+ function normalizeListLimit(limit) {
373
+ if (!Number.isInteger(limit) || limit < 1 || limit > HONUA_STUDIO_LIST_MAX_LIMIT) {
374
+ throw new TypeError(`limit must be an integer between 1 and ${HONUA_STUDIO_LIST_MAX_LIMIT}, received ${String(limit)}.`);
375
+ }
376
+ return limit;
377
+ }
160
378
  /** `/content-items/{itemId}/versions*` and `/version-comparisons` — immutable content versions. */
161
379
  export class HonuaStudioContentVersionsClient {
162
380
  #lifecycle;
@@ -187,7 +405,58 @@ export class HonuaStudioContentVersionsClient {
187
405
  return this.#lifecycle.request("POST", `/content-items/${encodeURIComponent(itemId)}/versions/${encodeURIComponent(versionId)}/reopen`, request, options);
188
406
  }
189
407
  }
190
- /** `/content-items/{itemId}/versions/{versionId}/publish-requests`. */
408
+ /** Default delay between {@link HonuaStudioPublicationRequestsClient.poll} attempts. */
409
+ export const HONUA_STUDIO_PUBLICATION_POLL_INTERVAL_MS = 1_000;
410
+ /** Default attempt bound for {@link HonuaStudioPublicationRequestsClient.poll}. */
411
+ export const HONUA_STUDIO_PUBLICATION_POLL_MAX_ATTEMPTS = 30;
412
+ /**
413
+ * Body keys a publication *submission* may never carry. Approval is a
414
+ * separate principal acting through the canonical Admin API/CLI, so the SDK
415
+ * refuses to serialize a submission that tries to assert its own decision,
416
+ * override policy, or seed a terminal status — see
417
+ * {@link HonuaStudioPublicationRequestsClient} for why this is enforced
418
+ * client-side as well as server-side.
419
+ *
420
+ * Matching is on the exact key (case-insensitively); an additive server field
421
+ * that merely *mentions* approval (`approvalPolicyId`, say) is unaffected.
422
+ */
423
+ const REJECTED_SUBMISSION_KEYS = new Set([
424
+ "status",
425
+ "state",
426
+ "approve",
427
+ "approved",
428
+ "approval",
429
+ "approvedat",
430
+ "approvedby",
431
+ "autoapprove",
432
+ "selfapprove",
433
+ "skipapproval",
434
+ "bypassapproval",
435
+ "bypasspolicy",
436
+ "policyoverride",
437
+ "overridepolicy",
438
+ "force",
439
+ ]);
440
+ /**
441
+ * `/content-items/{itemId}/versions/{versionId}/publish-requests` — submit an
442
+ * already-saved immutable version for publication and walk the resulting
443
+ * proposal to a final state.
444
+ *
445
+ * ## Approval is a different principal
446
+ *
447
+ * This client can **create**, **get** and **poll** a publication proposal. It
448
+ * deliberately exposes no approve, authorize, force, or policy-override
449
+ * capability, and {@link create} refuses to serialize a submission body that
450
+ * tries to smuggle one (see {@link REJECTED_SUBMISSION_KEYS}). A proposer
451
+ * therefore cannot approve their own publication, or bypass policy, through
452
+ * any method on this class — approval happens through the canonical Admin
453
+ * API/CLI acting as a separate principal, and the server enforces the same
454
+ * rule with a `403` (`code: "forbidden"`) if a proposer tries anyway.
455
+ *
456
+ * {@link HonuaStudioLifecycleClient.raw} is a generic HTTP escape hatch with
457
+ * no Studio approval semantics of its own; it reaches whatever the server
458
+ * exposes and is subject to exactly the same server-side authorization.
459
+ */
191
460
  export class HonuaStudioPublicationRequestsClient {
192
461
  #lifecycle;
193
462
  constructor(lifecycle) {
@@ -195,12 +464,243 @@ export class HonuaStudioPublicationRequestsClient {
195
464
  }
196
465
  /**
197
466
  * `POST /content-items/{itemId}/versions/{versionId}/publish-requests` —
198
- * persist a publication request and move the published pointer when
199
- * validation permits.
467
+ * submit an already-saved immutable version for publication.
468
+ *
469
+ * The submission carries only the identity of what is being published
470
+ * (`itemId`, `versionId`, the optional `contentHash` pin), the requested
471
+ * route/visibility `intent`, and an optional `idempotencyKey` so a replayed
472
+ * submit resolves to the same proposal. It never carries a decision:
473
+ * an approval, policy-override, or `status` key in `request` throws a
474
+ * {@link HonuaStudioError} with `code: "validation"` **before** anything is
475
+ * sent.
476
+ *
477
+ * The response carries the joined `operationInstanceId`, `proposalId`,
478
+ * `proposalUri`, `auditId` and `correlationId` identifiers verbatim.
479
+ */
480
+ // `async` so the client-side approval guard below surfaces as a rejected
481
+ // promise, exactly like every server-side failure on this client, rather
482
+ // than as a synchronous throw callers would have to catch separately.
483
+ async create(itemId, versionId, request = {}, options = {}) {
484
+ assertNoApprovalFields(request);
485
+ return this.#lifecycle.request("POST", `${this.#path(itemId, versionId)}/publish-requests`, request, options);
486
+ }
487
+ /**
488
+ * `GET /content-items/{itemId}/versions/{versionId}/publish-requests/{requestId}`
489
+ * — read one publication proposal's current status and joined identifiers.
490
+ *
491
+ * A proposal that does not exist, or is not reachable from this
492
+ * `itemId`/`versionId` pair, throws `code: "not-found"`; one outside the
493
+ * caller's owner/tenant scope throws `code: "forbidden"`.
200
494
  */
201
- create(itemId, versionId, request = {}, options = {}) {
202
- return this.#lifecycle.request("POST", `/content-items/${encodeURIComponent(itemId)}/versions/${encodeURIComponent(versionId)}/publish-requests`, request, options);
495
+ get(itemId, versionId, requestId, options = {}) {
496
+ return this.#lifecycle.request("GET", `${this.#path(itemId, versionId)}/publish-requests/${encodeURIComponent(requestId)}`, undefined, options);
203
497
  }
498
+ /**
499
+ * Poll one publication proposal until it reaches a terminal state or a
500
+ * bound is reached, whichever comes first.
501
+ *
502
+ * - **Bounded by construction.** Every poll stops after at most
503
+ * `maxAttempts` `GET`s (and `timeoutMs`, when given). There is no
504
+ * unbounded mode.
505
+ * - **A final publication URL only at `Active`.** `Rejected` and `Failed`
506
+ * end the poll with `terminal: true`, `active: false` and no URL.
507
+ * - **An unrecognized status is never final.** A state this release does
508
+ * not know keeps the poll waiting until a bound is hit, and comes back as
509
+ * `state: undefined, terminal: false, active: false`.
510
+ * - **Cancellable.** `options.signal` aborts the in-flight request and the
511
+ * wait between attempts; the returned promise rejects with the signal's
512
+ * abort reason — including when the abort lands mid-request, where the
513
+ * underlying `HonuaClient` would otherwise normalize the rejection to a
514
+ * generic SDK abort error.
515
+ * - **`timeoutMs` is a real wall-clock bound.** It aborts an in-flight `GET`
516
+ * as well as being checked between attempts, and the inter-attempt wait is
517
+ * clamped to the time left, so a hung response cannot leave the poll
518
+ * pending past the deadline even when the underlying `HonuaClient` has no
519
+ * `timeoutMs` of its own. A deadline reached with at least one observation
520
+ * returns `exhausted: "timeout"`; a deadline that expires before the very
521
+ * first response arrives has nothing to report and throws
522
+ * {@link HonuaTimeoutError}.
523
+ * - **Standard error handling.** Every non-2xx surfaces as
524
+ * {@link HonuaStudioError} with its RFC 7807 problem details, exactly as
525
+ * for every other method on this client.
526
+ */
527
+ async poll(itemId, versionId, requestId, options = {}) {
528
+ const maxAttempts = normalizePollBound(options.maxAttempts, HONUA_STUDIO_PUBLICATION_POLL_MAX_ATTEMPTS);
529
+ const intervalMs = normalizeInterval(options.intervalMs);
530
+ const timeoutMs = options.timeoutMs === undefined ? undefined : normalizeInterval(options.timeoutMs);
531
+ // The wall-clock bound owns its own abort signal rather than being a
532
+ // between-attempts `Date.now()` check alone: `HonuaClient` carries no
533
+ // request timeout unless the consumer configured one, so a `GET` that is
534
+ // accepted and never answered would otherwise hold the poll open forever
535
+ // in spite of a finite `timeoutMs`.
536
+ const deadline = createPollDeadline(timeoutMs, options.signal);
537
+ const requestOptions = { signal: deadline.signal, headers: options.headers };
538
+ let observed;
539
+ try {
540
+ // Bounded by construction: the loop counter is the only thing that drives
541
+ // it, `maxAttempts` is validated to be a finite integer >= 1, and every
542
+ // path out of the body either returns or advances the counter.
543
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
544
+ throwIfAborted(options.signal);
545
+ let request;
546
+ try {
547
+ request = await this.get(itemId, versionId, requestId, requestOptions);
548
+ }
549
+ catch (error) {
550
+ // The caller's cancellation is reported with the caller's own
551
+ // `reason`. `HonuaClient.pipelineFetch` normalizes an in-flight fetch
552
+ // abort into a generic SDK abort error, which would otherwise lose it.
553
+ throwIfAborted(options.signal);
554
+ if (deadline.expired())
555
+ return this.#timedOut(observed, attempt - 1, timeoutMs);
556
+ throw error;
557
+ }
558
+ const state = normalizeStudioPublicationStatus(request.status);
559
+ observed = { request, state };
560
+ options.onStatus?.(request, state);
561
+ if (isStudioPublicationTerminal(request.status)) {
562
+ return toPollOutcome(request, state, attempt, undefined);
563
+ }
564
+ if (attempt >= maxAttempts) {
565
+ return toPollOutcome(request, state, attempt, "max-attempts");
566
+ }
567
+ const remainingMs = deadline.remainingMs();
568
+ if (remainingMs !== undefined && remainingMs <= 0) {
569
+ return toPollOutcome(request, state, attempt, "timeout");
570
+ }
571
+ // Never sleep past the deadline: a `intervalMs` larger than the time
572
+ // left would overshoot the documented bound by a whole interval.
573
+ await delay(remainingMs === undefined ? intervalMs : Math.min(intervalMs, remainingMs), options.signal);
574
+ const leftAfterWait = deadline.remainingMs();
575
+ if (leftAfterWait !== undefined && leftAfterWait <= 0) {
576
+ return toPollOutcome(request, state, attempt, "timeout");
577
+ }
578
+ }
579
+ }
580
+ finally {
581
+ deadline.dispose();
582
+ }
583
+ // Unreachable while `maxAttempts >= 1`; kept so the bound is enforced by
584
+ // the type system rather than by a comment.
585
+ throw new TypeError(`maxAttempts must be an integer >= 1, received ${String(maxAttempts)}.`);
586
+ }
587
+ /**
588
+ * The wall-clock bound fired. With at least one observation the poll reports
589
+ * it the documented way (`exhausted: "timeout"`); with none there is no
590
+ * proposal to report, so the deadline surfaces as the SDK's own
591
+ * {@link HonuaTimeoutError} rather than an invented empty outcome.
592
+ */
593
+ #timedOut(observed, attempts, timeoutMs) {
594
+ if (!observed)
595
+ throw new HonuaTimeoutError(timeoutMs ?? 0);
596
+ return toPollOutcome(observed.request, observed.state, attempts, "timeout");
597
+ }
598
+ #path(itemId, versionId) {
599
+ return `/content-items/${encodeURIComponent(itemId)}/versions/${encodeURIComponent(versionId)}`;
600
+ }
601
+ }
602
+ function createPollDeadline(timeoutMs, callerSignal) {
603
+ if (timeoutMs === undefined) {
604
+ return {
605
+ signal: callerSignal,
606
+ expired: () => false,
607
+ remainingMs: () => undefined,
608
+ dispose: () => undefined,
609
+ };
610
+ }
611
+ const controller = new AbortController();
612
+ const expiresAt = Date.now() + timeoutMs;
613
+ let expired = false;
614
+ const timer = setTimeout(() => {
615
+ expired = true;
616
+ controller.abort();
617
+ }, timeoutMs);
618
+ const onCallerAbort = () => {
619
+ controller.abort(callerSignal ? abortReason(callerSignal) : undefined);
620
+ };
621
+ if (callerSignal?.aborted)
622
+ onCallerAbort();
623
+ else
624
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
625
+ return {
626
+ signal: controller.signal,
627
+ expired: () => expired,
628
+ remainingMs: () => expiresAt - Date.now(),
629
+ dispose: () => {
630
+ clearTimeout(timer);
631
+ callerSignal?.removeEventListener("abort", onCallerAbort);
632
+ },
633
+ };
634
+ }
635
+ function toPollOutcome(request, state, attempts, exhausted) {
636
+ return {
637
+ request,
638
+ state,
639
+ terminal: isStudioPublicationTerminal(request.status),
640
+ active: isStudioPublicationActive(request.status),
641
+ publicationUrl: studioPublicationUrl(request),
642
+ attempts,
643
+ exhausted,
644
+ };
645
+ }
646
+ /**
647
+ * Throw a client-side `validation` {@link HonuaStudioError} — nothing is sent
648
+ * — when a submission body carries an approval or policy-override key.
649
+ */
650
+ function assertNoApprovalFields(request) {
651
+ const offending = Object.keys(request).filter((key) => REJECTED_SUBMISSION_KEYS.has(key.toLowerCase()));
652
+ if (offending.length === 0)
653
+ return;
654
+ const rule = [
655
+ "the proposer cannot approve their own publication or override policy.",
656
+ "Approval is a separate principal acting through the Admin API/CLI.",
657
+ ].join(" ");
658
+ throw new HonuaStudioError("validation", 400, `A publication submission must not carry ${offending.join(", ")}: ${rule}`);
659
+ }
660
+ function normalizePollBound(value, fallback) {
661
+ if (value === undefined)
662
+ return fallback;
663
+ if (!Number.isInteger(value) || value < 1) {
664
+ throw new TypeError(`maxAttempts must be an integer >= 1, received ${String(value)}.`);
665
+ }
666
+ return value;
667
+ }
668
+ function normalizeInterval(value) {
669
+ if (value === undefined)
670
+ return HONUA_STUDIO_PUBLICATION_POLL_INTERVAL_MS;
671
+ if (!Number.isFinite(value) || value < 0) {
672
+ throw new TypeError(`Poll interval and timeout must be finite and >= 0, received ${String(value)}.`);
673
+ }
674
+ return value;
675
+ }
676
+ function throwIfAborted(signal) {
677
+ if (signal?.aborted)
678
+ throw abortReason(signal);
679
+ }
680
+ function abortReason(signal) {
681
+ return signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
682
+ }
683
+ /** `setTimeout` that settles early — by rejecting with the abort reason — when `signal` aborts. */
684
+ function delay(ms, signal) {
685
+ if (ms <= 0) {
686
+ throwIfAborted(signal);
687
+ return Promise.resolve();
688
+ }
689
+ return new Promise((resolve, reject) => {
690
+ if (signal?.aborted) {
691
+ reject(abortReason(signal));
692
+ return;
693
+ }
694
+ const timer = setTimeout(() => {
695
+ signal?.removeEventListener("abort", onAbort);
696
+ resolve();
697
+ }, ms);
698
+ const onAbort = () => {
699
+ clearTimeout(timer);
700
+ reject(abortReason(signal));
701
+ };
702
+ signal?.addEventListener("abort", onAbort, { once: true });
703
+ });
204
704
  }
205
705
  /** `/content-items/{itemId}/rollback-requests`. */
206
706
  export class HonuaStudioRollbackRequestsClient {
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Every non-2xx response from the Studio package lifecycle API
5
5
  * (`/api/v{version}/studio`) is either a handler-generated client error
6
- * (`400`/`404`/`409`) or a caught internal failure (`500`), and both use
6
+ * (`400`/`401`/`403`/`404`/`409`) or a caught internal failure (`500`), and
7
+ * both use
7
8
  * RFC 7807 problem details with `type: "https://honua.io/problems/studio"`
8
9
  * (see `docs/internal/admin-api/studio-package-lifecycle.md` in
9
10
  * `honua-server`). {@link HonuaStudioError} normalizes both into one typed
@@ -30,6 +31,11 @@
30
31
  * status code of the failed response:
31
32
  *
32
33
  * - `validation` — `400`, request or envelope failed validation.
34
+ * - `unauthorized` — `401`, the request carried no usable principal.
35
+ * - `forbidden` — `403`, the principal is authenticated but is not permitted
36
+ * to perform the operation — the owner/tenant scope ceiling, and the
37
+ * separate-approver rule that stops a proposer from approving their own
38
+ * publication (see {@link HonuaStudioPublicationRequestsClient}).
33
39
  * - `not-found` — `404`, the draft/version/content item does not exist (or,
34
40
  * per the API doc, is not reachable from the given `itemId`/`versionId`
35
41
  * pair — cross-item ids are treated as not found).
@@ -41,7 +47,7 @@
41
47
  *
42
48
  * @experimental
43
49
  */
44
- export type HonuaStudioProblemKind = "validation" | "not-found" | "generation-conflict" | "internal" | "unknown";
50
+ export type HonuaStudioProblemKind = "validation" | "unauthorized" | "forbidden" | "not-found" | "generation-conflict" | "internal" | "unknown";
45
51
  /**
46
52
  * RFC 7807 problem-details body returned by the Studio package lifecycle API.
47
53
  * `type` is `"https://honua.io/problems/studio"` on every documented error
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle-errors.d.ts","sourceRoot":"","sources":["../../../src/studio/lifecycle-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,sBAAsB,GAAG,YAAY,GAAG,WAAW,GAAG,qBAAqB,GAAG,UAAU,GAAG,SAAS,CAAC;AAEjH;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IACpD,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACnC;AAED,8FAA8F;AAC9F,eAAO,MAAM,yBAAyB,EAAG,kCAA2C,CAAC;AAErF;;;;;;;GAOG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,IAAI,EAAE,sBAAsB,CAAC;IAC7C,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,OAAO,EAAE,yBAAyB,GAAG,SAAS,CAAC;gBAG7D,IAAI,EAAE,sBAAsB,EAC5B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,yBAAyB,EACnC,OAAO,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAQ7C;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAEzF;AAED,oFAAoF;AACpF,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,sBAAsB,CAatF"}
1
+ {"version":3,"file":"lifecycle-errors.d.ts","sourceRoot":"","sources":["../../../src/studio/lifecycle-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,sBAAsB,GAC9B,YAAY,GACZ,cAAc,GACd,WAAW,GACX,WAAW,GACX,qBAAqB,GACrB,UAAU,GACV,SAAS,CAAC;AAEd;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IACpD,QAAQ,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CACnC;AAED,8FAA8F;AAC9F,eAAO,MAAM,yBAAyB,EAAG,kCAA2C,CAAC;AAErF;;;;;;;GAOG;AACH,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,IAAI,EAAE,sBAAsB,CAAC;IAC7C,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,OAAO,EAAE,yBAAyB,GAAG,SAAS,CAAC;gBAG7D,IAAI,EAAE,sBAAsB,EAC5B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,yBAAyB,EACnC,OAAO,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAQ7C;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAE5E;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAEzF;AAED,oFAAoF;AACpF,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,sBAAsB,CAiBtF"}
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Every non-2xx response from the Studio package lifecycle API
5
5
  * (`/api/v{version}/studio`) is either a handler-generated client error
6
- * (`400`/`404`/`409`) or a caught internal failure (`500`), and both use
6
+ * (`400`/`401`/`403`/`404`/`409`) or a caught internal failure (`500`), and
7
+ * both use
7
8
  * RFC 7807 problem details with `type: "https://honua.io/problems/studio"`
8
9
  * (see `docs/internal/admin-api/studio-package-lifecycle.md` in
9
10
  * `honua-server`). {@link HonuaStudioError} normalizes both into one typed
@@ -65,6 +66,10 @@ export function classifyStudioProblemStatus(statusCode) {
65
66
  switch (statusCode) {
66
67
  case 400:
67
68
  return "validation";
69
+ case 401:
70
+ return "unauthorized";
71
+ case 403:
72
+ return "forbidden";
68
73
  case 404:
69
74
  return "not-found";
70
75
  case 409: