@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/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # @phis/contracts
2
2
 
3
- The agreements phi-server keeps with what surrounds it. Two of them, under two subpaths, because
4
- they hold between different parties and freeze at different moments.
3
+ The agreements phi-server keeps with what surrounds it. Each under its own subpath, because they hold
4
+ between different parties and freeze at different moments.
5
5
 
6
6
  ```
7
7
  @phis/contracts/addon what phi-server and a separately shipped Add-on promise each other
8
8
  @phis/contracts/access the authorization vocabulary phi-server and @phis/ui both evaluate
9
- @phis/contracts/signals the signal vocabulary the UI declares against and phi-server validates
9
+ @phis/contracts/signals the signal vocabulary and address grammar the UI declares in, phi-server validates
10
+ @phis/contracts/catalog the Module category an Add-on declares, the UI groups by, a market will filter on
11
+ @phis/contracts/cms the CMS node identity both sides derive, and must derive alike
10
12
  ```
11
13
 
12
14
  There is deliberately no root export. A package you can import from the top invites everything that
@@ -45,6 +47,24 @@ looked wrong on its own, which is why it went unnoticed.
45
47
  Each side keeps its own viewer type and passes a projection onto `PhiAccessSubject`, so neither
46
48
  package has to adopt the other's shape.
47
49
 
50
+ ## `/cms`
51
+
52
+ The encoding of a CMS node's identity: how a Preset node's id is derived from the Module, the preset and
53
+ the node key, and how a draft node's id is derived from the revision it was made in.
54
+
55
+ This one is not a shape check. It is an arithmetic, and it is here for the opposite of the usual reason:
56
+ not because the two sides ask the same question, but because they must compute the same answer. A Preset
57
+ id is derived rather than allocated, so the site UI can address a node before any row exists and
58
+ phi-server can check that stored wiring points at nodes that are really there.
59
+
60
+ It stood in two copies until 2026-09-07 -- 170 lines in phi-server, 260 in `@phis/ui`, with
61
+ `hashPresetIdentity` identical line for line. Nothing would have announced a divergence. A changed
62
+ constant on one side produces ids that are still well formed and simply name nothing: Pages stop
63
+ resolving, drafts point at nodes that are not there, and both copies look correct on their own.
64
+
65
+ The two conveniences built on top stay with `@phis/ui`: the Page-key convention and the map helper are
66
+ its own, and phi-server has no use for either.
67
+
48
68
  ## `/signals`
49
69
 
50
70
  The closed vocabularies a widget's wiring is written in -- scopes, actions, value types -- and the shape
@@ -55,31 +75,102 @@ the way in. Two lists, one meaning; and when they were two lists they drifted. `
55
75
  `date`, `time` and `length`; phi-server had not. A length control's change signal -- from a widget the
56
76
  same release shipped -- was refused on save with "valueType is invalid".
57
77
 
78
+ The address families are here for a sharper reason than the vocabularies. A wiring is stored by the
79
+ Builder, checked by phi-server on the way in, and delivered by the site UI at runtime, so all three
80
+ parse the same string -- and two of them parsed it from separate copies until 2026-09-07. The server's
81
+ said so in its own comment: it "mirrors the grammar in @phis/ui", because it could not import it. With
82
+ them the rule that says which receivers a scope admits, which had been written out on both sides in
83
+ different words, and the one controller address the Site scope privileges, which is only worth
84
+ anything if both sides name the same string.
85
+
58
86
  Which schemas exist stays with the UI. phi-server checks that a JSON signal names *a* schema, never
59
87
  which one, and `isPhiSignalValueSchemaShape` is exactly that much: shape, not membership. The UI asks
60
88
  the stricter question, because it holds the module registry; phi-server must not, or it would refuse
61
89
  every third party's schema for never having heard of it.
62
90
 
91
+ ## `/catalog`
92
+
93
+ What a Module is for, as one closed list: `foundation`, `workspace`, `content`, `commerce`, `people`,
94
+ `operations`, `other`.
95
+
96
+ An Add-on declares it per Module -- per Module, because a package may ship a shop and a report and
97
+ neither answer would be true of the other. The site UI groups the Modules page by it, and a Module may
98
+ read another's category and show it.
99
+
100
+ It is here rather than in the site UI package because of the reader that cannot follow it there. A
101
+ marketplace is a phi-server Add-on: it compiles against this package and nothing else, never against
102
+ React, and it is meant to let a shopper filter on category. That filter is not built yet -- an
103
+ offering's category is still free text -- but it is why this list is a contract.
104
+
105
+ It is not in `/addon` on purpose. `/addon` is the frozen ABI a third party compiles against, and this
106
+ list grows as the product does -- an eighth category would lift the package everybody builds against,
107
+ for a change that concerns none of them. Nor does phi-server ask the membership question: a package
108
+ manifest carries its Modules' categories as plain strings, Core checks the shape, and the strict
109
+ question is asked where a registry is actually held. The same split as `/signals`.
110
+
111
+ The labels an operator reads are not here. Those are label-set keys, translated per site, and they
112
+ belong to the UI; this subpath holds the identifiers the parties spell the same way.
113
+
114
+ Beside the vocabulary sits the shape a Module package declares itself in, under `phis` in its
115
+ package.json: which Modules it carries, each one's category, and the language their titles and
116
+ descriptions are written in. A Module is compiled UI code, so reading that out of it means executing a
117
+ stranger's package -- inside the `phis` CLI, or inside a marketplace taking a submission. Neither
118
+ should, and a registry serves package.json without anybody fetching a tarball.
119
+
120
+ It is not a second manifest. A package carries one product at one version: the Add-on manifest is what
121
+ phi-server is handed when an artifact is installed, this is what a catalogue reads about the half that
122
+ never reaches phi-server. Same package, same version, different readers.
123
+
124
+ The author writes it, as they write every other field in that file. `phis module` can check that the
125
+ entries are present and well formed; whether `events` is the right answer for a given Module is a
126
+ judgement only the author holds. A package that declares nothing, or declares it wrongly, is refused at
127
+ intake rather than listed on a guess.
128
+
129
+ ## `/media`
130
+
131
+ The words and numbers a Site and phis both have to read the same way about an Asset: the Space kind's
132
+ wire name, the media kinds, the Folder flags, the presentation flags, the image variant keys, and the
133
+ two closed vocabularies that decide deliverability -- `lifecycle_status` and `delivery_policy`.
134
+
135
+ It is here because the values themselves travel and are then judged on the other side. The site UI
136
+ sends a flag mask as a list filter and an array of flag values when metadata is saved, and phis answers
137
+ the filter with `presentation_flags & mask <> 0` and refuses a value it does not know. An Asset's
138
+ payload carries `lifecycleStatus` and `deliveryPolicy` as the numbers they are in the row, and the UI
139
+ decides from them whether `next/image` may be pointed at the original.
140
+
141
+ What made it worth moving is what the copies did while nobody looked. Both sides kept their own table,
142
+ and phis still carried `Private`, `Archived` and `Restricted` long after lifecycle and delivery had
143
+ become columns of their own -- so the set of values its metadata route accepted was not the set the UI
144
+ offered. Nothing failed loudly; a drifting copy of a number never does.
145
+
146
+ The resolvers come with their vocabulary. `normalizePhiMediaKind` and
147
+ `resolvePhiMediaKindFromContentType` are total functions onto the kind list, and a content type that
148
+ lands on `document` in the browser and on `other` on the server files an upload as one thing and lists
149
+ it as another.
150
+
151
+ What stays outside: how a delivery URL is built, which is each side's own business, and phis's
152
+ `smallint` Space kind, which never reaches a Site -- the wire carries the name instead.
153
+
63
154
  ## What `/addon` covers
64
155
 
65
- - **Manifest** — `PhiServerAddonManifestV1`: identity, version, required core capabilities,
156
+ - **Manifest** — `PhisAddonManifestV1`: identity, version, required core capabilities,
66
157
  the routes an Add-on claims, the services it provides.
67
- - **Runtime** — `PhiServerAddonRuntimeV1`, `PhiServerAddonHandler`,
68
- `PhiServerAddonRequestContext`: the shape the artifact's default export must have.
69
- - **Schema** — `PhiServerAddonSchemaDescriptor` and its column, index, and constraint
158
+ - **Runtime** — `PhisAddonRuntimeV1`, `PhisAddonHandler`,
159
+ `PhisAddonRequestContext`: the shape the artifact's default export must have.
160
+ - **Schema** — `PhisAddonSchemaDescriptor` and its column, index, and constraint
70
161
  descriptors. Tables are declared, not migrated by hand; phi-server applies them.
71
162
  - **Service kinds** — the interfaces an Add-on may provide or consume, currently the media
72
163
  storage adapter (`PhisMediaStorageAdapter`, upload plans, object I/O).
73
- - **What Core offers** — `PHI_SERVER_CORE_CAPABILITIES`, the capability ids an Add-on may
74
- require; `PHI_SERVER_SERVICE_KINDS`, the service kinds Core owns; and
75
- `PHI_SERVER_SERVICE_INTERFACE_DIGESTS`, the digest of each kind's current interface. Declare
164
+ - **What Core offers** — `PHIS_CORE_CAPABILITIES`, the capability ids an Add-on may
165
+ require; `PHIS_SERVICE_KINDS`, the service kinds Core owns; and
166
+ `PHIS_SERVICE_INTERFACE_DIGESTS`, the digest of each kind's current interface. Declare
76
167
  against these names rather than spelling them out: Core refuses an Add-on whose digest is not
77
168
  the one this release offers, and a literal cannot be checked before you ship it.
78
169
 
79
170
  This list is a promise, not a plan. A capability appears here when Core delivers it, never
80
171
  before.
81
172
 
82
- `PHI_SERVER_ADDON_ABI_VERSION` is the single number a server checks an Add-on against.
173
+ `PHIS_ADDON_ABI_VERSION` is the single number a server checks an Add-on against.
83
174
 
84
175
  ## Admission rule
85
176
 
@@ -92,6 +183,15 @@ Into `/access`: only what **phi-server and `@phis/ui` must both evaluate**, beca
92
183
  in its own process. Not what both happen to use — a date formatter used on both sides is shared
93
184
  convenience, not a contract, and belongs to neither.
94
185
 
186
+ Into `/catalog`: only the vocabularies an Add-on **declares a Module in** and another party then reads
187
+ back — closed lists, identifiers only, never the words an operator sees. A vocabulary only the UI ever
188
+ reads stays with the UI.
189
+
190
+ Into `/media`: only what **crosses the wire as a value and is judged on the other side** — a number
191
+ stored in a column that a request filters on, a name a payload carries. Not a helper both sides happen
192
+ to have written twice: a copy is only a contract when disagreeing about it changes what a request
193
+ returns.
194
+
95
195
  The test for either is the same: name the two parties and the sentence they are promising each
96
196
  other. If that sentence cannot be written down, it does not go in here.
97
197
 
package/dist/access.d.ts CHANGED
@@ -30,6 +30,56 @@ export declare const PhiBaseRole: {
30
30
  readonly Supporter: number;
31
31
  readonly Accountant: number;
32
32
  };
33
+ /**
34
+ * What a stored email address says about itself, as bit flags.
35
+ *
36
+ * `user_emails` holds several addresses per account and marks one of them primary. That mark is a flag
37
+ * rather than its own column because the schema keeps every other such answer in `flags`, and a lone
38
+ * boolean is the one place a reader has to remember an exception.
39
+ *
40
+ * The position is fixed from the first row, and here more strictly than elsewhere: the unique index that
41
+ * enforces "one primary address per account" is partial on this very bit. Move the bit and the index
42
+ * silently guards a different question.
43
+ */
44
+ export declare const PhiUserEmailFlag: {
45
+ readonly Primary: number;
46
+ };
47
+ /**
48
+ * Where a stored email address came from.
49
+ *
50
+ * The question this answers is asked after the fact and cannot be reconstructed: did this person type
51
+ * their address themselves, did an administrator set it, or did an identity provider hand it over? An
52
+ * account that can be created from a command line makes that difference worth keeping.
53
+ *
54
+ * Numbers rather than free text, because the set is Core's and a string column accepts every typo as a
55
+ * new source. Below 20 are Core's own origins; from 20 upward are named providers, and they are named
56
+ * rather than left to the Add-on that connects them: the Add-on may be replaced, Google will not be.
57
+ * A provider without a number is `Provider`, and which one it was stands in `user_auth_identities`.
58
+ *
59
+ * There is deliberately no `unknown`. Every writer knows why it writes, and a zero default would let
60
+ * the one that does not pass unnoticed.
61
+ */
62
+ export declare const PhiUserEmailSource: {
63
+ readonly Operator: 1;
64
+ readonly Admin: 2;
65
+ readonly Registration: 3;
66
+ readonly Self: 4;
67
+ readonly Directory: 5;
68
+ readonly Provider: 9;
69
+ readonly Google: 20;
70
+ readonly Apple: 21;
71
+ readonly Microsoft: 22;
72
+ readonly GitHub: 23;
73
+ };
74
+ export type PhiUserEmailSourceValue = (typeof PhiUserEmailSource)[keyof typeof PhiUserEmailSource];
75
+ export declare function isPhiUserEmailSource(value: unknown): value is PhiUserEmailSourceValue;
76
+ /**
77
+ * Which source a login provider's key stands for.
78
+ *
79
+ * The mapping lives here because the numbers do: it is this file that says 20 means Google. A key with
80
+ * no number of its own is `Provider` rather than a guess, and `user_auth_identities` keeps the detail.
81
+ */
82
+ export declare function phiUserEmailSourceForProvider(providerKey: string): PhiUserEmailSourceValue;
33
83
  export type PhiRoleProviderId = `@${string}/${string}`;
34
84
  export type PhiGroupProviderId = `@${string}/${string}`;
35
85
  export type PhiViewerRoleClaim = {
package/dist/access.js CHANGED
@@ -30,6 +30,71 @@ export const PhiBaseRole = {
30
30
  Supporter: 1 << 5,
31
31
  Accountant: 1 << 6,
32
32
  };
33
+ /**
34
+ * What a stored email address says about itself, as bit flags.
35
+ *
36
+ * `user_emails` holds several addresses per account and marks one of them primary. That mark is a flag
37
+ * rather than its own column because the schema keeps every other such answer in `flags`, and a lone
38
+ * boolean is the one place a reader has to remember an exception.
39
+ *
40
+ * The position is fixed from the first row, and here more strictly than elsewhere: the unique index that
41
+ * enforces "one primary address per account" is partial on this very bit. Move the bit and the index
42
+ * silently guards a different question.
43
+ */
44
+ export const PhiUserEmailFlag = {
45
+ Primary: 1 << 0,
46
+ };
47
+ /**
48
+ * Where a stored email address came from.
49
+ *
50
+ * The question this answers is asked after the fact and cannot be reconstructed: did this person type
51
+ * their address themselves, did an administrator set it, or did an identity provider hand it over? An
52
+ * account that can be created from a command line makes that difference worth keeping.
53
+ *
54
+ * Numbers rather than free text, because the set is Core's and a string column accepts every typo as a
55
+ * new source. Below 20 are Core's own origins; from 20 upward are named providers, and they are named
56
+ * rather than left to the Add-on that connects them: the Add-on may be replaced, Google will not be.
57
+ * A provider without a number is `Provider`, and which one it was stands in `user_auth_identities`.
58
+ *
59
+ * There is deliberately no `unknown`. Every writer knows why it writes, and a zero default would let
60
+ * the one that does not pass unnoticed.
61
+ */
62
+ export const PhiUserEmailSource = {
63
+ Operator: 1,
64
+ Admin: 2,
65
+ Registration: 3,
66
+ Self: 4,
67
+ Directory: 5,
68
+ Provider: 9,
69
+ Google: 20,
70
+ Apple: 21,
71
+ Microsoft: 22,
72
+ GitHub: 23,
73
+ };
74
+ export function isPhiUserEmailSource(value) {
75
+ return typeof value === "number"
76
+ && Object.values(PhiUserEmailSource).includes(value);
77
+ }
78
+ /**
79
+ * Which source a login provider's key stands for.
80
+ *
81
+ * The mapping lives here because the numbers do: it is this file that says 20 means Google. A key with
82
+ * no number of its own is `Provider` rather than a guess, and `user_auth_identities` keeps the detail.
83
+ */
84
+ export function phiUserEmailSourceForProvider(providerKey) {
85
+ switch (providerKey.trim().toLowerCase()) {
86
+ case "google":
87
+ return PhiUserEmailSource.Google;
88
+ case "apple":
89
+ return PhiUserEmailSource.Apple;
90
+ case "microsoft":
91
+ return PhiUserEmailSource.Microsoft;
92
+ case "github":
93
+ return PhiUserEmailSource.GitHub;
94
+ default:
95
+ return PhiUserEmailSource.Provider;
96
+ }
97
+ }
33
98
  /**
34
99
  * Flags as they may be trusted.
35
100
  *
@@ -1,4 +1,5 @@
1
1
  export * from "./addon.js";
2
+ export * from "./assets.js";
2
3
  export * from "./capabilities.js";
3
4
  export * from "./core.js";
4
5
  export * from "./media-storage.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./addon.js";
2
+ export * from "./assets.js";
2
3
  export * from "./capabilities.js";
3
4
  export * from "./core.js";
4
5
  export * from "./media-storage.js";
package/dist/addon.d.ts CHANGED
@@ -11,35 +11,35 @@
11
11
  * no node builtins -- the Add-on states, and Core checks. If something here ever does more than hand
12
12
  * back a constant, it is in the wrong package.
13
13
  */
14
- import type { PhiServerAddonCapabilities, PhiServerAddonJobHandler, PhiServerSiteAccess } from "./capabilities.js";
15
- import type { PhiServerCoreCapabilityId, PhiServerServiceKind } from "./core.js";
16
- import type { PhiServerAddonQueryCatalog } from "./queries.js";
17
- import type { PhiServerAddonSchemaDescriptor } from "./schema.js";
18
- export declare const PHI_SERVER_ADDON_MANIFEST_VERSION: 1;
19
- export declare const PHI_SERVER_ADDON_ABI_VERSION: 1;
20
- export type PhiServerAddonAuthPolicy = "internal" | "site-user" | "site-admin" | "operator";
21
- export type PhiServerAddonHttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
22
- export type PhiServerAddonApiRouteDescriptor = {
14
+ import type { PhisAddonCapabilities, PhisAddonJobHandler, PhisSiteAccess } from "./capabilities.js";
15
+ import type { PhisCoreCapabilityId, PhisServiceKind } from "./core.js";
16
+ import type { PhisAddonQueryCatalog } from "./queries.js";
17
+ import type { PhisAddonSchemaDescriptor } from "./schema.js";
18
+ export declare const PHIS_ADDON_MANIFEST_VERSION: 1;
19
+ export declare const PHIS_ADDON_ABI_VERSION: 1;
20
+ export type PhisAddonAuthPolicy = "internal" | "site-user" | "site-admin" | "operator";
21
+ export type PhisAddonHttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
22
+ export type PhisAddonApiRouteDescriptor = {
23
23
  id: string;
24
- method: PhiServerAddonHttpMethod;
24
+ method: PhisAddonHttpMethod;
25
25
  path: string;
26
26
  handler: string;
27
- auth: PhiServerAddonAuthPolicy;
27
+ auth: PhisAddonAuthPolicy;
28
28
  siteScoped: boolean;
29
29
  bodyLimitBytes?: number;
30
30
  rateLimitClass?: string;
31
31
  timeoutMs?: number;
32
32
  };
33
- export type PhiServerAddonHookDescriptor = {
33
+ export type PhisAddonHookDescriptor = {
34
34
  id: string;
35
- method: PhiServerAddonHttpMethod;
35
+ method: PhisAddonHttpMethod;
36
36
  path: string;
37
37
  handler: string;
38
38
  bodyLimitBytes: number;
39
39
  rateLimitClass: string;
40
40
  timeoutMs: number;
41
41
  /**
42
- * Whether this hook is told which Site it was called for, through `x-phi-site-key`.
42
+ * Whether this hook is told which Site it was called for, through `x-phis-site-key`.
43
43
  *
44
44
  * A hook is unauthenticated by construction -- that is what makes it reachable by a payment provider
45
45
  * or by somebody else's Core. Without a Site it also has no capabilities at all, because every one of
@@ -50,7 +50,7 @@ export type PhiServerAddonHookDescriptor = {
50
50
  */
51
51
  siteScoped?: boolean;
52
52
  };
53
- export type PhiServerAddonCapabilityDescriptor = {
53
+ export type PhisAddonCapabilityDescriptor = {
54
54
  id: string;
55
55
  interfaceDigest: string;
56
56
  };
@@ -62,14 +62,14 @@ export type PhiServerAddonCapabilityDescriptor = {
62
62
  * reaches the implementation without a dynamic import. The interface digest is checked against what
63
63
  * this release offers, so an Add-on built for an older interface is refused rather than called.
64
64
  */
65
- export type PhiServerAddonServiceProviderDescriptor = {
66
- serviceKind: PhiServerServiceKind;
65
+ export type PhisAddonServiceProviderDescriptor = {
66
+ serviceKind: PhisServiceKind;
67
67
  providerKey: string;
68
68
  interfaceDigest: string;
69
69
  factoryExport: string;
70
- requiredCoreCapabilities: PhiServerCoreCapabilityId[];
70
+ requiredCoreCapabilities: PhisCoreCapabilityId[];
71
71
  };
72
- export type PhiServerAddonJobDescriptor = {
72
+ export type PhisAddonJobDescriptor = {
73
73
  id: string;
74
74
  handler: string;
75
75
  /**
@@ -88,7 +88,7 @@ export type PhiServerAddonJobDescriptor = {
88
88
  * check a value against its type before it is stored -- the same bargain as the schema and query
89
89
  * descriptors, one step smaller.
90
90
  */
91
- export type PhiServerAddonSettingDescriptor = {
91
+ export type PhisAddonSettingDescriptor = {
92
92
  name: string;
93
93
  type: "text" | "integer" | "boolean";
94
94
  /** Shown by the CLI when it lists what an Add-on can be configured with. */
@@ -107,8 +107,8 @@ export type PhiServerAddonSettingDescriptor = {
107
107
  * `siteAccess` names one of the Site's own claims; `role` names another role of this same Add-on. A
108
108
  * Site Admin may always grant any role of any Add-on, so a graph that reaches nobody still has a way in.
109
109
  */
110
- export type PhiServerAddonRoleGrantPolicy = {
111
- siteAccess: PhiServerSiteAccess;
110
+ export type PhisAddonRoleGrantPolicy = {
111
+ siteAccess: PhisSiteAccess;
112
112
  } | {
113
113
  role: string;
114
114
  };
@@ -122,23 +122,51 @@ export type PhiServerAddonRoleGrantPolicy = {
122
122
  * Core never interprets the name. It stores assignments, answers `has()`, and enforces `grantableBy`;
123
123
  * what `reviewer` means is the Add-on's business and stays there.
124
124
  */
125
- export type PhiServerAddonRoleDescriptor = {
125
+ export type PhisAddonGroupWriterDescriptor = {
126
+ /**
127
+ * Who may cause this Add-on to create a group or change who is in one.
128
+ *
129
+ * The same policy shape a role's `grantableBy` uses, evaluated the same way, and with the same floor:
130
+ * a Site Admin always may, so a badly declared condition never locks a Site out of its own groups.
131
+ */
132
+ allowedBy: PhisAddonRoleGrantPolicy;
133
+ };
134
+ export type PhisAddonRoleDescriptor = {
135
+ name: string;
136
+ description?: string;
137
+ grantableBy: PhisAddonRoleGrantPolicy;
138
+ };
139
+ /**
140
+ * A group this Add-on needs on every Site that runs it.
141
+ *
142
+ * Groups are Core's vocabulary about shared rows, so an Add-on cannot invent one at runtime and cannot
143
+ * put anybody in it -- the groups capability reads and grants nothing. What it can do is say which ones
144
+ * it needs, and Core creates them per Site when the Site asks for the Add-on. Who is in them stays with
145
+ * whoever administers the Site, which is the point: a package should not be able to hand out standing.
146
+ *
147
+ * The group carries this Add-on's id as its provider, and Core already filters group claims against the
148
+ * available providers -- so an Add-on's group stops granting anything the moment the Add-on is gone,
149
+ * without anybody having to remember to clean up.
150
+ */
151
+ export type PhisAddonGroupDescriptor = {
152
+ /** Unique within this Add-on. Frozen once a Site has one: memberships point at it by key. */
153
+ key: string;
154
+ /** What an administrator sees in the Site's group list. */
126
155
  name: string;
127
156
  description?: string;
128
- grantableBy: PhiServerAddonRoleGrantPolicy;
129
157
  };
130
- export type PhiServerAddonManifestV1 = {
131
- manifestVersion: typeof PHI_SERVER_ADDON_MANIFEST_VERSION;
158
+ export type PhisAddonManifestV1 = {
159
+ manifestVersion: typeof PHIS_ADDON_MANIFEST_VERSION;
132
160
  addonId: string;
133
161
  packageName: string;
134
162
  packageVersion: string;
135
163
  serverAbi: string;
136
- capabilities: PhiServerAddonCapabilityDescriptor[];
137
- serviceProviders: PhiServerAddonServiceProviderDescriptor[];
138
- requiredCoreCapabilities: PhiServerCoreCapabilityId[];
139
- apiRoutes: PhiServerAddonApiRouteDescriptor[];
140
- hooks: PhiServerAddonHookDescriptor[];
141
- jobs: PhiServerAddonJobDescriptor[];
164
+ capabilities: PhisAddonCapabilityDescriptor[];
165
+ serviceProviders: PhisAddonServiceProviderDescriptor[];
166
+ requiredCoreCapabilities: PhisCoreCapabilityId[];
167
+ apiRoutes: PhisAddonApiRouteDescriptor[];
168
+ hooks: PhisAddonHookDescriptor[];
169
+ jobs: PhisAddonJobDescriptor[];
142
170
  /**
143
171
  * What an operator may configure. Absent means nothing is.
144
172
  *
@@ -148,7 +176,7 @@ export type PhiServerAddonManifestV1 = {
148
176
  * fields may demand an explicit `null`, because no stored manifest predates them; later ones must
149
177
  * define what absence means and accept it.
150
178
  */
151
- settings?: PhiServerAddonSettingDescriptor[];
179
+ settings?: PhisAddonSettingDescriptor[];
152
180
  /**
153
181
  * The roles this Add-on defines. Absent means it defines none.
154
182
  *
@@ -156,14 +184,50 @@ export type PhiServerAddonManifestV1 = {
156
184
  * forgotten it. A name is frozen from the first assignment -- renaming one orphans every grant that
157
185
  * points at it, silently, because the grant is stored as text and text does not follow a rename.
158
186
  */
159
- roles?: PhiServerAddonRoleDescriptor[];
187
+ roles?: PhisAddonRoleDescriptor[];
188
+ /**
189
+ * The groups this Add-on needs. Absent means none.
190
+ *
191
+ * Optional like every field added after the manifest version was in the wild, and absent means the
192
+ * Add-on asks for no group rather than that it forgot to say so.
193
+ */
194
+ groups?: PhisAddonGroupDescriptor[];
195
+ /**
196
+ * Whether this Add-on may create groups of its own while it runs, and who may cause it to.
197
+ *
198
+ * The declared list above is for the groups a Site always has -- one per Add-on, known when the
199
+ * manifest was written. This is for the ones a Site produces as it runs: one per vendor, per team, per
200
+ * tenant, whose number and names nobody knew in advance.
201
+ *
202
+ * It stays a declaration rather than a capability an Add-on simply has, for the reason `grantableBy`
203
+ * exists: the manifest is covered by a digest the running code cannot rewrite, so the author states
204
+ * who may cause a group, and Core decides whether the condition is met. Without it, "an Add-on may
205
+ * make groups" would mean "an Add-on may make itself an authority container and fill it".
206
+ *
207
+ * What it may do is bounded by ownership either way: a group it creates carries its own id as the
208
+ * provider, and every write here refuses a group carrying anybody else's.
209
+ */
210
+ groupWriter?: PhisAddonGroupWriterDescriptor;
211
+ /**
212
+ * The Modules this package's other half contributes. Absent means it has no Module half.
213
+ *
214
+ * Each id is `<package name>/<module key>`, and the package name is this manifest's own -- one package
215
+ * carries one product, so there is nothing to resolve. What the field adds is the one thing Core
216
+ * cannot see from an artifact: whether a Module half exists at all. An Add-on that is only an Add-on,
217
+ * like a storage provider, says nothing here and is complete on its own.
218
+ *
219
+ * It is what lets enabling an Add-on for a Site check that the Site actually has the other half. A
220
+ * Site with the Add-on and not the Module has routes with no surface: nothing is broken, but an
221
+ * operator who thinks they switched a feature on finds nothing.
222
+ */
223
+ modules?: string[];
160
224
  /**
161
225
  * The tables this Add-on owns, or null when it owns none.
162
226
  *
163
227
  * There is no list of migration steps beside it. The descriptor is the desired shape; what has been
164
228
  * applied is recorded in the database, and Core derives the difference.
165
229
  */
166
- schema: PhiServerAddonSchemaDescriptor | null;
230
+ schema: PhisAddonSchemaDescriptor | null;
167
231
  /**
168
232
  * The statements this Add-on may run against its own tables, or null when it runs none.
169
233
  *
@@ -172,14 +236,14 @@ export type PhiServerAddonManifestV1 = {
172
236
  *
173
237
  * Optional for the reason `settings` is: it arrived after the manifest version was in the wild.
174
238
  */
175
- queries?: PhiServerAddonQueryCatalog | null;
239
+ queries?: PhisAddonQueryCatalog | null;
176
240
  /** The version of `schema`, or 0 when there is none. */
177
241
  migrationVersion: number;
178
242
  };
179
- export type PhiServerAddonRequestContext = {
243
+ export type PhisAddonRequestContext = {
180
244
  addonId: string;
181
245
  requestId: string;
182
- method: PhiServerAddonHttpMethod;
246
+ method: PhisAddonHttpMethod;
183
247
  url: string;
184
248
  headers: Headers;
185
249
  body: Uint8Array;
@@ -188,6 +252,15 @@ export type PhiServerAddonRequestContext = {
188
252
  site: {
189
253
  id: number;
190
254
  key: string;
255
+ /**
256
+ * Where this Site answers from, as the outside world reaches it.
257
+ *
258
+ * Core's own addresses are Site-relative, which is right for a page of that Site and useless in
259
+ * anything that leaves it: a listing another Core fetches, a link in an email, a picture in a
260
+ * feed. A handler cannot derive this -- a request carries the host it happened to arrive on, and
261
+ * an internal one carries none at all -- so it is the Site's own record that says it.
262
+ */
263
+ publicBaseUrl: string;
191
264
  } | null;
192
265
  actor: {
193
266
  kind: "operator";
@@ -205,10 +278,10 @@ export type PhiServerAddonRequestContext = {
205
278
  * Only what the manifest declared arrives. A capability is not fetched, looked up, or constructed by
206
279
  * the Add-on -- it is here or it is not, and it was already decided before the handler ran.
207
280
  */
208
- capabilities: PhiServerAddonCapabilities;
281
+ capabilities: PhisAddonCapabilities;
209
282
  signal: AbortSignal;
210
283
  };
211
- export type PhiServerAddonHandler = (context: PhiServerAddonRequestContext) => Promise<Response> | Response;
284
+ export type PhisAddonHandler = (context: PhisAddonRequestContext) => Promise<Response> | Response;
212
285
  /**
213
286
  * A service factory, as the runtime exports it.
214
287
  *
@@ -216,24 +289,24 @@ export type PhiServerAddonHandler = (context: PhiServerAddonRequestContext) => P
216
289
  * what makes that agreement checkable. The factory receives schema-validated profile configuration and
217
290
  * a restricted context, never a database handle or a general secret accessor.
218
291
  */
219
- export type PhiServerAddonServiceFactory = (config: unknown, context: unknown) => unknown;
220
- export type PhiServerAddonRuntimeV1 = {
221
- manifest: PhiServerAddonManifestV1;
222
- apiHandlers?: Readonly<Record<string, PhiServerAddonHandler>>;
223
- hookHandlers?: Readonly<Record<string, PhiServerAddonHandler>>;
224
- serviceFactories?: Readonly<Record<string, PhiServerAddonServiceFactory>>;
225
- jobHandlers?: Readonly<Record<string, PhiServerAddonJobHandler>>;
292
+ export type PhisAddonServiceFactory = (config: unknown, context: unknown) => unknown;
293
+ export type PhisAddonRuntimeV1 = {
294
+ manifest: PhisAddonManifestV1;
295
+ apiHandlers?: Readonly<Record<string, PhisAddonHandler>>;
296
+ hookHandlers?: Readonly<Record<string, PhisAddonHandler>>;
297
+ serviceFactories?: Readonly<Record<string, PhisAddonServiceFactory>>;
298
+ jobHandlers?: Readonly<Record<string, PhisAddonJobHandler>>;
226
299
  };
227
- export type PhiServerAddonRuntimeModuleV1 = {
228
- phiServerAddon: PhiServerAddonRuntimeV1;
300
+ export type PhisAddonRuntimeModuleV1 = {
301
+ phisAddon: PhisAddonRuntimeV1;
229
302
  };
230
- export type PhiServerAddonBuildEntry = {
303
+ export type PhisAddonBuildEntry = {
231
304
  enabled: boolean;
232
305
  manifestDigest: string;
233
- manifest: PhiServerAddonManifestV1;
234
- load: () => Promise<PhiServerAddonRuntimeModuleV1>;
306
+ manifest: PhisAddonManifestV1;
307
+ load: () => Promise<PhisAddonRuntimeModuleV1>;
235
308
  };
236
- export type PhiServerAddonBuildManifest = Readonly<Record<string, PhiServerAddonBuildEntry>>;
309
+ export type PhisAddonBuildManifest = Readonly<Record<string, PhisAddonBuildEntry>>;
237
310
  /**
238
311
  * One installed Add-on, as the operator's desired state records it.
239
312
  *
@@ -242,7 +315,7 @@ export type PhiServerAddonBuildManifest = Readonly<Record<string, PhiServerAddon
242
315
  * digests bind the three apart: `manifestDigest` covers the descriptors below, `artifactDigest` covers
243
316
  * the file in the install root, and the loader refuses a file that no longer matches.
244
317
  */
245
- export type PhiServerAddonDesiredStateEntry = {
318
+ export type PhisAddonDesiredStateEntry = {
246
319
  addonId: string;
247
320
  packageName: string;
248
321
  packageVersion: string;
@@ -250,10 +323,10 @@ export type PhiServerAddonDesiredStateEntry = {
250
323
  /** SHA-256 of the installed artifact, and the cache key its load address carries. */
251
324
  artifactDigest: string;
252
325
  /** The validated descriptors, as read from the artifact at installation. */
253
- manifest: PhiServerAddonManifestV1;
326
+ manifest: PhisAddonManifestV1;
254
327
  enabled: boolean;
255
328
  };
256
- export type PhiServerAddonDesiredState = {
329
+ export type PhisAddonDesiredState = {
257
330
  formatVersion: 1;
258
- addons: PhiServerAddonDesiredStateEntry[];
331
+ addons: PhisAddonDesiredStateEntry[];
259
332
  };
package/dist/addon.js CHANGED
@@ -11,5 +11,5 @@
11
11
  * no node builtins -- the Add-on states, and Core checks. If something here ever does more than hand
12
12
  * back a constant, it is in the wrong package.
13
13
  */
14
- export const PHI_SERVER_ADDON_MANIFEST_VERSION = 1;
15
- export const PHI_SERVER_ADDON_ABI_VERSION = 1;
14
+ export const PHIS_ADDON_MANIFEST_VERSION = 1;
15
+ export const PHIS_ADDON_ABI_VERSION = 1;