@wix/himalaya-cli 0.1622.0 → 0.1623.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.
@@ -464,6 +464,40 @@ top-level `code` on stderr.
464
464
  `navLogicBundle` that differs, or its `navigation` cannot be edited safely; the
465
465
  literal to merge is returned in `snippet` and nothing is written (exit 1).
466
466
 
467
+ ## `himi one-app access` codes
468
+
469
+ Carries the source's locked pages (members only, a badge, a paid plan) and its app-wide login requirement into a
470
+ migrated app. Success prints `ok: true` and a `status`: `written`, `unchanged`, `would_write` (`--dry-run`), `in_sync`
471
+ (`--check`) or `no_gates`; `unmapped` lists locked pages with no Himi screen, which are never guessed, and `loginWall`
472
+ says `required`, `not_required` or `unknown` with its `basis` (`evidence`, `flag` or `none`); when it is `required` it also
473
+ lists `enforcedOn` (`ios`, `android`) and `notEnforcedOn` (`web`: the web host enforces a wall only for its own account
474
+ mode). `loginWall.kept` is true when the source does not require login but the app already has a wall (for example one chosen with `--login-wall required`): it stays, and only an explicit `--login-wall none` is refused. `homeEntries` lists the locked pages whose entry is the app's home destination: it stays on the bar behind its lock screen and is never hidden. `himi one-app migrate` adds
475
+ the warning `access_policy_unavailable` to the manifest when the source did not report the login setting. Failures print `ok: false` and a top-level `code`
476
+ on stderr.
477
+
478
+ - `invalid_usage`: required options are missing, duplicated or unknown, `--screen` is not `<layoutItemId>=<screenId>`,
479
+ `--login-wall` is not `required` or `none`, or `--dry-run` and `--check` are both given (exit 2).
480
+ - `evidence_invalid`: the evidence manifest or `raw-structure.json` is missing, invalid or fails its recorded hash,
481
+ `access-policy.json` does not match the hash in the manifest, or the coverage ledger cannot be read or has rows whose
482
+ source changed (exit 1).
483
+ - `app_uninspectable`: `config.ts` or `dev/index.ts` cannot be read or parsed, `dev/index.ts` has no literal `SCREENS`,
484
+ or a file cannot be written; in that case every file written so far is put back as it was (exit 1).
485
+ - `access_engine_missing`: the app lacks `tier5-src/_shared/access-gates.ts` or
486
+ `tier5-src/himi-branded-lite-access-nav`, so a table naming gates would lock nothing; re-scaffold from the current
487
+ template (exit 1).
488
+ - `gate_screen_missing`: a `--screen` names a screen `dev/index.ts` does not declare (exit 1).
489
+ - `app_services_unavailable`: page locks read app-level state through app services, which only an app that has never
490
+ shipped an older binary may use, and the app neither declares `app: { newApp: true }` nor was given `--new-app`; nothing
491
+ is written (exit 1). `--new-app` is the author's statement that the app is new.
492
+ - `needs_manual_merge`: a locked page is mapped to another screen than the app carries (`moved` lists `layoutItemId`, `from`, `to`: the old screen is still wrapped in its lock), a screen payload holds more than `id`, `body` and `modifiers` (or is not a literal object), an entry or row already has a
493
+ different `hiddenPath`, `navigation.navLogicBundle` is a worker that does not answer the gates, `config.ts` has no
494
+ literal `auth`, or the app requires login (`requireLogin`) while the source no longer does; nothing is written (exit 1).
495
+ - `gates_removed`: the app carries gates that this run would not carry: the source has none, or fewer `--screen`
496
+ mappings were given than the app already has gates; `dropped` names the gate keys. Taking a lock out is not
497
+ automatic (exit 1).
498
+ - `access_out_of_sync` (`--check`): the app does not carry the source's rules as they are now; `outOfSync` names the
499
+ files, and a locked page with no `--screen` yet counts too, so a script that gates on the exit code never passes while a locked page is open (exit 1).
500
+
467
501
  ## `himi one-app validate-design` codes
468
502
 
469
503
  - `invalid_usage`: required options are missing or incompatible (exit 2).
@@ -388,6 +388,8 @@ Apply these interpretation rules:
388
388
  - Keep navigation visibility, reachability and data access separate. Reevaluate
389
389
  membership/plan/badge gates at runtime; an operator's successful read is not an
390
390
  end-user access policy. Never expose protected content beneath a visual gate.
391
+ Carry a locked page's rule with `himi one-app access` (see "Locked pages" below):
392
+ it is a presentation rule, and the data stays protected by its own Wix API.
391
393
  - Exclude `ntt.*` definitions and placements from coverage. Preserve configured
392
394
  header/top-bar intent; do not add unconfigured drawer or header surfaces.
393
395
  - Before publishing, run the migration-only top-bar coverage check:
@@ -1306,6 +1308,108 @@ then compare those four source journeys in the real host. Keep their missing
1306
1308
  behavior explicit in coverage and the report. A stock template screenshot or a
1307
1309
  passing identity check cannot substitute for the fresh reconstruction/device pass.
1308
1310
 
1311
+ ### Locked pages: members only, a badge, a paid plan
1312
+
1313
+ A One App owner can lock a page (a layout item) for members only, for holders of a badge or for holders of a paid
1314
+ plan, and choose whether a viewer who may not enter sees a lock screen with the owner's message and button, or no
1315
+ tab at all. Those rules are in the AUTHORED structure (`raw-structure.json`, `layoutItems[].permissions`). The
1316
+ structure a Visitor reads is already evaluated for that Visitor: every rule becomes "members only" and the badge and
1317
+ plan ids are gone, so never read the rules from an enriched view. `himi one-app coverage` lists each rule as an
1318
+ `access` row; the rows stay `unresolved` until you carry the rule:
1319
+
1320
+ ```bash
1321
+ himi one-app access --evidence-dir .himi/one-app --content . --new-app \
1322
+ --screen <layoutItemId>=<himiScreenId> --screen <layoutItemId>=<himiScreenId>
1323
+ ```
1324
+
1325
+ Pass every `--screen` of the app each time: a run that names fewer screens than the gates already in the app would
1326
+ drop a lock, and is refused (`gates_removed`, which lists what it would drop).
1327
+
1328
+ The route decision (which Himi screen a locked page became) is yours; no file records it, so pass it with `--screen`
1329
+ (or `--screens-file`). A locked page with no screen is listed under `unmapped`, never guessed. The command:
1330
+
1331
+ - writes `tier5-src/_shared/access-gates-table.ts`, the gate table the nav worker reads;
1332
+ - puts each gated screen behind a lock screen: the screen's `payload` becomes a `FragmentSlot` whose `open` case is
1333
+ the original screen, moved as it was, and whose default case is the lock, so the page body is not mounted until the
1334
+ viewer is shown to be allowed (a cold start, an unresolved value and an unknown value all render the lock);
1335
+ - binds the tab-bar and drawer entries, and the rows that open the screen, of every gate that may hide (hidden mode,
1336
+ or a lock only members see) to the gate's flag in the app-level nav worker: `$service:brandedLiteNav.accessHidden.<key>`
1337
+ on a tab or drawer entry, `$app:brandedLiteNav.accessHidden.<key>` on a row inside a screen. Tab and drawer
1338
+ entries also get `hiddenUntilResolved`, so they stay off the bar until the worker answers;
1339
+ The home destination (the first tab, the first rail item, or the first drawer row of an app with neither) is the one
1340
+ entry that is never hidden: the boot route, deep links and push taps root on it, and the nav check rejects
1341
+ `hiddenUntilResolved` there. A lock on the home page stays on the bar and shows its lock screen instead; the output
1342
+ lists it as `homeEntries`;
1343
+ - reads the gate's answer inside each lock screen from the same worker, through `$app:brandedLiteNav.accessState.<key>`.
1344
+ A screen has its own worker, so a `$service:` binding there would read that worker and never see the answer;
1345
+ `$app:` reads the app-level one. App services are a feature of apps that have never shipped an older binary, so the
1346
+ command needs your word that this app is new: `--new-app` writes `app: { newApp: true }` into `config.ts`. Without it
1347
+ (and without that declaration already in the file) the command stops with `app_services_unavailable` and writes
1348
+ nothing. An app that has already shipped gets page locks only after its native floor supports app services (1.9.0 or later):
1349
+ until then the command refuses it, and that is by design;
1350
+ - points the app-level bundle (`navigation.navLogicBundle`, or `app.logicBundle` when the app already uses that
1351
+ spelling; declaring both is refused) at `himi-branded-lite-access-nav`, the badges worker that also answers the
1352
+ gates. Ids that normalize to one gate key (`page-a`, `page_a`) get a short hash suffix, so no two pages share a decision; a key an earlier run wrote stays with its page, so a later run never renames it. Mapping a locked page to a different screen than before is refused (`needs_manual_merge`, `moved`): the old screen is still wrapped in its lock. An app whose source locks no page keeps the badges-only worker, byte for byte, and pays nothing;
1353
+ - ties each `access` row of the coverage ledger to the screen and gate (`target`), and never overwrites a target you
1354
+ set. The status stays `unresolved`: it moves only on recorded `access-state` evidence from a device.
1355
+
1356
+ It is idempotent. `--dry-run` writes nothing; `--check` fails with `access_out_of_sync` and names the files that no
1357
+ longer carry the source's rules, so run it after any change to the source or the screens. It refuses to edit what it
1358
+ cannot edit safely (`needs_manual_merge`) and removes nothing it wrote earlier (`gates_removed`).
1359
+
1360
+ **What the lock is.** It is PRESENTATION ONLY, as it is in the One App. The data behind a page stays protected by its
1361
+ own Wix API: a Visitor's call still gets a 401, 403 or 428, and a screen shows its sign-in state. The descriptor is
1362
+ served to every device, and the One App itself sends a locked page's structure to the viewer it denies, so static
1363
+ content in a locked screen is not a secret. What the lock keeps is the owner's rule on the tab, for public data too.
1364
+
1365
+ **Who sees what** (decided by the worker in `tier5-src/_shared/access-gates.ts`):
1366
+
1367
+ - No rule: open. Rules by precedence: badges, then paid plans, then members only.
1368
+ - A Visitor is blocked by every rule and always gets the sign-in lock, with the owner's message and button when the
1369
+ owner set them, else the stock copy. When only members may see the lock, the tab is hidden for a Visitor and the
1370
+ stock copy applies.
1371
+ - A member opens a badge gate with one of its badge ids (`POST /badges/v4/assignments/query`, filtered to the member)
1372
+ and a plan gate with an active plan order of one of its plan ids (`@himalaya/checkout` `activePlanIds`). A blocked
1373
+ member sees the lock for the rule's own kind: a badge asks to contact the business, a plan offers the plans, or asks to
1374
+ contact the business when none of its plans is on offer any more.
1375
+ - Hidden mode removes the tab for anyone blocked. There is NO owner or admin bypass: an owner uses the owner app.
1376
+ - Every gate starts locked and opens only on proof. A read that fails leaves the member locked with that rule's button,
1377
+ except that a gate already open stays open through a failed re-read (a blip while offline). Nothing is persisted or
1378
+ restored: a snapshot can never open a tab. A new identity closes every gate before the next answer is read.
1379
+
1380
+ **One cost of locking a list.** A screen whose body is a paged list now has that list inside the lock's `FragmentSlot`.
1381
+ The descriptor check reports it as the warning `paged_list_not_at_scroll_root`: Android recycles rows only for a list
1382
+ that sits directly in the root column, so a long locked list is drawn without recycling. Renderers accept it and the
1383
+ lock works; keep a locked list short, or lock a screen that holds the list in a detail page.
1384
+
1385
+ **Differences from the One App to report.** The plans lock does not name the plans (the One App does); the badge button
1386
+ opens the business chat without the prefilled request message; two stock lines (`plansDescription`, `contact*`) are this
1387
+ repository's own, because the One App's zero-plans strings are not in any source read. Translate the stock copy through
1388
+ the app's catalog like any other string. A gated screen's own worker still loads while locked (the lock is a
1389
+ presentation rule), and an entry that is not a tab, drawer entry or a row that opens the screen directly (a button, a
1390
+ link, a card) is not hidden: it opens the lock. A row bound inside a screen is hidden only once the app worker has answered: a
1391
+ node-level `hiddenPath` has no fail-closed mode (only tab, drawer and rail entries have `hiddenUntilResolved`), so until the worker
1392
+ answers, or for good when it never loads, a Visitor sees that row. Opening it shows the lock, never the page.
1393
+
1394
+ **The app-wide login requirement.** The dashboard's Sign up & login switch decides whether the source app asks for a
1395
+ sign-in before showing anything (`visitorModeEnabled` false) or lets people browse first. `himi one-app migrate` reads it
1396
+ as the site's Visitor (no owner login) and saves it as `access-policy.json`, bound to the manifest hash. It is
1397
+ supporting context: when the source does not report it, the value is `null` and the manifest warns
1398
+ (`access_policy_unavailable`); unknown is never read as either answer. `himi one-app access` then turns the app's own
1399
+ login wall on when the source requires login: `auth` in `config.ts` becomes `requireLogin(...)`, a one-line helper
1400
+ written beside the imports that sets `required: true` on the provider block. With the wall on, the app shows the sign-in
1401
+ (the stock one, or your own gate screen) before any content, and a Visitor session does not pass it. The command never
1402
+ takes a wall out (`needs_manual_merge`): dropping a login requirement is yours to decide. For evidence collected before
1403
+ the setting was recorded, or when the owner tells you, decide it explicitly with `--login-wall required|none`; the
1404
+ explicit choice wins over the evidence. The wall works with or without locked pages.
1405
+ The wall is enforced on iOS and Android. The web host enforces a wall only for its own account mode, so a
1406
+ migrated app opened in the web host still shows its content to a Visitor: the command's output says so
1407
+ (`loginWall.notEnforcedOn`), and a web release must not rely on it. Enforcing the wall on web is a separate follow-up, not part of this command.
1408
+
1409
+ **Check on a device**, as a Visitor, as a signed-in member who has neither the badge nor the plan, and as one who has
1410
+ it, and record the result as the rows' `access-state` evidence. The member probe of the badge read is the one thing no
1411
+ source read can prove: `queryBadgeAssignments` is declared for the app identity, so confirm it answers a member session.
1412
+
1309
1413
  ### Screen-content contract
1310
1414
 
1311
1415
  Use the current [screen design guide](https://apps.wix.com/_api/himi-serve/docs/designing-himi-screens.md) and the app's
@@ -8722,6 +8722,44 @@
8722
8722
  "jsdoc": "",
8723
8723
  "decl": "export function updateBadgesOrder(\n req: WixUpdateBadgesOrderRequest = {} as WixUpdateBadgesOrderRequest,\n opts?: WixCallOptions,\n): Promise<WixUpdateBadgesOrderResponse>;",
8724
8724
  "http": "PATCH /badges/v4/badges/order"
8725
+ },
8726
+ {
8727
+ "name": "WixBadgeAssignment",
8728
+ "kind": "interface",
8729
+ "jsdoc": "/** One badge given to one member. */",
8730
+ "decl": "export interface WixBadgeAssignment {\n id?: string;\n badgeId?: string;\n memberId?: string;\n createdDate?: string;\n updatedDate?: string;\n}"
8731
+ },
8732
+ {
8733
+ "name": "WixQueryBadgeAssignmentsRequest",
8734
+ "kind": "interface",
8735
+ "jsdoc": "",
8736
+ "decl": "export interface WixQueryBadgeAssignmentsRequest {\n query?: { filter?: Record<string, unknown>; cursorPaging?: WixCursorPaging };\n}"
8737
+ },
8738
+ {
8739
+ "name": "WixQueryBadgeAssignmentsResponse",
8740
+ "kind": "interface",
8741
+ "jsdoc": "",
8742
+ "decl": "export interface WixQueryBadgeAssignmentsResponse {\n badgeAssignments?: WixBadgeAssignment[];\n pagingMetadata?: WixCursorPagingMetadata;\n}"
8743
+ },
8744
+ {
8745
+ "name": "WixBadgeIcon",
8746
+ "kind": "interface",
8747
+ "jsdoc": "/** The badge icon exactly as the live API sends it. The generated `WixBadge.icon` is typed `string`\n * from the upstream package, but the service returns this image object. */",
8748
+ "decl": "export interface WixBadgeIcon {\n id?: string;\n url?: string;\n height?: number;\n width?: number;\n}"
8749
+ },
8750
+ {
8751
+ "name": "queryBadgeAssignments",
8752
+ "kind": "function",
8753
+ "jsdoc": "/** Which badges members hold — POST /badges/v4/assignments/query. A Visitor can read it\n * (`BADGES.BADGE_READ_MEMBERS`), so a members list can show badges without a sign-in.\n * Filter by `memberId: { $in: [...] }` and page with `cursorPaging`: a member can hold dozens of\n * badges, so one page of many members can need several calls.\n *\n * Prefer this over the generated `listMembersBadgeIds`, whose route\n * (`POST /badges/v4/badges/members`) answers 404 on the live service.\n *\n * @scope visitor — an anonymous Visitor reads it (200, observed live). The generated spec declares the app\n * identity only, so a signed-in member's read is not yet observed: an empty or refused answer means \"no badge\n * known\", never \"no badge held\". */",
8754
+ "decl": "export function queryBadgeAssignments(\n req: WixQueryBadgeAssignmentsRequest = {},\n opts?: WixCallOptions,\n): Promise<WixQueryBadgeAssignmentsResponse>;",
8755
+ "http": "POST /badges/v4/assignments/query",
8756
+ "scope": "visitor"
8757
+ },
8758
+ {
8759
+ "name": "badgeIconUrl",
8760
+ "kind": "function",
8761
+ "jsdoc": "/** The icon image URL of a badge, or \"\" when it has none. Reads the live `{ url }` object and also the\n * plain string the generated type promises, so a caller needs no cast. */",
8762
+ "decl": "export function badgeIconUrl(badge: Pick<WixBadge, \"icon\">): string;"
8725
8763
  }
8726
8764
  ]
8727
8765
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1622.0",
3
+ "version": "0.1623.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -72,5 +72,5 @@
72
72
  "artifactId": "himalaya-cli"
73
73
  }
74
74
  },
75
- "falconPackageHash": "8493c132a80fa7832da317fe9ee68a3a247dd79165b74cbcc352855c"
75
+ "falconPackageHash": "b76913451cea96b12d1d1a1c061494918590b615daded10ac1c988fc"
76
76
  }
@@ -0,0 +1,8 @@
1
+ // This app's access gates: the One App page rules (members only, badge, paid plan) the app keeps.
2
+ //
3
+ // EMPTY in a scaffold, and then nothing in `access-gates.ts` reads or publishes anything. `himi one-app access`
4
+ // replaces this file for a migrated One App whose source locks a page; do not edit it by hand, re-run the command
5
+ // (it is idempotent).
6
+ import type { AccessGate } from "./access-gates.js";
7
+
8
+ export const ACCESS_GATES: readonly AccessGate[] = [];
@@ -0,0 +1,341 @@
1
+ // One App access gates: who may open a tab, decided in the app-level nav worker.
2
+ //
3
+ // A One App owner can lock a page (layout item) for members only, for holders of a badge, or for holders of a
4
+ // paid plan, and choose whether a viewer who may not enter sees a lock screen or no tab at all. The One App
5
+ // evaluates that per viewer on its server (`DisplayCriteria.getLayoutItemContentDisplayInfo`, `EnrichedViewer`)
6
+ // and draws the lock on the client. A migrated Himi app carries the same rules in `access-gates-table.ts`
7
+ // (written by `himi one-app access`) and this module answers them for the viewer.
8
+ //
9
+ // PRESENTATION ONLY. The data behind a gated tab stays protected by its own Wix API (a Visitor's call still
10
+ // gets a 401/403/428). The descriptor is served to every device, so static content in it is not a secret; the
11
+ // One App also sends a gated page's structure to the viewer it denies. This is the rule an owner set on the
12
+ // tab, kept, and nothing stronger.
13
+ //
14
+ // FAIL CLOSED. Every gate starts `pending` (locked, and hidden when it may be hidden) and opens only on
15
+ // positive proof. A read that fails leaves the member locked with the call to action for that rule. The state is
16
+ // declared `notPersisted` and `notHydrated` on the service, so a restored snapshot can never unlock a tab, and a
17
+ // new identity drops every gate back to `pending` before the next answer is read.
18
+ //
19
+ // ONE FLAG PER GATE, bound by the app: the tab bar binds `$service:brandedLiteNav.accessHidden.<key>` (with
20
+ // `hiddenUntilResolved`), and the gated screen's `FragmentSlot` binds `$app:brandedLiteNav.accessState.<key>` (a screen's
21
+ // own worker is not this one, so `$app:` reads the app-level nav worker), with the locked case as its default, so an unresolved value renders the lock and the page body is not mounted.
22
+ //
23
+ // Shared by the two app-level nav workers (`himi-branded-lite-nav`, `himi-branded-lite-header-badges`), which
24
+ // name the same service. With an empty table nothing here reads anything.
25
+ import { hostSessionFromGlobal } from "@himalaya/iap";
26
+ import { activePlanIds } from "@himalaya/checkout";
27
+ import { queryBadgeAssignments } from "@himalaya/wix-badges";
28
+ import { getMyMember } from "@himalaya/wix-members";
29
+ import { listPublicPlans } from "@himalaya/wix-pricing-plans";
30
+ import { ACCESS_GATES } from "./access-gates-table.js";
31
+
32
+ // ───────────────────────────── the table ─────────────────────────────
33
+
34
+ /** What the owner required, as the One App stores it on the layout item. */
35
+ export type AccessRule =
36
+ | { kind: "membersOnly" }
37
+ | { kind: "badges"; ids: string[] }
38
+ | { kind: "paidPlans"; ids: string[] };
39
+
40
+ export interface AccessGate {
41
+ /** Flag-safe id (`[a-z0-9_]`), the key under `accessState` and `accessHidden`. */
42
+ key: string;
43
+ /** The One App layout item the rule was authored on. */
44
+ layoutItemId: string;
45
+ /** The Himi screen whose body is gated. */
46
+ screenId: string;
47
+ /** The page's title, for the lock screen. */
48
+ title: string;
49
+ rule: AccessRule;
50
+ /** `accessScreenSettings.screenVisible`: `locked` shows a lock screen, `hidden` removes the tab. */
51
+ mode: "locked" | "hidden";
52
+ /** `accessScreenSettings.membersOnly`: only a signed-in member may see the lock; a Visitor gets no tab. */
53
+ hideFromVisitors: boolean;
54
+ /** The owner's lock message and button label, or "" for the stock copy. */
55
+ message: string;
56
+ ctaTitle: string;
57
+ }
58
+
59
+ /** The lock screen a gated screen shows, and the one `pending` that renders until the first answer lands. */
60
+ export type AccessCase = "pending" | "open" | "signIn" | "plans" | "contact";
61
+
62
+ export interface AccessOutcome {
63
+ state: AccessCase;
64
+ /** The tab should not be listed at all. */
65
+ hidden: boolean;
66
+ }
67
+
68
+ /** Everything the rules ask about the viewer. */
69
+ export interface AccessViewer {
70
+ member: boolean;
71
+ badgeIds: ReadonlySet<string>;
72
+ /** Plans the member holds an active order for. */
73
+ planIds: ReadonlySet<string>;
74
+ /** Plans the site offers (public, not archived): a lock can only offer a plan that can still be bought. */
75
+ publicPlanIds: ReadonlySet<string>;
76
+ }
77
+
78
+ const anyIn = (ids: readonly string[], held: ReadonlySet<string>): boolean => ids.some((id) => held.has(id));
79
+
80
+ /** The plan ids a gate asks for; none for a rule of another kind. */
81
+ const requiredPlanIds = (gate: AccessGate): readonly string[] => gate.rule.kind === "paidPlans" ? gate.rule.ids : [];
82
+
83
+ /** Whether a Visitor or a member who may not enter should still see the tab (as a lock). */
84
+ function listed(gate: AccessGate, visitor: boolean): boolean {
85
+ if (gate.mode === "hidden") return false;
86
+ return !(visitor && gate.hideFromVisitors);
87
+ }
88
+
89
+ /**
90
+ * The One App's rule for one gate. Mirrors `DisplayCriteria.getLayoutItemContentDisplayInfo` and
91
+ * `EnrichedViewer.filterLayoutItems`:
92
+ * - a Visitor is blocked by every rule kind, and always gets the members-only lock (`signIn`);
93
+ * - a member holds the badge (one of its ids), holds an active plan (one of its ids), or passes a members-only rule;
94
+ * - a blocked member gets the lock for the rule's own kind: a badge asks to contact the business, a plan offers the
95
+ * plans, or asks to contact the business when none of its plans is on offer any more (hidden or archived), as the
96
+ * One App does (`LayoutItem.ts` `onPermissionZeroPlansClick`).
97
+ * There is no owner or admin bypass: an owner uses the owner app.
98
+ */
99
+ export function decide(gate: AccessGate, viewer: AccessViewer): AccessOutcome {
100
+ if (!viewer.member) return { state: "signIn", hidden: !listed(gate, true) };
101
+ switch (gate.rule.kind) {
102
+ case "membersOnly":
103
+ return { state: "open", hidden: false };
104
+ case "badges":
105
+ return anyIn(gate.rule.ids, viewer.badgeIds) ? { state: "open", hidden: false } : { state: "contact", hidden: !listed(gate, false) };
106
+ case "paidPlans":
107
+ if (anyIn(gate.rule.ids, viewer.planIds)) return { state: "open", hidden: false };
108
+ return { state: anyIn(gate.rule.ids, viewer.publicPlanIds) ? "plans" : "contact", hidden: !listed(gate, false) };
109
+ }
110
+ }
111
+
112
+ /** Before any answer: locked, and hidden for every gate that some viewer would not see listed. */
113
+ export function pendingOutcome(gate: AccessGate): AccessOutcome {
114
+ return { state: "pending", hidden: gate.mode === "hidden" || gate.hideFromVisitors };
115
+ }
116
+
117
+ // ───────────────────────────── the state ─────────────────────────────
118
+
119
+ export interface AccessGateState {
120
+ /** `AccessCase` per gate key. Bound by the gated screen's `FragmentSlot`. */
121
+ accessState: Record<string, AccessCase>;
122
+ /** True per gate key when the tab is not listed. Bound by the tab bar, fail-closed. */
123
+ accessHidden: Record<string, boolean>;
124
+ }
125
+
126
+ export const ACCESS_STATE_KEYS: ("accessState" | "accessHidden")[] = ["accessState", "accessHidden"];
127
+
128
+ export function pendingAccessState(gates: readonly AccessGate[]): AccessGateState {
129
+ const accessState: Record<string, AccessCase> = {};
130
+ const accessHidden: Record<string, boolean> = {};
131
+ for (const gate of gates) {
132
+ const outcome = pendingOutcome(gate);
133
+ accessState[gate.key] = outcome.state;
134
+ accessHidden[gate.key] = outcome.hidden;
135
+ }
136
+ return { accessState, accessHidden };
137
+ }
138
+
139
+ /** The initial service state: every gate pending. Empty for an app with no gates. */
140
+ export const ACCESS_GATES_INITIAL: AccessGateState = pendingAccessState(ACCESS_GATES);
141
+
142
+ /** The action signatures a service that uses `accessGates` declares in its `defineService` generic. */
143
+ // A `type` for the same reason as `HeaderBadgeActionSignatures`: `defineService` wants an index signature.
144
+ export type AccessGateActionSignatures<S> = {
145
+ /** Re-read who the viewer is and what they hold, then re-decide every gate. Only the newest read lands. */
146
+ refreshAccess: (s: S) => Promise<S>;
147
+ /** Put every gate back to pending (a new identity), committed at once so nothing stale stays open. */
148
+ dropAccess: (s: S) => Promise<S>;
149
+ /** Reserved lifecycle hooks the app-service host delivers to every service that declares them. */
150
+ onForeground: (s: S) => Promise<S>;
151
+ onIdentityChange: (s: S) => Promise<S>;
152
+ };
153
+
154
+ export interface AccessGates<S> {
155
+ actions: AccessGateActionSignatures<S>;
156
+ /** Call once after `defineService`: the launch read, and the session clock's notifications. */
157
+ start(): void;
158
+ }
159
+
160
+ type Update<S> = (s: S, change: (current: S) => Promise<S>) => Promise<S>;
161
+ type Self = () => { refreshAccess(): Promise<unknown>; dropAccess(): Promise<unknown> };
162
+
163
+ const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
164
+
165
+ /** The plans the site offers: public and not archived (the public list already leaves archived plans out). */
166
+ async function readPublicPlanIds(): Promise<Set<string>> {
167
+ const res = await listPublicPlans();
168
+ return new Set((res.plans ?? []).filter((plan) => plan.id && !plan.archived).map((plan) => plan.id));
169
+ }
170
+
171
+ /** The bridge's refusal of a viewer who is not a member: `HTTP 401` or `HTTP 403` in its rejection text (which a caller
172
+ * may prefix). A Visitor gets exactly `403 PERMISSION_DENIED` from `/members/my` (observed live). Any other status
173
+ * (a 408, a 429, a 5xx) or a network failure says nothing about who the viewer is, so it must not decide anyone as a Visitor. */
174
+ function isRefusal(error: unknown): boolean {
175
+ const text = error instanceof Error ? error.message : String(error);
176
+ const at = text.indexOf("HTTP ");
177
+ if (at < 0) return false;
178
+ const status = Number(text.slice(at + 5, at + 8));
179
+ return status === 401 || status === 403;
180
+ }
181
+
182
+ /** Most pages of badge assignments one member can need (100 per page). A member past this is not realistic. */
183
+ const MAX_BADGE_PAGES = 20;
184
+
185
+ /** The badge ids a member holds. A read that cannot finish rejects, so the caller keeps the member locked. */
186
+ async function readBadgeIds(memberId: string): Promise<Set<string>> {
187
+ const held = new Set<string>();
188
+ const seen = new Set<string>();
189
+ let cursor: string | undefined;
190
+ for (let page = 0; page < MAX_BADGE_PAGES; page++) {
191
+ // The cursor encodes the filter: a Wix query refuses a filter sent beside a cursor, so only the first page carries it.
192
+ const res = await queryBadgeAssignments({
193
+ query: cursor ? { cursorPaging: { limit: 100, cursor } } : { filter: { memberId: { $in: [memberId] } }, cursorPaging: { limit: 100 } },
194
+ });
195
+ for (const assignment of res.badgeAssignments ?? []) if (assignment.badgeId) held.add(assignment.badgeId);
196
+ const next = res.pagingMetadata?.cursors?.next;
197
+ if (!res.pagingMetadata?.hasNext) return held;
198
+ // More pages were promised but no cursor to reach them: a truncated answer, not a complete one. It must fail the read (the
199
+ // member stays locked, or an already-open page stays open) instead of passing a partial set off as everything.
200
+ if (!next) throw new Error("Badge assignment page said more were available but gave no cursor.");
201
+ if (seen.has(next)) throw new Error("Badge assignment pagination did not advance.");
202
+ seen.add(next);
203
+ cursor = next;
204
+ }
205
+ throw new Error("A member holds more badge assignments than one read pages.");
206
+ }
207
+
208
+ /**
209
+ * @param gates the app's table (a test passes its own)
210
+ * @param update serializes state changes for the worker, so overlapping actions build on the preceding result
211
+ * @param self the worker's own service handle (read lazily: it does not exist yet when this is called)
212
+ */
213
+ export function accessGates<S extends AccessGateState>(update: Update<S>, self: Self, gates: readonly AccessGate[] = ACCESS_GATES): AccessGates<S> {
214
+ /** Which read is the newest: an older one that finishes later must not overwrite a newer answer. */
215
+ let read = 0;
216
+ /** The host session generation the gates on show were decided under. */
217
+ let generation = -1;
218
+ const session = hostSessionFromGlobal();
219
+ const needsBadges = gates.some((gate) => gate.rule.kind === "badges");
220
+ const needsPlans = gates.some((gate) => gate.rule.kind === "paidPlans");
221
+
222
+ /** The signed-in member's id, `null` for a Visitor, `undefined` when it could not be told (a failed read that is
223
+ * not a refusal, such as being offline). A host that fences calls to the member publishes the id with its
224
+ * session clock; any other host (web) answers it from the member record, which a Visitor cannot read. */
225
+ async function memberIdOf(): Promise<string | null | undefined> {
226
+ if (session.identityBound) {
227
+ const snapshot = session.current();
228
+ if (snapshot.kind !== "member") return null;
229
+ const id = session.identity(snapshot)?.memberId;
230
+ if (id) return id;
231
+ }
232
+ try {
233
+ return (await getMyMember({ fieldsets: ["PUBLIC"] })).member?.id || null;
234
+ } catch (error) {
235
+ return isRefusal(error) ? null : undefined;
236
+ }
237
+ }
238
+
239
+ const pending = (current: S): S => ({ ...current, ...pendingAccessState(gates) });
240
+
241
+ const actions: AccessGateActionSignatures<S> = {
242
+ refreshAccess: async (s) => {
243
+ if (!gates.length) return s;
244
+ const mine = ++read;
245
+ const startedUnder = session.current().generation;
246
+ const memberId = await memberIdOf();
247
+ let badgeIds = new Set<string>();
248
+ let planIds = new Set<string>();
249
+ let publicPlanIds = new Set<string>();
250
+ let badgesFailed = false;
251
+ let plansFailed = false;
252
+ if (memberId) {
253
+ const [badges, plans] = await Promise.all([
254
+ needsBadges ? readBadgeIds(memberId).catch(() => { badgesFailed = true; return new Set<string>(); }) : Promise.resolve(new Set<string>()),
255
+ needsPlans ? activePlanIds().then((ids) => new Set(ids)).catch(() => { plansFailed = true; return new Set<string>(); }) : Promise.resolve(new Set<string>()),
256
+ ]);
257
+ badgeIds = badges;
258
+ planIds = plans;
259
+ // Which plans can still be bought matters only to a member a plan gate blocks: read it then, not for everyone.
260
+ const required = gates.map(requiredPlanIds);
261
+ if (plansFailed) {
262
+ // What the member holds is unknown: offer every required plan, the plans list is the useful action.
263
+ publicPlanIds = new Set(required.flat());
264
+ } else if (needsPlans && required.some((ids) => ids.length > 0 && !anyIn(ids, planIds))) {
265
+ // A read of the offer that fails offers every required plan too, and the plans list reads for itself.
266
+ publicPlanIds = await readPublicPlanIds().catch(() => new Set(required.flat()));
267
+ }
268
+ }
269
+ const viewer: AccessViewer = { member: memberId !== null && memberId !== undefined, badgeIds, planIds, publicPlanIds };
270
+ return update(s, async (current) => {
271
+ // Drop an answer read for another read or another identity: the newer one decides.
272
+ if (mine !== read || session.current().generation !== startedUnder) return current;
273
+ // Who the viewer is could not be told: change nothing (pending stays pending, so locked).
274
+ if (memberId === undefined) return current;
275
+ const accessState: Record<string, AccessCase> = {};
276
+ const accessHidden: Record<string, boolean> = {};
277
+ for (const gate of gates) {
278
+ const readFailed = (gate.rule.kind === "badges" && badgesFailed) || (gate.rule.kind === "paidPlans" && plansFailed);
279
+ // A failed read must not lock out a member already shown the page (a blip while offline). Otherwise the
280
+ // member is treated as not holding the badge or plan: locked, with that rule's call to action.
281
+ if (readFailed && current.accessState[gate.key] === "open") {
282
+ accessState[gate.key] = "open";
283
+ accessHidden[gate.key] = false;
284
+ continue;
285
+ }
286
+ const outcome = decide(gate, viewer);
287
+ accessState[gate.key] = outcome.state;
288
+ accessHidden[gate.key] = outcome.hidden;
289
+ }
290
+ // Publish only a change: an unchanged answer reaches no view (and costs the worker nothing).
291
+ if (sameJson(current.accessState, accessState) && sameJson(current.accessHidden, accessHidden)) return current;
292
+ return { ...current, accessState, accessHidden };
293
+ });
294
+ },
295
+ dropAccess: async (s) => {
296
+ ++read; // a read already in flight belongs to the previous identity
297
+ return update(s, async (current) => pending(current));
298
+ },
299
+ onForeground: async (s) => update(s, async (current) => current).finally(() => { void self().refreshAccess(); }),
300
+ onIdentityChange: async (s) => {
301
+ ++read;
302
+ const dropped = await update(s, async (current) => pending(current));
303
+ void self().refreshAccess();
304
+ return dropped;
305
+ },
306
+ };
307
+
308
+ /** A session notification: a new generation is a new identity, so the previous one's gates close first. */
309
+ async function onSession(): Promise<void> {
310
+ const now = session.current().generation;
311
+ if (now !== generation) {
312
+ generation = now;
313
+ await self().dropAccess();
314
+ }
315
+ await self().refreshAccess();
316
+ }
317
+
318
+ return {
319
+ actions,
320
+ start(): void {
321
+ if (!gates.length) return;
322
+ // Subscribing notifies at once, which is the launch read; a host with no clock needs the explicit one.
323
+ let notified = false;
324
+ session.subscribe(() => { notified = true; void onSession(); });
325
+ if (!notified) void self().refreshAccess();
326
+ },
327
+ };
328
+ }
329
+
330
+ /**
331
+ * The reserved lifecycle hooks of a service that serves more than one piece (header badges, access gates): the host
332
+ * calls one `onForeground` and one `onIdentityChange`, so each piece's hook runs in turn.
333
+ */
334
+ export function chainLifecycle<S>(
335
+ ...pieces: Array<{ onForeground: (s: S) => Promise<S>; onIdentityChange: (s: S) => Promise<S> }>
336
+ ): { onForeground: (s: S) => Promise<S>; onIdentityChange: (s: S) => Promise<S> } {
337
+ return {
338
+ onForeground: async (s) => { let next = s; for (const piece of pieces) next = await piece.onForeground(next); return next; },
339
+ onIdentityChange: async (s) => { let next = s; for (const piece of pieces) next = await piece.onIdentityChange(next); return next; },
340
+ };
341
+ }
@@ -0,0 +1,45 @@
1
+ // The app-level nav worker (`navigation.navLogicBundle`) of a migrated One App that keeps locked pages: the live counts
2
+ // behind the header's bell, chat and cart buttons (the same piece as `himi-branded-lite-header-badges`), plus the page
3
+ // access gates (members only, badge, paid plan) the source's owner set (`_shared/access-gates.ts`).
4
+ //
5
+ // It is a separate bundle on purpose. The gates read the member, their badges and their plans, which an app that locks
6
+ // no page has no use for, and a worker's source is parsed at every boot. `himi one-app access` points an app's
7
+ // `navLogicBundle` here only when the source locks a page; every other app keeps the badges-only worker, unchanged.
8
+ //
9
+ // The service is named `brandedLiteNav`, like the full worker's, so a top-bar action binds the same
10
+ // `$service:brandedLiteNav.<field>` path and the tab bar and gated screens bind `accessHidden` and `accessState`
11
+ // under it. A scaffold ships this source INERT: nothing loads it until the app's config names it.
12
+
13
+ import { defineService } from "@himalaya/state";
14
+ import { HEADER_BADGE_KEYS, headerBadges, type HeaderBadgeActionSignatures, type HeaderBadges, type HeaderBadgeState } from "../_shared/header-badges.js";
15
+ import { ACCESS_GATES_INITIAL, ACCESS_STATE_KEYS, accessGates, chainLifecycle, type AccessGateActionSignatures, type AccessGates, type AccessGateState } from "../_shared/access-gates.js";
16
+
17
+ type State = HeaderBadgeState & AccessGateState;
18
+
19
+ // State changes await native work; overlapping actions must use the preceding result.
20
+ let stateQueue: Promise<State> | undefined;
21
+ function updateInOrder(s: State, update: (current: State) => Promise<State>): Promise<State> {
22
+ const previous = stateQueue ?? Promise.resolve(s);
23
+ const result = previous.then(update);
24
+ stateQueue = result.catch(() => previous);
25
+ return result;
26
+ }
27
+
28
+ const badges: HeaderBadges<State> = headerBadges<State>(updateInOrder, () => svc);
29
+ const access: AccessGates<State> = accessGates<State>(updateInOrder, () => svc);
30
+
31
+ const svc = defineService<State, HeaderBadgeActionSignatures<State> & AccessGateActionSignatures<State>>("brandedLiteNav", {
32
+ // Every count unavailable until the first read lands, and no failure to report. Every gate pending (locked).
33
+ state: { notificationsBadge: null, chatBadge: null, cartBadge: null, errorText: "", ...ACCESS_GATES_INITIAL },
34
+ // The counts and the gates describe THIS run (see _shared/header-badges.ts, _shared/access-gates.ts): never stored,
35
+ // never restored, so a snapshot can neither show another day's numbers nor keep a tab open.
36
+ notPersisted: [...HEADER_BADGE_KEYS, ...ACCESS_STATE_KEYS],
37
+ notHydrated: [...HEADER_BADGE_KEYS, ...ACCESS_STATE_KEYS],
38
+ // The host calls one `onForeground` and one `onIdentityChange`: each piece runs in turn.
39
+ actions: { ...badges.actions, ...access.actions, ...chainLifecycle<State>(badges.actions, access.actions) },
40
+ });
41
+
42
+ export { svc };
43
+
44
+ badges.start();
45
+ access.start();