@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.
@@ -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.
@@ -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