@civitai/sdk 0.14.1 → 0.16.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/README.md CHANGED
@@ -96,6 +96,7 @@ the refusal sits at the surface, not at startup:
96
96
  |---|---|
97
97
  | `app.storage.*` | Works. This is the token app storage requires |
98
98
  | `app.sharedStorage.*` | Reads work, anonymous included. Writes additionally need a signed-in viewer past the server's minimum-trust gate, so a `403` there is about the VIEWER, not the token |
99
+ | `app.subListings.*` | Works for a signed-in viewer when the token carries `apps:store:items:write` — never in a dev-token, dev-tunnel or review session |
99
100
  | `app.site` on `blocks/…` | Works. These are the routes the token was minted for |
100
101
  | `app.site.get('me')` and the rest of `/api/v1` | The API's own 401/403, with `auth: "oauth"` named in the message. `status` and `body` are untouched, so a caller can still branch on them |
101
102
  | `app.workflows.*` | Works. Generation for a block goes here, with the `ai:write:budgeted` scope |
@@ -273,7 +274,12 @@ Anonymous viewers may read `shared` collections and nothing else
273
274
  (`sign_in_required`). Creating or editing a document in a viewer-written shared
274
275
  collection needs the consent-gated `apps:storage:publish`; deleting your own, marks
275
276
  and counters need only `apps:storage`. Shared writes pass a minimum-trust gate and
276
- moderation. Only phase-one operations exist: no batch writes, change feed, TTL or
277
+ moderation. A shared `write: "own"` collection whose manifest declares
278
+ `ownedModelVersions` (pointers such as `/workflow/modelVersionId`) refuses a create
279
+ or `put` whose `data` holds, at one of those fields, anything but a model version
280
+ the viewer owns: `resource_not_owned` (403), `details.pointer` naming the field,
281
+ before moderation and before any write budget. An absent field is allowed;
282
+ ownership is checked only when the document is written. Only phase-one operations exist: no batch writes, change feed, TTL or
277
283
  owner-written documents yet.
278
284
 
279
285
  The legacy stores below keep working, unchanged, while apps move; their methods are
@@ -428,6 +434,62 @@ an existence oracle, so do not report that as "someone else owns this". Put ever
428
434
  string other viewers will read in `title`/`body`, which the server moderates;
429
435
  `data` is unmoderated app structure.
430
436
 
437
+ ## Store sub-listings
438
+
439
+ `app.subListings` publishes an item a viewer made in your app — a row they
440
+ authored in [shared storage](#shared-storage) — as its **own App Store card**,
441
+ under the viewer's name, badged "in ‹your app›". Three methods over
442
+ `/api/v1/blocks/sub-listings/*`: `upsert`, `withdraw`, `mine`.
443
+
444
+ ```ts
445
+ import { SubListingError } from '@civitai/sdk';
446
+
447
+ try {
448
+ const { id, status, pendingEdit } = await app.subListings.upsert({
449
+ itemKey: 'preset-7f3a', // the key of the viewer's own shared-storage row
450
+ title: 'Moody film portraits',
451
+ subPath: 'p/preset-7f3a', // opens /apps/run/<your-app-slug>/p/preset-7f3a
452
+ tagline: null, // a left-out optional is CLEARED: re-send what you want to keep
453
+ });
454
+ } catch (error) {
455
+ if (error instanceof SubListingError && error.code === 'insufficient_scope') {
456
+ // dev-token, dev-tunnel and review sessions never carry the scope: hide the control
457
+ } else throw error;
458
+ }
459
+
460
+ const { withdrawn } = await app.subListings.withdraw('preset-7f3a');
461
+ const { items } = await app.subListings.mine(); // oldest first, with review status
462
+ ```
463
+
464
+ - **`upsert` replaces the whole item, it does not patch it.** An optional that is
465
+ `null` or `undefined` is left out of the request, and the server stores a
466
+ left-out optional as empty — so an edit that sends only `title` clears the
467
+ item's tagline and image and resets its rating to the app's. Re-send every
468
+ field you want to keep.
469
+ - **The server body is strict** — an unknown key is `400 invalid_body` — so the
470
+ client sends only `itemKey`, `title`, `subPath`, `tagline`, `imageId` and
471
+ `contentRating`, whatever else the object you pass carries. Every other rule (lengths, the `subPath` shape, the rating floor, the image
472
+ checks) is the server's and comes back as a refusal.
473
+ - **A refusal is a `SubListingError`** — an `ApiError` with a typed `code`
474
+ (`not_enabled`, `untrusted`, `item_not_found`, `not_your_item`, `text_rejected`,
475
+ `image_not_public`, `image_not_yours`, `rating_too_loose`, `rate_limited`,
476
+ `author_cap`, `conflict`, `unavailable`, …). Branch on `code`; the message is a
477
+ server sentence, not UI copy. A refusal with no `code` stays a plain `ApiError`.
478
+ A `429`'s `Retry-After` header is not exposed.
479
+ - **Every item waits for a moderator.** `status` is `pending`, `approved`,
480
+ `hidden` or `withdrawn`; an `upsert` answered `hidden` changed nothing, and the
481
+ app cannot lift it. Editing an approved item stages the edit
482
+ (`pendingEdit: true`) while the card keeps serving the approved version.
483
+ - `withdraw` answering `withdrawn: false` is a success: nothing of the viewer's was
484
+ active under that key, or a moderator has hidden it.
485
+
486
+ 🔴 It needs the scope `apps:store:items:write` in the manifest (with a
487
+ `scopeJustifications` entry), and that scope is consent-exempt, refused to an
488
+ anonymous viewer, and **never minted for dev-token, dev-tunnel or review
489
+ sessions** — so these calls only succeed from an approved version of your app,
490
+ and Civitai must also enable your app for sub-listings (`not_enabled` until then).
491
+ Build the publish path to degrade quietly.
492
+
431
493
  ## Multi-step workflows
432
494
 
433
495
  `app.workflows` is how a **block** generates. It goes through the host's
@@ -917,6 +979,13 @@ Where it differs from the server on purpose:
917
979
  not a manifest validator otherwise; `civitai app validate` is.
918
980
  - A list `nextCursor` is an opaque token, refused for any other collection, sort or
919
981
  filter than the query that issued it. Treat it as opaque against the server too.
982
+ - It has no model versions of its own, so a collection's `ownedModelVersions` rule
983
+ checks writes against the viewer's `modelVersions` — the ids that viewer owns,
984
+ none by default (so every id is refused, as the server refuses one that is not
985
+ the writer's):
986
+ `fake.as({ viewer: { username: 'bob', modelVersions: [4317] } })`. It does not
987
+ model the server's ownership lookup failing (a retryable 503
988
+ `service_unavailable`).
920
989
 
921
990
  `fake.calls` records every request and answer. Options also cover smaller `limits`,
922
991
  `consent` (revoked scopes, or `'unavailable'`), `appStatus`, `disabled` and a custom
@@ -7,6 +7,7 @@ import { type SharedStorageClient } from '../shared-storage/index.js';
7
7
  import { type DocumentStorage } from '../doc-store/index.js';
8
8
  import { type SiteClient } from '../site/index.js';
9
9
  import { type StorageClient } from '../storage/index.js';
10
+ import { type SubListingsClient } from '../sub-listings/index.js';
10
11
  import { type WorkflowsClient } from '../workflows/index.js';
11
12
  export interface AppClient {
12
13
  /**
@@ -52,6 +53,16 @@ export interface AppClient {
52
53
  * clear a server-side minimum-trust gate.
53
54
  */
54
55
  readonly sharedStorage: SharedStorageClient;
56
+ /**
57
+ * The viewer's own App Store items for this app: `upsert`, `withdraw`, `mine`
58
+ * over the `blocks/sub-listings/*` routes. A refusal the server explained is a
59
+ * `SubListingError` with a typed `code`.
60
+ *
61
+ * 🔴 Requires the block token and `apps:store:items:write`, which is never
62
+ * minted for dev-token, dev-tunnel or review sessions and is refused to an
63
+ * anonymous viewer — so build the publish path to degrade quietly.
64
+ */
65
+ readonly subListings: SubListingsClient;
55
66
  /**
56
67
  * Generation for a BLOCK: estimate, submit, watch and cancel through the
57
68
  * host's `blocks/workflows/*` routes, which price, budget and attribute the
package/dist/app/index.js CHANGED
@@ -8,6 +8,7 @@ import { createSharedStorageClient, } from '../shared-storage/index.js';
8
8
  import { createDocumentStorage } from '../doc-store/index.js';
9
9
  import { createSiteClient, DEFAULT_SITE_URL } from '../site/index.js';
10
10
  import { createStorageClient } from '../storage/index.js';
11
+ import { createSubListingsClient } from '../sub-listings/index.js';
11
12
  import { createWorkflowsClient } from '../workflows/index.js';
12
13
  /** Long enough for a slow host page; short enough that a block opened on its own says so. */
13
14
  const DEFAULT_INIT_TIMEOUT_MS = 10_000;
@@ -82,16 +83,17 @@ const OAUTH_OPT_IN = 'declare `auth: "oauth"` in block.manifest.json';
82
83
  const BLOCK_NAMESPACE = /^\/*blocks\//;
83
84
  function createAppClient(session, options, holdsBlockTokenNow = () => false, holdsOAuthTokenNow = () => false) {
84
85
  const http = (baseUrl) => createHttp({ session, baseUrl, fetch: options.fetch });
85
- // One instance for all four site-hosted surfaces: the storage and workflows
86
- // clients' routes live under the same base URL, so a `siteUrl` override
87
- // redirects them together.
86
+ // One instance for every site-hosted surface: the storage, sub-listings and
87
+ // workflows clients' routes live under the same base URL, so a `siteUrl`
88
+ // override redirects them together.
88
89
  // Wrapped once, so `BLOCK_NAMESPACE` is the single place that decides where the
89
- // diagnosis applies. None of the four `blocks/*` clients needs an exemption
90
+ // diagnosis applies. None of the five `blocks/*` clients needs an exemption
90
91
  // of its own: their routes are `blocks/app-storage/*`, `blocks/shared-storage/*`,
91
- // `blocks/storage/*` and `blocks/workflows/*`, which that namespace already excludes, and
92
- // `test/storage/seam.test.ts`, `test/shared-storage/seam.test.ts`,
93
- // `test/doc-store/client.test.ts` and `test/workflows/seam.test.ts` pin the four
94
- // `BASE` values there textually.
92
+ // `blocks/storage/*`, `blocks/sub-listings/*` and `blocks/workflows/*`, which that
93
+ // namespace already excludes, and `test/storage/seam.test.ts`,
94
+ // `test/shared-storage/seam.test.ts`, `test/doc-store/client.test.ts`,
95
+ // `test/sub-listings/sub-listings.test.ts` and `test/workflows/seam.test.ts` pin the
96
+ // five `BASE` values there textually.
95
97
  const siteHttp = explainApiRefusal(http(options.siteUrl ?? DEFAULT_SITE_URL), holdsBlockTokenNow);
96
98
  return {
97
99
  site: createSiteClient(siteHttp),
@@ -100,6 +102,7 @@ function createAppClient(session, options, holdsBlockTokenNow = () => false, hol
100
102
  // reference to the other.
101
103
  storage: { ...createStorageClient(siteHttp), ...createDocumentStorage(siteHttp) },
102
104
  sharedStorage: createSharedStorageClient(siteHttp),
105
+ subListings: createSubListingsClient(siteHttp),
103
106
  workflows: createWorkflowsClient(explainWorkflowRefusal(siteHttp, holdsOAuthTokenNow)),
104
107
  orchestration: createOrchestrationClient(refuseBlockToken(http(options.orchestrationUrl ?? DEFAULT_ORCHESTRATION_URL), holdsBlockTokenNow)),
105
108
  requestGrants: (scopes, opts) => session.requestGrants(scopes, opts),
@@ -25,6 +25,13 @@ export interface FakeStorageViewer {
25
25
  trusted?: boolean;
26
26
  /** `details.reason` when untrusted. Default `account_too_new`. */
27
27
  untrustedReason?: string;
28
+ /**
29
+ * Ids of the model versions whose model this viewer owns — what a collection's
30
+ * `ownedModelVersions` rule checks a write against. Default: none, so every id
31
+ * at a declared field is refused `resource_not_owned`, as the server refuses a
32
+ * version that is not the writer's (or does not exist).
33
+ */
34
+ modelVersions?: readonly number[];
28
35
  }
29
36
  /** Who is calling: a viewer (or `null`, anonymous) holding a token of some kind and scopes. */
30
37
  export interface FakeStorageCaller {
@@ -18,10 +18,15 @@ import { createDocumentStorage } from './index.js';
18
18
  * the fake is CREATED rather than per request; and a list cursor is an opaque token
19
19
  * bound to its whole query, not the server's sort-bound keyset string.
20
20
  *
21
+ * A collection's `ownedModelVersions` rule is enforced; the fake has no model
22
+ * versions of its own, so which ones a viewer owns is `FakeStorageViewer.modelVersions`
23
+ * (default none).
24
+ *
21
25
  * NOT MODELLED: moderator hides (a hidden document reads as not found on the server;
22
26
  * nothing here hides one), moderator exemption from the scanner, the review-preview
23
- * namespace, and the token middleware's other refusals (revoked instance, context
24
- * binding, an approval read that fails).
27
+ * namespace, the token middleware's other refusals (revoked instance, context
28
+ * binding, an approval read that fails), and a failed ownership lookup (the
29
+ * server's retryable 503 `service_unavailable`).
25
30
  */
26
31
  // ---------------------------------------------------------------------------
27
32
  // Bounds — the server's, one copy here (the contract run pins the ones it reaches)
@@ -85,6 +90,7 @@ const STATUS = {
85
90
  not_eligible: 403,
86
91
  not_author: 403,
87
92
  write_policy: 403,
93
+ resource_not_owned: 403,
88
94
  not_found: 404,
89
95
  collection_not_declared: 404,
90
96
  method_not_allowed: 405,
@@ -97,6 +103,11 @@ const STATUS = {
97
103
  moderation_unavailable: 503,
98
104
  consent_unavailable: 503,
99
105
  };
106
+ /** The manifest's `ownedModelVersions` pointer: 1 to 4 plain identifier segments. */
107
+ const OWNED_POINTER = /^(\/[A-Za-z_][A-Za-z0-9_]{0,63}){1,4}$/u;
108
+ const MAX_OWNED_POINTERS = 4;
109
+ /** Postgres `integer`: the server refuses a larger id before querying for it. */
110
+ const INT4_MAX = 2_147_483_647;
100
111
  const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
101
112
  /** Declarations the platform does not serve yet, each with what to do instead. */
102
113
  const PHASED = [
@@ -144,12 +155,30 @@ function parseManifest(manifest) {
144
155
  else
145
156
  errors.push(`${at}.indexes.${path}: type must be one of ${INDEX_TYPES.join(', ')}`);
146
157
  }
158
+ const ownedModelVersions = [];
159
+ if (raw.ownedModelVersions !== undefined) {
160
+ const list = raw.ownedModelVersions;
161
+ if (!shared || raw.write !== 'own') {
162
+ errors.push(`${at}.ownedModelVersions: applies only to shared collections with write "own"`);
163
+ }
164
+ else if (!Array.isArray(list) ||
165
+ list.length < 1 ||
166
+ list.length > MAX_OWNED_POINTERS ||
167
+ !list.every((p) => typeof p === 'string' && OWNED_POINTER.test(p)) ||
168
+ new Set(list).size !== list.length) {
169
+ errors.push(`${at}.ownedModelVersions: must be 1 to ${MAX_OWNED_POINTERS} distinct pointers such as "/workflow/modelVersionId"`);
170
+ }
171
+ else {
172
+ ownedModelVersions.push(...list);
173
+ }
174
+ }
147
175
  const decl = {
148
176
  visibility: raw.visibility,
149
177
  counters: shared ? names(raw.counters) : [],
150
178
  marks: shared ? names(raw.marks) : [],
151
179
  indexes,
152
180
  showAuthor: shared && raw.showAuthor === true,
181
+ ownedModelVersions,
153
182
  };
154
183
  if (shared && (raw.write === 'own' || raw.write === 'none'))
155
184
  decl.write = raw.write;
@@ -893,6 +922,41 @@ export function createFakeStorage(options) {
893
922
  });
894
923
  }
895
924
  }
925
+ /**
926
+ * The collection's `ownedModelVersions` rule, as the server's
927
+ * `doc-store-ownership.ts` applies it: a pointer that resolves to nothing is
928
+ * allowed; every value found must be a positive `integer` id — all of them are
929
+ * checked before any ownership — and then the writer's own version. One code
930
+ * and message for every reason, so a refusal reveals nothing about whether a
931
+ * version exists.
932
+ */
933
+ function assertOwnedModelVersions(decl, data, viewer) {
934
+ if (decl.ownedModelVersions.length === 0)
935
+ return;
936
+ const notOwned = (pointer) => refuse('resource_not_owned', 'the model version at this field is not one the author owns', { pointer });
937
+ const claimed = [];
938
+ for (const pointer of decl.ownedModelVersions) {
939
+ let cur = data;
940
+ let found = true;
941
+ for (const seg of pointer.split('/').slice(1)) {
942
+ if (!isObject(cur) || !Object.prototype.hasOwnProperty.call(cur, seg)) {
943
+ found = false;
944
+ break;
945
+ }
946
+ cur = cur[seg];
947
+ }
948
+ if (!found)
949
+ continue;
950
+ if (typeof cur !== 'number' || !Number.isInteger(cur) || cur < 1 || cur > INT4_MAX)
951
+ throw notOwned(pointer);
952
+ claimed.push({ pointer, id: cur });
953
+ }
954
+ const owned = new Set(viewer.modelVersions ?? []);
955
+ for (const { pointer, id } of claimed) {
956
+ if (!owned.has(id))
957
+ throw notOwned(pointer);
958
+ }
959
+ }
896
960
  /** The server's refusals that are ITS fault: refunded without a rejection charge. */
897
961
  const isOurFault = (err) => !(err instanceof Refusal) ||
898
962
  (err.code === 'rate_limited' ? err.details?.reason === 'contention' : ['storage_disabled', 'moderation_unavailable', 'consent_unavailable'].includes(err.code));
@@ -1032,6 +1096,11 @@ export function createFakeStorage(options) {
1032
1096
  throw invalid('ifVersion must be a non-negative integer');
1033
1097
  deleteVersion = Number(raw);
1034
1098
  }
1099
+ // Every write that sets `data`, before the trust gate, the write budget and the
1100
+ // moderation scan (and so before the store's own checks), as the server orders it.
1101
+ const newData = created?.data ?? putIn?.data;
1102
+ if (newData)
1103
+ assertOwnedModelVersions(decl, newData, viewer);
1035
1104
  const write = (moderated, landed) => {
1036
1105
  if (created) {
1037
1106
  if (moderated)
@@ -171,7 +171,7 @@ export interface DocumentStorage {
171
171
  * Every code the server documents for these routes. The type stays open
172
172
  * (`string & {}`) so a code a later server adds still reaches the caller.
173
173
  */
174
- export type StorageErrorCode = 'invalid_request' | 'index_not_declared' | 'unauthenticated' | 'sign_in_required' | 'insufficient_scope' | 'consent_required' | 'not_eligible' | 'not_author' | 'write_policy' | 'not_found' | 'collection_not_declared' | 'method_not_allowed' | 'version_conflict' | 'index_building' | 'cursor_expired' | 'too_large' | 'quota_exceeded' | 'content_rejected' | 'rate_limited' | 'internal_error' | 'storage_disabled' | 'moderation_unavailable' | 'consent_unavailable' | 'service_unavailable' | (string & {});
174
+ export type StorageErrorCode = 'invalid_request' | 'index_not_declared' | 'unauthenticated' | 'sign_in_required' | 'insufficient_scope' | 'consent_required' | 'not_eligible' | 'not_author' | 'write_policy' | 'resource_not_owned' | 'not_found' | 'collection_not_declared' | 'method_not_allowed' | 'version_conflict' | 'index_building' | 'cursor_expired' | 'too_large' | 'quota_exceeded' | 'content_rejected' | 'rate_limited' | 'internal_error' | 'storage_disabled' | 'moderation_unavailable' | 'consent_unavailable' | 'service_unavailable' | (string & {});
175
175
  /**
176
176
  * A refusal from the document store, in its one envelope `{ code, message, error, details? }`.
177
177
  * Branch on `code`; `status` and `body` are {@link ApiError}'s, as sent.
package/dist/index.d.ts CHANGED
@@ -11,6 +11,8 @@ export type { GrantOptions, Scope, TokenOptions, TokenSource } from './session/i
11
11
  export type { SiteClient } from './site/index.js';
12
12
  export type { SharedItem, SharedListQuery, SharedListResult, SharedStorageCallOptions, SharedStorageClient, SharedValue, } from './shared-storage/index.js';
13
13
  export type { StorageCallOptions, StorageClient, StorageKeyEntry, StorageListQuery, StorageListResult, StorageQuota, } from './storage/index.js';
14
+ export { SubListingError } from './sub-listings/index.js';
15
+ export type { MySubListing, SubListingCallOptions, SubListingContentRating, SubListingErrorCode, SubListingStatus, SubListingsClient, SubListingUpsertInput, SubListingUpsertResult, SubListingWithdrawResult, } from './sub-listings/index.js';
14
16
  export { StorageError } from './doc-store/index.js';
15
17
  export type { DocumentAuthor, DocumentCallOptions, DocumentListQuery, DocumentPage, DocumentStorage, DocumentWhereValue, StorageCollection, StorageCollectionInfo, StorageDocument, StorageErrorCode, StorageIndexType, StorageInfo, StorageLimits, StorageUsage, WriteOptions, } from './doc-store/index.js';
16
18
  export { SubmissionUnconfirmedError } from './workflows/index.js';
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  export { initialize } from './app/index.js';
17
17
  export { createSignIn, SignInError } from './sign-in/index.js';
18
18
  export { SCOPES } from './session/index.js';
19
+ export { SubListingError } from './sub-listings/index.js';
19
20
  export { StorageError } from './doc-store/index.js';
20
21
  export { SubmissionUnconfirmedError } from './workflows/index.js';
21
22
  export { isTerminal, isTerminalStatus } from './orchestration/index.js';
@@ -0,0 +1,137 @@
1
+ import { ApiError, type Http } from '../http/index.js';
2
+ export interface SubListingCallOptions {
3
+ signal?: AbortSignal;
4
+ }
5
+ /** A sub-listing's review state. Only `approved` items are in the store. */
6
+ export type SubListingStatus = 'pending' | 'approved' | 'hidden' | 'withdrawn';
7
+ /** The listing rating ladder, least to most mature. */
8
+ export type SubListingContentRating = 'g' | 'pg' | 'pg13' | 'r' | 'x';
9
+ /**
10
+ * What `upsert` publishes: one item the VIEWER made in this app, as its own
11
+ * App Store card under the viewer's name.
12
+ *
13
+ * The server owns every rule (lengths, the `subPath` shape, the rating floor)
14
+ * and answers a breach with a {@link SubListingError}; nothing is pre-checked
15
+ * here, so the rules have one home.
16
+ */
17
+ export interface SubListingUpsertInput {
18
+ /**
19
+ * The key of a row in this app's SHARED storage (`app.sharedStorage`) that the
20
+ * viewer authored and that is not hidden. One sub-listing per key: publishing
21
+ * the same key again edits it, and every call REPLACES the whole item — an
22
+ * optional you leave out is cleared, so re-send the ones you want to keep.
23
+ */
24
+ itemKey: string;
25
+ title: string;
26
+ /** Optional. Left out (`null`/`undefined`), the item has no tagline. */
27
+ tagline?: string | null;
28
+ /**
29
+ * An image the viewer uploaded and that is publicly visible. Left out
30
+ * (`null`/`undefined`), the card shows the app's cover.
31
+ */
32
+ imageId?: number | null;
33
+ /**
34
+ * Where the card opens your app: `/apps/run/<your-app-slug>/<subPath>`. One to
35
+ * four `/`-separated segments of letters, digits, `_` and `-`.
36
+ */
37
+ subPath: string;
38
+ /**
39
+ * No less mature than the app's own rating. Left out (`null`/`undefined`), the
40
+ * item inherits the app's rating.
41
+ */
42
+ contentRating?: SubListingContentRating | null;
43
+ }
44
+ export interface SubListingUpsertResult {
45
+ id: string;
46
+ /**
47
+ * 🔴 `hidden` is an ANSWER, not a failure: a moderator took the item down, the
48
+ * app cannot lift it, and the upsert changed nothing.
49
+ */
50
+ status: SubListingStatus;
51
+ /** True while an edit to an approved item waits for a moderator; the card keeps serving the approved version. */
52
+ pendingEdit: boolean;
53
+ }
54
+ export interface SubListingWithdrawResult {
55
+ ok: true;
56
+ /**
57
+ * 🔴 `false` is a SUCCESS: nothing of this viewer's was active under that key —
58
+ * or a moderator has hidden it, which the app cannot change.
59
+ */
60
+ withdrawn: boolean;
61
+ }
62
+ /** One of the viewer's own items for this app, as `mine` lists it. */
63
+ export interface MySubListing {
64
+ id: string;
65
+ itemKey: string;
66
+ status: SubListingStatus;
67
+ title: string;
68
+ pendingEdit: boolean;
69
+ /** Why a moderator hid it, when one said. */
70
+ statusReason: string | null;
71
+ /** Why a moderator rejected the last staged edit, when one said. */
72
+ editRejectionReason: string | null;
73
+ /** The wire's ISO string, revived once. */
74
+ updatedAt: Date;
75
+ }
76
+ /**
77
+ * The `code` of a sub-listing refusal.
78
+ *
79
+ * The named codes are the ones the three sub-listing routes raise themselves,
80
+ * plus the two token refusals an app should expect here: `insufficient_scope`
81
+ * (the token lacks `apps:store:items:write` — every dev-token, dev-tunnel and
82
+ * review session) and `context_binding` (an anonymous viewer). Any other wire
83
+ * code still arrives, as a plain string.
84
+ */
85
+ export type SubListingErrorCode = 'invalid_body' | 'anonymous' | 'not_enabled' | 'untrusted' | 'item_not_found' | 'not_your_item' | 'text_rejected' | 'image_not_yours' | 'image_not_public' | 'rating_too_loose' | 'rate_limited' | 'author_cap' | 'conflict' | 'unavailable' | 'insufficient_scope' | 'context_binding' | (string & {});
86
+ /**
87
+ * A refusal from a sub-listing route, whose body is `{ error, code }`. Branch on
88
+ * `code`: `error` (this error's `message`) is a server sentence, not UI copy.
89
+ * `status` and `body` are {@link ApiError}'s, as sent.
90
+ *
91
+ * ⚠ A `429` carries a `Retry-After` header this client does not expose
92
+ * (`ApiError` keeps no headers).
93
+ */
94
+ export declare class SubListingError extends ApiError {
95
+ readonly code: SubListingErrorCode;
96
+ constructor(status: number, code: SubListingErrorCode, message: string, body: unknown, options?: ErrorOptions);
97
+ }
98
+ /**
99
+ * The viewer's own App Store items for THIS app — sub-listings of the app's
100
+ * listing, each published by the viewer under their name and reviewed by a
101
+ * moderator before it shows in the store.
102
+ *
103
+ * 🔴 Requires the block token and the scope `apps:store:items:write`, which is
104
+ * consent-exempt, signed-in viewers only, and never minted for dev-token,
105
+ * dev-tunnel or review sessions — so a refusal here is a normal outcome, and the
106
+ * publish path must degrade quietly. The app must also be enabled for
107
+ * sub-listings by Civitai (`not_enabled` until it is).
108
+ *
109
+ * 🔴 EVERY FAILURE REJECTS: a refusal the server explained is a
110
+ * {@link SubListingError}; any other failure is the {@link ApiError} or
111
+ * `CivitaiError` it was.
112
+ */
113
+ export interface SubListingsClient {
114
+ /**
115
+ * Publishes, or edits, the item at `itemKey`. `POST blocks/sub-listings/upsert`.
116
+ *
117
+ * 🔴 The server's body is STRICT — an unknown key is `400 invalid_body` — so
118
+ * only the six fields of {@link SubListingUpsertInput} are sent. An optional
119
+ * that is `null` or `undefined` is left out rather than sent; the server stores
120
+ * either as empty.
121
+ *
122
+ * 🔴 It REPLACES, it does not patch: an edit that leaves out `tagline`,
123
+ * `imageId` or `contentRating` clears it (on an approved item, the cleared
124
+ * version is what goes to the moderator). Re-send every field you want to keep.
125
+ */
126
+ upsert(input: SubListingUpsertInput, opts?: SubListingCallOptions): Promise<SubListingUpsertResult>;
127
+ /** Takes the viewer's item at `itemKey` out of the store. `POST blocks/sub-listings/withdraw`. */
128
+ withdraw(itemKey: string, opts?: SubListingCallOptions): Promise<SubListingWithdrawResult>;
129
+ /**
130
+ * The viewer's items for this app, oldest first, at most 200, with their
131
+ * review state. `GET blocks/sub-listings/mine`.
132
+ */
133
+ mine(opts?: SubListingCallOptions): Promise<{
134
+ items: MySubListing[];
135
+ }>;
136
+ }
137
+ export declare function createSubListingsClient(http: Http): SubListingsClient;
@@ -0,0 +1,122 @@
1
+ import { CivitaiError } from '../core/errors.js';
2
+ import { ApiError } from '../http/index.js';
3
+ /** Where the three routes this client wraps live under the site base URL. */
4
+ const BASE = 'blocks/sub-listings';
5
+ /**
6
+ * A refusal from a sub-listing route, whose body is `{ error, code }`. Branch on
7
+ * `code`: `error` (this error's `message`) is a server sentence, not UI copy.
8
+ * `status` and `body` are {@link ApiError}'s, as sent.
9
+ *
10
+ * ⚠ A `429` carries a `Retry-After` header this client does not expose
11
+ * (`ApiError` keeps no headers).
12
+ */
13
+ export class SubListingError extends ApiError {
14
+ code;
15
+ constructor(status, code, message, body, options) {
16
+ super(status, message, body);
17
+ if (options?.cause !== undefined)
18
+ Object.defineProperty(this, 'cause', { value: options.cause });
19
+ this.name = 'SubListingError';
20
+ this.code = code;
21
+ }
22
+ }
23
+ export function createSubListingsClient(http) {
24
+ const call = async (method, op, body, signal) => {
25
+ try {
26
+ return await http(method, `${BASE}/${op}`, { body, signal });
27
+ }
28
+ catch (error) {
29
+ throw toSubListingError(error);
30
+ }
31
+ };
32
+ return {
33
+ async upsert(input, { signal } = {}) {
34
+ const res = await call('POST', 'upsert', upsertBody(input), signal);
35
+ if (typeof res?.id !== 'string' ||
36
+ typeof res.status !== 'string' ||
37
+ typeof res.pendingEdit !== 'boolean') {
38
+ throw new CivitaiError('sub-listings upsert: malformed reply');
39
+ }
40
+ return {
41
+ id: res.id,
42
+ status: res.status,
43
+ pendingEdit: res.pendingEdit,
44
+ };
45
+ },
46
+ async withdraw(itemKey, { signal } = {}) {
47
+ const res = await call('POST', 'withdraw', { itemKey }, signal);
48
+ if (typeof res?.withdrawn !== 'boolean') {
49
+ throw new CivitaiError('sub-listings withdraw: reply carried no `withdrawn` flag');
50
+ }
51
+ return { ok: true, withdrawn: res.withdrawn };
52
+ },
53
+ async mine({ signal } = {}) {
54
+ const res = await call('GET', 'mine', undefined, signal);
55
+ // 🔴 NO `?? []`. An empty list is a real answer ("nothing in the store"),
56
+ // and a reply without `items` must not be dressed up as it.
57
+ if (!Array.isArray(res?.items)) {
58
+ throw new CivitaiError('sub-listings mine: reply carried no `items` array');
59
+ }
60
+ return { items: res.items.map(toMySubListing) };
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * The request body: the six fields, named rather than spread, so an extra
66
+ * property on the caller's object never reaches the strict server body; and each
67
+ * optional one only when it holds a value.
68
+ */
69
+ function upsertBody(input) {
70
+ const body = {
71
+ itemKey: input.itemKey,
72
+ title: input.title,
73
+ subPath: input.subPath,
74
+ };
75
+ if (input.tagline != null)
76
+ body.tagline = input.tagline;
77
+ if (input.imageId != null)
78
+ body.imageId = input.imageId;
79
+ if (input.contentRating != null)
80
+ body.contentRating = input.contentRating;
81
+ return body;
82
+ }
83
+ /** An {@link ApiError} whose body carries a string `code` becomes a {@link SubListingError}; anything else passes through. */
84
+ function toSubListingError(error) {
85
+ if (!(error instanceof ApiError) || error instanceof SubListingError)
86
+ return error;
87
+ const body = error.body;
88
+ if (body == null || typeof body !== 'object' || Array.isArray(body))
89
+ return error;
90
+ const { code } = body;
91
+ if (typeof code !== 'string')
92
+ return error;
93
+ return new SubListingError(error.status, code, error.message, body, { cause: error });
94
+ }
95
+ function toMySubListing(row) {
96
+ const r = (row ?? {});
97
+ const nullableString = (v) => v === null || typeof v === 'string';
98
+ if (typeof r.id !== 'string' ||
99
+ typeof r.itemKey !== 'string' ||
100
+ typeof r.status !== 'string' ||
101
+ typeof r.title !== 'string' ||
102
+ typeof r.pendingEdit !== 'boolean' ||
103
+ !nullableString(r.statusReason) ||
104
+ !nullableString(r.editRejectionReason) ||
105
+ typeof r.updatedAt !== 'string') {
106
+ throw new CivitaiError('sub-listings mine: malformed item');
107
+ }
108
+ const updatedAt = new Date(r.updatedAt);
109
+ if (Number.isNaN(updatedAt.getTime())) {
110
+ throw new CivitaiError('sub-listings mine: malformed item timestamp');
111
+ }
112
+ return {
113
+ id: r.id,
114
+ itemKey: r.itemKey,
115
+ status: r.status,
116
+ title: r.title,
117
+ pendingEdit: r.pendingEdit,
118
+ statusReason: r.statusReason,
119
+ editRejectionReason: r.editRejectionReason,
120
+ updatedAt,
121
+ };
122
+ }
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.14.1";
2
+ export declare const VERSION = "0.16.0";
@@ -1,3 +1,3 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.14.1";
3
+ export const VERSION = "0.16.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",