@phis/contracts 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.d.ts CHANGED
@@ -10,16 +10,16 @@
10
10
  * exactly what this removes. An Add-on that needs one of them is either cut wrong or needs a Core
11
11
  * service.
12
12
  */
13
- export declare const PHI_SERVER_ADDON_SCHEMA_DESCRIPTOR_VERSION: 1;
13
+ export declare const PHIS_ADDON_SCHEMA_DESCRIPTOR_VERSION: 1;
14
14
  /**
15
15
  * What a column holds, said abstractly so Core can map it.
16
16
  *
17
17
  * `identity` is a generated key and may appear only in a primary key; the width is Core's business, not
18
18
  * the Add-on's.
19
19
  */
20
- export type PhiServerAddonColumnType = "identity" | "smallInteger" | "integer" | "bigInteger" | "text" | "string" | "boolean" | "instant" | "json" | "uuid" | "binary";
20
+ export type PhisAddonColumnType = "identity" | "smallInteger" | "integer" | "bigInteger" | "text" | "string" | "boolean" | "instant" | "json" | "uuid" | "binary";
21
21
  /** The closed vocabulary a default may be drawn from. Anything wider would be an expression. */
22
- export type PhiServerAddonColumnDefault = {
22
+ export type PhisAddonColumnDefault = {
23
23
  kind: "literal";
24
24
  value: string | number | boolean;
25
25
  } | {
@@ -36,27 +36,45 @@ export type PhiServerAddonColumnDefault = {
36
36
  * binds the pair rather than the id alone. Core never references a group any other way, and an Add-on
37
37
  * that could would be able to name a group belonging to a different Site.
38
38
  */
39
- export declare const PHI_SERVER_ADDON_CORE_REFERENCES: {
39
+ export declare const PHIS_ADDON_CORE_REFERENCES: {
40
40
  readonly site: "core.site";
41
41
  readonly user: "core.user";
42
42
  readonly group: "core.group";
43
+ /**
44
+ * A stored file, which is the one Core row an Add-on points at without having written it.
45
+ *
46
+ * Core keeps the facts about an object -- where it is, how big, what type, which digest -- in one
47
+ * place for every upload it has ever handled, whoever asked. An Add-on's asset row names one of them
48
+ * and holds nothing about it, so there is no second copy to fall out of step, and no key an Add-on
49
+ * could pass anywhere.
50
+ */
51
+ readonly mediaAsset: "core.mediaAsset";
43
52
  };
44
- export type PhiServerAddonCoreReference = (typeof PHI_SERVER_ADDON_CORE_REFERENCES)[keyof typeof PHI_SERVER_ADDON_CORE_REFERENCES];
53
+ export type PhisAddonCoreReference = (typeof PHIS_ADDON_CORE_REFERENCES)[keyof typeof PHIS_ADDON_CORE_REFERENCES];
45
54
  /**
46
55
  * A delete rule, offered as two values and no third.
47
56
  *
48
57
  * An Add-on must not be able to refuse a Core deletion: an operator who cannot delete a user because an
49
58
  * installed package forbids it is looking at a constraint error from code they may not know they have.
50
59
  */
51
- export type PhiServerAddonDeleteRule = "cascade" | "setNull";
52
- export type PhiServerAddonValueColumnDescriptor = {
60
+ export type PhisAddonDeleteRule = "cascade" | "setNull";
61
+ export type PhisAddonValueColumnDescriptor = {
53
62
  name: string;
54
- type: PhiServerAddonColumnType;
63
+ type: PhisAddonColumnType;
55
64
  /** Required for `string`, forbidden otherwise. */
56
65
  maxLength?: number;
57
66
  nullable?: boolean;
67
+ /**
68
+ * Unique -- within the Site, when the table is Site-scoped.
69
+ *
70
+ * Core prepends the Site column, because the Add-on cannot: it may not declare `site_id` and so has
71
+ * nowhere to name the pair. Without that, uniqueness would reach across every tenant on the instance,
72
+ * and the second Site to install the Add-on would collide with the first over a value neither of them
73
+ * can see. Instance-wide uniqueness is therefore not expressible on a Site-scoped table, which is the
74
+ * intended answer rather than a missing feature.
75
+ */
58
76
  unique?: boolean;
59
- default?: PhiServerAddonColumnDefault;
77
+ default?: PhisAddonColumnDefault;
60
78
  };
61
79
  /**
62
80
  * A column that points at something, and therefore has no type of its own.
@@ -66,18 +84,25 @@ export type PhiServerAddonValueColumnDescriptor = {
66
84
  * shape a foreign key takes -- there is no separate list of them, and no composite key, because a
67
85
  * reference that needs two columns is pointing at something an Add-on should not be reaching into.
68
86
  */
69
- export type PhiServerAddonReferenceColumnDescriptor = {
87
+ export type PhisAddonReferenceColumnDescriptor = {
70
88
  name: string;
71
- /** A Core table under its symbolic name, or one of this Add-on's own tables. */
72
- references: PhiServerAddonCoreReference | {
89
+ /**
90
+ * A Core table under its symbolic name, or one of this Add-on's own tables.
91
+ *
92
+ * A reference to a Site-scoped table is bound together with its Site, the way a group reference is:
93
+ * Core writes `(column, site_id)` against the target's `(key, site_id)`, and both tables must be
94
+ * Site-scoped. The id alone would let a row of one Site name a row of another, and nothing about the
95
+ * declaration would show it -- the column would look exactly the same and the constraint would hold.
96
+ */
97
+ references: PhisAddonCoreReference | {
73
98
  table: string;
74
99
  };
75
100
  nullable?: boolean;
76
101
  unique?: boolean;
77
102
  /** `setNull` requires a nullable column. */
78
- onDelete: PhiServerAddonDeleteRule;
103
+ onDelete: PhisAddonDeleteRule;
79
104
  };
80
- export type PhiServerAddonColumnDescriptor = PhiServerAddonValueColumnDescriptor | PhiServerAddonReferenceColumnDescriptor;
105
+ export type PhisAddonColumnDescriptor = PhisAddonValueColumnDescriptor | PhisAddonReferenceColumnDescriptor;
81
106
  /**
82
107
  * A constraint, in structured form.
83
108
  *
@@ -88,7 +113,7 @@ export type PhiServerAddonColumnDescriptor = PhiServerAddonValueColumnDescriptor
88
113
  * Regular expressions are absent on purpose. Their syntax differs between database systems, and taking
89
114
  * them would buy back the dependency this file exists to remove.
90
115
  */
91
- export type PhiServerAddonTableConstraintDescriptor = {
116
+ export type PhisAddonTableConstraintDescriptor = {
92
117
  kind: "range";
93
118
  name: string;
94
119
  column: string;
@@ -119,21 +144,115 @@ export type PhiServerAddonTableConstraintDescriptor = {
119
144
  columns: string[];
120
145
  nonNullCount: number;
121
146
  };
122
- export type PhiServerAddonIndexDescriptor = {
147
+ /**
148
+ * What an index is for, said abstractly so Core can choose the structure.
149
+ *
150
+ * `ordered` is the ordinary one: equality, ranges, and the orderings a paged select runs under.
151
+ * `text` is for substring search, names exactly one text column, and is what makes `contains` more than
152
+ * a scan -- measured on 200,000 rows, 72 ms without it and 0.5 ms with. It cannot be unique, because it
153
+ * indexes fragments rather than values.
154
+ *
155
+ * Absent means `ordered`: an index declared before this field existed had not forgotten it.
156
+ */
157
+ export type PhisAddonIndexKind = "ordered" | "text";
158
+ export type PhisAddonIndexDescriptor = {
123
159
  name: string;
160
+ kind?: PhisAddonIndexKind;
124
161
  columns: string[];
162
+ /**
163
+ * Unique, and read exactly as `unique` on a column: within the Site, on a Site-scoped table, with the
164
+ * Site column prepended by Core.
165
+ *
166
+ * A non-unique index is only an access path and is left as it was written. Which order helps depends
167
+ * on the query, and Core does not know it.
168
+ */
125
169
  unique?: boolean;
126
170
  };
127
- export type PhiServerAddonTableDescriptor = {
171
+ /**
172
+ * A named place on a row where a file belongs.
173
+ *
174
+ * The names are the Add-on's -- `logo`, `package`, `screenshots` -- and the set of them is closed, which
175
+ * is what makes the rest possible. Core can only enforce a size and a type where it knows which slot is
176
+ * being filled, and it can only tell an abandoned object from a live one where the vocabulary is finite.
177
+ * An Add-on that needs no files declares none and gets no table.
178
+ *
179
+ * The object key is Core's, never the Add-on's. Two rows cannot be made to name the same object, a key
180
+ * cannot be spelled that reaches outside the Add-on's prefix, and replacing a file writes a new key
181
+ * rather than overwriting the old -- overwriting destroys the current file before the row that replaces
182
+ * it is committed, and a new key leaves the old one behind for the sweep to collect.
183
+ *
184
+ * Requires `siteScoped`. An object lives in one Site's store, because that is where the Site's Storage
185
+ * Profile points; a row shared across Sites has no store its file could belong to.
186
+ */
187
+ export type PhisAddonAssetSlotDescriptor = {
188
+ name: string;
189
+ /**
190
+ * `one` is a slot that holds the current file and forgets the previous one; `many` accumulates.
191
+ *
192
+ * Enforced on the finished file only. An upload replacing a `one` slot exists alongside the file it
193
+ * will replace until it is complete, because the alternative is a row with nothing behind it for as
194
+ * long as the transfer takes -- and a transfer that fails would have destroyed the file it never
195
+ * managed to replace.
196
+ */
197
+ cardinality: "one" | "many";
198
+ /** Refused before a byte is accepted, and not raisable by the Add-on that declared it. */
199
+ maxBytes: number;
200
+ /**
201
+ * The exact types this slot takes, as full media types. Empty is not "anything", it is refused.
202
+ *
203
+ * Stated per slot rather than per Add-on because the answers differ within one package: a logo is a
204
+ * handful of image types and a release artifact is one archive type, and a single list wide enough for
205
+ * both permits an image where the archive goes.
206
+ */
207
+ contentTypes: string[];
208
+ /**
209
+ * Who may fetch the file once it is there. `public` when absent.
210
+ *
211
+ * Stated per slot for the same reason the types are, and the two cases really do differ: the logo on
212
+ * a listing has to be readable by anyone who can see the listing, and the release artifact behind it
213
+ * must not be. Declaring it is what stops a package from having to choose one answer for both.
214
+ *
215
+ * It is here rather than left to a later setting because an installed package would otherwise carry
216
+ * whatever the default happened to be, and a file that was public once has been public.
217
+ */
218
+ delivery?: "public" | "authenticated" | "internal";
219
+ /**
220
+ * A column on the owning table that Core keeps the file's delivery path in.
221
+ *
222
+ * Without it, showing a row's picture means asking for its files -- one question per row, so a page
223
+ * of fifty costs fifty. With it the path arrives with the row, in the statement that was being run
224
+ * anyway, and a listing is one query again.
225
+ *
226
+ * The column is Core's: it is created from this declaration, written when a file is settled, rewritten
227
+ * when a file replaces it, cleared when it is taken away, and refused as an assignment in every
228
+ * declared query. An Add-on that could write it could point a row at somebody else's file.
229
+ *
230
+ * The path rather than the Media Asset id, because the path is what gets used and the id alone is not
231
+ * enough to build one: a delivery path carries the cache revision, and reading that back would be the
232
+ * join this exists to avoid.
233
+ *
234
+ * Only on a `one` slot. A slot that accumulates has no single path to mirror, and a column holding the
235
+ * newest of several would be a fact nobody asked for.
236
+ */
237
+ mirrorColumn?: string;
238
+ };
239
+ export type PhisAddonTableDescriptor = {
128
240
  name: string;
129
241
  /**
130
- * Core adds the Site column, its foreign key, and its cascade, and puts the Site filter into every
131
- * query that reads this table. The Add-on neither writes the column nor is able to forget it.
242
+ * Core adds the Site column, its foreign key, and its cascade, puts the Site filter into every query
243
+ * that reads this table, and prepends it to every uniqueness rule the table declares. The Add-on
244
+ * neither writes the column nor is able to forget it.
132
245
  *
133
246
  * This is more than a reference column: it is the boundary a query is compiled against. A table that
134
247
  * merely wants to mention a Site uses a reference column instead.
248
+ *
249
+ * Required, and required as a word rather than as an absence. `false` is a real answer -- a category
250
+ * list, a currency table, a set of country codes belong to the Add-on and to no Site -- but it is an
251
+ * answer somebody has to give. Left optional, the unguarded table is what an author gets for saying
252
+ * nothing, and every other boundary in this file works the other way round: Core supplies it and the
253
+ * Add-on cannot forget it. Here it cannot supply it, so it insists on being told.
135
254
  */
136
- siteScoped?: boolean;
255
+ siteScoped: boolean;
137
256
  /**
138
257
  * Core adds the owner column and its foreign key, and answers whether a row belongs to the acting
139
258
  * user. The Add-on neither writes the column nor decides what "own" means, which is the point: the
@@ -142,6 +261,12 @@ export type PhiServerAddonTableDescriptor = {
142
261
  * The column is nullable and a deleted user leaves it null rather than taking the rows along. A row
143
262
  * without an owner therefore belongs to nobody, and no membership level reaches it through ownership.
144
263
  *
264
+ * Requires `siteScoped`, for the reason a group does. Everywhere else Core weighs a person it weighs
265
+ * them on a Site -- an authorization is read as a pair, and a disabled membership answers no. Ownership
266
+ * without a Site is the one place that pair would come apart: the same account would reach a row from
267
+ * a Site it was never admitted to, and the comparison that let it through looked at nothing but a user
268
+ * id.
269
+ *
145
270
  * This is authority, not history. A table that only wants to record who created a row uses an
146
271
  * ordinary `core.user` reference column, which grants nothing.
147
272
  */
@@ -155,10 +280,19 @@ export type PhiServerAddonTableDescriptor = {
155
280
  * reference column instead.
156
281
  */
157
282
  groupScoped?: boolean;
158
- columns: PhiServerAddonColumnDescriptor[];
283
+ columns: PhisAddonColumnDescriptor[];
159
284
  primaryKey: string[];
160
- indexes?: PhiServerAddonIndexDescriptor[];
161
- constraints?: PhiServerAddonTableConstraintDescriptor[];
285
+ indexes?: PhisAddonIndexDescriptor[];
286
+ constraints?: PhisAddonTableConstraintDescriptor[];
287
+ /**
288
+ * The files rows of this table may carry, which Core keeps in a table of its own beside this one.
289
+ *
290
+ * A child table rather than a column per slot, because the slots are the Add-on's to name and their
291
+ * number is the Add-on's to choose -- and because a gallery is a slot with more than one file in it,
292
+ * which no column can hold. The child cascades: removing a row removes the rows describing its files,
293
+ * and the objects they named become collectable.
294
+ */
295
+ assets?: PhisAddonAssetSlotDescriptor[];
162
296
  };
163
297
  /**
164
298
  * The shape an Add-on's schema should have -- not the steps to reach it.
@@ -166,8 +300,8 @@ export type PhiServerAddonTableDescriptor = {
166
300
  * `version` is the schema version the manifest reports, and Core compares this description against the
167
301
  * one it recorded to decide what to run.
168
302
  */
169
- export type PhiServerAddonSchemaDescriptor = {
170
- descriptorVersion: typeof PHI_SERVER_ADDON_SCHEMA_DESCRIPTOR_VERSION;
303
+ export type PhisAddonSchemaDescriptor = {
304
+ descriptorVersion: typeof PHIS_ADDON_SCHEMA_DESCRIPTOR_VERSION;
171
305
  version: number;
172
- tables: PhiServerAddonTableDescriptor[];
306
+ tables: PhisAddonTableDescriptor[];
173
307
  };
package/dist/schema.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * exactly what this removes. An Add-on that needs one of them is either cut wrong or needs a Core
11
11
  * service.
12
12
  */
13
- export const PHI_SERVER_ADDON_SCHEMA_DESCRIPTOR_VERSION = 1;
13
+ export const PHIS_ADDON_SCHEMA_DESCRIPTOR_VERSION = 1;
14
14
  /**
15
15
  * A Core table an Add-on may point at, named symbolically.
16
16
  *
@@ -22,8 +22,17 @@ export const PHI_SERVER_ADDON_SCHEMA_DESCRIPTOR_VERSION = 1;
22
22
  * binds the pair rather than the id alone. Core never references a group any other way, and an Add-on
23
23
  * that could would be able to name a group belonging to a different Site.
24
24
  */
25
- export const PHI_SERVER_ADDON_CORE_REFERENCES = {
25
+ export const PHIS_ADDON_CORE_REFERENCES = {
26
26
  site: "core.site",
27
27
  user: "core.user",
28
28
  group: "core.group",
29
+ /**
30
+ * A stored file, which is the one Core row an Add-on points at without having written it.
31
+ *
32
+ * Core keeps the facts about an object -- where it is, how big, what type, which digest -- in one
33
+ * place for every upload it has ever handled, whoever asked. An Add-on's asset row names one of them
34
+ * and holds nothing about it, so there is no second copy to fall out of step, and no key an Add-on
35
+ * could pass anywhere.
36
+ */
37
+ mediaAsset: "core.mediaAsset",
29
38
  };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What phis says about a capability provider, in the words the site UI reads it with.
3
+ *
4
+ * `/api/v1/site/capabilities` reports, per provider, whether what a Site's Modules require is actually
5
+ * being offered. phis decides the verdict; the UI acts on it, deactivating a Module whose provider is
6
+ * not `available` and naming the reason in its diagnostics. A verdict one side can produce and the
7
+ * other cannot recognise is not an error anywhere -- it is a Module that quietly stays off.
8
+ *
9
+ * The state was declared twice, once on each side, in the same six words. That is the arrangement that
10
+ * let the log services drift while both files still compiled, so it lives here now -- and the shape
11
+ * around it followed, because a snapshot phis only built and the UI only read was the same arrangement
12
+ * one level up.
13
+ *
14
+ * A provider is Core or an Add-on. Core is one itself, and the one most Modules depend on, which is
15
+ * why nothing here is named for Add-ons.
16
+ */
17
+ /** `@scope/name` -- Core is `@phis/server/core`. */
18
+ export type PhiCapabilityProviderId = `@${string}/${string}`;
19
+ /** `@scope/name:v<major>` -- a capability at the interface version it is offered under. */
20
+ export type PhiCapabilityId = `@${string}/${string}:v${number}`;
21
+ export type PhiCapabilityState = "available" | "missing" | "disabled" | "incompatible" | "misconfigured" | "unavailable";
22
+ export type PhiCapabilityDescriptor = {
23
+ id: PhiCapabilityId;
24
+ interfaceDigest: string;
25
+ };
26
+ export type PhiCapabilityProvider = {
27
+ providerId: PhiCapabilityProviderId;
28
+ state: PhiCapabilityState;
29
+ diagnosticCode: string | null;
30
+ capabilities: readonly PhiCapabilityDescriptor[];
31
+ };
32
+ export type PhiCapabilitySnapshot = {
33
+ siteKey: string;
34
+ releaseBuildId: string | null;
35
+ buildManifestDigest: string;
36
+ providers: readonly PhiCapabilityProvider[];
37
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What phis says about a capability provider, in the words the site UI reads it with.
3
+ *
4
+ * `/api/v1/site/capabilities` reports, per provider, whether what a Site's Modules require is actually
5
+ * being offered. phis decides the verdict; the UI acts on it, deactivating a Module whose provider is
6
+ * not `available` and naming the reason in its diagnostics. A verdict one side can produce and the
7
+ * other cannot recognise is not an error anywhere -- it is a Module that quietly stays off.
8
+ *
9
+ * The state was declared twice, once on each side, in the same six words. That is the arrangement that
10
+ * let the log services drift while both files still compiled, so it lives here now -- and the shape
11
+ * around it followed, because a snapshot phis only built and the UI only read was the same arrangement
12
+ * one level up.
13
+ *
14
+ * A provider is Core or an Add-on. Core is one itself, and the one most Modules depend on, which is
15
+ * why nothing here is named for Add-ons.
16
+ */
17
+ export {};
package/dist/signals.d.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * of widget shapes that belongs to the UI: phi-server checks that a JSON signal names *a* schema, never
11
11
  * which one, so the catalogue is not something the two must agree on.
12
12
  */
13
+ import { type PhiCmsInstanceId } from "./cms.js";
13
14
  export declare const PHI_SIGNAL_SCOPES: readonly ["widget", "layout", "region", "page", "area", "site"];
14
15
  export type PhiSignalScope = (typeof PHI_SIGNAL_SCOPES)[number];
15
16
  export declare const PHI_SIGNAL_ACTIONS: readonly ["activate", "change", "toggle", "start", "stop", "clear", "open", "close", "reload", "flush", "filter", "drop"];
@@ -34,3 +35,60 @@ export declare function isPhiSignalValueType(value: unknown): value is PhiSignal
34
35
  * The last separator wins, because a scoped package name contains one too.
35
36
  */
36
37
  export declare function isPhiSignalValueSchemaShape(value: unknown): value is PhiSignalValueSchema;
38
+ /**
39
+ * Whether a string can name an npm package.
40
+ *
41
+ * The lexical rule the controller address family starts from, and the one phi-server had copied under
42
+ * a different name to validate the same addresses. It is the same test `@phis/ui` keeps beside its own
43
+ * package name for the grammars that do not travel -- module ids, module-scoped keys. If a second
44
+ * grammar in this package needs it, that is the moment to give it a subpath of its own.
45
+ */
46
+ export declare function isPhiNpmPackageName(value: string): boolean;
47
+ /**
48
+ * The address families a signal may name, and nothing else.
49
+ *
50
+ * A wiring is stored by the Builder, validated by phi-server on the way in, and delivered by the site
51
+ * UI at runtime. All three read the same string, so all three have to parse it alike -- and until
52
+ * 2026-09-07 two of them did it from separate copies, one of which said in its own comment that it
53
+ * "mirrors the grammar in @phis/ui" because it could not import it.
54
+ *
55
+ * `cms:` addresses a Widget or Layout instance, optionally one control inside it. `region:` addresses a
56
+ * Region of the route's active context. `controller:` addresses one Controller instance in the
57
+ * namespace of the Module that owns it.
58
+ */
59
+ export type PhiSignalAddress = `cms:${PhiCmsInstanceId}` | `cms:${PhiCmsInstanceId}:${string}` | `region:${string}` | `controller:${string}/${string}:${string}`;
60
+ export type PhiControllerSignalAddress = Extract<PhiSignalAddress, `controller:${string}`>;
61
+ export type PhiSignalSender = PhiSignalAddress | null;
62
+ export type PhiSignalReceiver = PhiSignalAddress | "broadcast" | null;
63
+ export type PhiSignalAddressFamily = "cms" | "region";
64
+ /**
65
+ * The one address the Site scope privileges.
66
+ *
67
+ * Site-scoped signals reach across Areas, so nothing addressable inside one may be named in that scope
68
+ * -- no CMS instance, no Region, no broadcast. The Core Runtime Controller is the exception, and both
69
+ * sides have to name the same string for the rule to mean anything. `@phis/ui` builds it from its own
70
+ * constants and asserts it matches this one.
71
+ */
72
+ export declare const PHI_CORE_RUNTIME_CONTROLLER_ADDRESS: PhiControllerSignalAddress;
73
+ export declare function isPhiSignalAddressSegment(value: string): boolean;
74
+ /**
75
+ * A controller lives in the namespace of the Module that owns it, so a plugin key is either a bare
76
+ * package name or that package followed by `/modules/<module>/<namespace>`. Foreign packages use
77
+ * either form; the grammar does not privilege first-party keys.
78
+ */
79
+ export declare function isPhiControllerPluginKey(value: string): boolean;
80
+ export declare function isPhiControllerSignalAddress(address: unknown): address is PhiControllerSignalAddress;
81
+ /** The instance a `cms:` address names, or null when the string is not one. */
82
+ export declare function readPhiCmsSignalAddressInstanceId(address: string): PhiCmsInstanceId | null;
83
+ export declare function isPhiRegionSignalAddress(address: string): boolean;
84
+ export declare function isPhiSignalAddress(value: unknown): value is PhiSignalAddress;
85
+ export declare function readPhiSignalAddress(value: unknown): PhiSignalAddress | undefined;
86
+ export declare function isPhiSignalReceiver(value: unknown): value is PhiSignalReceiver;
87
+ /**
88
+ * Why a receiver may not be addressed in a scope, said in one sentence, or nothing when it may.
89
+ *
90
+ * The same sentence twice over until now: phi-server refused these on the way in, the site UI refused
91
+ * to deliver them at runtime, and neither knew the other's wording. What the two still do differently
92
+ * is what they do with the answer -- one rejects a write, the other drops a signal.
93
+ */
94
+ export declare function readPhiSignalReceiverScopeProblem(scope: PhiSignalScope, receiver: PhiSignalReceiver): string | null;
package/dist/signals.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * of widget shapes that belongs to the UI: phi-server checks that a JSON signal names *a* schema, never
11
11
  * which one, so the catalogue is not something the two must agree on.
12
12
  */
13
+ import { isPhiCmsInstanceId } from "./cms.js";
13
14
  export const PHI_SIGNAL_SCOPES = [
14
15
  "widget",
15
16
  "layout",
@@ -83,3 +84,120 @@ export function isPhiSignalValueSchemaShape(value) {
83
84
  const leaf = value.slice(marker + PHI_SIGNAL_VALUE_SCHEMA_NAMESPACE.length + PHI_SIGNAL_VALUE_SCHEMA_SEPARATOR.length * 2);
84
85
  return leaf.length > 0 && !leaf.includes(PHI_SIGNAL_VALUE_SCHEMA_SEPARATOR);
85
86
  }
87
+ /**
88
+ * Whether a string can name an npm package.
89
+ *
90
+ * The lexical rule the controller address family starts from, and the one phi-server had copied under
91
+ * a different name to validate the same addresses. It is the same test `@phis/ui` keeps beside its own
92
+ * package name for the grammars that do not travel -- module ids, module-scoped keys. If a second
93
+ * grammar in this package needs it, that is the moment to give it a subpath of its own.
94
+ */
95
+ export function isPhiNpmPackageName(value) {
96
+ if (!value || value.includes(":") || value.length > 214) {
97
+ return false;
98
+ }
99
+ return value.startsWith("@")
100
+ ? /^@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*$/.test(value)
101
+ : /^[a-z0-9][a-z0-9._~-]*$/.test(value);
102
+ }
103
+ /**
104
+ * The one address the Site scope privileges.
105
+ *
106
+ * Site-scoped signals reach across Areas, so nothing addressable inside one may be named in that scope
107
+ * -- no CMS instance, no Region, no broadcast. The Core Runtime Controller is the exception, and both
108
+ * sides have to name the same string for the rule to mean anything. `@phis/ui` builds it from its own
109
+ * constants and asserts it matches this one.
110
+ */
111
+ export const PHI_CORE_RUNTIME_CONTROLLER_ADDRESS = "controller:@phis/ui/modules/core/controller/default:default";
112
+ export function isPhiSignalAddressSegment(value) {
113
+ return value.length > 0 && !value.includes("/") && !value.includes(":");
114
+ }
115
+ /**
116
+ * A controller lives in the namespace of the Module that owns it, so a plugin key is either a bare
117
+ * package name or that package followed by `/modules/<module>/<namespace>`. Foreign packages use
118
+ * either form; the grammar does not privilege first-party keys.
119
+ */
120
+ export function isPhiControllerPluginKey(value) {
121
+ const marker = value.indexOf("/modules/");
122
+ if (marker < 0) {
123
+ return isPhiNpmPackageName(value);
124
+ }
125
+ const rest = value.slice(marker + "/modules/".length).split("/");
126
+ return (isPhiNpmPackageName(value.slice(0, marker)) &&
127
+ rest.length === 2 &&
128
+ rest.every((part) => isPhiSignalAddressSegment(part)));
129
+ }
130
+ export function isPhiControllerSignalAddress(address) {
131
+ if (typeof address !== "string" || !address.startsWith("controller:")) {
132
+ return false;
133
+ }
134
+ const body = address.slice("controller:".length);
135
+ const instanceSeparatorIndex = body.lastIndexOf(":");
136
+ if (instanceSeparatorIndex <= 0 || instanceSeparatorIndex === body.length - 1) {
137
+ return false;
138
+ }
139
+ const namespacedType = body.slice(0, instanceSeparatorIndex);
140
+ const instanceKey = body.slice(instanceSeparatorIndex + 1);
141
+ const controllerSeparatorIndex = namespacedType.lastIndexOf("/");
142
+ if (controllerSeparatorIndex <= 0 || controllerSeparatorIndex === namespacedType.length - 1) {
143
+ return false;
144
+ }
145
+ return (isPhiControllerPluginKey(namespacedType.slice(0, controllerSeparatorIndex)) &&
146
+ isPhiSignalAddressSegment(namespacedType.slice(controllerSeparatorIndex + 1)) &&
147
+ isPhiSignalAddressSegment(instanceKey));
148
+ }
149
+ /** The instance a `cms:` address names, or null when the string is not one. */
150
+ export function readPhiCmsSignalAddressInstanceId(address) {
151
+ if (!address.startsWith("cms:")) {
152
+ return null;
153
+ }
154
+ const parts = address.slice("cms:".length).split(":");
155
+ if ((parts.length !== 1 && parts.length !== 2) ||
156
+ !isPhiCmsInstanceId(parts[0]) ||
157
+ (parts.length === 2 && !isPhiSignalAddressSegment(parts[1]))) {
158
+ return null;
159
+ }
160
+ return parts[0];
161
+ }
162
+ export function isPhiRegionSignalAddress(address) {
163
+ return address.startsWith("region:") && isPhiSignalAddressSegment(address.slice("region:".length));
164
+ }
165
+ export function isPhiSignalAddress(value) {
166
+ if (typeof value !== "string") {
167
+ return false;
168
+ }
169
+ const address = value.trim();
170
+ return readPhiCmsSignalAddressInstanceId(address) != null ||
171
+ isPhiRegionSignalAddress(address) ||
172
+ isPhiControllerSignalAddress(address);
173
+ }
174
+ export function readPhiSignalAddress(value) {
175
+ return isPhiSignalAddress(value) ? value.trim() : undefined;
176
+ }
177
+ export function isPhiSignalReceiver(value) {
178
+ return value === null || value === "broadcast" || isPhiSignalAddress(value);
179
+ }
180
+ /**
181
+ * Why a receiver may not be addressed in a scope, said in one sentence, or nothing when it may.
182
+ *
183
+ * The same sentence twice over until now: phi-server refused these on the way in, the site UI refused
184
+ * to deliver them at runtime, and neither knew the other's wording. What the two still do differently
185
+ * is what they do with the answer -- one rejects a write, the other drops a signal.
186
+ */
187
+ export function readPhiSignalReceiverScopeProblem(scope, receiver) {
188
+ if (scope !== "site" || receiver === null) {
189
+ return null;
190
+ }
191
+ if (receiver === "broadcast") {
192
+ return "must not broadcast in Site scope";
193
+ }
194
+ if (readPhiCmsSignalAddressInstanceId(receiver) != null) {
195
+ return "must not target a CMS instance in Site scope";
196
+ }
197
+ if (isPhiRegionSignalAddress(receiver)) {
198
+ return "must not target a Region in Site scope";
199
+ }
200
+ return receiver === PHI_CORE_RUNTIME_CONTROLLER_ADDRESS
201
+ ? null
202
+ : "may target only the Core Runtime Controller in Site scope";
203
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The membership ladder of a Site group, as both ends must count it.
3
+ *
4
+ * These numbers cross the wire. A membership row is written by `PATCH .../members/<userId>` carrying
5
+ * `membershipFlags` as an integer, and the administration surface offers the levels to choose from, so
6
+ * the site UI does arithmetic on the same bits phis stores. (The Add-on boundary is the other case:
7
+ * there a level crosses by name, as `PHIS_GROUP_LEVELS`, because an Add-on has no reason to
8
+ * know the bits at all.)
9
+ *
10
+ * They were declared on both sides and had drifted, which cost a level. phis counts four --
11
+ * `GROUPS_AND_STORAGE.md` enforces `Manager => Editor => Author => Member` -- and the UI counted
12
+ * three, calling `1|2` a Contributor and `1|2|4` a Manager. So the UI's Manager was phis's Editor:
13
+ * granting it left out the bit that administers membership and deletes what others made, and the
14
+ * viewer was shown a level they did not have. An actual Manager, at `1|2|4|8`, the UI could not name.
15
+ *
16
+ * The levels are cumulative, and that is the point of the ordering rather than an implementation
17
+ * detail: "may edit only their own" is a narrowing of "may edit", so an Author sits below an Editor
18
+ * on one ladder rather than beside them on another.
19
+ */
20
+ export declare const PhiGroupMembershipFlags: {
21
+ /** Reads what the group holds. */
22
+ readonly Member: 1;
23
+ /** Creates, and may edit and delete what they created. */
24
+ readonly Author: number;
25
+ /** Reaches what others created. */
26
+ readonly Editor: number;
27
+ /** Deletes what anyone created, and administers the group's membership. */
28
+ readonly Manager: number;
29
+ };
30
+ export type PhiGroupMembershipFlagValue = (typeof PhiGroupMembershipFlags)[keyof typeof PhiGroupMembershipFlags];
31
+ /** Ascending, so a selector offers them in the order they widen. */
32
+ export declare const PHI_GROUP_MEMBERSHIP_LEVELS: readonly [1, number, number, number];
33
+ /**
34
+ * The level a stored value amounts to, highest bit first, or `null` if it is not one.
35
+ *
36
+ * Reading downwards is what makes an unknown higher bit fail closed rather than round up.
37
+ */
38
+ export declare function readPhiGroupMembershipLevel(value: unknown): PhiGroupMembershipFlagValue | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The membership ladder of a Site group, as both ends must count it.
3
+ *
4
+ * These numbers cross the wire. A membership row is written by `PATCH .../members/<userId>` carrying
5
+ * `membershipFlags` as an integer, and the administration surface offers the levels to choose from, so
6
+ * the site UI does arithmetic on the same bits phis stores. (The Add-on boundary is the other case:
7
+ * there a level crosses by name, as `PHIS_GROUP_LEVELS`, because an Add-on has no reason to
8
+ * know the bits at all.)
9
+ *
10
+ * They were declared on both sides and had drifted, which cost a level. phis counts four --
11
+ * `GROUPS_AND_STORAGE.md` enforces `Manager => Editor => Author => Member` -- and the UI counted
12
+ * three, calling `1|2` a Contributor and `1|2|4` a Manager. So the UI's Manager was phis's Editor:
13
+ * granting it left out the bit that administers membership and deletes what others made, and the
14
+ * viewer was shown a level they did not have. An actual Manager, at `1|2|4|8`, the UI could not name.
15
+ *
16
+ * The levels are cumulative, and that is the point of the ordering rather than an implementation
17
+ * detail: "may edit only their own" is a narrowing of "may edit", so an Author sits below an Editor
18
+ * on one ladder rather than beside them on another.
19
+ */
20
+ export const PhiGroupMembershipFlags = {
21
+ /** Reads what the group holds. */
22
+ Member: 1,
23
+ /** Creates, and may edit and delete what they created. */
24
+ Author: 1 | 2,
25
+ /** Reaches what others created. */
26
+ Editor: 1 | 2 | 4,
27
+ /** Deletes what anyone created, and administers the group's membership. */
28
+ Manager: 1 | 2 | 4 | 8,
29
+ };
30
+ /** Ascending, so a selector offers them in the order they widen. */
31
+ export const PHI_GROUP_MEMBERSHIP_LEVELS = [
32
+ PhiGroupMembershipFlags.Member,
33
+ PhiGroupMembershipFlags.Author,
34
+ PhiGroupMembershipFlags.Editor,
35
+ PhiGroupMembershipFlags.Manager,
36
+ ];
37
+ /**
38
+ * The level a stored value amounts to, highest bit first, or `null` if it is not one.
39
+ *
40
+ * Reading downwards is what makes an unknown higher bit fail closed rather than round up.
41
+ */
42
+ export function readPhiGroupMembershipLevel(value) {
43
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0)
44
+ return null;
45
+ if (value > PhiGroupMembershipFlags.Manager)
46
+ return null;
47
+ if ((value & 8) !== 0)
48
+ return PhiGroupMembershipFlags.Manager;
49
+ if ((value & 4) !== 0)
50
+ return PhiGroupMembershipFlags.Editor;
51
+ if ((value & 2) !== 0)
52
+ return PhiGroupMembershipFlags.Author;
53
+ return PhiGroupMembershipFlags.Member;
54
+ }