@civitai/app-sdk 0.51.1 → 0.52.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 +1 -1
- package/dist/blocks/browsingLevel.d.ts +52 -11
- package/dist/blocks/browsingLevel.js +30 -7
- package/dist/blocks/index.d.ts +1 -1
- package/dist/blocks/messages.d.ts +25 -7
- package/dist/blocks/scopes.d.ts +3 -1
- package/dist/blocks/scopes.js +15 -1
- package/dist/blocks/types.d.ts +51 -0
- package/dist/manifest/index.d.ts +1 -1
- package/dist/safe-storage/index.d.ts +19 -2
- package/dist/safe-storage/index.js +19 -2
- package/package.json +1 -1
- package/schemas/app-block/v1.json +50 -3
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ interface BlockInitPayload {
|
|
|
134
134
|
|
|
135
135
|
| Export | What |
|
|
136
136
|
|---|---|
|
|
137
|
-
| `BLOCK_SCOPES` / `BLOCK_SCOPE_PATTERN` |
|
|
137
|
+
| `BLOCK_SCOPES` / `BLOCK_SCOPE_PATTERN` | Every known block scope string (the authoritative enum the canonical schema validates `scopes` against) + the `domain:verb:target` format-helper regex. Read the current set from `BLOCK_SCOPES` itself — scopes are added and retired, so no count is quoted here. A scope is valid only if it's a member of `BLOCK_SCOPES`, matching the [canonical schema](https://civitai.com/schemas/app-block/v1.json). |
|
|
138
138
|
| `isMessage(data, type)` | Discriminator-only message narrowing (see above). |
|
|
139
139
|
| `isModelSlotContext(ctx)` / `isPageSlotContext(ctx)` | Runtime narrowing for the `slotId`-discriminated `BlockContext` union. Real checks on a value that crossed a `postMessage` boundary — they verify every field they assert, not just `slotId`. |
|
|
140
140
|
| `isSignedIn(viewer)` | **The sign-in gate.** `isSignedIn(useBlockContext().viewer)` — do not open-code it as `viewer !== null` or `viewer?.signedIn === true`. Which of those is correct has already changed once with the host contract, and this is the one place it is decided. It reads neither `viewer.id` nor `viewer.username` (both `@deprecated`), so nothing written through it changes when those are removed. Need the identity rather than the presence? `useViewer()` — scope-gated and audited per call. |
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The host (civitai/civitai) projects an authoritative `maxBrowsingLevel`
|
|
5
5
|
* BITMASK into `BLOCK_INIT` — the max NSFW levels the surrounding color-domain
|
|
6
|
-
* allows (computed server-side from `domainBrowsingCeiling(color)`)
|
|
7
|
-
*
|
|
8
|
-
* affordances
|
|
6
|
+
* allows (computed server-side from `domainBrowsingCeiling(color)`) — and,
|
|
7
|
+
* alongside it, the viewer-narrowed `effectiveBrowsingLevel`. A block decides
|
|
8
|
+
* whether to surface mature affordances from the PAIR, via
|
|
9
|
+
* {@link effectiveBrowsingCeiling}; `@civitai/blocks-react`'s
|
|
10
|
+
* `useDomainMaturity()` is the React wrapper over exactly that. The domain
|
|
11
|
+
* ceiling alone is not the answer — see {@link ColorDomain}.
|
|
9
12
|
*
|
|
10
13
|
* The per-level bit VALUES below mirror civitai/civitai's server `NsfwLevel`
|
|
11
14
|
* enum. They are STABLE wire values (a level's bit never changes), so it is
|
|
@@ -44,7 +47,7 @@ export declare const SFW_LEVELS: number;
|
|
|
44
47
|
*/
|
|
45
48
|
export declare const NSFW_LEVELS: number;
|
|
46
49
|
/**
|
|
47
|
-
* True when the
|
|
50
|
+
* True when the given browsing-level ceiling permits NO NSFW content.
|
|
48
51
|
*
|
|
49
52
|
* Derived purely from the BITMASK: SFW ⇔ the ceiling has no NSFW bits set.
|
|
50
53
|
*
|
|
@@ -54,7 +57,17 @@ export declare const NSFW_LEVELS: number;
|
|
|
54
57
|
* block must therefore treat "unknown" as SFW and hide mature affordances
|
|
55
58
|
* until proven otherwise.
|
|
56
59
|
*
|
|
57
|
-
*
|
|
60
|
+
* ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
|
|
61
|
+
* `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
|
|
62
|
+
* two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
|
|
63
|
+
* negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
|
|
64
|
+
* not neutralise it either. Stated rather than fixed: adding the guard is a
|
|
65
|
+
* behaviour change and belongs in its own release.
|
|
66
|
+
*
|
|
67
|
+
* @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
|
|
68
|
+
* the domain mask because that was the only ceiling when this shipped; when
|
|
69
|
+
* gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
|
|
70
|
+
* or this answers "is the DOMAIN SFW?" and not "may I show THIS viewer".
|
|
58
71
|
* @example
|
|
59
72
|
* isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.PG13); // true (green/blue)
|
|
60
73
|
* isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.X); // false (mature)
|
|
@@ -62,14 +75,24 @@ export declare const NSFW_LEVELS: number;
|
|
|
62
75
|
*/
|
|
63
76
|
export declare function isSfwCeiling(maxBrowsingLevel?: number | null): boolean;
|
|
64
77
|
/**
|
|
65
|
-
* True when a specific browsing `level` bit is permitted by the
|
|
78
|
+
* True when a specific browsing `level` bit is permitted by the given ceiling.
|
|
66
79
|
*
|
|
67
80
|
* **Fail-closed.** A missing / null / non-finite ceiling permits ONLY SFW
|
|
68
81
|
* levels (PG / PG13) — same fail-closed posture as {@link isSfwCeiling}. A
|
|
69
82
|
* non-finite / non-positive `level` returns `false`.
|
|
70
83
|
*
|
|
84
|
+
* ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
|
|
85
|
+
* `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
|
|
86
|
+
* two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
|
|
87
|
+
* negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
|
|
88
|
+
* not neutralise it either. Stated rather than fixed: adding the guard is a
|
|
89
|
+
* behaviour change and belongs in its own release.
|
|
90
|
+
*
|
|
71
91
|
* @param level a single `BrowsingLevel` bit (e.g. `BrowsingLevel.R`).
|
|
72
|
-
* @param maxBrowsingLevel the
|
|
92
|
+
* @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
|
|
93
|
+
* the domain mask because that was the only ceiling when this shipped; when
|
|
94
|
+
* gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
|
|
95
|
+
* or this answers what the DOMAIN permits and not what THIS viewer may see.
|
|
73
96
|
* @example
|
|
74
97
|
* isLevelAllowed(BrowsingLevel.R, BrowsingLevel.PG | BrowsingLevel.PG13); // false
|
|
75
98
|
* isLevelAllowed(BrowsingLevel.PG13, undefined); // true
|
|
@@ -126,10 +149,28 @@ export declare function isLevelAllowed(level: number, maxBrowsingLevel?: number
|
|
|
126
149
|
*/
|
|
127
150
|
export declare function effectiveBrowsingCeiling(maxBrowsingLevel?: number | null, effectiveBrowsingLevel?: number | null): number;
|
|
128
151
|
/**
|
|
129
|
-
* The color-domain a block is rendered inside, as projected by the host.
|
|
130
|
-
* SFW policy is
|
|
131
|
-
*
|
|
132
|
-
*
|
|
152
|
+
* The color-domain a block is rendered inside, as projected by the host.
|
|
153
|
+
* Informational ONLY — the SFW policy is server-side, and this string is not
|
|
154
|
+
* it. `null` / absent means the host did not project a domain (treat as
|
|
155
|
+
* unknown ⇒ fail-closed SFW).
|
|
156
|
+
*
|
|
157
|
+
* 🔴 Never gate on this string, and never on `maxBrowsingLevel` alone. Resolve
|
|
158
|
+
* the ceiling with {@link effectiveBrowsingCeiling}, then test it with
|
|
159
|
+
* {@link isSfwCeiling} or {@link isLevelAllowed}:
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* const eff = effectiveBrowsingCeiling(maxBrowsingLevel, effectiveBrowsingLevel);
|
|
163
|
+
* // 🔴 OPPOSITE POLARITIES — do not copy these two lines as a uniform pair.
|
|
164
|
+
* if (isSfwCeiling(eff)) hideMatureAffordances(); // true = SFW ⇒ HIDE
|
|
165
|
+
* if (isLevelAllowed(BrowsingLevel.R, eff)) showR(); // true = allowed ⇒ SHOW
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* This docblock used to say *"use `isSfwCeiling` on the accompanying
|
|
169
|
+
* `maxBrowsingLevel` mask instead"*. The predicate was never the problem — both
|
|
170
|
+
* predicates take WHATEVER CEILING YOU PASS — the problem was passing the
|
|
171
|
+
* domain one, which is identical for every viewer on `civitai.red` including
|
|
172
|
+
* one whose own NSFW setting is off, so it cannot answer "may I show THIS
|
|
173
|
+
* viewer mature content".
|
|
133
174
|
*/
|
|
134
175
|
export type ColorDomain = 'green' | 'blue' | 'red';
|
|
135
176
|
//# sourceMappingURL=browsingLevel.d.ts.map
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The host (civitai/civitai) projects an authoritative `maxBrowsingLevel`
|
|
5
5
|
* BITMASK into `BLOCK_INIT` — the max NSFW levels the surrounding color-domain
|
|
6
|
-
* allows (computed server-side from `domainBrowsingCeiling(color)`)
|
|
7
|
-
*
|
|
8
|
-
* affordances
|
|
6
|
+
* allows (computed server-side from `domainBrowsingCeiling(color)`) — and,
|
|
7
|
+
* alongside it, the viewer-narrowed `effectiveBrowsingLevel`. A block decides
|
|
8
|
+
* whether to surface mature affordances from the PAIR, via
|
|
9
|
+
* {@link effectiveBrowsingCeiling}; `@civitai/blocks-react`'s
|
|
10
|
+
* `useDomainMaturity()` is the React wrapper over exactly that. The domain
|
|
11
|
+
* ceiling alone is not the answer — see {@link ColorDomain}.
|
|
9
12
|
*
|
|
10
13
|
* The per-level bit VALUES below mirror civitai/civitai's server `NsfwLevel`
|
|
11
14
|
* enum. They are STABLE wire values (a level's bit never changes), so it is
|
|
@@ -42,7 +45,7 @@ export const SFW_LEVELS = BrowsingLevel.PG | BrowsingLevel.PG13;
|
|
|
42
45
|
*/
|
|
43
46
|
export const NSFW_LEVELS = BrowsingLevel.R | BrowsingLevel.X | BrowsingLevel.XXX;
|
|
44
47
|
/**
|
|
45
|
-
* True when the
|
|
48
|
+
* True when the given browsing-level ceiling permits NO NSFW content.
|
|
46
49
|
*
|
|
47
50
|
* Derived purely from the BITMASK: SFW ⇔ the ceiling has no NSFW bits set.
|
|
48
51
|
*
|
|
@@ -52,7 +55,17 @@ export const NSFW_LEVELS = BrowsingLevel.R | BrowsingLevel.X | BrowsingLevel.XXX
|
|
|
52
55
|
* block must therefore treat "unknown" as SFW and hide mature affordances
|
|
53
56
|
* until proven otherwise.
|
|
54
57
|
*
|
|
55
|
-
*
|
|
58
|
+
* ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
|
|
59
|
+
* `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
|
|
60
|
+
* two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
|
|
61
|
+
* negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
|
|
62
|
+
* not neutralise it either. Stated rather than fixed: adding the guard is a
|
|
63
|
+
* behaviour change and belongs in its own release.
|
|
64
|
+
*
|
|
65
|
+
* @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
|
|
66
|
+
* the domain mask because that was the only ceiling when this shipped; when
|
|
67
|
+
* gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
|
|
68
|
+
* or this answers "is the DOMAIN SFW?" and not "may I show THIS viewer".
|
|
56
69
|
* @example
|
|
57
70
|
* isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.PG13); // true (green/blue)
|
|
58
71
|
* isSfwCeiling(BrowsingLevel.PG | BrowsingLevel.X); // false (mature)
|
|
@@ -65,14 +78,24 @@ export function isSfwCeiling(maxBrowsingLevel) {
|
|
|
65
78
|
return (maxBrowsingLevel & NSFW_LEVELS) === 0;
|
|
66
79
|
}
|
|
67
80
|
/**
|
|
68
|
-
* True when a specific browsing `level` bit is permitted by the
|
|
81
|
+
* True when a specific browsing `level` bit is permitted by the given ceiling.
|
|
69
82
|
*
|
|
70
83
|
* **Fail-closed.** A missing / null / non-finite ceiling permits ONLY SFW
|
|
71
84
|
* levels (PG / PG13) — same fail-closed posture as {@link isSfwCeiling}. A
|
|
72
85
|
* non-finite / non-positive `level` returns `false`.
|
|
73
86
|
*
|
|
87
|
+
* ⚠ A NEGATIVE ceiling is none of those three and fails OPEN:
|
|
88
|
+
* `isSfwCeiling(-1)` is `false` and `isLevelAllowed(XXX, -1)` is `true`, because
|
|
89
|
+
* two's complement sets every bit. {@link effectiveBrowsingCeiling} guards a
|
|
90
|
+
* negative VIEWER level but not a negative DOMAIN ceiling, so the recipe does
|
|
91
|
+
* not neutralise it either. Stated rather than fixed: adding the guard is a
|
|
92
|
+
* behaviour change and belongs in its own release.
|
|
93
|
+
*
|
|
74
94
|
* @param level a single `BrowsingLevel` bit (e.g. `BrowsingLevel.R`).
|
|
75
|
-
* @param maxBrowsingLevel the
|
|
95
|
+
* @param maxBrowsingLevel the ceiling to test — WHICHEVER you pass. Named for
|
|
96
|
+
* the domain mask because that was the only ceiling when this shipped; when
|
|
97
|
+
* gating for a viewer pass {@link effectiveBrowsingCeiling}'s result instead,
|
|
98
|
+
* or this answers what the DOMAIN permits and not what THIS viewer may see.
|
|
76
99
|
* @example
|
|
77
100
|
* isLevelAllowed(BrowsingLevel.R, BrowsingLevel.PG | BrowsingLevel.PG13); // false
|
|
78
101
|
* isLevelAllowed(BrowsingLevel.PG13, undefined); // true
|
package/dist/blocks/index.d.ts
CHANGED
|
@@ -77,5 +77,5 @@ export { isModelSlotContext, isPageSlotContext } from './types.js';
|
|
|
77
77
|
* in `types.ts` for which of the two it uses and why.
|
|
78
78
|
*/
|
|
79
79
|
export { isSignedIn } from './types.js';
|
|
80
|
-
export type { BlockContext, KnownSlotId, ModelSlotId, PageSlotContext, PageSlotId, UnknownSlotContext, BlockManifest, BlockManifestV1, BlockSettings, BlockToken, ContentRating, ManifestBooleanField, ManifestIframe, ManifestNumberField, ManifestPage, ManifestPreview, ManifestSettingField, ManifestSettings, ManifestStringField, ManifestTarget, ModelSlotContext, SettingScope, SettingWidget, Theme, ViewerInfo, BlockCheckpointInfo, BlockResourceInfo, BlockResourcePickerType, BlockSourceImage, BlockUploadedImageInfo, BlockGenerationSourceImageInfo, BlockPendingImageInfo, BlockImageScanResult, BlockUploadPurpose, BlockTextToImageParams, BlockWorkflowSnapshot, BuzzAccountType, ShowcaseImage, WorkflowBody, WorkflowBodyTextToImage, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyRecipe, WorkflowBodyCustomComfyInline, InlineComfyNode, WorkflowBodyStep, WorkflowBodyPassThroughStep, WorkflowStatus, BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockViewer, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './types.js';
|
|
80
|
+
export type { BlockContext, KnownSlotId, ModelSlotId, PageSlotContext, PageSlotId, UnknownSlotContext, BlockManifest, BlockManifestGood, BlockManifestV1, BlockSettings, BlockToken, ContentRating, ManifestBooleanField, ManifestIframe, ManifestNumberField, ManifestPage, ManifestPreview, ManifestSettingField, ManifestSettings, ManifestStringField, ManifestTarget, ModelSlotContext, SettingScope, SettingWidget, Theme, ViewerInfo, BlockCheckpointInfo, BlockResourceInfo, BlockResourcePickerType, BlockSourceImage, BlockUploadedImageInfo, BlockGenerationSourceImageInfo, BlockPendingImageInfo, BlockImageScanResult, BlockUploadPurpose, BlockTextToImageParams, BlockWorkflowSnapshot, BuzzAccountType, ShowcaseImage, WorkflowBody, WorkflowBodyTextToImage, WorkflowBodyCustomComfy, WorkflowBodyCustomComfyRecipe, WorkflowBodyCustomComfyInline, InlineComfyNode, WorkflowBodyStep, WorkflowBodyPassThroughStep, WorkflowStatus, BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockViewer, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './types.js';
|
|
81
81
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -150,8 +150,21 @@ export interface BlockInitPayload {
|
|
|
150
150
|
/**
|
|
151
151
|
* The color-domain the block is rendered inside (`green` | `blue` | `red`),
|
|
152
152
|
* or `null` when the host did not resolve one. Informational ONLY — the SFW
|
|
153
|
-
* policy is server-side;
|
|
154
|
-
*
|
|
153
|
+
* policy is server-side; never gate on this string. Resolve the ceiling
|
|
154
|
+
* first, then test it — in any runtime:
|
|
155
|
+
*
|
|
156
|
+
* ```ts
|
|
157
|
+
* const eff = effectiveBrowsingCeiling(maxBrowsingLevel, effectiveBrowsingLevel);
|
|
158
|
+
* // 🔴 OPPOSITE POLARITIES — do not copy these two lines as a uniform pair.
|
|
159
|
+
* if (isSfwCeiling(eff)) hideMatureAffordances(); // true = SFW ⇒ HIDE
|
|
160
|
+
* if (isLevelAllowed(BrowsingLevel.R, eff)) showR(); // true = allowed ⇒ SHOW
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* 🔴 `effectiveBrowsingCeiling` returns a BITMASK, not a boolean — an SFW
|
|
164
|
+
* ceiling is `3`, which is truthy, so branching on it directly shows mature
|
|
165
|
+
* content to a viewer who may not see it. It needs one of the two predicates
|
|
166
|
+
* above. In React, `@civitai/blocks-react`'s `useDomainMaturity()` returns
|
|
167
|
+
* `isSfw` / `isLevelAllowed` already composed this way.
|
|
155
168
|
*
|
|
156
169
|
* Sent by civitai/civitai PR #2670. A host that predates it omits this field
|
|
157
170
|
* (reads `undefined`).
|
|
@@ -161,8 +174,9 @@ export interface BlockInitPayload {
|
|
|
161
174
|
* Authoritative browsing-level BITMASK = the max NSFW levels the domain
|
|
162
175
|
* allows, computed server-side from `domainBrowsingCeiling(color)` (green/
|
|
163
176
|
* blue → SFW, red → all). Bits mirror the server `NsfwLevel` (see
|
|
164
|
-
* `browsingLevel.ts`).
|
|
165
|
-
*
|
|
177
|
+
* `browsingLevel.ts`). `isSfwCeiling(maxBrowsingLevel)` answers "is this
|
|
178
|
+
* DOMAIN SFW?" — not "may I show THIS viewer mature content"; for that, see
|
|
179
|
+
* the warning below and {@link effectiveBrowsingLevel}.
|
|
166
180
|
*
|
|
167
181
|
* Sent by civitai/civitai PR #2670. A host that predates it omits this field
|
|
168
182
|
* (reads `undefined`); the SDK fail-closes to SFW when it is absent.
|
|
@@ -193,9 +207,13 @@ export interface BlockInitPayload {
|
|
|
193
207
|
* WIDER than the domain permits and is not permission for anything.
|
|
194
208
|
*
|
|
195
209
|
* ADDITIVE + OPTIONAL. A host that predates it omits the field, in which case
|
|
196
|
-
*
|
|
197
|
-
* behaviour that host already had.
|
|
198
|
-
*
|
|
210
|
+
* the ceiling falls back to {@link maxBrowsingLevel} — i.e. exactly the
|
|
211
|
+
* behaviour that host already had. Do not read this field directly: pass it
|
|
212
|
+
* through `effectiveBrowsingCeiling`, which applies that fallback and the
|
|
213
|
+
* fail-closed defaults for you. `@civitai/blocks-react`'s
|
|
214
|
+
* `useDomainMaturity()` is the React wrapper over the same thing — it is one
|
|
215
|
+
* way to get this right, not the only one, since this module is
|
|
216
|
+
* runtime-agnostic and two of the starters ship without React.
|
|
199
217
|
*/
|
|
200
218
|
effectiveBrowsingLevel?: number;
|
|
201
219
|
}
|
package/dist/blocks/scopes.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export declare const BLOCK_SCOPES: {
|
|
|
24
24
|
readonly COLLECTIONS_WRITE_SELF: "collections:write:self";
|
|
25
25
|
readonly COLLECTIONS_READ_PRIVATE: "collections:read:private";
|
|
26
26
|
readonly POSTS_WRITE_SELF: "posts:write:self";
|
|
27
|
+
readonly GOODS_READ_SELF: "goods:read:self";
|
|
28
|
+
readonly GOODS_PURCHASE_SELF: "goods:purchase:self";
|
|
27
29
|
};
|
|
28
30
|
export type BlockScopeKey = keyof typeof BLOCK_SCOPES;
|
|
29
31
|
export type BlockScope = (typeof BLOCK_SCOPES)[BlockScopeKey];
|
|
@@ -33,7 +35,7 @@ export type BlockScope = (typeof BLOCK_SCOPES)[BlockScopeKey];
|
|
|
33
35
|
*
|
|
34
36
|
* NOTE: this regex is **not** the authoritative validity contract. The
|
|
35
37
|
* canonical manifest schema (https://civitai.com/schemas/app-block/v1.json)
|
|
36
|
-
* validates `scopes` by MEMBERSHIP in a fixed enum — i.e. the
|
|
38
|
+
* validates `scopes` by MEMBERSHIP in a fixed enum — i.e. exactly the values in
|
|
37
39
|
* {@link BLOCK_SCOPES}. `defineBlock` therefore gates on membership in
|
|
38
40
|
* `BLOCK_SCOPES`; this pattern is kept only as a FORMAT HEURISTIC to give a
|
|
39
41
|
* pointed error message (e.g. distinguishing a malformed/PascalCase scope from
|
package/dist/blocks/scopes.js
CHANGED
|
@@ -48,6 +48,20 @@ export const BLOCK_SCOPES = {
|
|
|
48
48
|
// per-post confirm rendering the SERVER'S resolution of the request, and the
|
|
49
49
|
// server re-runs every guard.
|
|
50
50
|
POSTS_WRITE_SELF: 'posts:write:self',
|
|
51
|
+
// goods:* — the DIGITAL GOODS rail (civitai/civitai#5171): the platform sells
|
|
52
|
+
// a manifest-declared entitlement to the viewer for Buzz, on the app's behalf.
|
|
53
|
+
// These were live on the SERVER and in the canonical schema while absent here,
|
|
54
|
+
// so `defineBlock` rejected both and no app scaffolded from this repo could
|
|
55
|
+
// DECLARE them — the hooks that call them were unreachable. Found by the
|
|
56
|
+
// round-0 reachability question on app-starters#489.
|
|
57
|
+
//
|
|
58
|
+
// `goods:read:self` is CONSENT-EXEMPT by design: the read is scoped
|
|
59
|
+
// server-side to the calling app's own appBlockId, so it can only ever return
|
|
60
|
+
// what that app itself sold and there is no third-party data to consent to.
|
|
61
|
+
// `goods:purchase:self` is CONSENT-GATED — money out of the viewer's balance
|
|
62
|
+
// always needs an explicit grant. Do not collapse the two.
|
|
63
|
+
GOODS_READ_SELF: 'goods:read:self',
|
|
64
|
+
GOODS_PURCHASE_SELF: 'goods:purchase:self',
|
|
51
65
|
};
|
|
52
66
|
/**
|
|
53
67
|
* Format helper for the block-scope shape — 3 OR 4 colon-separated lowercase
|
|
@@ -55,7 +69,7 @@ export const BLOCK_SCOPES = {
|
|
|
55
69
|
*
|
|
56
70
|
* NOTE: this regex is **not** the authoritative validity contract. The
|
|
57
71
|
* canonical manifest schema (https://civitai.com/schemas/app-block/v1.json)
|
|
58
|
-
* validates `scopes` by MEMBERSHIP in a fixed enum — i.e. the
|
|
72
|
+
* validates `scopes` by MEMBERSHIP in a fixed enum — i.e. exactly the values in
|
|
59
73
|
* {@link BLOCK_SCOPES}. `defineBlock` therefore gates on membership in
|
|
60
74
|
* `BLOCK_SCOPES`; this pattern is kept only as a FORMAT HEURISTIC to give a
|
|
61
75
|
* pointed error message (e.g. distinguishing a malformed/PascalCase scope from
|
package/dist/blocks/types.d.ts
CHANGED
|
@@ -1402,6 +1402,40 @@ export interface ManifestPreview {
|
|
|
1402
1402
|
description: string;
|
|
1403
1403
|
screenshots?: string[];
|
|
1404
1404
|
}
|
|
1405
|
+
/**
|
|
1406
|
+
* One entry of a manifest's `goods[]`. Mirrors the canonical schema exactly;
|
|
1407
|
+
* `id`, `title` and `priceBuzz` are required there and so are they here.
|
|
1408
|
+
*
|
|
1409
|
+
* 🔴 DECLARED BEFORE `BlockManifestV1`'S DOCBLOCK ON PURPOSE. Inserting an
|
|
1410
|
+
* interface BETWEEN a docblock and the declaration it documents does not just
|
|
1411
|
+
* look untidy — `tsc` emits both comments onto THIS interface in
|
|
1412
|
+
* `dist/blocks/types.d.ts` and leaves `BlockManifestV1` undocumented, so the
|
|
1413
|
+
* published types told a reader that "only `blockId`, `version`, `name`,
|
|
1414
|
+
* `contentRating` and `scopes` are required" about a type with three fields and
|
|
1415
|
+
* different requirements. Keep any new sibling above this comment or below
|
|
1416
|
+
* `BlockManifestV1`.
|
|
1417
|
+
*/
|
|
1418
|
+
export interface BlockManifestGood {
|
|
1419
|
+
/** Lowercase alphanumeric with `-`/`_`, at most 64 chars. Colon-free, because it is composed into a redis key and a ledger external id. */
|
|
1420
|
+
id: string;
|
|
1421
|
+
/** At most 80 chars. */
|
|
1422
|
+
title: string;
|
|
1423
|
+
/** At most 500 chars. */
|
|
1424
|
+
description?: string;
|
|
1425
|
+
/**
|
|
1426
|
+
* Whole Buzz, minimum 2 — at a price of 1 the owner's floored 70% share is
|
|
1427
|
+
* ZERO, so the app would sell an item and earn nothing from it, permanently.
|
|
1428
|
+
*/
|
|
1429
|
+
priceBuzz: number;
|
|
1430
|
+
/**
|
|
1431
|
+
* What the entitlement grants. `app_unlock` marks a one-time unlock of the app
|
|
1432
|
+
* itself; it is RECORDED identically today and the platform does not yet act
|
|
1433
|
+
* on it, so declaring it buys nothing unless you intend that later behaviour.
|
|
1434
|
+
*/
|
|
1435
|
+
kind?: 'good' | 'app_unlock';
|
|
1436
|
+
/** Opaque app payload, carried verbatim onto the entitlement. Never interpreted by the platform. */
|
|
1437
|
+
payload?: Record<string, unknown>;
|
|
1438
|
+
}
|
|
1405
1439
|
/**
|
|
1406
1440
|
* v1 manifest shape. Mirrors `schemas/app-block/v1.json` — keep them in sync.
|
|
1407
1441
|
*
|
|
@@ -1452,6 +1486,23 @@ export interface BlockManifestV1 {
|
|
|
1452
1486
|
* with the canonical schema's `scopeJustifications` (civitai #3195).
|
|
1453
1487
|
*/
|
|
1454
1488
|
scopeJustifications?: Record<string, string>;
|
|
1489
|
+
/**
|
|
1490
|
+
* Optional DIGITAL GOODS catalog — entitlements the platform sells to a viewer
|
|
1491
|
+
* for Buzz on this app's behalf. Manifest-governed and REVIEW-GATED: the
|
|
1492
|
+
* catalog a moderator approves is the catalog that can be sold, and changing a
|
|
1493
|
+
* price means shipping a new version and being re-reviewed.
|
|
1494
|
+
*
|
|
1495
|
+
* Declaring goods is not by itself permission to sell — the manifest must also
|
|
1496
|
+
* carry `goods:purchase:self` in `scopes`.
|
|
1497
|
+
*
|
|
1498
|
+
* 🔴 THE TYPE WAS ABSENT WHILE THE SCHEMA PROPERTY EXISTED, so `defineBlock`'s
|
|
1499
|
+
* documented inline-literal form rejected a `goods` declaration with TS2353
|
|
1500
|
+
* even though Ajv accepted the same manifest from a JSON file. Nothing could
|
|
1501
|
+
* catch that: there is a vendored-schema↔`BLOCK_SCOPES` cross-check but no
|
|
1502
|
+
* schema-properties↔interface-keys check, and the repo's manifest tests cast.
|
|
1503
|
+
* Kept in lockstep with the canonical schema's `goods`.
|
|
1504
|
+
*/
|
|
1505
|
+
goods?: BlockManifestGood[];
|
|
1455
1506
|
/** Optional; the canonical declares no required sub-field. */
|
|
1456
1507
|
iframe?: ManifestIframe;
|
|
1457
1508
|
/** Full-page surface descriptor (W10). */
|
package/dist/manifest/index.d.ts
CHANGED
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
export { defineBlock, SCHEMA_DIVERGENCES, KNOWN_GAPS, loadCanonicalSchema } from './defineBlock.js';
|
|
16
16
|
export type { DefineBlockConfig } from './defineBlock.js';
|
|
17
17
|
export { BlockManifestError } from '../blocks/manifestError.js';
|
|
18
|
-
export type { BlockManifest, BlockManifestV1 } from '../blocks/types.js';
|
|
18
|
+
export type { BlockManifest, BlockManifestGood, BlockManifestV1 } from '../blocks/types.js';
|
|
19
19
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@civitai/app-sdk/safe-storage` — survive an opaque-origin sandbox.
|
|
3
3
|
*
|
|
4
|
+
* 🔴 `@civitai/sdk`'s `src/safe-storage/index.ts` is a deliberate independent
|
|
5
|
+
* COPY of this file (the successor must not depend on its predecessor). The two
|
|
6
|
+
* bodies are held byte-identical modulo comments by
|
|
7
|
+
* `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix here goes red
|
|
8
|
+
* until it is applied there too.
|
|
9
|
+
*
|
|
4
10
|
* Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
|
|
5
11
|
* deliberately WITHOUT `allow-same-origin`. The document therefore has an
|
|
6
12
|
* **opaque origin**, and there is no origin to key web storage against, so the
|
|
@@ -54,8 +60,19 @@ export declare function createMemoryStorage(): Storage;
|
|
|
54
60
|
* with an in-memory `Storage`, so third-party code that touches it unguarded
|
|
55
61
|
* cannot throw.
|
|
56
62
|
*
|
|
57
|
-
* - **No-op where storage works.** A healthy `Storage` is
|
|
58
|
-
* its contents are
|
|
63
|
+
* - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
|
|
64
|
+
* never replaced and its contents are unchanged, but it *is* written to:
|
|
65
|
+
* classifying it means a real round trip, so each of `localStorage` and
|
|
66
|
+
* `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
|
|
67
|
+
* immediately followed by `removeItem`. MEASURED against the built artifact
|
|
68
|
+
* with instrumented healthy stores: exactly those four operations, the store
|
|
69
|
+
* objects not replaced, contents identical before and after. Per the Web
|
|
70
|
+
* Storage spec every one of those writes queues a `storage` event in the
|
|
71
|
+
* *other* documents that share the store, so at a REAL origin a cross-tab
|
|
72
|
+
* listener that does not filter by key sees spurious events (the two
|
|
73
|
+
* `localStorage` writes are the ones another tab receives). At the opaque
|
|
74
|
+
* origin this module exists for there is no other same-origin document, so
|
|
75
|
+
* there is nothing to receive them.
|
|
59
76
|
* - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
|
|
60
77
|
* - **Never loses readable data.** When the old store reads but refuses writes
|
|
61
78
|
* (full / disabled), its entries are copied into the fallback first, so the
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@civitai/app-sdk/safe-storage` — survive an opaque-origin sandbox.
|
|
3
3
|
*
|
|
4
|
+
* 🔴 `@civitai/sdk`'s `src/safe-storage/index.ts` is a deliberate independent
|
|
5
|
+
* COPY of this file (the successor must not depend on its predecessor). The two
|
|
6
|
+
* bodies are held byte-identical modulo comments by
|
|
7
|
+
* `tests/guards/safe-storage-copy-parity.test.mjs`, so a fix here goes red
|
|
8
|
+
* until it is applied there too.
|
|
9
|
+
*
|
|
4
10
|
* Civitai Apps run in an iframe sandboxed as `allow-scripts allow-forms`,
|
|
5
11
|
* deliberately WITHOUT `allow-same-origin`. The document therefore has an
|
|
6
12
|
* **opaque origin**, and there is no origin to key web storage against, so the
|
|
@@ -272,8 +278,19 @@ function seedFrom(existing, into) {
|
|
|
272
278
|
* with an in-memory `Storage`, so third-party code that touches it unguarded
|
|
273
279
|
* cannot throw.
|
|
274
280
|
*
|
|
275
|
-
* - **No-op where storage works.** A healthy `Storage` is
|
|
276
|
-
* its contents are
|
|
281
|
+
* - **No-op where storage works — but not a no-TOUCH.** A healthy `Storage` is
|
|
282
|
+
* never replaced and its contents are unchanged, but it *is* written to:
|
|
283
|
+
* classifying it means a real round trip, so each of `localStorage` and
|
|
284
|
+
* `sessionStorage` gets `setItem('__civitai_app_sdk_storage_probe__', '1')`
|
|
285
|
+
* immediately followed by `removeItem`. MEASURED against the built artifact
|
|
286
|
+
* with instrumented healthy stores: exactly those four operations, the store
|
|
287
|
+
* objects not replaced, contents identical before and after. Per the Web
|
|
288
|
+
* Storage spec every one of those writes queues a `storage` event in the
|
|
289
|
+
* *other* documents that share the store, so at a REAL origin a cross-tab
|
|
290
|
+
* listener that does not filter by key sees spurious events (the two
|
|
291
|
+
* `localStorage` writes are the ones another tab receives). At the opaque
|
|
292
|
+
* origin this module exists for there is no other same-origin document, so
|
|
293
|
+
* there is nothing to receive them.
|
|
277
294
|
* - **No-op where storage is absent** (Node/SSR/workers). Nothing is invented.
|
|
278
295
|
* - **Never loses readable data.** When the old store reads but refuses writes
|
|
279
296
|
* (full / disabled), its entries are copied into the fallback first, so the
|
package/package.json
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"auth": {
|
|
74
74
|
"type": "string",
|
|
75
|
-
"description": "Which credential the host hands the block: \"block-token\" (default when omitted) is the block-scoped JWT; \"oauth\"
|
|
75
|
+
"description": "Which credential the host hands the block: \"block-token\" (default when omitted) is the block-scoped JWT; \"oauth\" asks for an opaque OAuth access token for the block's own OauthClient, which ordinary /api/v1 routes accept as a viewer bearer. Minting it is gated on a server flag that is not generally enabled, and also requires a signed-in viewer and a declared \"user:read:self\" scope; when any of those is unmet the host hands back a block JWT instead of failing, so branch on the token kind the host reports rather than on this field. An OAuth token is refused by the postMessage bridge procedures, which verify a block JWT only, and declaring \"oauth\" alongside any \"apps:storage:*\" scope is refused at submit time.",
|
|
76
76
|
"enum": ["block-token", "oauth"]
|
|
77
77
|
},
|
|
78
78
|
"trustTier": {
|
|
@@ -99,13 +99,15 @@
|
|
|
99
99
|
"collections:read:self",
|
|
100
100
|
"collections:write:self",
|
|
101
101
|
"collections:read:private",
|
|
102
|
-
"posts:write:self"
|
|
102
|
+
"posts:write:self",
|
|
103
|
+
"goods:read:self",
|
|
104
|
+
"goods:purchase:self"
|
|
103
105
|
]
|
|
104
106
|
}
|
|
105
107
|
},
|
|
106
108
|
"scopeJustifications": {
|
|
107
109
|
"type": "object",
|
|
108
|
-
"description": "Per-scope justification: a map of scope-id → free-text rationale explaining WHY the app needs that permission, shown to the moderator during review. REQUIRED for SENSITIVE scopes — any declared scope that can spend or read the viewer's Buzz, read the viewer's private data, or write data other users see (e.g. `ai:write:budgeted`, `social:tip:self`, `buzz:read:self`, `collections:read:private`, `apps:storage:shared:write`, `posts:write:self`) MUST carry a non-empty justification here, or the manifest is rejected at submit time. OPTIONAL for non-sensitive scopes — omit those and the manifest stays valid. Every key MUST be a scope also present in `scopes` (justifications for scopes you don't request are rejected). Each value is a non-empty string of at most 500 characters. The requirement is enforced imperatively by the manifest validator (not expressed as JSON-Schema conditionals here). NOTE: the justification captures the developer's STATED rationale only; the platform does not verify the truth of the claims.",
|
|
110
|
+
"description": "Per-scope justification: a map of scope-id → free-text rationale explaining WHY the app needs that permission, shown to the moderator during review. REQUIRED for SENSITIVE scopes — any declared scope that can spend or read the viewer's Buzz, read the viewer's private data, or write data other users see (e.g. `ai:write:budgeted`, `social:tip:self`, `goods:purchase:self`, `buzz:read:self`, `collections:read:private`, `apps:storage:shared:write`, `posts:write:self`) MUST carry a non-empty justification here, or the manifest is rejected at submit time. OPTIONAL for non-sensitive scopes — omit those and the manifest stays valid. Every key MUST be a scope also present in `scopes` (justifications for scopes you don't request are rejected). Each value is a non-empty string of at most 500 characters. The requirement is enforced imperatively by the manifest validator (not expressed as JSON-Schema conditionals here). NOTE: the justification captures the developer's STATED rationale only; the platform does not verify the truth of the claims.",
|
|
109
111
|
"additionalProperties": {
|
|
110
112
|
"type": "string",
|
|
111
113
|
"minLength": 1,
|
|
@@ -221,6 +223,51 @@
|
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
},
|
|
226
|
+
"goods": {
|
|
227
|
+
"type": "array",
|
|
228
|
+
"description": "Optional DIGITAL GOODS catalog — entitlements the platform sells to a viewer on your app's behalf, for Buzz. Manifest-governed and REVIEW-GATED: the catalog a moderator approves is the catalog that can be sold, and changing a price means shipping a new version and being re-reviewed. The platform owns the ledger (who bought what, when, at what price, and its refund state); the good's MEANING is your app's business — read the viewer's entitlements from GET /api/v1/blocks/entitlements and keep the semantics in your own app storage. Declaring goods does not by itself let you sell: the app must also declare the `goods:purchase:self` scope (and `goods:read:self` to read entitlements back), and the viewer must consent. Sales split platform 30% / app owner 70%, paid immediately. Kept in lockstep with the bounds in src/shared/constants/block-goods.constants.ts (a drift-guard test enforces equality); the imperative validator in that same module is authoritative.",
|
|
229
|
+
"maxItems": 32,
|
|
230
|
+
"items": {
|
|
231
|
+
"type": "object",
|
|
232
|
+
"additionalProperties": false,
|
|
233
|
+
"required": ["id", "title", "priceBuzz"],
|
|
234
|
+
"properties": {
|
|
235
|
+
"id": {
|
|
236
|
+
"type": "string",
|
|
237
|
+
"description": "Stable identifier for this good, unique within the manifest. This is what a purchase and an entitlement are keyed by, so changing it in a later version orphans every entitlement already granted under the old id. Lowercase letters, digits, - and _, starting with a letter or digit.",
|
|
238
|
+
"minLength": 1,
|
|
239
|
+
"maxLength": 64,
|
|
240
|
+
"pattern": "^[a-z0-9][a-z0-9_-]*$"
|
|
241
|
+
},
|
|
242
|
+
"title": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"description": "Human-readable name shown to the viewer at purchase. Non-empty; the server measures the TRIMMED length, so this maxLength is never more permissive than the server.",
|
|
245
|
+
"minLength": 1,
|
|
246
|
+
"maxLength": 80
|
|
247
|
+
},
|
|
248
|
+
"description": {
|
|
249
|
+
"type": "string",
|
|
250
|
+
"description": "Optional one-paragraph explanation of what the viewer gets.",
|
|
251
|
+
"maxLength": 500
|
|
252
|
+
},
|
|
253
|
+
"priceBuzz": {
|
|
254
|
+
"type": "integer",
|
|
255
|
+
"description": "Price in whole Buzz. Buzz has no sub-unit, so this must be a whole number, and the minimum is 2 rather than 1 because the app owner's 70% share is floored — a 1 Buzz item would earn its owner nothing, permanently. Capped so a single purchase can never drain an account; a viewer also has a daily ceiling across every app they have installed, so a purchase can be refused with a clean 4xx even at a legal price. Both bounds are kept in lockstep with BLOCK_GOOD_MIN_PRICE_BUZZ / BLOCK_GOOD_MAX_PRICE_BUZZ in src/shared/constants/block-goods.constants.ts by a drift-guard test.",
|
|
256
|
+
"minimum": 2,
|
|
257
|
+
"maximum": 50000
|
|
258
|
+
},
|
|
259
|
+
"kind": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"description": "What the entitlement grants, from the platform's point of view. \"good\" (the default) is an ordinary in-app purchase the platform records and does not interpret. \"app_unlock\" marks a one-time unlock of the app itself; it is RECORDED IDENTICALLY today and the platform does NOT yet act on it — the paid-app access gate is a later change that reads this value. Declaring it now buys nothing, so leave it unset unless you intend that later behaviour.",
|
|
262
|
+
"enum": ["good", "app_unlock"]
|
|
263
|
+
},
|
|
264
|
+
"payload": {
|
|
265
|
+
"type": "object",
|
|
266
|
+
"description": "Optional OPAQUE payload copied verbatim onto the entitlement at purchase and handed back to your app unchanged. The platform never reads or interprets it. It is manifest-sourced rather than client-supplied precisely so that what an entitlement carries is something a moderator saw. Must serialize to at most 2048 bytes."
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
224
271
|
"targets": {
|
|
225
272
|
"type": "array",
|
|
226
273
|
"description": "Model-page slot targets. Each target's slotId must be a known registered model slot (not the page slot). Optional for page-only apps.",
|