@civitai/sdk 0.14.0 → 0.15.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/BREAKING.md +2 -2
- package/README.md +64 -5
- package/dist/app/index.d.ts +11 -0
- package/dist/app/index.js +11 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/shared-storage/index.d.ts +8 -3
- package/dist/sub-listings/index.d.ts +137 -0
- package/dist/sub-listings/index.js +122 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
package/BREAKING.md
CHANGED
|
@@ -204,8 +204,8 @@ bridge already called, so behaviour is identical on both transports.
|
|
|
204
204
|
- **Anonymous viewers may NEVER write or vote.** An anon write is rejected by the write scope's own binding in
|
|
205
205
|
`withBlockScope`, before the handler runs, so it surfaces as **`403`** rather than the handler's own `401`.
|
|
206
206
|
- **Authenticated writers must clear a minimum-trust gate.** After the anon check, writes call
|
|
207
|
-
`assertSharedWriteTrust` — account age,
|
|
208
|
-
viewer is not automatically a permitted writer.
|
|
207
|
+
`assertSharedWriteTrust` — account age, completed onboarding, not banned or muted, and verified email *or* a
|
|
208
|
+
linked OAuth account. A signed-in viewer is not automatically a permitted writer.
|
|
209
209
|
- **Tenancy is structural, not checked.** Every query interpolates a schema name derived from the **verified
|
|
210
210
|
token**, never from client input. An app cannot address another app's shared storage because it cannot name it.
|
|
211
211
|
- **Rate limits are per-operation and per-namespace**, so one operation cannot starve another — browsing a feed
|
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 |
|
|
@@ -336,7 +337,7 @@ every viewer reads and writes, unlike `app.storage`, which is private per viewer
|
|
|
336
337
|
Five methods: `list`, `get`, `append`, `update`, `withdraw`.
|
|
337
338
|
|
|
338
339
|
```ts
|
|
339
|
-
const { items, nextCursor } = await app.sharedStorage.list({
|
|
340
|
+
const { items, nextCursor } = await app.sharedStorage.list({ limit: 20 }); // newest first
|
|
340
341
|
const { key } = await app.sharedStorage.append({ title: 'My deck', data: { cards } });
|
|
341
342
|
const row = await app.sharedStorage.get(key); // null when hidden or absent
|
|
342
343
|
await app.sharedStorage.update(key, { title: 'Renamed' });
|
|
@@ -362,9 +363,9 @@ other.** This is the opposite of `app.storage`, so do not carry that gate over:
|
|
|
362
363
|
supported path — and may **never** write. An anon write is a `403` from the
|
|
363
364
|
scope binding, not the `401` a missing token gives.
|
|
364
365
|
- **A signed-in viewer is not automatically a permitted writer.** Writes clear a
|
|
365
|
-
server-side minimum-trust gate (account age,
|
|
366
|
-
linked OAuth account). Treat a write refusal
|
|
367
|
-
message in the UI, not an unexpected error.
|
|
366
|
+
server-side minimum-trust gate (account age, completed onboarding, verified
|
|
367
|
+
email or a linked OAuth account, not banned or muted). Treat a write refusal
|
|
368
|
+
as a normal outcome with a real message in the UI, not an unexpected error.
|
|
368
369
|
|
|
369
370
|
So gate the write affordances on `app.viewer`, and render the reads regardless.
|
|
370
371
|
|
|
@@ -379,7 +380,9 @@ that type-checks:
|
|
|
379
380
|
`app.storage`'s, absence proves the scan completed.
|
|
380
381
|
- **`append` takes no key.** The server mints a ULID and returns it, so one
|
|
381
382
|
viewer cannot overwrite another's row. Use `update` for an in-place edit of a
|
|
382
|
-
row this viewer authored.
|
|
383
|
+
row this viewer authored. It also means `list`'s `prefix` is no namespace of
|
|
384
|
+
yours: a prefix you choose (`'deck:'`) matches none of the rows `append`
|
|
385
|
+
creates. Put what you filter by in `data` and filter the page yourself.
|
|
383
386
|
- **`value` is `unknown` on the way out.** The row was written by some other
|
|
384
387
|
viewer's copy of your app, possibly an older version, so its shape is a fact
|
|
385
388
|
about the data rather than a promise the client can make. Narrow it yourself.
|
|
@@ -426,6 +429,62 @@ an existence oracle, so do not report that as "someone else owns this". Put ever
|
|
|
426
429
|
string other viewers will read in `title`/`body`, which the server moderates;
|
|
427
430
|
`data` is unmoderated app structure.
|
|
428
431
|
|
|
432
|
+
## Store sub-listings
|
|
433
|
+
|
|
434
|
+
`app.subListings` publishes an item a viewer made in your app — a row they
|
|
435
|
+
authored in [shared storage](#shared-storage) — as its **own App Store card**,
|
|
436
|
+
under the viewer's name, badged "in ‹your app›". Three methods over
|
|
437
|
+
`/api/v1/blocks/sub-listings/*`: `upsert`, `withdraw`, `mine`.
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { SubListingError } from '@civitai/sdk';
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const { id, status, pendingEdit } = await app.subListings.upsert({
|
|
444
|
+
itemKey: 'preset-7f3a', // the key of the viewer's own shared-storage row
|
|
445
|
+
title: 'Moody film portraits',
|
|
446
|
+
subPath: 'p/preset-7f3a', // opens /apps/run/<your-app-slug>/p/preset-7f3a
|
|
447
|
+
tagline: null, // a left-out optional is CLEARED: re-send what you want to keep
|
|
448
|
+
});
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (error instanceof SubListingError && error.code === 'insufficient_scope') {
|
|
451
|
+
// dev-token, dev-tunnel and review sessions never carry the scope: hide the control
|
|
452
|
+
} else throw error;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const { withdrawn } = await app.subListings.withdraw('preset-7f3a');
|
|
456
|
+
const { items } = await app.subListings.mine(); // oldest first, with review status
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **`upsert` replaces the whole item, it does not patch it.** An optional that is
|
|
460
|
+
`null` or `undefined` is left out of the request, and the server stores a
|
|
461
|
+
left-out optional as empty — so an edit that sends only `title` clears the
|
|
462
|
+
item's tagline and image and resets its rating to the app's. Re-send every
|
|
463
|
+
field you want to keep.
|
|
464
|
+
- **The server body is strict** — an unknown key is `400 invalid_body` — so the
|
|
465
|
+
client sends only `itemKey`, `title`, `subPath`, `tagline`, `imageId` and
|
|
466
|
+
`contentRating`, whatever else the object you pass carries. Every other rule (lengths, the `subPath` shape, the rating floor, the image
|
|
467
|
+
checks) is the server's and comes back as a refusal.
|
|
468
|
+
- **A refusal is a `SubListingError`** — an `ApiError` with a typed `code`
|
|
469
|
+
(`not_enabled`, `untrusted`, `item_not_found`, `not_your_item`, `text_rejected`,
|
|
470
|
+
`image_not_public`, `image_not_yours`, `rating_too_loose`, `rate_limited`,
|
|
471
|
+
`author_cap`, `conflict`, `unavailable`, …). Branch on `code`; the message is a
|
|
472
|
+
server sentence, not UI copy. A refusal with no `code` stays a plain `ApiError`.
|
|
473
|
+
A `429`'s `Retry-After` header is not exposed.
|
|
474
|
+
- **Every item waits for a moderator.** `status` is `pending`, `approved`,
|
|
475
|
+
`hidden` or `withdrawn`; an `upsert` answered `hidden` changed nothing, and the
|
|
476
|
+
app cannot lift it. Editing an approved item stages the edit
|
|
477
|
+
(`pendingEdit: true`) while the card keeps serving the approved version.
|
|
478
|
+
- `withdraw` answering `withdrawn: false` is a success: nothing of the viewer's was
|
|
479
|
+
active under that key, or a moderator has hidden it.
|
|
480
|
+
|
|
481
|
+
🔴 It needs the scope `apps:store:items:write` in the manifest (with a
|
|
482
|
+
`scopeJustifications` entry), and that scope is consent-exempt, refused to an
|
|
483
|
+
anonymous viewer, and **never minted for dev-token, dev-tunnel or review
|
|
484
|
+
sessions** — so these calls only succeed from an approved version of your app,
|
|
485
|
+
and Civitai must also enable your app for sub-listings (`not_enabled` until then).
|
|
486
|
+
Build the publish path to degrade quietly.
|
|
487
|
+
|
|
429
488
|
## Multi-step workflows
|
|
430
489
|
|
|
431
490
|
`app.workflows` is how a **block** generates. It goes through the host's
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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
|
|
86
|
-
// clients' routes live under the same base URL, so a `siteUrl`
|
|
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
|
|
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
|
|
92
|
-
//
|
|
93
|
-
// `test/
|
|
94
|
-
// `
|
|
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),
|
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';
|
|
@@ -51,7 +51,11 @@ export interface SharedItem {
|
|
|
51
51
|
viewerVoted: boolean;
|
|
52
52
|
}
|
|
53
53
|
export interface SharedListQuery {
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Narrows to keys starting with this. Escaped server-side against LIKE wildcards.
|
|
56
|
+
* `append` mints every key as a ULID, so a prefix of your own choosing matches
|
|
57
|
+
* none of the rows it creates.
|
|
58
|
+
*/
|
|
55
59
|
prefix?: string;
|
|
56
60
|
/** The server bounds this and applies its own default; this client sends none. */
|
|
57
61
|
limit?: number;
|
|
@@ -94,8 +98,9 @@ export interface SharedListResult {
|
|
|
94
98
|
* write is refused by the scope binding before the handler runs, so it arrives
|
|
95
99
|
* as **403**, not the 401 a missing token gives.
|
|
96
100
|
* - A signed-in viewer is not automatically a permitted writer: writes clear a
|
|
97
|
-
* minimum-trust gate (account age,
|
|
98
|
-
* account). Treat a write refusal as a
|
|
101
|
+
* minimum-trust gate (account age, completed onboarding, verified email or a
|
|
102
|
+
* linked OAuth account, not banned or muted). Treat a write refusal as a
|
|
103
|
+
* normal outcome and say so in the UI.
|
|
99
104
|
*
|
|
100
105
|
* 🔴 EVERY FAILURE REJECTS. No path here resolves to mean "not written" or
|
|
101
106
|
* "could not read". A caller that must not act on a partial view branches on the
|
|
@@ -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.
|
|
2
|
+
export declare const VERSION = "0.15.0";
|
package/package.json
CHANGED