@proveanything/smartlinks 1.15.3 → 1.15.5
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/dist/api/appConfiguration.d.ts +90 -1
- package/dist/api/appConfiguration.js +117 -0
- package/dist/api/collection.d.ts +6 -2
- package/dist/api/collection.js +6 -2
- package/dist/cache.d.ts +19 -0
- package/dist/cache.js +25 -0
- package/dist/docs/API_SUMMARY.md +157 -11
- package/dist/docs/app-data-storage.md +2 -0
- package/dist/docs/appConfig.md +483 -0
- package/dist/docs/overview.md +20 -0
- package/dist/docs/proof-product-data-scoping.md +459 -0
- package/dist/openapi.yaml +222 -4
- package/dist/types/appConfiguration.d.ts +83 -0
- package/dist/types/collection.d.ts +11 -2
- package/dist/types/product.d.ts +37 -0
- package/dist/types/proof.d.ts +39 -3
- package/docs/API_SUMMARY.md +157 -11
- package/docs/app-data-storage.md +2 -0
- package/docs/appConfig.md +483 -0
- package/docs/overview.md +20 -0
- package/docs/proof-product-data-scoping.md +459 -0
- package/openapi.yaml +222 -4
- package/package.json +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# `appConfig` — collection settings contract
|
|
2
|
+
|
|
3
|
+
**Settings group:** `appConfig` (a.k.a. `settings/appConfig`, app id `appConfig`)
|
|
4
|
+
**Owner:** platform (`entitlements-reconcile` edge function is the sole authority for the `system` block)
|
|
5
|
+
**Written by:** wizard signup, admin tools, module installer, add-on toggles, `entitlements-reconcile`
|
|
6
|
+
**Read by:** every microapp that needs to know "what is installed / entitled / metered" for a collection
|
|
7
|
+
|
|
8
|
+
This document is the source of truth for the shape and semantics of the
|
|
9
|
+
`appConfig` settings document, and for the SDK helpers microapps should use
|
|
10
|
+
to read it — in particular `appConfiguration.isFeatureEnabled()`, the
|
|
11
|
+
standard way for a subapp to check whether a feature flag is on for a
|
|
12
|
+
collection.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 0. TypeScript definition
|
|
17
|
+
|
|
18
|
+
The SDK exports this as `AppConfigSettings` (see `src/types/appConfiguration.ts`).
|
|
19
|
+
The document also carries a `systemPrivate` block (admin-only overrides and
|
|
20
|
+
internal metadata) that's stripped by the API for non-admin callers — see §5.
|
|
21
|
+
Its shape is intentionally not part of this SDK's public type surface, so it
|
|
22
|
+
doesn't appear below; treat it as opaque and never rely on it from client code.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
/** Full appConfig settings document for a SmartLinks collection. */
|
|
26
|
+
export interface AppConfigSettings {
|
|
27
|
+
id: 'appConfig';
|
|
28
|
+
|
|
29
|
+
// ── USER-OWNED (client may PATCH freely) ──────────────────────────
|
|
30
|
+
/** Installed modules — the customer's chosen surface. */
|
|
31
|
+
apps: AppEntry[];
|
|
32
|
+
/** Optional mirror of add-on keys the customer has toggled. */
|
|
33
|
+
addOns?: string[];
|
|
34
|
+
/** Wizard hint — which base plan the customer picked pre-checkout. */
|
|
35
|
+
requestedBasePlanId?: string;
|
|
36
|
+
|
|
37
|
+
/** How the account handles individual physical items. */
|
|
38
|
+
itemRecordMode?: 'registered' | 'owned' | null;
|
|
39
|
+
virtualItemsEnabled?: boolean;
|
|
40
|
+
|
|
41
|
+
// ── SYSTEM-OWNED, PUBLIC (reconcile writes; client read-only) ─────
|
|
42
|
+
/** Resolved entitlement truth. Read this to gate features. */
|
|
43
|
+
system?: SystemBlock;
|
|
44
|
+
|
|
45
|
+
// Also carries a `systemPrivate` block, stripped for non-admin reads.
|
|
46
|
+
// Not part of the public SDK contract — see §5.
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One installed module instance on this collection. */
|
|
50
|
+
export interface AppEntry {
|
|
51
|
+
id: string; // instance id (typically === uniqueName)
|
|
52
|
+
uniqueName: string; // module id from the module catalog
|
|
53
|
+
customName?: string; // display name, overrideable
|
|
54
|
+
active: boolean; // user toggle
|
|
55
|
+
all?: boolean; // legacy scope flag
|
|
56
|
+
live?: boolean; // legacy flag
|
|
57
|
+
faIcon?: string; // optional FA icon override
|
|
58
|
+
versionChannel?: 'stable' | 'beta' | 'dev';
|
|
59
|
+
/** Set by reconcile when app category is not in entitledAppGroups. */
|
|
60
|
+
reason?: 'not_entitled';
|
|
61
|
+
[k: string]: unknown; // per-module inline settings preserved
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolved entitlements — safe for every client to read. */
|
|
65
|
+
export interface SystemBlock {
|
|
66
|
+
basePlanId?: string;
|
|
67
|
+
addOnKeys?: string[];
|
|
68
|
+
apps?: string[]; // apps unlocked by add-ons
|
|
69
|
+
features?: Record<string, boolean>; // explicit overrides only — see §4.4 for absent-key resolution
|
|
70
|
+
meters?: Record<string, MeterEntry>;
|
|
71
|
+
entitledAppGroups?: string[]; // lowercased category names
|
|
72
|
+
/** 'enterprise' flips every feature flag's default to on unless explicitly false — see §4.4/§4.7. */
|
|
73
|
+
accountType?: 'enterprise' | 'standard';
|
|
74
|
+
syncedAt?: string; // ISO-8601 UTC
|
|
75
|
+
syncedFromSubscriptionId?: string;
|
|
76
|
+
/** Diagnostic: which override keys reconcile applied on this pass. */
|
|
77
|
+
appliedOverrides?: AppliedOverridesSummary;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MeterEntry {
|
|
81
|
+
included: number;
|
|
82
|
+
stripePriceId?: string;
|
|
83
|
+
stripeMeterId?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AppliedOverridesSummary {
|
|
87
|
+
features?: string[];
|
|
88
|
+
meters?: string[];
|
|
89
|
+
entitledAppGroups?: string[];
|
|
90
|
+
addOnKeys?: string[];
|
|
91
|
+
apps?: string[];
|
|
92
|
+
accountType?: boolean;
|
|
93
|
+
note?: string;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`systemPrivate`'s shape is deliberately omitted here — see §5.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 1. Where it lives
|
|
102
|
+
|
|
103
|
+
Per SmartLinks collection, in the `appConfig` settings group — but for
|
|
104
|
+
public (non-admin) reads, **don't call the settings-group endpoint
|
|
105
|
+
directly.** As of 2026-07-18, `/public/collection/:id/app/config`
|
|
106
|
+
(`collection.getAppsConfig()`) returns the same entitlements data merged
|
|
107
|
+
into its response alongside the app catalog, so one call gets both:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import * as SL from '@proveanything/smartlinks';
|
|
111
|
+
|
|
112
|
+
// Recommended — one request, cached (see §6.0)
|
|
113
|
+
const cfg = await SL.appConfiguration.getAppConfig(collectionId);
|
|
114
|
+
console.log(cfg.apps); // app catalog (AppConfig[]) — see §3.1
|
|
115
|
+
console.log(cfg.system?.features); // entitlements — see §4
|
|
116
|
+
|
|
117
|
+
// Equivalent low-level call — getAppConfig() is a thin, cached wrapper over this
|
|
118
|
+
const cfgRaw = await SL.collection.getAppsConfig(collectionId);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`systemPrivate` (§5) is **never** returned by this endpoint — it's public-only.
|
|
122
|
+
If you need it (admin surfaces), call the settings-group endpoint directly:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const cfgAdmin = await SL.appConfiguration.getConfig({
|
|
126
|
+
collectionId,
|
|
127
|
+
appId: 'appConfig', // group id
|
|
128
|
+
admin: true, // required for systemPrivate
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Admin writes to `system` / `systemPrivate` MUST go through
|
|
133
|
+
`entitlements-reconcile` (never patch them directly from the client).
|
|
134
|
+
- The settings-group endpoint's own `apps` field is shaped differently from
|
|
135
|
+
the combined endpoint's — see §3.1 before assuming they're interchangeable.
|
|
136
|
+
|
|
137
|
+
## 2. Access tiers
|
|
138
|
+
|
|
139
|
+
Three tiers, symmetric in-gate / out-gate rules:
|
|
140
|
+
|
|
141
|
+
| Block | Client read | Client write | Admin read | Admin write path |
|
|
142
|
+
| ---------------- | ----------- | ------------ | ---------- | ---------------------- |
|
|
143
|
+
| user-owned root | yes | yes | yes | client SDK |
|
|
144
|
+
| `system` | yes | no | yes | `entitlements-reconcile` only |
|
|
145
|
+
| `systemPrivate` | **stripped** | no | yes | `plan-migration` `write_app_config` (super-admin) |
|
|
146
|
+
|
|
147
|
+
The strip happens at the settings API layer — `systemPrivate` genuinely
|
|
148
|
+
does not leave the server for non-admin callers. This convention
|
|
149
|
+
generalises to every settings group, not just `appConfig`.
|
|
150
|
+
|
|
151
|
+
## 3. `apps[]` — installed modules
|
|
152
|
+
|
|
153
|
+
See `AppEntry` in §0. Notes:
|
|
154
|
+
|
|
155
|
+
- `id` and `uniqueName` are historically distinct but currently
|
|
156
|
+
identical for new installs. Consumers should key off `uniqueName`.
|
|
157
|
+
- `customName` is seeded from the module definition's `name`.
|
|
158
|
+
- `active: false` + `reason: "not_entitled"` means reconcile flagged
|
|
159
|
+
this app as outside the current plan's `entitledAppGroups`. UI should
|
|
160
|
+
show it as disabled with an upgrade prompt; do not delete it.
|
|
161
|
+
- Unknown extra keys are preserved by reconcile — safe to add per-module
|
|
162
|
+
settings inline.
|
|
163
|
+
|
|
164
|
+
### 3.1 Two different `apps[]` shapes — don't conflate them
|
|
165
|
+
|
|
166
|
+
This settings document's own `apps` field is `AppEntry[]` — installed
|
|
167
|
+
*instances* (`uniqueName`, `customName`, `active`, `reason`).
|
|
168
|
+
|
|
169
|
+
`collection.getAppsConfig()` / `appConfiguration.getAppConfig()` (§1, §6.0)
|
|
170
|
+
return a **different** `apps` field: `AppConfig[]`, the module *catalog*
|
|
171
|
+
definitions (`srcAppId`, `category`, `usage`, iframe/manifest URLs — see
|
|
172
|
+
`src/types/collection.ts`), used for resolving which bundle to load. The
|
|
173
|
+
entitlements fields (`system`, `addOns`, `itemRecordMode`, etc.) got merged
|
|
174
|
+
onto that response; the `AppEntry[]` shape did not replace it.
|
|
175
|
+
|
|
176
|
+
If you need the installed-instance list (`active`/`reason` per app), fetch
|
|
177
|
+
the settings group directly (§1); if you need the catalog + entitlements
|
|
178
|
+
together, use `appConfiguration.getAppConfig()`.
|
|
179
|
+
|
|
180
|
+
## 4. `system` — reconciled truth
|
|
181
|
+
|
|
182
|
+
Written only by `entitlements-reconcile`. Represents the union of:
|
|
183
|
+
|
|
184
|
+
- the customer's base plan (`grantsFeatures`, `grantsAppGroups`)
|
|
185
|
+
- every add-on line item on their Stripe subscription
|
|
186
|
+
- `systemPrivate.overrides` (applied last, always wins)
|
|
187
|
+
|
|
188
|
+
### 4.1 `basePlanId`
|
|
189
|
+
|
|
190
|
+
String id of the tier. Sourced from Stripe subscription metadata
|
|
191
|
+
(`basePlanId` or legacy `planId`). Examples: `simple_redirect`,
|
|
192
|
+
`rich_redirect`, `inform`, `enrich`, `engage`, `hub_inform`,
|
|
193
|
+
`hub_enrich`, `hub_engage`.
|
|
194
|
+
|
|
195
|
+
### 4.2 `addOnKeys[]`
|
|
196
|
+
|
|
197
|
+
Sorted array of every add-on key currently on the subscription. Matches
|
|
198
|
+
`platform-addons` catalog `key` field.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const hasCustomDomain = cfg.system.addOnKeys.includes('addon_custom_domain');
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### 4.3 `apps[]` (system-side)
|
|
205
|
+
|
|
206
|
+
Sorted array of app ids granted by add-ons whose `category === 'apps'`.
|
|
207
|
+
Entitlement view — not the same as the top-level installed `apps[]`.
|
|
208
|
+
Prefer `entitledAppGroups` for gating.
|
|
209
|
+
|
|
210
|
+
### 4.4 `features`
|
|
211
|
+
|
|
212
|
+
Flat map of feature flag → explicit `true`/`false` override.
|
|
213
|
+
|
|
214
|
+
**A missing key is not "off" — it means "use the accountType default."**
|
|
215
|
+
Resolution rule (see `resolveFeature()` / `isFeatureEnabled()`, §6.0):
|
|
216
|
+
|
|
217
|
+
| `features[flag]` | `accountType: 'enterprise'` | `accountType: 'standard'` / missing |
|
|
218
|
+
| ----------------- | ---------------------------- | -------------------------------------- |
|
|
219
|
+
| `true` (explicit) | on | on |
|
|
220
|
+
| `false` (explicit) | **off** | off |
|
|
221
|
+
| absent | **on** (enterprise default) | off (standard default) |
|
|
222
|
+
|
|
223
|
+
This is why reading `cfg.system.features.custom_domain` directly is wrong
|
|
224
|
+
for enterprise accounts — an absent key there does NOT mean disabled:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
// ❌ WRONG for enterprise accounts — absent key silently reads as "off"
|
|
228
|
+
if (cfg.system.features.custom_domain) enableCustomDomainUI();
|
|
229
|
+
|
|
230
|
+
// ✅ CORRECT — applies the enterprise default
|
|
231
|
+
if (await appConfiguration.isFeatureEnabled(collectionId, 'custom_domain')) {
|
|
232
|
+
enableCustomDomainUI();
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Feature flag naming: bare snake_case, no `feature_` prefix.
|
|
237
|
+
|
|
238
|
+
Always resolve flags through `appConfiguration.isFeatureEnabled(collectionId, flag)`
|
|
239
|
+
/ `isFeatureEnabledSync()` (§6.0), or `appConfiguration.resolveFeature(cfg.system, flag)`
|
|
240
|
+
if you already have a `system` block from elsewhere — never read
|
|
241
|
+
`cfg.system.features[flag]` directly.
|
|
242
|
+
|
|
243
|
+
### 4.5 `meters`
|
|
244
|
+
|
|
245
|
+
Map keyed by add-on key. Apps reporting usage should call the
|
|
246
|
+
metered-usage endpoint using `stripeMeterId` (see
|
|
247
|
+
`platform-metered-usage-endpoint.md`).
|
|
248
|
+
|
|
249
|
+
### 4.6 `entitledAppGroups[]`
|
|
250
|
+
|
|
251
|
+
Sorted, lowercased category names the customer's plan unlocks. Match
|
|
252
|
+
against a module's `category` (see `src/config/app-categories.ts`):
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
"authenticity" | "documentation" | "digital-product-passports" |
|
|
256
|
+
"commerce" | "engagement" | "ai" | "integration" | "other"
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Empty array = permissive.** Reconcile treats an empty array as "no
|
|
260
|
+
gating configured yet" and does not flag any apps.
|
|
261
|
+
|
|
262
|
+
### 4.7 `accountType`
|
|
263
|
+
|
|
264
|
+
Explicit account tier. `"enterprise"` flips the default for **every**
|
|
265
|
+
feature flag to on — not just a curated "everything on" baseline — so an
|
|
266
|
+
app that has never heard of a given flag still reads it as enabled for an
|
|
267
|
+
enterprise account. See §4.4 for the exact resolution rule. Sourced
|
|
268
|
+
from `systemPrivate.overrides.accountType` and copied here by reconcile
|
|
269
|
+
so the client never has to peek at private data. Missing = standard
|
|
270
|
+
(every flag defaults off unless explicitly `true`).
|
|
271
|
+
|
|
272
|
+
### 4.8 `syncedAt` / `syncedFromSubscriptionId`
|
|
273
|
+
|
|
274
|
+
Diagnostic. `syncedAt` is ISO-8601 UTC. `syncedFromSubscriptionId`
|
|
275
|
+
absent means "no subscription on file, defaults applied".
|
|
276
|
+
|
|
277
|
+
### 4.9 `appliedOverrides`
|
|
278
|
+
|
|
279
|
+
Diagnostic — records which keys came from `systemPrivate.overrides`
|
|
280
|
+
this reconcile pass, so admins can see comp-driven flips vs
|
|
281
|
+
Stripe-derived truth without opening the private block.
|
|
282
|
+
|
|
283
|
+
## 5. `systemPrivate` — admin-only overrides & metadata
|
|
284
|
+
|
|
285
|
+
Super-admin-owned block for enterprise / custom deals and internal
|
|
286
|
+
metadata, applied by `entitlements-reconcile` after Stripe derivation
|
|
287
|
+
(§4) so it always wins. Its shape is intentionally **not documented
|
|
288
|
+
here or in the SDK types** — it's internal to the platform, not part of
|
|
289
|
+
the public `appConfig` contract.
|
|
290
|
+
|
|
291
|
+
- Stripped from every non-admin response at the API boundary (§2).
|
|
292
|
+
- Edited only via the `plan-migration` action `write_app_config` (admin) —
|
|
293
|
+
the account detail page has a dedicated editor card.
|
|
294
|
+
- Client apps should NEVER attempt to read or depend on `systemPrivate`;
|
|
295
|
+
the SDK's public types don't expose it, and the API strips it anyway.
|
|
296
|
+
- What it feeds into `system` (which client apps DO read) is documented
|
|
297
|
+
where it lands: `system.accountType` (§4.7) and `system.appliedOverrides`
|
|
298
|
+
(§4.9).
|
|
299
|
+
|
|
300
|
+
## 6. Consumer patterns
|
|
301
|
+
|
|
302
|
+
### 6.0 Feature gate — recommended: `isFeatureEnabled()`
|
|
303
|
+
|
|
304
|
+
This is the standard way for a subapp to check whether a feature is on for
|
|
305
|
+
a collection. It fetches (and caches) the whole `appConfig` document the
|
|
306
|
+
first time it's called for a collection, then reads through that cache on
|
|
307
|
+
every subsequent call — so calling it once per component, or once per
|
|
308
|
+
render, doesn't cause repeat network requests.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
if (await SL.appConfiguration.isFeatureEnabled(collectionId, 'custom_domain')) {
|
|
312
|
+
enableCustomDomainUI();
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
It's always `async` — the very first call for a collection has nothing
|
|
317
|
+
cached yet, so there's no way to answer without an `await`. If you need a
|
|
318
|
+
synchronous read (e.g. inside a render function that can't await), warm the
|
|
319
|
+
cache once during app init and use the sync variant afterwards:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// Once, e.g. in a top-level effect or before first render:
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
SL.appConfiguration.isFeatureEnabled(collectionId, 'custom_domain')
|
|
325
|
+
}, [collectionId]);
|
|
326
|
+
|
|
327
|
+
// Anywhere else, read synchronously — no await:
|
|
328
|
+
const enabled = SL.appConfiguration.isFeatureEnabledSync(collectionId, 'custom_domain');
|
|
329
|
+
if (enabled === undefined) {
|
|
330
|
+
// not warmed yet this page load — treat as "not yet known", not "off"
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`isFeatureEnabledSync` returns `boolean | undefined`: `undefined` means the
|
|
335
|
+
cache hasn't been warmed yet for this collection, not that the flag is off.
|
|
336
|
+
Don't treat `undefined` as `false`.
|
|
337
|
+
|
|
338
|
+
The cache TTL is 5 minutes (in-memory + sessionStorage, cleared on full
|
|
339
|
+
page reload). Pass `{ force: true }` to `getAppConfig` / `isFeatureEnabled`
|
|
340
|
+
to bypass it — e.g. immediately after your own code calls
|
|
341
|
+
`entitlements-reconcile` and the flags may have changed:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
await SL.appConfiguration.isFeatureEnabled(collectionId, 'custom_domain', { force: true });
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### 6.1 Reading the raw config
|
|
348
|
+
|
|
349
|
+
If you need more than one flag, or other parts of the document, fetch the
|
|
350
|
+
whole thing once and resolve multiple flags off it with `resolveFeature()`
|
|
351
|
+
— still cached, still one network request. **Do not** read
|
|
352
|
+
`cfg.system.features[flag]` directly (`Boolean(...)`/truthiness checks); an
|
|
353
|
+
absent key means "use the accountType default" (§4.4), not "off":
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const cfg = await SL.appConfiguration.getAppConfig(collectionId);
|
|
357
|
+
const enabled = SL.appConfiguration.resolveFeature(cfg?.system, flag);
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### 6.2 App entitlement gate
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
const cfg = await SL.appConfiguration.getAppConfig(collectionId);
|
|
364
|
+
const groups = new Set(cfg?.system?.entitledAppGroups ?? []);
|
|
365
|
+
const canInstall = groups.size === 0 || groups.has(myModuleCategory.toLowerCase());
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### 6.3 Enterprise detection
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
const cfg = await SL.appConfiguration.getAppConfig(collectionId);
|
|
372
|
+
const isEnterprise = cfg?.system?.accountType === 'enterprise';
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### 6.4 Meter lookup
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const cfg = await SL.appConfiguration.getAppConfig(collectionId);
|
|
379
|
+
const meter = cfg?.system?.meters?.[addOnKey];
|
|
380
|
+
if (meter) reportUsage(meter.stripeMeterId, quantity);
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### 6.5 Reacting to changes
|
|
384
|
+
|
|
385
|
+
`appConfig` is not push-based today — read it on load and after any
|
|
386
|
+
subscription mutation you initiate. Client code that changes
|
|
387
|
+
entitlements MUST call `entitlements-reconcile` afterwards, then re-read
|
|
388
|
+
with `{ force: true }` (§6.0) rather than trusting the cache or merging
|
|
389
|
+
with a stale local copy.
|
|
390
|
+
|
|
391
|
+
## 7. Write paths
|
|
392
|
+
|
|
393
|
+
| Actor | Endpoint | Writes |
|
|
394
|
+
| --------------------------- | ---------------------------------------------------------------------------- | ------------------------- |
|
|
395
|
+
| Wizard signup | `stripe-wizard-webhook` → `entitlements-reconcile` | full doc |
|
|
396
|
+
| Add-on toggle (client) | `entitlements-reconcile` (`changes[]`) | full doc |
|
|
397
|
+
| Module install / uninstall | client `setConfig` on `apps[]`, then `entitlements-reconcile` (no changes) | `apps[]` + `system` |
|
|
398
|
+
| Admin migration | `plan-migration` action `apply` | full doc |
|
|
399
|
+
| Admin manual edit | `plan-migration` action `write_app_config` | full doc verbatim (incl. `systemPrivate`) |
|
|
400
|
+
| Stripe webhook | `entitlements-reconcile` | `system` block |
|
|
401
|
+
|
|
402
|
+
Clients MUST NOT write directly to `system` or `systemPrivate`.
|
|
403
|
+
`entitlements-reconcile` merges user-owned fields through untouched.
|
|
404
|
+
|
|
405
|
+
## 8. Item handling (`itemRecordMode`, `virtualItemsEnabled`)
|
|
406
|
+
|
|
407
|
+
Two **independent** root-level, user-owned keys describing how the
|
|
408
|
+
account treats individual physical items. Reconcile never touches
|
|
409
|
+
them.
|
|
410
|
+
|
|
411
|
+
Three axes (do NOT conflate):
|
|
412
|
+
|
|
413
|
+
1. **Virtual items** — `virtualItemsEnabled: boolean`. Algorithmic
|
|
414
|
+
IDs, no per-item row. Battery serials, scan-to-collect points,
|
|
415
|
+
bulk QR sheets. Can be on with `itemRecordMode` off.
|
|
416
|
+
2. **Item records** — `itemRecordMode`. Materialise a persistent row
|
|
417
|
+
per physical item. `null` / missing = **disabled** (no separate
|
|
418
|
+
`"none"` enum — reads collapse to `mode === "off"` via the
|
|
419
|
+
`useItemRecordMode()` hook).
|
|
420
|
+
- `"registered"` — tag row, no owner. Authenticity / warranty /
|
|
421
|
+
provenance without a customer relationship.
|
|
422
|
+
- `"owned"` — tag row + owner + interaction history. Requires
|
|
423
|
+
`basePlanId >= "engage"`. Enables claim / transfer / CRM.
|
|
424
|
+
3. **CRM** — downstream consequence of `owned`; not a peer flag.
|
|
425
|
+
|
|
426
|
+
**Billing:** the usage ledger stamps `tier` at event time from
|
|
427
|
+
`itemRecordMode`. Writes with `mode === null` MUST be rejected. Mode
|
|
428
|
+
changes are forward-only per record — existing items keep their tier.
|
|
429
|
+
|
|
430
|
+
**UI:** rendered as a single "Item handling" section at the top of
|
|
431
|
+
the customer add-ons page (`src/components/pricing/ItemHandlingSection`)
|
|
432
|
+
because these settings change the whole UX shape (claim, ownership,
|
|
433
|
+
CRM visibility), not just a capability flag.
|
|
434
|
+
|
|
435
|
+
## 9. Migration notes
|
|
436
|
+
|
|
437
|
+
- Legacy `entitlements` settings group is deprecated. Everything moved
|
|
438
|
+
into `appConfig.system`.
|
|
439
|
+
- `appConfig.apps[]` is the master install list. Old split between
|
|
440
|
+
`entitlements.apps` and `settings.apps` is gone.
|
|
441
|
+
- Empty `system.features` is legitimate for base plans that grant no
|
|
442
|
+
features yet.
|
|
443
|
+
- `systemOverrides` (old root-level key) → `systemPrivate.overrides`.
|
|
444
|
+
One-off backfill moves the block and re-runs reconcile.
|
|
445
|
+
|
|
446
|
+
## 10. Change log
|
|
447
|
+
|
|
448
|
+
- **2026-07-17**: Initial contract published; `appConfig.system` becomes
|
|
449
|
+
canonical entitlements source of truth. `entitlements` group deprecated.
|
|
450
|
+
- **2026-07-17**: Added `systemOverrides` for additive comps; reconcile
|
|
451
|
+
records `system.appliedOverrides`.
|
|
452
|
+
- **2026-07-18**: Renamed `systemOverrides` → `systemPrivate.overrides`
|
|
453
|
+
and formalised the three-tier access model (user / system /
|
|
454
|
+
systemPrivate). API strips `systemPrivate` for non-admin readers.
|
|
455
|
+
Added `system.accountType` derived from
|
|
456
|
+
`systemPrivate.overrides.accountType`. Added top-of-doc TypeScript
|
|
457
|
+
definition.
|
|
458
|
+
- **2026-07-18**: Added SDK helpers `appConfiguration.getAppConfig()`
|
|
459
|
+
(typed + cached), `isFeatureEnabled()` (async, cache-backed feature
|
|
460
|
+
gate), and `isFeatureEnabledSync()` (cache-only, for callers that can't
|
|
461
|
+
await). Renamed the exported TypeScript type to `AppConfigSettings`
|
|
462
|
+
to avoid a name collision with the existing per-module `AppConfig`
|
|
463
|
+
type in `src/types/collection.ts`.
|
|
464
|
+
- **2026-07-18**: `/public/collection/:id/app/config` (`collection.getAppsConfig()`)
|
|
465
|
+
now returns the `appConfig` entitlements data (`system`, `addOns`,
|
|
466
|
+
`requestedBasePlanId`, `itemRecordMode`, `virtualItemsEnabled`) merged
|
|
467
|
+
alongside its existing app-catalog `apps[]`. `appConfiguration.getAppConfig()`
|
|
468
|
+
was repointed at this endpoint (dropping its `admin` option — this endpoint
|
|
469
|
+
is public-only); the settings-group endpoint (`appConfiguration.getConfig({ appId: 'appConfig' })`)
|
|
470
|
+
is still the only way to reach `systemPrivate`. See §3.1 for why the two
|
|
471
|
+
endpoints' `apps[]` fields are shaped differently and shouldn't be conflated.
|
|
472
|
+
- **2026-07-18**: `system.features` changed from "only truthy flags appear"
|
|
473
|
+
to explicit `Record<string, boolean>` overrides. Added the `accountType`
|
|
474
|
+
default rule (§4.4): enterprise accounts default every flag to on unless
|
|
475
|
+
explicitly `false`; standard accounts default off unless explicitly `true`.
|
|
476
|
+
Added `appConfiguration.resolveFeature(system, flag)` — the pure resolver
|
|
477
|
+
behind `isFeatureEnabled()`/`isFeatureEnabledSync()`, exposed only for the
|
|
478
|
+
rare case of already holding a `system` block from somewhere other than
|
|
479
|
+
`getAppConfig()`. There is no separate admin feature-check path — `system`
|
|
480
|
+
is public data on every read, so admin surfaces should call
|
|
481
|
+
`isFeatureEnabled()` like everything else. Reading `cfg.system.features[flag]`
|
|
482
|
+
directly is no longer correct for enterprise accounts — always resolve
|
|
483
|
+
through one of these three.
|
package/docs/overview.md
CHANGED
|
@@ -80,6 +80,8 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
|
|
|
80
80
|
| **Contact Search** | `docs/contact-search.md` | Admin contact search: free-text, typeahead, identity/tag/JSONB filters, and pagination |
|
|
81
81
|
| **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
|
|
82
82
|
| **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
|
|
83
|
+
| **Product/Proof Data Scoping** | `docs/proof-product-data-scoping.md` | Canonical spec for `product.data`/`.admin` and `proof.data`/`.admin`/`.values` (owner/personal) — who can read and write each bucket |
|
|
84
|
+
| **appConfig / Feature Flags** | `docs/appConfig.md` | `appConfig` settings contract — installed apps, `system.features`/`entitledAppGroups`/`meters`, `isFeatureEnabled()` helper |
|
|
83
85
|
|
|
84
86
|
---
|
|
85
87
|
|
|
@@ -200,10 +202,28 @@ For config/settings visibility, remember: root fields are the normal shared payl
|
|
|
200
202
|
|
|
201
203
|
Prefer `app.records` over `setDataItem` when the data is becoming a real entity that needs lifecycle, ownership, visibility, relationships, or filtering. Keep `setDataItem` for simple keyed scoped documents and config-adjacent content.
|
|
202
204
|
|
|
205
|
+
### Feature Flags & Entitlements (`appConfig`)
|
|
206
|
+
|
|
207
|
+
The platform-owned `appConfig` settings group (`system.features`, `system.meters`, `system.entitledAppGroups`, installed `apps[]`) tells a microapp what's installed and entitled for a collection. Use `SL.appConfiguration.isFeatureEnabled(collectionId, flag)` to gate a feature — it's the standard, cache-backed way to do this rather than hand-rolling a `getConfig` call:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
if (await SL.appConfiguration.isFeatureEnabled(collectionId, 'custom_domain')) {
|
|
211
|
+
enableCustomDomainUI();
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
It's async (the first check for a collection has nothing cached yet); `SL.appConfiguration.isFeatureEnabledSync()` gives a synchronous, cache-only read (`boolean | undefined`) for callers that warmed the cache earlier and can't await. See `docs/appConfig.md` for the full contract, including `systemPrivate` (admin-only overrides, always stripped for non-admin reads).
|
|
216
|
+
|
|
203
217
|
### Attestations (Proof-level data)
|
|
204
218
|
|
|
205
219
|
For data attached to specific proof instances, use `SL.attestation.create()` and `SL.attestation.list()`.
|
|
206
220
|
|
|
221
|
+
### Custom Data Scoping (Product & Proof)
|
|
222
|
+
|
|
223
|
+
`product.data`/`product.admin` and `proof.data`/`proof.admin`/`proof.values` are the canonical buckets for custom fields on products and proofs — `data` is public and business-writable, `admin` is business-only, and `proof.values` additionally supports owner-scoped (`values.owner`) and per-user (`values.personal[userId]`) sub-keys. This is a different mechanism from the app-scoped storage in `docs/app-data-storage.md` (which is for app-owned config/records, not the product/proof documents themselves).
|
|
224
|
+
|
|
225
|
+
See `docs/proof-product-data-scoping.md` for the full read/write authority matrix, worked examples, and the `productFields`/`proofFields` collection-settings schemas that drive field-config editors.
|
|
226
|
+
|
|
207
227
|
---
|
|
208
228
|
|
|
209
229
|
## Error Handling
|