@phis/contracts 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@ and change at different moments.
6
6
 
7
7
  ```
8
8
  @phis/contracts/addon what phis and a separately shipped Add-on promise each other
9
+ @phis/contracts/payments the approved Payment consumer, Provider and merchant-profile types
9
10
  @phis/contracts/access the authorization vocabulary phis and @phis/ui both evaluate
10
11
  @phis/contracts/signals the signal vocabulary and address grammar the UI declares in and phis validates
11
12
  @phis/contracts/catalog the Module categories an Add-on declares, the UI groups by and a market filters on
@@ -45,7 +46,7 @@ The types and constants an Add-on declares or implements. Types and constants on
45
46
  `server-only`, no React, no Next.
46
47
 
47
48
  An Add-on is compiled to a single ESM artifact and installed into a running phis through the `phis` CLI;
48
- it is never built into the server. This subpath is the only thing an Add-on compiles against, and it must
49
+ it is never built into the server. This subpath is the shared Add-on entrypoint it compiles against, and it must
49
50
  not depend on phis itself. For an Add-on author it is a build-time dependency: everything it exports is
50
51
  erased at compile time apart from a handful of constants.
51
52
 
@@ -61,10 +62,10 @@ What it covers:
61
62
  (`PhisMediaStorageAdapter`, upload plans, object I/O) and Directory. `PHIS_SERVICE_KINDS` names them
62
63
  and `PHIS_SERVICE_INTERFACE_DIGESTS` holds the digest of each kind's current interface; Core refuses an
63
64
  Add-on built against a different digest at install.
64
- - **Core capabilities** -- `PHIS_CORE_CAPABILITIES`, the capability ids an Add-on may require. Declare
65
- against these constants rather than spelling the ids out. The list is the vocabulary and may reserve a
66
- name before Core delivers it (`resource-links`, `support`); an Add-on that requires a capability this
67
- release does not deliver is refused rather than handed a missing object.
65
+ - **Core capabilities** -- `PHIS_CORE_CAPABILITIES`, the catalogue ids an Add-on may require. Declare
66
+ against these constants rather than spelling the ids out. Core separately validates delivery to
67
+ Add-ons; a catalogue id such as `authentication` may serve Site Modules without a corresponding
68
+ Add-on context field. Payment ids accompany the implemented Core capability/service/event catalogue.
68
69
 
69
70
  `PHIS_ADDON_ABI_VERSION` is the single number phis checks an Add-on against.
70
71
 
@@ -72,6 +73,23 @@ How an Add-on is laid out, compiled, installed, activated and upgraded is descri
72
73
  [`ADDON_HOWTO.md`](https://github.com/phis-dev/phis-server/blob/main/ADDON_HOWTO.md); the rules behind it
73
74
  are in [`SERVER_ADDONS.md`](https://github.com/phis-dev/phis-server/blob/main/SERVER_ADDONS.md).
74
75
 
76
+ ## `/payments`
77
+
78
+ The approved v1 consumer capability, Provider adapter, checkout plans, normalized money,
79
+ payment/operation/refund records, merchant profile/grant types and owning-consumer event facts.
80
+ All exports are types; there is no secret accessor implementation, checkout SDK, exchange-rate
81
+ conversion or Payment engine here. The same types are re-exported by `/addon`.
82
+
83
+ Consumers own their prices in CHF, EUR, USD or another supported currency. Every payment uses one
84
+ currency; capture and refunds inherit it. Core never infers conversion or settlement currency.
85
+
86
+ Core implements Payment execution separately. Its catalogue exports `payments`, `paymentNotifications`,
87
+ the Payment service kind/interface digest and `payment.changed`. Notification ingress is available
88
+ only in a bound hook context. The Add-on event context discriminates thread and Payment subjects;
89
+ thread producers remain explicitly typed as thread events.
90
+ See the normative [Payment contract](https://github.com/phis-dev/phis-server/blob/main/PAYMENTS.md)
91
+ and [Stripe/profile contract](https://github.com/phis-dev/phis-server/blob/main/PAYMENTS_STRIPE.md).
92
+
75
93
  ## `/access`
76
94
 
77
95
  Claim shapes, policy shapes, and the evaluator that decides them. Unlike `/addon`, this is runtime code
@@ -3,6 +3,7 @@ export * from "./assets.js";
3
3
  export * from "./capabilities.js";
4
4
  export * from "./core.js";
5
5
  export * from "./media-storage.js";
6
+ export * from "./payments.js";
6
7
  export * from "./queries.js";
7
8
  export * from "./resource-links.js";
8
9
  export * from "./schema.js";
@@ -3,6 +3,7 @@ export * from "./assets.js";
3
3
  export * from "./capabilities.js";
4
4
  export * from "./core.js";
5
5
  export * from "./media-storage.js";
6
+ export * from "./payments.js";
6
7
  export * from "./queries.js";
7
8
  export * from "./resource-links.js";
8
9
  export * from "./schema.js";
package/dist/addon.d.ts CHANGED
@@ -65,7 +65,9 @@ export type PhisAddonHookDescriptor = {
65
65
  *
66
66
  * Absent is not a lesser variant of present: an unverified hook writes nothing at all -- not through
67
67
  * `data:v1`, not by opening a thread -- because nothing distinguishes its caller from anybody who
68
- * found the address. A hook that needs to write declares how it can be believed.
68
+ * found the address. A hook that needs ambient write authority declares how it can be believed.
69
+ * Target v1 Payment notification ingress instead verifies through its bound Core capability and
70
+ * reconciles Core-owned payment state without granting ambient hook write authority.
69
71
  */
70
72
  verification?: PhisAddonHookVerificationDescriptor;
71
73
  /**
@@ -12,7 +12,8 @@
12
12
  * at the point where it made the mistake.
13
13
  */
14
14
  import type { PhisAssetsCapabilityV1 } from "./assets.js";
15
- import type { PhisAddonEventId } from "./core.js";
15
+ import type { PhisAddonThreadEventId } from "./core.js";
16
+ import type { PhisPaymentChangedEventFacts, PhisPaymentNotificationsCapabilityV1, PhisPaymentsCapabilityV1 } from "./payments.js";
16
17
  import type { PhisDataCapabilityV1 } from "./queries.js";
17
18
  import type { PhisResourceLinksCapabilityV1 } from "./resource-links.js";
18
19
  import type { PhisSupportCapabilityV1 } from "./support.js";
@@ -102,6 +103,10 @@ export type PhisAddonCapabilities = {
102
103
  support?: PhisSupportCapabilityV1;
103
104
  settings?: PhisSettingsCapabilityV1;
104
105
  roles?: PhisRolesCapabilityV1;
106
+ /** Bound to the declaring consumer and Site when required. */
107
+ payments?: PhisPaymentsCapabilityV1;
108
+ /** Supplied only to an original, bound Payment Provider hook request. */
109
+ paymentNotifications?: PhisPaymentNotificationsCapabilityV1;
105
110
  };
106
111
  /**
107
112
  * The roles capability: `@phis/server/roles:v1`.
@@ -206,9 +211,19 @@ export type PhisStorageCapabilityV1 = {
206
211
  * own would be a package that could plant one.
207
212
  */
208
213
  export type PhisSecretsCapabilityV1 = {
209
- /** The value, or null when the operator has not configured it. */
210
- read(name: string): Promise<string | null>;
214
+ /**
215
+ * The value, or null when the operator has not configured it.
216
+ *
217
+ * `scope` says which of the two an operator can set: `"site"` (the default) is the one set with
218
+ * `--site` for the Site this call runs on; `"addon"` is the one set without `--site`, shared by every
219
+ * Site the Add-on runs on -- one credential for one external account, such as a single app
220
+ * registration. The two are never substituted for each other: a Site secret that is missing is
221
+ * `null`, not the shared one.
222
+ */
223
+ read(name: string, scope?: PhisSecretScope): Promise<string | null>;
211
224
  };
225
+ /** Which secret `PhisSecretsCapabilityV1.read` names: the Site's own, or the one shared by all Sites. */
226
+ export type PhisSecretScope = "site" | "addon";
212
227
  /**
213
228
  * What an Add-on may learn about a person.
214
229
  *
@@ -357,24 +372,26 @@ export type PhisAddonJobContext = {
357
372
  * this Site something, an event is this Site telling an Add-on something, and both run as the Add-on
358
373
  * rather than as a person.
359
374
  *
360
- * Delivery is bounded by the same link. An Add-on is told about a thread it is synced with and about no
361
- * other, because a Site-wide feed of everything anybody writes is the listing `threads:v1` refuses,
362
- * arriving by push instead of by pull.
375
+ * Thread delivery is bounded by a synced link. The Payment event reaches only its stored
376
+ * consuming Add-on. The subject kind and event discriminate those two ownership models; approved
377
+ * Payment facts are delivered through Core's durable owner-directed outbox.
363
378
  */
364
- export type PhisAddonEventContext = {
379
+ export type PhisAddonEventFacts = {
380
+ event: PhisAddonThreadEventId;
381
+ subject: {
382
+ kind: "thread";
383
+ threadId: number;
384
+ messageId: number | null;
385
+ };
386
+ } | PhisPaymentChangedEventFacts;
387
+ export type PhisAddonEventContext = PhisAddonEventFacts & {
365
388
  addonId: string;
366
- event: PhisAddonEventId;
367
389
  /** This delivery, stable across retries, so a handler can recognise one it already settled. */
368
390
  deliveryId: string;
369
391
  site: {
370
392
  id: number;
371
393
  key: string;
372
394
  };
373
- /** The thread the event happened in, and the message where the event is about one. */
374
- subject: {
375
- threadId: number;
376
- messageId: number | null;
377
- };
378
395
  capabilities: PhisAddonCapabilities;
379
396
  signal: AbortSignal;
380
397
  };
package/dist/catalog.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type PhiCmsAreaKey } from "./cms.js";
1
2
  /**
2
3
  * What a Module is for, as an operator reads it on the Modules page.
3
4
  *
@@ -64,6 +65,19 @@ export type PhisModulePackageEntry = {
64
65
  * asks at intake, where refusing costs the publisher a message rather than the operator a page.
65
66
  */
66
67
  category: string;
68
+ /**
69
+ * The Areas the Module may serve, and the only place a package states them.
70
+ *
71
+ * Here and not in the Module's definition, because the reader that needs them first cannot run the
72
+ * definition: `phis module` writes one Client projection per Area and must know, without loading a
73
+ * stranger's code, which packages' Client doors belong in which. A definition that stated them as
74
+ * well would be a second answer that can disagree with this one; the generated projection hands
75
+ * these to the definition instead.
76
+ *
77
+ * Membership, unlike the category: an Area is a place the Site renders, and one this build has no
78
+ * place for cannot be served at all.
79
+ */
80
+ eligibleAreas: PhiCmsAreaKey[];
67
81
  };
68
82
  /**
69
83
  * What a Module package says about itself, without being run.
package/dist/catalog.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isPhiCmsAreaKey } from "./cms.js";
1
2
  /**
2
3
  * What a Module is for, as an operator reads it on the Modules page.
3
4
  *
@@ -103,14 +104,25 @@ export function readPhisModulePackageDeclaration(packageJson) {
103
104
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
104
105
  throw new Error(`"${at}" must be an object.`);
105
106
  }
106
- const { moduleId, category } = entry;
107
+ const { moduleId, category, eligibleAreas } = entry;
107
108
  if (typeof moduleId !== "string" || !moduleId.trim()) {
108
109
  throw new Error(`"${at}.moduleId" must be a non-empty string.`);
109
110
  }
110
111
  if (typeof category !== "string" || !category.trim()) {
111
112
  throw new Error(`"${at}.category" must be a non-empty string.`);
112
113
  }
113
- return { moduleId, category };
114
+ if (!Array.isArray(eligibleAreas) || eligibleAreas.length === 0) {
115
+ throw new Error(`"${at}.eligibleAreas" must name at least one Area.`);
116
+ }
117
+ for (const area of eligibleAreas) {
118
+ if (!isPhiCmsAreaKey(area)) {
119
+ throw new Error(`"${at}.eligibleAreas" names "${String(area)}", which is not an Area.`);
120
+ }
121
+ }
122
+ if (new Set(eligibleAreas).size !== eligibleAreas.length) {
123
+ throw new Error(`"${at}.eligibleAreas" names an Area twice.`);
124
+ }
125
+ return { moduleId, category, eligibleAreas: [...eligibleAreas] };
114
126
  });
115
127
  return { sourceLocale: typeof sourceLocale === "string" ? sourceLocale : null, modules };
116
128
  }
package/dist/cms.d.ts CHANGED
@@ -50,6 +50,17 @@ export type PhiCmsRegionTypeValue = (typeof PhiCmsRegionType)[keyof typeof PhiCm
50
50
  * Bits 2, 5 and 7 are unassigned. That is what makes the numbers worth stating once: 8 reads like the
51
51
  * fourth Area and is the third, and phi-server stores it as `area_id` where nothing spells it out.
52
52
  */
53
+ /**
54
+ * The Areas a Site has, by name.
55
+ *
56
+ * The numbering below already lived here, because both sides store it and a renumbering neither of
57
+ * them noticed would put a Page in a different Area than it was written into. The names are the same
58
+ * agreement read the other way round and stood in two more copies -- one per repository -- so a Site
59
+ * that gained an Area would have had to gain it three times.
60
+ */
61
+ export declare const PHI_CMS_AREA_KEYS: readonly ["public", "app", "admin", "builder", "editor", "accounting"];
62
+ export type PhiCmsAreaKey = (typeof PHI_CMS_AREA_KEYS)[number];
63
+ export declare function isPhiCmsAreaKey(value: unknown): value is PhiCmsAreaKey;
53
64
  export declare const PhiCmsVisibilityContext: {
54
65
  readonly PublicArea: number;
55
66
  readonly AppArea: number;
@@ -62,7 +73,9 @@ export declare const PhiCmsVisibilityContext: {
62
73
  * The flags a CMS row carries, in the one numbering both sides read them from.
63
74
  *
64
75
  * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
65
- * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
76
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds. The
77
+ * same numbering serves a config's own `flags`, where a Region's and an Overlay's behaviour live
78
+ * (`Sticky`, `NoCloseButton`, ...): a yes-or-no answer in a stored record is a bit, never a boolean.
66
79
  *
67
80
  * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
68
81
  * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
@@ -77,6 +90,26 @@ export declare const PhiCmsFlags: {
77
90
  readonly Collapsed: number;
78
91
  readonly NoTranslate: number;
79
92
  readonly NoIndex: number;
93
+ /** Stays in view while the page scrolls (Header and Sider families). */
94
+ readonly Sticky: number;
95
+ /** A Sider that takes the viewport height below its offset. */
96
+ readonly FullHeight: number;
97
+ /** A Sider the viewer can fold to its collapsed width. */
98
+ readonly Collapsible: number;
99
+ /** The Header chrome renders no close button. */
100
+ readonly NoCloseButton: number;
101
+ /** Escape does not request dismissal. */
102
+ readonly NoEscapeClose: number;
103
+ /** A Modal centred in the viewport. */
104
+ readonly Centered: number;
105
+ /** A Drawer the viewer can resize. */
106
+ readonly Resizable: number;
107
+ /** A Drawer that pushes a nested Drawer aside (`pushDistance` states how far). */
108
+ readonly Push: number;
109
+ /** Pointer input reaches the page behind the Overlay. */
110
+ readonly MaskPassesPointer: number;
111
+ /** An outside pointer action does not request dismissal. */
112
+ readonly MaskKeepsOpen: number;
80
113
  };
81
114
  export declare const PhiCmsRevisionFlags: {
82
115
  readonly Draft: number;
package/dist/cms.js CHANGED
@@ -49,6 +49,26 @@ export const PhiCmsRegionType = {
49
49
  * Bits 2, 5 and 7 are unassigned. That is what makes the numbers worth stating once: 8 reads like the
50
50
  * fourth Area and is the third, and phi-server stores it as `area_id` where nothing spells it out.
51
51
  */
52
+ /**
53
+ * The Areas a Site has, by name.
54
+ *
55
+ * The numbering below already lived here, because both sides store it and a renumbering neither of
56
+ * them noticed would put a Page in a different Area than it was written into. The names are the same
57
+ * agreement read the other way round and stood in two more copies -- one per repository -- so a Site
58
+ * that gained an Area would have had to gain it three times.
59
+ */
60
+ export const PHI_CMS_AREA_KEYS = [
61
+ "public",
62
+ "app",
63
+ "admin",
64
+ "builder",
65
+ "editor",
66
+ "accounting",
67
+ ];
68
+ const PHI_CMS_AREA_KEY_SET = new Set(PHI_CMS_AREA_KEYS);
69
+ export function isPhiCmsAreaKey(value) {
70
+ return typeof value === "string" && PHI_CMS_AREA_KEY_SET.has(value);
71
+ }
52
72
  export const PhiCmsVisibilityContext = {
53
73
  PublicArea: 1 << 0,
54
74
  AppArea: 1 << 1,
@@ -61,7 +81,9 @@ export const PhiCmsVisibilityContext = {
61
81
  * The flags a CMS row carries, in the one numbering both sides read them from.
62
82
  *
63
83
  * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
64
- * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
84
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds. The
85
+ * same numbering serves a config's own `flags`, where a Region's and an Overlay's behaviour live
86
+ * (`Sticky`, `NoCloseButton`, ...): a yes-or-no answer in a stored record is a bit, never a boolean.
65
87
  *
66
88
  * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
67
89
  * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
@@ -76,6 +98,35 @@ export const PhiCmsFlags = {
76
98
  Collapsed: 1 << 4,
77
99
  NoTranslate: 1 << 5,
78
100
  NoIndex: 1 << 6,
101
+ /*
102
+ * A Region's behaviour, carried in its config's `flags`: a Region stored on a Page is a config under
103
+ * the Page's `layoutConfig` and has no row of its own, so the config is the one place both kinds have.
104
+ */
105
+ /** Stays in view while the page scrolls (Header and Sider families). */
106
+ Sticky: 1 << 7,
107
+ /** A Sider that takes the viewport height below its offset. */
108
+ FullHeight: 1 << 8,
109
+ /** A Sider the viewer can fold to its collapsed width. */
110
+ Collapsible: 1 << 9,
111
+ /*
112
+ * An Overlay's behaviour, carried in its config's `flags` the same way. The bits name the departure
113
+ * from the Overlay's defaults, so an unset flag set is the plain Overlay: a close button, Escape
114
+ * closes, the mask stops the pointer and closes on a click.
115
+ */
116
+ /** The Header chrome renders no close button. */
117
+ NoCloseButton: 1 << 10,
118
+ /** Escape does not request dismissal. */
119
+ NoEscapeClose: 1 << 11,
120
+ /** A Modal centred in the viewport. */
121
+ Centered: 1 << 12,
122
+ /** A Drawer the viewer can resize. */
123
+ Resizable: 1 << 13,
124
+ /** A Drawer that pushes a nested Drawer aside (`pushDistance` states how far). */
125
+ Push: 1 << 14,
126
+ /** Pointer input reaches the page behind the Overlay. */
127
+ MaskPassesPointer: 1 << 15,
128
+ /** An outside pointer action does not request dismissal. */
129
+ MaskKeepsOpen: 1 << 16,
79
130
  };
80
131
  export const PhiCmsRevisionFlags = {
81
132
  Draft: 1 << 0,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The grammar of a runtime condition: when a Widget shows, when a Form field is visible or disabled.
3
+ *
4
+ * Stored inside descriptors phis-server keeps, a Form's `visibleWhen` among them, so it is read here and
5
+ * read once -- the renderer evaluates what the server accepted, by the same rules.
6
+ */
7
+ import { type PhiControllerSignalAddress, type PhiSignalAddress } from "./signals.js";
8
+ /**
9
+ * Where a condition reads the value it judges.
10
+ *
11
+ * `row`, `form`, `controller` and `widget` are values the page produced: a record under the cursor, what
12
+ * has been typed, what a Controller holds, what a neighbouring Widget found out. `feature` is what this
13
+ * Site was configured to have -- a named fact a Module publishes about its own setup, such as whether a
14
+ * sign-in method is switched on, settled before anything renders and never seen by the browser. `page`
15
+ * is the one the visitor arrived with --
16
+ * the address of the page they opened, which is state as much as any of the others and the only kind
17
+ * that is already settled before the first Widget renders. A confirmation link carries its token there,
18
+ * an external login returns with its outcome there, and a Widget that should appear only for one of
19
+ * those can say so without a component of its own to read the query string.
20
+ */
21
+ export declare const PHI_RUNTIME_CONDITION_SOURCES: readonly ["row", "form", "controller", "page", "widget", "feature"];
22
+ export declare const PHI_RUNTIME_CONDITION_OPERATORS: readonly ["truthy", "falsy", "equals", "contains"];
23
+ export declare const PHI_RUNTIME_CONDITION_GROUP_MATCHES: readonly ["all", "any"];
24
+ export type PhiRuntimeConditionSource = (typeof PHI_RUNTIME_CONDITION_SOURCES)[number];
25
+ export type PhiRuntimeConditionOperator = (typeof PHI_RUNTIME_CONDITION_OPERATORS)[number];
26
+ export type PhiRuntimeConditionGroupMatch = (typeof PHI_RUNTIME_CONDITION_GROUP_MATCHES)[number];
27
+ export type PhiRuntimeConditionValue = string;
28
+ export type PhiRuntimeValueCondition = {
29
+ source: PhiRuntimeConditionSource;
30
+ controllerAddress?: PhiControllerSignalAddress;
31
+ /**
32
+ * Which Widget is being asked, for a `widget` condition.
33
+ *
34
+ * A Controller is state that outlives the Widgets reading it; a Widget reports only what it found out
35
+ * for itself. Both answer on the same channel, so a Widget beside another one -- a preview of what a
36
+ * link will do, next to the form that does it -- needs no Controller of its own to be composed with.
37
+ */
38
+ widgetAddress?: PhiSignalAddress;
39
+ valuePath: string;
40
+ operator: PhiRuntimeConditionOperator;
41
+ value?: PhiRuntimeConditionValue;
42
+ /**
43
+ * How to read a sender that has not said anything yet. Absent means "not matched".
44
+ *
45
+ * Hiding until told is right for a fact that is being fetched: a form that spends a confirmation link
46
+ * must not appear before the link has been checked. It is wrong for a state that only a later action
47
+ * can bring about -- nobody is halfway through a second authentication factor when the page opens, so
48
+ * a Widget standing back "while a step is under way" would stand back forever, waiting for a report
49
+ * that nothing is happening.
50
+ */
51
+ whenUnavailable?: PhiRuntimeConditionResult;
52
+ reason?: string;
53
+ };
54
+ export type PhiRuntimeConditionExpression = PhiRuntimeValueCondition | {
55
+ match: PhiRuntimeConditionGroupMatch;
56
+ conditions: readonly PhiRuntimeConditionExpression[];
57
+ };
58
+ export type PhiRuntimeConditionResult = "matched" | "not-matched" | "unavailable";
59
+ /**
60
+ * The address of the page as the visitor opened it, which is what a `page` condition reads.
61
+ *
62
+ * A missing query parameter is an answer rather than a gap: `falsy` on `query.token` matches on the
63
+ * page without one, which is how a form with two stages knows which of them it is on.
64
+ */
65
+ export type PhiRuntimePageConditionState = {
66
+ path: string;
67
+ query: Readonly<Record<string, string>>;
68
+ };
69
+ /**
70
+ * What the active Modules report about this Site's configuration, by namespaced name.
71
+ *
72
+ * A Module publishes these the way it publishes signal capabilities: deliberately, under a name it
73
+ * keeps. A condition reads `auth.password`, not a field of somebody's internal context, so renaming
74
+ * something inside a Module cannot silently change which Widgets a page shows.
75
+ */
76
+ export type PhiRuntimeFeatureState = Readonly<Record<string, unknown>>;
77
+ export type PhiRuntimeConditionSourceValues = {
78
+ row?: Record<string, unknown> | null;
79
+ features?: PhiRuntimeFeatureState | null;
80
+ form?: Record<string, unknown> | null;
81
+ controllers?: Readonly<Record<string, Record<string, unknown>>> | null;
82
+ /** Reported by Widgets, keyed by the address each one sends from. */
83
+ widgets?: Readonly<Record<string, Record<string, unknown>>> | null;
84
+ page?: PhiRuntimePageConditionState | null;
85
+ };
86
+ export type PhiRuntimeConditionStateSignalValue = {
87
+ state: Record<string, unknown>;
88
+ };
89
+ export declare function readPhiRuntimeValueCondition(value: unknown): PhiRuntimeValueCondition | null;
90
+ export declare function readPhiRuntimeConditionExpression(value: unknown): PhiRuntimeConditionExpression | null;
91
+ export declare function collectPhiRuntimeValueConditions(expression: PhiRuntimeConditionExpression | null | undefined): PhiRuntimeValueCondition[];
92
+ export declare function combinePhiRuntimeConditionExpressions(match: PhiRuntimeConditionGroupMatch, expressions: readonly (PhiRuntimeConditionExpression | null | undefined)[]): PhiRuntimeConditionExpression | undefined;
93
+ export declare function readPhiRuntimeConditionStateSignalValue(value: unknown): PhiRuntimeConditionStateSignalValue | null;
94
+ export declare function readPhiRuntimeConditionValue(input: unknown, valuePath: string): unknown;
95
+ export declare function matchesPhiRuntimeValueCondition(condition: PhiRuntimeValueCondition, sources: PhiRuntimeConditionSourceValues): boolean;
96
+ export declare function evaluatePhiRuntimeConditionExpression(expression: PhiRuntimeConditionExpression, sources: PhiRuntimeConditionSourceValues): PhiRuntimeConditionResult;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The grammar of a runtime condition: when a Widget shows, when a Form field is visible or disabled.
3
+ *
4
+ * Stored inside descriptors phis-server keeps, a Form's `visibleWhen` among them, so it is read here and
5
+ * read once -- the renderer evaluates what the server accepted, by the same rules.
6
+ */
7
+ import { readPhiControllerSignalAddress, readPhiSignalAddress, } from "./signals.js";
8
+ import { isPhiRecord, readPhiDotPath } from "./record.js";
9
+ /**
10
+ * Where a condition reads the value it judges.
11
+ *
12
+ * `row`, `form`, `controller` and `widget` are values the page produced: a record under the cursor, what
13
+ * has been typed, what a Controller holds, what a neighbouring Widget found out. `feature` is what this
14
+ * Site was configured to have -- a named fact a Module publishes about its own setup, such as whether a
15
+ * sign-in method is switched on, settled before anything renders and never seen by the browser. `page`
16
+ * is the one the visitor arrived with --
17
+ * the address of the page they opened, which is state as much as any of the others and the only kind
18
+ * that is already settled before the first Widget renders. A confirmation link carries its token there,
19
+ * an external login returns with its outcome there, and a Widget that should appear only for one of
20
+ * those can say so without a component of its own to read the query string.
21
+ */
22
+ export const PHI_RUNTIME_CONDITION_SOURCES = ["row", "form", "controller", "page", "widget", "feature"];
23
+ export const PHI_RUNTIME_CONDITION_OPERATORS = ["truthy", "falsy", "equals", "contains"];
24
+ export const PHI_RUNTIME_CONDITION_GROUP_MATCHES = ["all", "any"];
25
+ function readConditionValue(value) {
26
+ return typeof value === "string" ? value : undefined;
27
+ }
28
+ function readConditionSource(value) {
29
+ return PHI_RUNTIME_CONDITION_SOURCES.includes(value)
30
+ ? value
31
+ : null;
32
+ }
33
+ export function readPhiRuntimeValueCondition(value) {
34
+ if (!isPhiRecord(value))
35
+ return null;
36
+ const source = readConditionSource(value.source);
37
+ const operator = value.operator === "truthy" || value.operator === "falsy" ||
38
+ value.operator === "equals" || value.operator === "contains"
39
+ ? value.operator
40
+ : null;
41
+ const valuePath = typeof value.valuePath === "string" ? value.valuePath.trim() : "";
42
+ const whenUnavailable = value.whenUnavailable === "matched" || value.whenUnavailable === "not-matched"
43
+ ? value.whenUnavailable
44
+ : undefined;
45
+ const controllerAddress = source === "controller"
46
+ ? readPhiControllerSignalAddress(value.controllerAddress)
47
+ : undefined;
48
+ const widgetAddress = source === "widget"
49
+ ? readPhiSignalAddress(value.widgetAddress)
50
+ : undefined;
51
+ const conditionValue = readConditionValue(value.value);
52
+ if (!source ||
53
+ !operator ||
54
+ !valuePath ||
55
+ (source === "controller" && !controllerAddress) ||
56
+ (source === "widget" && !widgetAddress) ||
57
+ (operator !== "truthy" && operator !== "falsy" && conditionValue === undefined)) {
58
+ return null;
59
+ }
60
+ return {
61
+ source,
62
+ controllerAddress,
63
+ widgetAddress,
64
+ valuePath,
65
+ operator,
66
+ whenUnavailable,
67
+ value: operator === "truthy" || operator === "falsy" ? undefined : conditionValue,
68
+ reason: typeof value.reason === "string" && value.reason.trim() ? value.reason.trim() : undefined,
69
+ };
70
+ }
71
+ export function readPhiRuntimeConditionExpression(value) {
72
+ const leaf = readPhiRuntimeValueCondition(value);
73
+ if (leaf)
74
+ return leaf;
75
+ if (!isPhiRecord(value) || (value.match !== "all" && value.match !== "any") || !Array.isArray(value.conditions)) {
76
+ return null;
77
+ }
78
+ const conditions = value.conditions.map(readPhiRuntimeConditionExpression);
79
+ if (conditions.length === 0 || conditions.some((condition) => condition == null)) {
80
+ return null;
81
+ }
82
+ return {
83
+ match: value.match,
84
+ conditions: conditions,
85
+ };
86
+ }
87
+ export function collectPhiRuntimeValueConditions(expression) {
88
+ if (!expression)
89
+ return [];
90
+ if ("source" in expression)
91
+ return [expression];
92
+ return expression.conditions.flatMap(collectPhiRuntimeValueConditions);
93
+ }
94
+ export function combinePhiRuntimeConditionExpressions(match, expressions) {
95
+ const conditions = expressions.filter((entry) => Boolean(entry));
96
+ if (conditions.length === 0)
97
+ return undefined;
98
+ if (conditions.length === 1)
99
+ return conditions[0];
100
+ return { match, conditions };
101
+ }
102
+ export function readPhiRuntimeConditionStateSignalValue(value) {
103
+ return isPhiRecord(value) && isPhiRecord(value.state) ? { state: value.state } : null;
104
+ }
105
+ export function readPhiRuntimeConditionValue(input, valuePath) {
106
+ return readPhiDotPath(input, valuePath);
107
+ }
108
+ function resolvePhiRuntimeConditionSource(condition, sources) {
109
+ if (condition.source === "row")
110
+ return sources.row ?? null;
111
+ if (condition.source === "form")
112
+ return sources.form ?? null;
113
+ if (condition.source === "page")
114
+ return sources.page ?? null;
115
+ if (condition.source === "feature")
116
+ return sources.features ?? null;
117
+ if (condition.source === "widget") {
118
+ return condition.widgetAddress ? sources.widgets?.[condition.widgetAddress] ?? null : null;
119
+ }
120
+ return condition.controllerAddress
121
+ ? sources.controllers?.[condition.controllerAddress] ?? null
122
+ : null;
123
+ }
124
+ export function matchesPhiRuntimeValueCondition(condition, sources) {
125
+ const source = resolvePhiRuntimeConditionSource(condition, sources);
126
+ if (source == null)
127
+ return false;
128
+ const current = readPhiRuntimeConditionValue(source, condition.valuePath);
129
+ if ((condition.source === "controller" || condition.source === "widget") && current === undefined)
130
+ return false;
131
+ if (condition.operator === "truthy")
132
+ return Boolean(current);
133
+ if (condition.operator === "falsy")
134
+ return !current;
135
+ if (condition.operator === "equals")
136
+ return Object.is(current, condition.value);
137
+ return Array.isArray(current)
138
+ ? current.some((entry) => typeof entry === "string" && entry === condition.value)
139
+ : typeof current === "string" && typeof condition.value === "string"
140
+ ? current.includes(condition.value)
141
+ : false;
142
+ }
143
+ export function evaluatePhiRuntimeConditionExpression(expression, sources) {
144
+ if ("source" in expression) {
145
+ const source = resolvePhiRuntimeConditionSource(expression, sources);
146
+ if (source == null)
147
+ return expression.whenUnavailable ?? "unavailable";
148
+ if ((expression.source === "controller" || expression.source === "widget") &&
149
+ readPhiRuntimeConditionValue(source, expression.valuePath) === undefined) {
150
+ return expression.whenUnavailable ?? "unavailable";
151
+ }
152
+ return matchesPhiRuntimeValueCondition(expression, sources) ? "matched" : "not-matched";
153
+ }
154
+ const results = expression.conditions.map((condition) => evaluatePhiRuntimeConditionExpression(condition, sources));
155
+ if (expression.match === "all") {
156
+ if (results.some((result) => result === "not-matched"))
157
+ return "not-matched";
158
+ return results.every((result) => result === "matched") ? "matched" : "unavailable";
159
+ }
160
+ if (results.some((result) => result === "matched"))
161
+ return "matched";
162
+ return results.every((result) => result === "not-matched") ? "not-matched" : "unavailable";
163
+ }