@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.
@@ -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
+ }
package/dist/cms.d.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The numbers a CMS row carries, in the one reading both sides must give them.
3
+ *
4
+ * A status, a Region type, a visibility mask and a flag set are written by the site UI, checked and
5
+ * stored by phi-server, and read back by both -- as columns, as fields of a write payload, and as
6
+ * values inside a stored tree. They are the same kind of agreement as the identity below and were
7
+ * kept the same way the arithmetic was: two files, identical line for line, in two repositories.
8
+ *
9
+ * The visibility mask is the one that shows what that costs. Its bits are not dense -- public 1,
10
+ * app 2, admin 8, editor 16, accounting 64, builder 256 -- so a side that renumbered them would not
11
+ * fail anything loudly. It would store a Page in a different Area than the other side reads it from,
12
+ * and every row would look correct where it was written.
13
+ */
14
+ export declare const PhiCmsPageType: {
15
+ readonly Standard: 0;
16
+ readonly Landing: 1;
17
+ readonly Legal: 2;
18
+ readonly System: 3;
19
+ readonly Redirect: 4;
20
+ };
21
+ export declare const PhiCmsStatus: {
22
+ readonly Draft: 0;
23
+ readonly Published: 1;
24
+ readonly Archived: 2;
25
+ readonly Deleted: 3;
26
+ };
27
+ export declare const PhiCmsRegionStatus: {
28
+ readonly Draft: 0;
29
+ readonly Active: 1;
30
+ readonly Disabled: 2;
31
+ };
32
+ /** Numbered with room between the groups, so a Region can be added beside its neighbours. */
33
+ export declare const PhiCmsRegionType: {
34
+ readonly HeaderTop: 10;
35
+ readonly HeaderMain: 11;
36
+ readonly HeaderBottom: 12;
37
+ readonly SiderLeft: 20;
38
+ readonly SiderRight: 21;
39
+ readonly Hero: 25;
40
+ readonly Content: 30;
41
+ readonly FooterTop: 39;
42
+ readonly Footer: 40;
43
+ readonly FooterBottom: 41;
44
+ readonly Drawer: 50;
45
+ };
46
+ export type PhiCmsRegionTypeValue = (typeof PhiCmsRegionType)[keyof typeof PhiCmsRegionType];
47
+ /**
48
+ * Which Areas a row is visible in, as one value per Area rather than a running number.
49
+ *
50
+ * Bits 2, 5 and 7 are unassigned. That is what makes the numbers worth stating once: 8 reads like the
51
+ * fourth Area and is the third, and phi-server stores it as `area_id` where nothing spells it out.
52
+ */
53
+ export declare const PhiCmsVisibilityContext: {
54
+ readonly PublicArea: number;
55
+ readonly AppArea: number;
56
+ readonly AdminArea: number;
57
+ readonly EditorArea: number;
58
+ readonly AccountingArea: number;
59
+ readonly BuilderArea: number;
60
+ };
61
+ /**
62
+ * The flags a CMS row carries, in the one numbering both sides read them from.
63
+ *
64
+ * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
65
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
66
+ *
67
+ * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
68
+ * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
69
+ * lives here, on the record, and not on the route descriptor -- a descriptor is read fresh on every
70
+ * request and would overrule the Operator on every one of them.
71
+ */
72
+ export declare const PhiCmsFlags: {
73
+ readonly SiteCustom: number;
74
+ readonly Hidden: number;
75
+ readonly MobileOnly: number;
76
+ readonly DesktopOnly: number;
77
+ readonly Collapsed: number;
78
+ readonly NoTranslate: number;
79
+ readonly NoIndex: number;
80
+ };
81
+ export declare const PhiCmsRevisionFlags: {
82
+ readonly Draft: number;
83
+ readonly Published: number;
84
+ readonly Deleted: number;
85
+ };
86
+ export declare const DEFAULT_PHI_CMS_VISIBILITY_MASK: number;
87
+ declare const PHI_CMS_INSTANCE_ID_BRAND: unique symbol;
88
+ export type PhiCmsInstanceId = string & {
89
+ readonly [PHI_CMS_INSTANCE_ID_BRAND]: "PhiCmsInstanceId";
90
+ };
91
+ export type PhiCmsInstanceOrigin = "preset" | "draft";
92
+ export type PhiCmsInstanceDomain = "area" | "page" | "navigation";
93
+ export type PhiCmsInstanceIdDescriptor = {
94
+ version: 1;
95
+ origin: "preset";
96
+ domain: PhiCmsInstanceDomain;
97
+ } | {
98
+ version: 1;
99
+ origin: "draft";
100
+ domain: PhiCmsInstanceDomain;
101
+ draftRevisionId: number;
102
+ sequence: number;
103
+ };
104
+ export type PhiCmsPresetInstanceIdentity = {
105
+ domain: PhiCmsInstanceDomain;
106
+ ownerModuleId: string;
107
+ presetKey: string;
108
+ nodeKey: string;
109
+ };
110
+ export declare function createPhiDraftCmsInstanceId({ domain, draftRevisionId, sequence, }: {
111
+ domain: PhiCmsInstanceDomain;
112
+ draftRevisionId: number;
113
+ sequence: number;
114
+ }): PhiCmsInstanceId;
115
+ export declare function createPhiPresetCmsInstanceId({ domain, ownerModuleId, presetKey, nodeKey, }: PhiCmsPresetInstanceIdentity): PhiCmsInstanceId;
116
+ export declare function readPhiCmsInstanceIdDescriptor(value: unknown): PhiCmsInstanceIdDescriptor | null;
117
+ export declare function isPhiCmsInstanceId(value: unknown): value is PhiCmsInstanceId;
118
+ export declare function readPhiCmsInstanceId(value: unknown): PhiCmsInstanceId | null;
119
+ export declare function comparePhiCmsInstanceIds(left: PhiCmsInstanceId, right: PhiCmsInstanceId): 1 | 0 | -1;
120
+ export {};
package/dist/cms.js ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The numbers a CMS row carries, in the one reading both sides must give them.
3
+ *
4
+ * A status, a Region type, a visibility mask and a flag set are written by the site UI, checked and
5
+ * stored by phi-server, and read back by both -- as columns, as fields of a write payload, and as
6
+ * values inside a stored tree. They are the same kind of agreement as the identity below and were
7
+ * kept the same way the arithmetic was: two files, identical line for line, in two repositories.
8
+ *
9
+ * The visibility mask is the one that shows what that costs. Its bits are not dense -- public 1,
10
+ * app 2, admin 8, editor 16, accounting 64, builder 256 -- so a side that renumbered them would not
11
+ * fail anything loudly. It would store a Page in a different Area than the other side reads it from,
12
+ * and every row would look correct where it was written.
13
+ */
14
+ export const PhiCmsPageType = {
15
+ Standard: 0,
16
+ Landing: 1,
17
+ Legal: 2,
18
+ System: 3,
19
+ Redirect: 4,
20
+ };
21
+ export const PhiCmsStatus = {
22
+ Draft: 0,
23
+ Published: 1,
24
+ Archived: 2,
25
+ Deleted: 3,
26
+ };
27
+ export const PhiCmsRegionStatus = {
28
+ Draft: 0,
29
+ Active: 1,
30
+ Disabled: 2,
31
+ };
32
+ /** Numbered with room between the groups, so a Region can be added beside its neighbours. */
33
+ export const PhiCmsRegionType = {
34
+ HeaderTop: 10,
35
+ HeaderMain: 11,
36
+ HeaderBottom: 12,
37
+ SiderLeft: 20,
38
+ SiderRight: 21,
39
+ Hero: 25,
40
+ Content: 30,
41
+ FooterTop: 39,
42
+ Footer: 40,
43
+ FooterBottom: 41,
44
+ Drawer: 50,
45
+ };
46
+ /**
47
+ * Which Areas a row is visible in, as one value per Area rather than a running number.
48
+ *
49
+ * Bits 2, 5 and 7 are unassigned. That is what makes the numbers worth stating once: 8 reads like the
50
+ * fourth Area and is the third, and phi-server stores it as `area_id` where nothing spells it out.
51
+ */
52
+ export const PhiCmsVisibilityContext = {
53
+ PublicArea: 1 << 0,
54
+ AppArea: 1 << 1,
55
+ AdminArea: 1 << 3,
56
+ EditorArea: 1 << 4,
57
+ AccountingArea: 1 << 6,
58
+ BuilderArea: 1 << 8,
59
+ };
60
+ /**
61
+ * The flags a CMS row carries, in the one numbering both sides read them from.
62
+ *
63
+ * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
64
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
65
+ *
66
+ * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
67
+ * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
68
+ * lives here, on the record, and not on the route descriptor -- a descriptor is read fresh on every
69
+ * request and would overrule the Operator on every one of them.
70
+ */
71
+ export const PhiCmsFlags = {
72
+ SiteCustom: 1 << 0,
73
+ Hidden: 1 << 1,
74
+ MobileOnly: 1 << 2,
75
+ DesktopOnly: 1 << 3,
76
+ Collapsed: 1 << 4,
77
+ NoTranslate: 1 << 5,
78
+ NoIndex: 1 << 6,
79
+ };
80
+ export const PhiCmsRevisionFlags = {
81
+ Draft: 1 << 0,
82
+ Published: 1 << 1,
83
+ Deleted: 1 << 2,
84
+ };
85
+ export const DEFAULT_PHI_CMS_VISIBILITY_MASK = PhiCmsVisibilityContext.PublicArea;
86
+ /**
87
+ * The identity a CMS node carries, in the one encoding both sides must compute alike.
88
+ *
89
+ * A node id is not allocated, it is derived: a Preset node's id is a hash of the Module, the preset
90
+ * and the node key, so the same node has the same id before any row exists and after every
91
+ * republish. The site UI derives it to address blocks and Pages; phi-server derives it to check that
92
+ * stored wiring points at nodes that are really there, and to write Page identities of its own.
93
+ *
94
+ * Which is why this cannot be two implementations agreeing by inspection -- and it was exactly that:
95
+ * `hashPresetIdentity` stood twice, line for line, in `@phis/ui` and in phi-server. The two had to
96
+ * produce identical bytes forever, and a changed constant on one side would not have failed
97
+ * anything loudly. It would have given one process a different Page identity than the other, and
98
+ * Pages would have stopped resolving with nothing to point at.
99
+ *
100
+ * That makes it the exception this package's rule allows for. Everywhere else the rule is shape here,
101
+ * membership with whoever holds the registry; here there is no shape to check, only an arithmetic --
102
+ * and the reason it belongs here is precisely that the arithmetic must be one.
103
+ */
104
+ const PHI_CMS_INSTANCE_ID_BYTE_LENGTH = 12;
105
+ const PHI_CMS_INSTANCE_ID_ENCODED_LENGTH = 16;
106
+ const PHI_CMS_INSTANCE_ID_VERSION = 1;
107
+ const PHI_CMS_INSTANCE_ID_ORIGIN_PRESET = 1;
108
+ const PHI_CMS_INSTANCE_ID_ORIGIN_DRAFT = 2;
109
+ const PHI_CMS_INSTANCE_ID_VERSION_SHIFT = 4;
110
+ const PHI_CMS_INSTANCE_ID_ORIGIN_MASK = 0x0f;
111
+ const PHI_CMS_INSTANCE_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/;
112
+ const PHI_CMS_BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
113
+ const PHI_CMS_MAX_DRAFT_REVISION_ID = 0xffff_ffff_ffff;
114
+ const PHI_CMS_MAX_DRAFT_SEQUENCE = 0xffff_ffff;
115
+ const PHI_CMS_INSTANCE_DOMAIN_CODES = {
116
+ area: 1,
117
+ page: 2,
118
+ navigation: 3,
119
+ };
120
+ const PHI_CMS_INSTANCE_DOMAINS_BY_CODE = new Map(Object.entries(PHI_CMS_INSTANCE_DOMAIN_CODES).map(([domain, code]) => [
121
+ code,
122
+ domain,
123
+ ]));
124
+ function encodeBase64Url(bytes) {
125
+ let output = "";
126
+ for (let index = 0; index < bytes.length; index += 3) {
127
+ const first = bytes[index] ?? 0;
128
+ const second = bytes[index + 1] ?? 0;
129
+ const third = bytes[index + 2] ?? 0;
130
+ output += PHI_CMS_BASE64URL_ALPHABET[first >>> 2];
131
+ output += PHI_CMS_BASE64URL_ALPHABET[((first & 0x03) << 4) | (second >>> 4)];
132
+ output += PHI_CMS_BASE64URL_ALPHABET[((second & 0x0f) << 2) | (third >>> 6)];
133
+ output += PHI_CMS_BASE64URL_ALPHABET[third & 0x3f];
134
+ }
135
+ return output;
136
+ }
137
+ function decodeBase64Url(value) {
138
+ if (!PHI_CMS_INSTANCE_ID_PATTERN.test(value)) {
139
+ return null;
140
+ }
141
+ const bytes = new Uint8Array(PHI_CMS_INSTANCE_ID_BYTE_LENGTH);
142
+ for (let index = 0; index < value.length; index += 4) {
143
+ const first = PHI_CMS_BASE64URL_ALPHABET.indexOf(value[index]);
144
+ const second = PHI_CMS_BASE64URL_ALPHABET.indexOf(value[index + 1]);
145
+ const third = PHI_CMS_BASE64URL_ALPHABET.indexOf(value[index + 2]);
146
+ const fourth = PHI_CMS_BASE64URL_ALPHABET.indexOf(value[index + 3]);
147
+ if (first < 0 || second < 0 || third < 0 || fourth < 0) {
148
+ return null;
149
+ }
150
+ const byteIndex = (index / 4) * 3;
151
+ bytes[byteIndex] = (first << 2) | (second >>> 4);
152
+ bytes[byteIndex + 1] = ((second & 0x0f) << 4) | (third >>> 2);
153
+ bytes[byteIndex + 2] = ((third & 0x03) << 6) | fourth;
154
+ }
155
+ return bytes;
156
+ }
157
+ function createHeader(origin) {
158
+ return (PHI_CMS_INSTANCE_ID_VERSION << PHI_CMS_INSTANCE_ID_VERSION_SHIFT) | origin;
159
+ }
160
+ function readHeader(bytes) {
161
+ const version = bytes[0] >>> PHI_CMS_INSTANCE_ID_VERSION_SHIFT;
162
+ const origin = bytes[0] & PHI_CMS_INSTANCE_ID_ORIGIN_MASK;
163
+ return { version, origin };
164
+ }
165
+ function assertSafeIntegerInRange(value, min, max, label) {
166
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
167
+ throw new Error(`${label} must be a safe integer from ${min} through ${max}.`);
168
+ }
169
+ }
170
+ function writeUnsignedBigEndian(bytes, offset, length, value) {
171
+ let remaining = value;
172
+ for (let index = offset + length - 1; index >= offset; index -= 1) {
173
+ bytes[index] = remaining % 256;
174
+ remaining = Math.floor(remaining / 256);
175
+ }
176
+ }
177
+ function readUnsignedBigEndian(bytes, offset, length) {
178
+ let value = 0;
179
+ for (let index = offset; index < offset + length; index += 1) {
180
+ value = (value * 256) + bytes[index];
181
+ }
182
+ return value;
183
+ }
184
+ function normalizeIdentityPart(value, label) {
185
+ const normalized = value.trim();
186
+ if (!normalized) {
187
+ throw new Error(`${label} must not be empty.`);
188
+ }
189
+ return normalized;
190
+ }
191
+ function hashPresetIdentity(value) {
192
+ const bytes = new TextEncoder().encode(value);
193
+ const state = new Uint32Array([0x811c9dc5, 0x9e3779b9, 0x85ebca6b]);
194
+ const primes = [0x01000193, 0x27d4eb2d, 0x165667b1];
195
+ for (const byte of bytes) {
196
+ for (let index = 0; index < state.length; index += 1) {
197
+ state[index] = Math.imul(state[index] ^ (byte + index * 17), primes[index]) >>> 0;
198
+ state[index] = state[index] ^ (state[index] >>> 13);
199
+ }
200
+ }
201
+ const result = new Uint8Array(10);
202
+ for (let index = 0; index < result.length; index += 1) {
203
+ const lane = index % state.length;
204
+ state[lane] = Math.imul(state[lane] ^ (index + bytes.length), primes[lane]) >>> 0;
205
+ result[index] = state[lane] >>> ((index % 4) * 8);
206
+ }
207
+ return result;
208
+ }
209
+ export function createPhiDraftCmsInstanceId({ domain, draftRevisionId, sequence, }) {
210
+ assertSafeIntegerInRange(draftRevisionId, 1, PHI_CMS_MAX_DRAFT_REVISION_ID, "Draft revision id");
211
+ assertSafeIntegerInRange(sequence, 1, PHI_CMS_MAX_DRAFT_SEQUENCE, "Draft node sequence");
212
+ const bytes = new Uint8Array(PHI_CMS_INSTANCE_ID_BYTE_LENGTH);
213
+ bytes[0] = createHeader(PHI_CMS_INSTANCE_ID_ORIGIN_DRAFT);
214
+ bytes[1] = PHI_CMS_INSTANCE_DOMAIN_CODES[domain];
215
+ writeUnsignedBigEndian(bytes, 2, 6, draftRevisionId);
216
+ writeUnsignedBigEndian(bytes, 8, 4, sequence);
217
+ return encodeBase64Url(bytes);
218
+ }
219
+ export function createPhiPresetCmsInstanceId({ domain, ownerModuleId, presetKey, nodeKey, }) {
220
+ const identity = [
221
+ `v${PHI_CMS_INSTANCE_ID_VERSION}`,
222
+ domain,
223
+ normalizeIdentityPart(ownerModuleId, "Preset owner module id"),
224
+ normalizeIdentityPart(presetKey, "Preset key"),
225
+ normalizeIdentityPart(nodeKey, "Preset node key"),
226
+ ].join("\u001f");
227
+ const bytes = new Uint8Array(PHI_CMS_INSTANCE_ID_BYTE_LENGTH);
228
+ bytes[0] = createHeader(PHI_CMS_INSTANCE_ID_ORIGIN_PRESET);
229
+ bytes[1] = PHI_CMS_INSTANCE_DOMAIN_CODES[domain];
230
+ bytes.set(hashPresetIdentity(identity), 2);
231
+ return encodeBase64Url(bytes);
232
+ }
233
+ export function readPhiCmsInstanceIdDescriptor(value) {
234
+ if (typeof value !== "string" || value.length !== PHI_CMS_INSTANCE_ID_ENCODED_LENGTH) {
235
+ return null;
236
+ }
237
+ const bytes = decodeBase64Url(value);
238
+ if (!bytes) {
239
+ return null;
240
+ }
241
+ const header = readHeader(bytes);
242
+ if (header.version !== PHI_CMS_INSTANCE_ID_VERSION) {
243
+ return null;
244
+ }
245
+ const domain = PHI_CMS_INSTANCE_DOMAINS_BY_CODE.get(bytes[1]);
246
+ if (!domain) {
247
+ return null;
248
+ }
249
+ if (header.origin === PHI_CMS_INSTANCE_ID_ORIGIN_PRESET) {
250
+ return { version: 1, origin: "preset", domain };
251
+ }
252
+ if (header.origin !== PHI_CMS_INSTANCE_ID_ORIGIN_DRAFT) {
253
+ return null;
254
+ }
255
+ const draftRevisionId = readUnsignedBigEndian(bytes, 2, 6);
256
+ const sequence = readUnsignedBigEndian(bytes, 8, 4);
257
+ if (draftRevisionId < 1 || sequence < 1) {
258
+ return null;
259
+ }
260
+ return { version: 1, origin: "draft", domain, draftRevisionId, sequence };
261
+ }
262
+ export function isPhiCmsInstanceId(value) {
263
+ return readPhiCmsInstanceIdDescriptor(value) !== null;
264
+ }
265
+ export function readPhiCmsInstanceId(value) {
266
+ return isPhiCmsInstanceId(value) ? value : null;
267
+ }
268
+ export function comparePhiCmsInstanceIds(left, right) {
269
+ return left === right ? 0 : left < right ? -1 : 1;
270
+ }
package/dist/core.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * without hashing anything -- Core recomputes them and checks, which is where that belongs.
15
15
  */
16
16
  /** The Core capabilities an Add-on may require. */
17
- export declare const PHI_SERVER_CORE_CAPABILITIES: {
17
+ export declare const PHIS_CORE_CAPABILITIES: {
18
18
  readonly authentication: "@phis/server/authentication:v1";
19
19
  /**
20
20
  * "May this actor", in all three of its sources: does the row belong to them, what level do they
@@ -31,6 +31,14 @@ export declare const PHI_SERVER_CORE_CAPABILITIES: {
31
31
  /** Group facts -- which groups exist, who is in them. What follows from them is `authorization`. */
32
32
  readonly groups: "@phis/server/groups:v1";
33
33
  readonly storage: "@phis/server/storage:v1";
34
+ /**
35
+ * The files a row carries, in the slots its table declared.
36
+ *
37
+ * Separate from `storage` because they answer different questions. `storage` hands out a key space an
38
+ * Add-on manages itself; this hands out places Core knows about, which is what makes the file of a
39
+ * deleted row collectable rather than merely gone from view.
40
+ */
41
+ readonly assets: "@phis/server/assets:v1";
34
42
  /** Reading the secrets an operator set for this Add-on, and no others. */
35
43
  readonly secrets: "@phis/server/secrets:v1";
36
44
  /** Reading what an operator configured for this Add-on on this Site. */
@@ -38,7 +46,7 @@ export declare const PHI_SERVER_CORE_CAPABILITIES: {
38
46
  /** Whether the acting user holds one of this Add-on's own declared roles. */
39
47
  readonly roles: "@phis/server/roles:v1";
40
48
  };
41
- export type PhiServerCoreCapabilityId = (typeof PHI_SERVER_CORE_CAPABILITIES)[keyof typeof PHI_SERVER_CORE_CAPABILITIES];
49
+ export type PhisCoreCapabilityId = (typeof PHIS_CORE_CAPABILITIES)[keyof typeof PHIS_CORE_CAPABILITIES];
42
50
  /**
43
51
  * The Core-owned service kinds an Add-on may implement.
44
52
  *
@@ -46,11 +54,11 @@ export type PhiServerCoreCapabilityId = (typeof PHI_SERVER_CORE_CAPABILITIES)[ke
46
54
  * is something Core already owns and an Add-on supplies an implementation of -- S3 for Media Storage,
47
55
  * LDAP for a Directory. Core keeps selecting the configured implementation itself.
48
56
  */
49
- export declare const PHI_SERVER_SERVICE_KINDS: {
57
+ export declare const PHIS_SERVICE_KINDS: {
50
58
  readonly mediaStorage: "@phis/server/service/media-storage";
51
59
  readonly directory: "@phis/server/service/directory";
52
60
  };
53
- export type PhiServerServiceKind = (typeof PHI_SERVER_SERVICE_KINDS)[keyof typeof PHI_SERVER_SERVICE_KINDS];
61
+ export type PhisServiceKind = (typeof PHIS_SERVICE_KINDS)[keyof typeof PHIS_SERVICE_KINDS];
54
62
  /**
55
63
  * The digest of the interface each service kind currently has.
56
64
  *
@@ -62,5 +70,5 @@ export type PhiServerServiceKind = (typeof PHI_SERVER_SERVICE_KINDS)[keyof typeo
62
70
  * The digest is `sha256("phi-server-service-kind:<kind>:v<interface version>")`. That recipe is
63
71
  * documented for whoever has to produce the next value; nothing needs to run it to read a manifest.
64
72
  */
65
- export declare const PHI_SERVER_SERVICE_INTERFACE_VERSIONS: Readonly<Record<PhiServerServiceKind, number>>;
66
- export declare const PHI_SERVER_SERVICE_INTERFACE_DIGESTS: Readonly<Record<PhiServerServiceKind, string>>;
73
+ export declare const PHIS_SERVICE_INTERFACE_VERSIONS: Readonly<Record<PhisServiceKind, number>>;
74
+ export declare const PHIS_SERVICE_INTERFACE_DIGESTS: Readonly<Record<PhisServiceKind, string>>;
package/dist/core.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * without hashing anything -- Core recomputes them and checks, which is where that belongs.
15
15
  */
16
16
  /** The Core capabilities an Add-on may require. */
17
- export const PHI_SERVER_CORE_CAPABILITIES = {
17
+ export const PHIS_CORE_CAPABILITIES = {
18
18
  authentication: "@phis/server/authentication:v1",
19
19
  /**
20
20
  * "May this actor", in all three of its sources: does the row belong to them, what level do they
@@ -31,6 +31,14 @@ export const PHI_SERVER_CORE_CAPABILITIES = {
31
31
  /** Group facts -- which groups exist, who is in them. What follows from them is `authorization`. */
32
32
  groups: "@phis/server/groups:v1",
33
33
  storage: "@phis/server/storage:v1",
34
+ /**
35
+ * The files a row carries, in the slots its table declared.
36
+ *
37
+ * Separate from `storage` because they answer different questions. `storage` hands out a key space an
38
+ * Add-on manages itself; this hands out places Core knows about, which is what makes the file of a
39
+ * deleted row collectable rather than merely gone from view.
40
+ */
41
+ assets: "@phis/server/assets:v1",
34
42
  /** Reading the secrets an operator set for this Add-on, and no others. */
35
43
  secrets: "@phis/server/secrets:v1",
36
44
  /** Reading what an operator configured for this Add-on on this Site. */
@@ -45,7 +53,7 @@ export const PHI_SERVER_CORE_CAPABILITIES = {
45
53
  * is something Core already owns and an Add-on supplies an implementation of -- S3 for Media Storage,
46
54
  * LDAP for a Directory. Core keeps selecting the configured implementation itself.
47
55
  */
48
- export const PHI_SERVER_SERVICE_KINDS = {
56
+ export const PHIS_SERVICE_KINDS = {
49
57
  mediaStorage: "@phis/server/service/media-storage",
50
58
  directory: "@phis/server/service/directory",
51
59
  };
@@ -60,11 +68,11 @@ export const PHI_SERVER_SERVICE_KINDS = {
60
68
  * The digest is `sha256("phi-server-service-kind:<kind>:v<interface version>")`. That recipe is
61
69
  * documented for whoever has to produce the next value; nothing needs to run it to read a manifest.
62
70
  */
63
- export const PHI_SERVER_SERVICE_INTERFACE_VERSIONS = {
64
- [PHI_SERVER_SERVICE_KINDS.mediaStorage]: 1,
65
- [PHI_SERVER_SERVICE_KINDS.directory]: 1,
71
+ export const PHIS_SERVICE_INTERFACE_VERSIONS = {
72
+ [PHIS_SERVICE_KINDS.mediaStorage]: 1,
73
+ [PHIS_SERVICE_KINDS.directory]: 1,
66
74
  };
67
- export const PHI_SERVER_SERVICE_INTERFACE_DIGESTS = {
68
- [PHI_SERVER_SERVICE_KINDS.mediaStorage]: "45a871522511f7d77841ca8dd0113b423b3cfd4cb921f143cd1d3d116cab6952",
69
- [PHI_SERVER_SERVICE_KINDS.directory]: "2cac7397c7beb1b8e71fc67f5cae3be89182dddf096895570a15deafa1adbfc9",
75
+ export const PHIS_SERVICE_INTERFACE_DIGESTS = {
76
+ [PHIS_SERVICE_KINDS.mediaStorage]: "45a871522511f7d77841ca8dd0113b423b3cfd4cb921f143cd1d3d116cab6952",
77
+ [PHIS_SERVICE_KINDS.directory]: "2cac7397c7beb1b8e71fc67f5cae3be89182dddf096895570a15deafa1adbfc9",
70
78
  };
package/dist/http.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The request headers phis and the site UI must spell the same way.
3
+ *
4
+ * These five names are a wire protocol between processes that deploy independently. phis reads them in
5
+ * its guards and route handlers; the site UI writes them in its proxies, gateways and browser widgets.
6
+ * A disagreement is not a type error and not a failing test -- it is a 403 from `guardApiV1` or a 400
7
+ * from `requireSiteFromExplicitKeyRequest`, at runtime, in whichever direction the mismatch happens to
8
+ * point.
9
+ *
10
+ * They were string literals in both repositories before this file existed, eighty-odd of them, and the
11
+ * one place that had bothered to name a constant named it in only one of the two. Renaming the prefix
12
+ * from `x-phi-` to `x-phis-` is what made that visible; the point of collecting them here is that the
13
+ * next such change is one edit rather than a search.
14
+ */
15
+ /** Carries the public or internal API token on every `/api/v1/*` request. */
16
+ export declare const PHIS_TOKEN_HEADER: "x-phis-token";
17
+ /**
18
+ * Names the Site a request is scoped to, and is the only thing that does.
19
+ *
20
+ * There is deliberately no host, origin or referer fallback: those are caller-controlled, so a request
21
+ * without this header could otherwise claim any Site whose hostname it spoofed.
22
+ */
23
+ export declare const PHIS_SITE_KEY_HEADER: "x-phis-site-key";
24
+ /** Names the CMS area a media request acts in, which decides what the actor may reach. */
25
+ export declare const PHIS_AREA_HEADER: "x-phis-area";
26
+ /**
27
+ * Hand the original request path and query from the site proxy to the server helpers behind it.
28
+ *
29
+ * Both are set and read inside the site runtime and never reach phis; they exist because a Server
30
+ * Component cannot otherwise see the URL the proxy rewrote.
31
+ */
32
+ export declare const PHIS_REQUEST_PATH_HEADER: "x-phis-request-path";
33
+ export declare const PHIS_REQUEST_SEARCH_HEADER: "x-phis-request-search";