@phis/contracts 0.2.0 → 0.4.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.
Files changed (51) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +175 -67
  3. package/dist/access.d.ts +63 -0
  4. package/dist/access.js +78 -0
  5. package/dist/addon-index.d.ts +5 -0
  6. package/dist/addon-index.js +5 -0
  7. package/dist/addon.d.ts +181 -57
  8. package/dist/addon.js +2 -2
  9. package/dist/assets.d.ts +165 -0
  10. package/dist/assets.js +1 -0
  11. package/dist/capabilities.d.ts +175 -33
  12. package/dist/capabilities.js +2 -2
  13. package/dist/catalog.d.ts +104 -0
  14. package/dist/catalog.js +116 -0
  15. package/dist/cms.d.ts +120 -0
  16. package/dist/cms.js +270 -0
  17. package/dist/core.d.ts +52 -6
  18. package/dist/core.js +55 -8
  19. package/dist/http.d.ts +33 -0
  20. package/dist/http.js +33 -0
  21. package/dist/locale.d.ts +14 -0
  22. package/dist/locale.js +14 -0
  23. package/dist/logging.d.ts +49 -0
  24. package/dist/logging.js +17 -0
  25. package/dist/media-storage.d.ts +371 -0
  26. package/dist/media-storage.js +84 -0
  27. package/dist/media.d.ts +272 -0
  28. package/dist/media.js +421 -0
  29. package/dist/queries.d.ts +280 -48
  30. package/dist/queries.js +10 -1
  31. package/dist/resource-links.d.ts +94 -0
  32. package/dist/resource-links.js +27 -0
  33. package/dist/schema.d.ts +160 -26
  34. package/dist/schema.js +11 -2
  35. package/dist/server-capabilities.d.ts +37 -0
  36. package/dist/server-capabilities.js +17 -0
  37. package/dist/signals.d.ts +58 -0
  38. package/dist/signals.js +118 -0
  39. package/dist/site-groups.d.ts +53 -0
  40. package/dist/site-groups.js +54 -0
  41. package/dist/support.d.ts +390 -0
  42. package/dist/support.js +148 -0
  43. package/dist/threads.d.ts +363 -0
  44. package/dist/threads.js +146 -0
  45. package/dist/translation.d.ts +125 -0
  46. package/dist/translation.js +15 -0
  47. package/dist/user-state.d.ts +132 -0
  48. package/dist/user-state.js +92 -0
  49. package/package.json +64 -4
  50. package/dist/index.d.ts +0 -6
  51. package/dist/index.js +0 -6
@@ -11,7 +11,12 @@
11
11
  * are absent rather than throwing: an Add-on that reaches for one it did not declare finds `undefined`
12
12
  * at the point where it made the mistake.
13
13
  */
14
- import type { PhiServerDataCapabilityV1 } from "./queries.js";
14
+ import type { PhisAssetsCapabilityV1 } from "./assets.js";
15
+ import type { PhisAddonEventId } from "./core.js";
16
+ import type { PhisDataCapabilityV1 } from "./queries.js";
17
+ import type { PhisResourceLinksCapabilityV1 } from "./resource-links.js";
18
+ import type { PhisSupportCapabilityV1 } from "./support.js";
19
+ import type { PhisThreadsCapabilityV1 } from "./threads.js";
15
20
  /**
16
21
  * The cumulative group levels, by name.
17
22
  *
@@ -19,22 +24,22 @@ import type { PhiServerDataCapabilityV1 } from "./queries.js";
19
24
  * to do arithmetic on them, and a value that never leaves Core is one an Add-on cannot come to depend
20
25
  * on. Each level does everything the one before it does and one thing more.
21
26
  */
22
- export declare const PHI_SERVER_GROUP_LEVELS: readonly ["member", "author", "editor", "manager"];
23
- export type PhiServerGroupLevel = (typeof PHI_SERVER_GROUP_LEVELS)[number];
27
+ export declare const PHIS_GROUP_LEVELS: readonly ["member", "author", "editor", "manager"];
28
+ export type PhisGroupLevel = (typeof PHIS_GROUP_LEVELS)[number];
24
29
  /**
25
30
  * What an actor wants to do to one row.
26
31
  *
27
32
  * Creating is absent on purpose. There is no row to ask about yet, and whether an Add-on may write at
28
33
  * all is settled before its handler runs.
29
34
  */
30
- export type PhiServerRowIntent = "read" | "update" | "delete";
35
+ export type PhisRowIntent = "read" | "update" | "delete";
31
36
  /**
32
37
  * A Site capability an Add-on may ask after, named symbolically.
33
38
  *
34
39
  * An Add-on never states a role flag. Which capabilities are askable is then a decision, and Core is
35
40
  * free to change what a role means without an installed Add-on holding a stale bitmask.
36
41
  */
37
- export declare const PHI_SERVER_SITE_ACCESS: {
42
+ export declare const PHIS_SITE_ACCESS: {
38
43
  readonly siteAdmin: "site-admin";
39
44
  readonly developerTools: "developer-tools";
40
45
  readonly structureAuthoring: "structure-authoring";
@@ -44,7 +49,7 @@ export declare const PHI_SERVER_SITE_ACCESS: {
44
49
  readonly support: "support";
45
50
  readonly accounting: "accounting";
46
51
  };
47
- export type PhiServerSiteAccess = (typeof PHI_SERVER_SITE_ACCESS)[keyof typeof PHI_SERVER_SITE_ACCESS];
52
+ export type PhisSiteAccess = (typeof PHIS_SITE_ACCESS)[keyof typeof PHIS_SITE_ACCESS];
48
53
  /**
49
54
  * The authorization capability: `@phis/server/authorization:v1`.
50
55
  *
@@ -60,7 +65,7 @@ export type PhiServerSiteAccess = (typeof PHI_SERVER_SITE_ACCESS)[keyof typeof P
60
65
  * A table that is neither owner- nor group-scoped has nothing for this to decide and always answers
61
66
  * false: absence of a scope is not absence of a restriction.
62
67
  */
63
- export type PhiServerAuthorizationCapabilityV1 = {
68
+ export type PhisAuthorizationCapabilityV1 = {
64
69
  /**
65
70
  * Whether the acting user may do this to one row of one of this Add-on's own tables.
66
71
  *
@@ -72,12 +77,12 @@ export type PhiServerAuthorizationCapabilityV1 = {
72
77
  mayActOnRow(input: {
73
78
  table: string;
74
79
  id: number | string;
75
- intent: PhiServerRowIntent;
80
+ intent: PhisRowIntent;
76
81
  }): Promise<boolean>;
77
82
  /** The level the acting user effectively holds in one group, or null when they are not in it. */
78
- groupLevel(groupId: number): Promise<PhiServerGroupLevel | null>;
83
+ groupLevel(groupId: number): Promise<PhisGroupLevel | null>;
79
84
  /** Whether the acting user holds one Site capability, with the Core Admin override applied. */
80
- hasSiteAccess(access: PhiServerSiteAccess): Promise<boolean>;
85
+ hasSiteAccess(access: PhisSiteAccess): Promise<boolean>;
81
86
  };
82
87
  /**
83
88
  * The capabilities a handler may find on its context, keyed as the Core catalog keys them.
@@ -85,14 +90,18 @@ export type PhiServerAuthorizationCapabilityV1 = {
85
90
  * Optional throughout: presence follows the manifest, and a capability this release does not offer
86
91
  * stops the Add-on at the dispatcher rather than arriving as a broken object.
87
92
  */
88
- export type PhiServerAddonCapabilities = {
89
- authorization?: PhiServerAuthorizationCapabilityV1;
90
- data?: PhiServerDataCapabilityV1;
91
- storage?: PhiServerStorageCapabilityV1;
92
- secrets?: PhiServerSecretsCapabilityV1;
93
- groups?: PhiServerGroupsCapabilityV1;
94
- settings?: PhiServerSettingsCapabilityV1;
95
- roles?: PhiServerRolesCapabilityV1;
93
+ export type PhisAddonCapabilities = {
94
+ authorization?: PhisAuthorizationCapabilityV1;
95
+ data?: PhisDataCapabilityV1;
96
+ storage?: PhisStorageCapabilityV1;
97
+ assets?: PhisAssetsCapabilityV1;
98
+ secrets?: PhisSecretsCapabilityV1;
99
+ groups?: PhisGroupsCapabilityV1;
100
+ threads?: PhisThreadsCapabilityV1;
101
+ resourceLinks?: PhisResourceLinksCapabilityV1;
102
+ support?: PhisSupportCapabilityV1;
103
+ settings?: PhisSettingsCapabilityV1;
104
+ roles?: PhisRolesCapabilityV1;
96
105
  };
97
106
  /**
98
107
  * The roles capability: `@phis/server/roles:v1`.
@@ -105,7 +114,7 @@ export type PhiServerAddonCapabilities = {
105
114
  * handed over, the same way a stored setting nobody declared is not -- so an Add-on never sees a role it
106
115
  * does not know, whatever has accumulated across versions.
107
116
  */
108
- export type PhiServerRolesCapabilityV1 = {
117
+ export type PhisRolesCapabilityV1 = {
109
118
  /** Whether the acting user holds this role here. False when nobody is acting. */
110
119
  has(role: string): Promise<boolean>;
111
120
  /** Every declared role the acting user holds, in declaration order. */
@@ -124,18 +133,39 @@ export type PhiServerRolesCapabilityV1 = {
124
133
  * Media Space with its folders, quotas and delivery policies. This is for what an Add-on keeps for
125
134
  * itself: a release artifact, a generated preview, an export waiting to be fetched.
126
135
  */
127
- export type PhiServerStorageCapabilityV1 = {
136
+ export type PhisStorageCapabilityV1 = {
137
+ /**
138
+ * Both directions are streams, because two of the three things this store is for -- a release
139
+ * artifact and an export waiting to be fetched -- are the things that grow. A buffer would make the
140
+ * Site's largest allowed object the memory one call costs, on a path that only moves bytes.
141
+ *
142
+ * `byteSize` is declared rather than measured. The store's ceiling is booked before a byte is
143
+ * written, so that a write arriving at the same time as another cannot find room that the other
144
+ * already took, and nothing can be booked that has not been stated. Core counts while it writes and
145
+ * refuses a body that turns out not to be the size it claimed, so the declaration is a promise the
146
+ * Add-on is held to rather than one it is trusted on.
147
+ *
148
+ * An Add-on that already holds its bytes wraps them in a one-chunk `ReadableStream` and states
149
+ * `bytes.byteLength`.
150
+ */
128
151
  put(input: {
129
152
  key: string;
130
- body: Uint8Array;
153
+ body: ReadableStream<Uint8Array>;
154
+ byteSize: number;
131
155
  contentType: string;
132
156
  }): Promise<{
133
157
  key: string;
134
158
  byteSize: number;
135
159
  }>;
136
- /** Null rather than throwing for an object that is not there. */
160
+ /**
161
+ * Null rather than throwing for an object that is not there.
162
+ *
163
+ * The body arrives as it is read. `byteSize` comes with it so a caller that does need every byte can
164
+ * decide before it starts collecting them.
165
+ */
137
166
  get(key: string): Promise<{
138
- body: Uint8Array;
167
+ body: ReadableStream<Uint8Array>;
168
+ byteSize: number;
139
169
  contentType: string;
140
170
  } | null>;
141
171
  head(key: string): Promise<{
@@ -147,6 +177,23 @@ export type PhiServerStorageCapabilityV1 = {
147
177
  remove(key: string): Promise<boolean>;
148
178
  /** The Add-on's own keys under a prefix, relative as they were given. */
149
179
  list(prefix?: string): Promise<string[]>;
180
+ /**
181
+ * What this Add-on holds on this Site, and what it is allowed to hold.
182
+ *
183
+ * The ceilings are the operator's and are enforced by Core: a `put` past one is refused whether or
184
+ * not anybody read this first. It is here so an Add-on can say *why* -- a refusal an interface can
185
+ * explain before it happens beats one that arrives as an error, and a vendor who can see they are at
186
+ * their limit does not file a bug about it.
187
+ *
188
+ * `null` means no ceiling. Both counts are kept as running totals rather than counted on demand,
189
+ * because counting would mean listing the store -- the unbounded read the ceiling exists to prevent.
190
+ */
191
+ usage(): Promise<{
192
+ bytes: number;
193
+ objects: number;
194
+ quotaBytes: number | null;
195
+ quotaObjects: number | null;
196
+ }>;
150
197
  };
151
198
  /**
152
199
  * The secrets capability: `@phis/server/secrets:v1`.
@@ -158,7 +205,7 @@ export type PhiServerStorageCapabilityV1 = {
158
205
  * There is no writer. An operator sets a secret with `phis secret set`; a package that could write its
159
206
  * own would be a package that could plant one.
160
207
  */
161
- export type PhiServerSecretsCapabilityV1 = {
208
+ export type PhisSecretsCapabilityV1 = {
162
209
  /** The value, or null when the operator has not configured it. */
163
210
  read(name: string): Promise<string | null>;
164
211
  };
@@ -168,7 +215,7 @@ export type PhiServerSecretsCapabilityV1 = {
168
215
  * Deliberately the same projection one group member gets of another: a display name, a company only
169
216
  * where the group discloses it, and never an address. An Add-on renders "listed by" and nothing more.
170
217
  */
171
- export type PhiServerUserProjection = {
218
+ export type PhisUserProjection = {
172
219
  userId: number;
173
220
  displayName: string | null;
174
221
  companyName: string | null;
@@ -182,22 +229,87 @@ export type PhiServerUserProjection = {
182
229
  * its actor could already see in them. It cannot become a directory of a Site's users, because there is
183
230
  * no question here that reaches past the actor's own membership.
184
231
  */
185
- export type PhiServerGroupsCapabilityV1 = {
186
- /** The groups the acting user belongs to on this Site. */
232
+ export type PhisGroupsCapabilityV1 = {
233
+ /**
234
+ * The groups this Add-on declared, on this Site, with the ids Core gave them.
235
+ *
236
+ * The one answer here that is not about the acting user, and it does not have to be: these are the
237
+ * Add-on's own groups, created because its manifest asked for them. It needs the id to write a
238
+ * group-scoped row, and there is no other way to learn one -- a key is what the manifest states, an
239
+ * id is what the database assigns. An unauthenticated hook may ask, because the answer says nothing
240
+ * about anybody.
241
+ */
242
+ declared(): Promise<Array<{
243
+ id: number;
244
+ key: string;
245
+ name: string;
246
+ }>>;
247
+ /**
248
+ * The groups the acting user belongs to on this Site, and how far into each one they stand.
249
+ *
250
+ * The level is here because an Add-on that administers its own groups has to be able to ask "is this
251
+ * person a Manager of *this* group" -- Core's own check is per Add-on and cannot answer it. Without
252
+ * it, an Add-on whose policy admits its vendors would let any of them into any of its groups.
253
+ */
187
254
  myGroups(): Promise<Array<{
188
255
  id: number;
189
256
  key: string;
190
257
  name: string;
258
+ level: PhisGroupLevel;
191
259
  }>>;
192
260
  /** The members of one group the acting user is in, projected as a member sees them. */
193
- members(groupId: number): Promise<PhiServerUserProjection[]>;
261
+ members(groupId: number): Promise<PhisUserProjection[]>;
194
262
  /**
195
263
  * One person, if the acting user shares a group with them.
196
264
  *
197
265
  * Null otherwise, and null is the whole point: it is what stops a row's owner id from becoming a
198
266
  * lookup into everybody.
199
267
  */
200
- user(userId: number): Promise<PhiServerUserProjection | null>;
268
+ user(userId: number): Promise<PhisUserProjection | null>;
269
+ /**
270
+ * Makes a group of this Add-on's own, or hands back the one that is already there.
271
+ *
272
+ * Only where the manifest declares a `groupWriter`, and only for an acting user its policy admits.
273
+ * Asking twice is the way to learn an id: the key is the Add-on's to choose and the id is the
274
+ * database's to assign, so the call is an upsert rather than a create that fails the second time.
275
+ *
276
+ * The group carries this Add-on as its provider, which is what every write below checks. Core already
277
+ * filters group claims against the available providers, so one of these stops granting anything the
278
+ * moment the Add-on is gone -- and comes back with it, members included.
279
+ */
280
+ create(input: {
281
+ key: string;
282
+ name: string;
283
+ }): Promise<{
284
+ id: number;
285
+ key: string;
286
+ name: string;
287
+ }>;
288
+ /**
289
+ * Puts somebody in one of this Add-on's groups, at one level.
290
+ *
291
+ * Refused for a group this Add-on does not own, and for somebody who is not a member of the Site --
292
+ * a row owned by an account that cannot sign in here is a row nobody can reach.
293
+ */
294
+ addMember(input: {
295
+ groupId: number;
296
+ userId: number;
297
+ level: PhisGroupLevel;
298
+ }): Promise<boolean>;
299
+ /** Takes somebody out again. False where there was nothing to take out. */
300
+ removeMember(input: {
301
+ groupId: number;
302
+ userId: number;
303
+ }): Promise<boolean>;
304
+ /**
305
+ * Retires one of this Add-on's groups.
306
+ *
307
+ * Not deleting: the row and its key stay, because access policies are serialized against the
308
+ * provider-qualified key and releasing it would let a later group of the same name inherit them.
309
+ */
310
+ retire(input: {
311
+ groupId: number;
312
+ }): Promise<boolean>;
201
313
  };
202
314
  /**
203
315
  * The settings capability: `@phis/server/settings:v1`.
@@ -212,7 +324,7 @@ export type PhiServerGroupsCapabilityV1 = {
212
324
  * A setting the operator has not set answers with the declared default, so an Add-on has one shape to
213
325
  * handle rather than two, and `null` means genuinely unset with no default to fall back to.
214
326
  */
215
- export type PhiServerSettingsCapabilityV1 = {
327
+ export type PhisSettingsCapabilityV1 = {
216
328
  read(name: string): Promise<string | number | boolean | null>;
217
329
  all(): Promise<Readonly<Record<string, string | number | boolean | null>>>;
218
330
  };
@@ -224,7 +336,7 @@ export type PhiServerSettingsCapabilityV1 = {
224
336
  * is reachable only from `phis addon job run` and never from a route -- an Add-on that could start its
225
337
  * own job from a request would have built itself a way around the ladder.
226
338
  */
227
- export type PhiServerAddonJobContext = {
339
+ export type PhisAddonJobContext = {
228
340
  addonId: string;
229
341
  jobId: string;
230
342
  /** This run, for correlating what the job logs with what the operator started. */
@@ -234,7 +346,37 @@ export type PhiServerAddonJobContext = {
234
346
  id: number;
235
347
  key: string;
236
348
  };
237
- capabilities: PhiServerAddonCapabilities;
349
+ capabilities: PhisAddonCapabilities;
350
+ signal: AbortSignal;
351
+ };
352
+ /**
353
+ * One event Core hands an Add-on after the fact.
354
+ *
355
+ * A handler runs with no actor -- nobody called it, something happened -- and reaches exactly what the
356
+ * link that caused the delivery reaches. It is the counterpart of a hook: a hook is the outside telling
357
+ * this Site something, an event is this Site telling an Add-on something, and both run as the Add-on
358
+ * rather than as a person.
359
+ *
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.
363
+ */
364
+ export type PhisAddonEventContext = {
365
+ addonId: string;
366
+ event: PhisAddonEventId;
367
+ /** This delivery, stable across retries, so a handler can recognise one it already settled. */
368
+ deliveryId: string;
369
+ site: {
370
+ id: number;
371
+ key: string;
372
+ };
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
+ capabilities: PhisAddonCapabilities;
238
379
  signal: AbortSignal;
239
380
  };
240
- export type PhiServerAddonJobHandler = (context: PhiServerAddonJobContext) => Promise<void> | void;
381
+ export type PhisAddonEventHandler = (context: PhisAddonEventContext) => Promise<void> | void;
382
+ export type PhisAddonJobHandler = (context: PhisAddonJobContext) => Promise<void> | void;
@@ -18,14 +18,14 @@
18
18
  * to do arithmetic on them, and a value that never leaves Core is one an Add-on cannot come to depend
19
19
  * on. Each level does everything the one before it does and one thing more.
20
20
  */
21
- export const PHI_SERVER_GROUP_LEVELS = ["member", "author", "editor", "manager"];
21
+ export const PHIS_GROUP_LEVELS = ["member", "author", "editor", "manager"];
22
22
  /**
23
23
  * A Site capability an Add-on may ask after, named symbolically.
24
24
  *
25
25
  * An Add-on never states a role flag. Which capabilities are askable is then a decision, and Core is
26
26
  * free to change what a role means without an installed Add-on holding a stale bitmask.
27
27
  */
28
- export const PHI_SERVER_SITE_ACCESS = {
28
+ export const PHIS_SITE_ACCESS = {
29
29
  siteAdmin: "site-admin",
30
30
  developerTools: "developer-tools",
31
31
  structureAuthoring: "structure-authoring",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What a Module is for, as an operator reads it on the Modules page.
3
+ *
4
+ * An Add-on declares it, per Module and not per package, because a package may ship a shop and a
5
+ * report and neither answer would be true of the other. The site UI groups the Modules page by it, and
6
+ * a Module may read another's category and show it.
7
+ *
8
+ * Here rather than in the site UI package because of the reader that cannot follow it there: a
9
+ * marketplace is a phi-server Add-on, it compiles against this package and nothing else, and it is
10
+ * meant to let a shopper filter on category. That filter is not built yet -- the offering's category is
11
+ * still free text -- but it is the reason this list is a contract and not a UI detail.
12
+ *
13
+ * Deliberately not in `/addon`. That subpath is the frozen ABI a third party compiles against, and this
14
+ * list grows as the product does: an eighth category would lift the package everybody builds against,
15
+ * for a change that concerns none of them. phi-server never asks the membership question either -- a
16
+ * package manifest carries its Modules' categories as plain strings, Core checks the shape, and the
17
+ * stricter question is asked where a registry is actually held. That is the same split `/signals`
18
+ * makes, for the same reason.
19
+ *
20
+ * A closed list rather than free text, because free text produced a bijection: every one of the
21
+ * eighteen built-in Modules answered the field with its own name, so the column repeated the title it
22
+ * stood next to and no Add-on could have guessed a value another Module already used. An Add-on that
23
+ * fits nothing here says `other` rather than inventing a nineteenth.
24
+ *
25
+ * Several values claim no built-in at all, and that is deliberate. A vocabulary that only describes
26
+ * what already exists is right for an internal registry and wrong for a market: the shelf has to be
27
+ * there before the goods, or the first arrival is filed under `other` and nobody ever finds it. So
28
+ * this list is read as a question about what a Module is for, not as an index of what we happen to
29
+ * have built.
30
+ *
31
+ * An Area's base Module is `foundation` whatever the Area does, because it is not a Module the
32
+ * operator chose -- it is the ground the Area stands on, and the row for it is the locked one. So is a
33
+ * Module no site can sensibly do without: locales, logs, revisions, the theme, the user table. Those
34
+ * are switchable and the base Modules are not, but the question the category answers is what a Module
35
+ * is for, not whether it can be turned off -- and the answer for all of them is the same: they are what
36
+ * a site is administered with, not something it was extended by.
37
+ *
38
+ * Distinct from the plugin categories the site UI keeps, which file a Widget or Layout into a drawer of
39
+ * the Builder's insert picker. That is a question about where a block is found, not about what a Module
40
+ * is, and it is answered by the UI alone.
41
+ */
42
+ export declare const PHI_RUNTIME_MODULE_CATEGORIES: readonly ["foundation", "workspace", "content", "media", "commerce", "identity", "communication", "events", "analytics", "integration", "operations", "other"];
43
+ export type PhiRuntimeModuleCategory = (typeof PHI_RUNTIME_MODULE_CATEGORIES)[number];
44
+ export declare function isPhiRuntimeModuleCategory(value: unknown): value is PhiRuntimeModuleCategory;
45
+ /**
46
+ * The category to file a Module under, for a reader that did not compile against this list.
47
+ *
48
+ * A separately shipped Add-on may name a category added after the site it is installed on was built.
49
+ * Refusing it would cost the operator the whole Modules page over one unknown word, so an unrecognised
50
+ * category reads as `other` -- which is what an Add-on that fits nothing is meant to say anyway.
51
+ *
52
+ * A Module built in the same tree never reaches this: its category is typed, so a typo is a compile
53
+ * error long before anything renders. This is for the values that arrive already compiled.
54
+ */
55
+ export declare function readPhiRuntimeModuleCategory(value: unknown): PhiRuntimeModuleCategory;
56
+ /** The package.json key a Module package declares itself under. */
57
+ export declare const PHIS_MODULE_PACKAGE_KEY: "phis";
58
+ /** One Module a package contributes, as its package.json states it. */
59
+ export type PhisModulePackageEntry = {
60
+ moduleId: string;
61
+ /**
62
+ * Shape, not membership. A category this build has never heard of is still a well-formed
63
+ * declaration; whether it is a category *we* offer is the question a party with the list asks, and
64
+ * asks at intake, where refusing costs the publisher a message rather than the operator a page.
65
+ */
66
+ category: string;
67
+ };
68
+ /**
69
+ * What a Module package says about itself, without being run.
70
+ *
71
+ * A Module is compiled UI code: React, the Site's bundler, four fixed entry points. Reading a category
72
+ * out of it means executing a stranger's package inside whatever process asked -- the `phis` CLI, or a
73
+ * marketplace taking a submission. Neither should, so the package states it instead, in the one file it
74
+ * already has and that a registry serves without anybody fetching a tarball.
75
+ *
76
+ * Not a second manifest. A package carries one product at one version: the Add-on manifest is what
77
+ * phi-server is handed when an artifact is installed, and this is what a catalogue reads about the half
78
+ * that never reaches phi-server at all. Same package, same version, different readers.
79
+ *
80
+ * Written by the author, like every other field in that file, and their responsibility to keep true to
81
+ * their own Modules. Nothing compares the two automatically: `phis module` can say whether the entries
82
+ * are there and well formed, but whether `events` is the right answer for that Module is a judgement
83
+ * only the author holds. A package that says nothing here, or says it wrongly, is refused by a
84
+ * marketplace rather than listed on a guess.
85
+ */
86
+ export type PhisModulePackageDeclaration = {
87
+ /**
88
+ * The language the Modules' own titles and descriptions are written in. Null means `en`.
89
+ *
90
+ * Per package rather than per Module: one package carries one product, written by one hand. A
91
+ * catalogue that translates has to know what it is translating from -- told the wrong source it does
92
+ * not fail, it produces fluent nonsense, which is the one kind of wrong nobody sees.
93
+ */
94
+ sourceLocale: string | null;
95
+ modules: PhisModulePackageEntry[];
96
+ };
97
+ /**
98
+ * Reads the declaration out of a parsed package.json.
99
+ *
100
+ * Null when the key is absent, which is a legitimate answer: a storage adapter is an Add-on and nothing
101
+ * else, and has no Module half to describe. Present but malformed throws, because that is an author's
102
+ * mistake and silence would file it next to the honest absence.
103
+ */
104
+ export declare function readPhisModulePackageDeclaration(packageJson: unknown): PhisModulePackageDeclaration | null;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What a Module is for, as an operator reads it on the Modules page.
3
+ *
4
+ * An Add-on declares it, per Module and not per package, because a package may ship a shop and a
5
+ * report and neither answer would be true of the other. The site UI groups the Modules page by it, and
6
+ * a Module may read another's category and show it.
7
+ *
8
+ * Here rather than in the site UI package because of the reader that cannot follow it there: a
9
+ * marketplace is a phi-server Add-on, it compiles against this package and nothing else, and it is
10
+ * meant to let a shopper filter on category. That filter is not built yet -- the offering's category is
11
+ * still free text -- but it is the reason this list is a contract and not a UI detail.
12
+ *
13
+ * Deliberately not in `/addon`. That subpath is the frozen ABI a third party compiles against, and this
14
+ * list grows as the product does: an eighth category would lift the package everybody builds against,
15
+ * for a change that concerns none of them. phi-server never asks the membership question either -- a
16
+ * package manifest carries its Modules' categories as plain strings, Core checks the shape, and the
17
+ * stricter question is asked where a registry is actually held. That is the same split `/signals`
18
+ * makes, for the same reason.
19
+ *
20
+ * A closed list rather than free text, because free text produced a bijection: every one of the
21
+ * eighteen built-in Modules answered the field with its own name, so the column repeated the title it
22
+ * stood next to and no Add-on could have guessed a value another Module already used. An Add-on that
23
+ * fits nothing here says `other` rather than inventing a nineteenth.
24
+ *
25
+ * Several values claim no built-in at all, and that is deliberate. A vocabulary that only describes
26
+ * what already exists is right for an internal registry and wrong for a market: the shelf has to be
27
+ * there before the goods, or the first arrival is filed under `other` and nobody ever finds it. So
28
+ * this list is read as a question about what a Module is for, not as an index of what we happen to
29
+ * have built.
30
+ *
31
+ * An Area's base Module is `foundation` whatever the Area does, because it is not a Module the
32
+ * operator chose -- it is the ground the Area stands on, and the row for it is the locked one. So is a
33
+ * Module no site can sensibly do without: locales, logs, revisions, the theme, the user table. Those
34
+ * are switchable and the base Modules are not, but the question the category answers is what a Module
35
+ * is for, not whether it can be turned off -- and the answer for all of them is the same: they are what
36
+ * a site is administered with, not something it was extended by.
37
+ *
38
+ * Distinct from the plugin categories the site UI keeps, which file a Widget or Layout into a drawer of
39
+ * the Builder's insert picker. That is a question about where a block is found, not about what a Module
40
+ * is, and it is answered by the UI alone.
41
+ */
42
+ export const PHI_RUNTIME_MODULE_CATEGORIES = [
43
+ "foundation",
44
+ "workspace",
45
+ "content",
46
+ "media",
47
+ "commerce",
48
+ "identity",
49
+ "communication",
50
+ "events",
51
+ "analytics",
52
+ "integration",
53
+ "operations",
54
+ "other",
55
+ ];
56
+ const PHI_RUNTIME_MODULE_CATEGORY_SET = new Set(PHI_RUNTIME_MODULE_CATEGORIES);
57
+ export function isPhiRuntimeModuleCategory(value) {
58
+ return typeof value === "string" && PHI_RUNTIME_MODULE_CATEGORY_SET.has(value);
59
+ }
60
+ /**
61
+ * The category to file a Module under, for a reader that did not compile against this list.
62
+ *
63
+ * A separately shipped Add-on may name a category added after the site it is installed on was built.
64
+ * Refusing it would cost the operator the whole Modules page over one unknown word, so an unrecognised
65
+ * category reads as `other` -- which is what an Add-on that fits nothing is meant to say anyway.
66
+ *
67
+ * A Module built in the same tree never reaches this: its category is typed, so a typo is a compile
68
+ * error long before anything renders. This is for the values that arrive already compiled.
69
+ */
70
+ export function readPhiRuntimeModuleCategory(value) {
71
+ return isPhiRuntimeModuleCategory(value) ? value : "other";
72
+ }
73
+ /** The package.json key a Module package declares itself under. */
74
+ export const PHIS_MODULE_PACKAGE_KEY = "phis";
75
+ /**
76
+ * Reads the declaration out of a parsed package.json.
77
+ *
78
+ * Null when the key is absent, which is a legitimate answer: a storage adapter is an Add-on and nothing
79
+ * else, and has no Module half to describe. Present but malformed throws, because that is an author's
80
+ * mistake and silence would file it next to the honest absence.
81
+ */
82
+ export function readPhisModulePackageDeclaration(packageJson) {
83
+ if (typeof packageJson !== "object" || packageJson === null) {
84
+ return null;
85
+ }
86
+ const declared = packageJson[PHIS_MODULE_PACKAGE_KEY];
87
+ if (declared === undefined) {
88
+ return null;
89
+ }
90
+ if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
91
+ throw new Error(`"${PHIS_MODULE_PACKAGE_KEY}" in package.json must be an object.`);
92
+ }
93
+ const block = declared;
94
+ const sourceLocale = block.sourceLocale;
95
+ if (sourceLocale !== undefined && (typeof sourceLocale !== "string" || !sourceLocale.trim())) {
96
+ throw new Error(`"${PHIS_MODULE_PACKAGE_KEY}.sourceLocale" must be a non-empty string when stated.`);
97
+ }
98
+ if (!Array.isArray(block.modules)) {
99
+ throw new Error(`"${PHIS_MODULE_PACKAGE_KEY}.modules" must be an array.`);
100
+ }
101
+ const modules = block.modules.map((entry, index) => {
102
+ const at = `${PHIS_MODULE_PACKAGE_KEY}.modules[${index}]`;
103
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
104
+ throw new Error(`"${at}" must be an object.`);
105
+ }
106
+ const { moduleId, category } = entry;
107
+ if (typeof moduleId !== "string" || !moduleId.trim()) {
108
+ throw new Error(`"${at}.moduleId" must be a non-empty string.`);
109
+ }
110
+ if (typeof category !== "string" || !category.trim()) {
111
+ throw new Error(`"${at}.category" must be a non-empty string.`);
112
+ }
113
+ return { moduleId, category };
114
+ });
115
+ return { sourceLocale: typeof sourceLocale === "string" ? sourceLocale : null, modules };
116
+ }