@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 +70 -1
- package/dist/app/index.d.ts +11 -0
- package/dist/app/index.js +11 -8
- package/dist/doc-store/fake.d.ts +7 -0
- package/dist/doc-store/fake.js +71 -2
- package/dist/doc-store/index.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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/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.
|
|
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
|
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/doc-store/fake.d.ts
CHANGED
|
@@ -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 {
|
package/dist/doc-store/fake.js
CHANGED
|
@@ -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,
|
|
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.
|
|
2
|
+
export declare const VERSION = "0.16.0";
|
package/package.json
CHANGED