@phis/contracts 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -5
- package/dist/addon-index.d.ts +1 -0
- package/dist/addon-index.js +1 -0
- package/dist/addon.d.ts +3 -1
- package/dist/capabilities.d.ts +30 -13
- package/dist/catalog.d.ts +14 -0
- package/dist/catalog.js +14 -2
- package/dist/cms.d.ts +34 -1
- package/dist/cms.js +52 -1
- package/dist/conditions.d.ts +96 -0
- package/dist/conditions.js +163 -0
- package/dist/controls.d.ts +91 -0
- package/dist/controls.js +134 -0
- package/dist/core.d.ts +17 -1
- package/dist/core.js +17 -0
- package/dist/forms.d.ts +496 -0
- package/dist/forms.js +508 -0
- package/dist/http.d.ts +10 -1
- package/dist/http.js +10 -1
- package/dist/layout.d.ts +22 -0
- package/dist/layout.js +30 -0
- package/dist/payments.d.ts +274 -0
- package/dist/payments.js +8 -0
- package/dist/record.d.ts +8 -0
- package/dist/record.js +24 -0
- package/dist/references.d.ts +139 -0
- package/dist/references.js +241 -0
- package/dist/resource-links.d.ts +13 -7
- package/dist/schema.d.ts +9 -0
- package/dist/signals.d.ts +1 -0
- package/dist/signals.js +3 -0
- package/dist/support.d.ts +184 -0
- package/dist/support.js +11 -0
- package/dist/threads.d.ts +29 -0
- package/dist/timeline.d.ts +39 -0
- package/dist/timeline.js +53 -0
- package/package.json +37 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one side of the house means when it points at a Page: `@phis/server/references:v1`.
|
|
3
|
+
*
|
|
4
|
+
* The codec stood in two copies, in phis-ui and in phi-server, for the same reason the CMS instance id
|
|
5
|
+
* did before it moved here -- and with the same risk, which is that two sides agreeing byte for byte is
|
|
6
|
+
* a thing nobody notices stopping. It is one file now.
|
|
7
|
+
*
|
|
8
|
+
* **A reference is short because a Page already has a name.** It used to be base64-encoded JSON carrying
|
|
9
|
+
* the whole identity, which for a Module Page ran to 98 characters in every Markdown body that linked to
|
|
10
|
+
* one. The envelope was never protection -- it is encoding, not a signature, and the server revalidates
|
|
11
|
+
* Site and Area regardless -- so what it actually bought was a payload the server could unpack. It no
|
|
12
|
+
* longer needs to: a Module Page is named by the CMS Page id derived from `(ownerModuleId, presetKey)`,
|
|
13
|
+
* which is recomputable rather than allocated and therefore stands before any row does.
|
|
14
|
+
*
|
|
15
|
+
* The two variants are shaped differently on purpose. A Site Page carries its Page Scope id as a plain
|
|
16
|
+
* number because the server writes that number into a foreign key with `ON DELETE RESTRICT`: the
|
|
17
|
+
* database itself refuses to delete a Page something still points at, and nothing derived could take
|
|
18
|
+
* that column's place. A Module Page has no row to point a foreign key at -- the Site may never have
|
|
19
|
+
* touched it -- so it carries the derived id, which the server passes on without ever unpacking.
|
|
20
|
+
*/
|
|
21
|
+
import { createPhiPresetCmsInstanceId, isPhiCmsAreaKey, readPhiCmsInstanceIdDescriptor, } from "./cms.js";
|
|
22
|
+
export const PHIS_INTERNAL_PAGE_SCHEME = "phis:page/";
|
|
23
|
+
export const PHIS_INTERNAL_ASSET_SCHEME = "phis:asset/";
|
|
24
|
+
/** The Page itself, not a node inside it -- the one place a Page id differs from a node id. */
|
|
25
|
+
const PHI_CMS_PAGE_NODE_KEY = "page";
|
|
26
|
+
const PHI_PAGE_REFERENCE_VERSION = "v1";
|
|
27
|
+
const PHI_PAGE_REFERENCE_SITE_TAG = "s";
|
|
28
|
+
const PHI_PAGE_REFERENCE_MODULE_TAG = "p";
|
|
29
|
+
const PHI_MODULE_ID_PATTERN = /^@[^/]+\/[^/]+(?:\/[^/]+)*$/;
|
|
30
|
+
const PHI_PAGE_SCOPE_ID_PATTERN = /^[1-9]\d*$/u;
|
|
31
|
+
/**
|
|
32
|
+
* A Module Page's identity, hashed to one token both sides can recompute.
|
|
33
|
+
*
|
|
34
|
+
* Recomputable rather than allocated, so no row has to exist before the Page can be addressed, and it
|
|
35
|
+
* never mentions the path: reassigning where a Page answers leaves everything pointing at it alone.
|
|
36
|
+
*/
|
|
37
|
+
export function createPhiPresetCmsPageId(identity) {
|
|
38
|
+
return createPhiPresetCmsInstanceId({
|
|
39
|
+
...identity,
|
|
40
|
+
domain: "page",
|
|
41
|
+
nodeKey: PHI_CMS_PAGE_NODE_KEY,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function isPositiveInteger(value) {
|
|
45
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
46
|
+
}
|
|
47
|
+
function readPresetPageId(value) {
|
|
48
|
+
const descriptor = readPhiCmsInstanceIdDescriptor(value);
|
|
49
|
+
return descriptor?.origin === "preset" && descriptor.domain === "page"
|
|
50
|
+
? value
|
|
51
|
+
: null;
|
|
52
|
+
}
|
|
53
|
+
export function createPhiPageReference(target) {
|
|
54
|
+
if (target.kind === "site") {
|
|
55
|
+
if (!isPositiveInteger(target.pageScopeId)) {
|
|
56
|
+
throw new Error("Phi Page Scope ids must be positive integers.");
|
|
57
|
+
}
|
|
58
|
+
return `${PHI_PAGE_REFERENCE_VERSION}${PHI_PAGE_REFERENCE_SITE_TAG}${target.pageScopeId}`;
|
|
59
|
+
}
|
|
60
|
+
/*
|
|
61
|
+
* Checked here rather than left to the hash. A malformed owner id hashes perfectly well and produces
|
|
62
|
+
* a reference that resolves to nothing, for a reason nobody can read back out of it -- the one real
|
|
63
|
+
* cost of a one-way id, paid once at the only place that can still see what went in.
|
|
64
|
+
*/
|
|
65
|
+
if (!PHI_MODULE_ID_PATTERN.test(target.ownerModuleId.trim())) {
|
|
66
|
+
throw new Error("Invalid Phi Module id.");
|
|
67
|
+
}
|
|
68
|
+
const pageId = createPhiPresetCmsPageId({
|
|
69
|
+
ownerModuleId: target.ownerModuleId,
|
|
70
|
+
presetKey: target.presetKey,
|
|
71
|
+
});
|
|
72
|
+
return `${PHI_PAGE_REFERENCE_VERSION}${PHI_PAGE_REFERENCE_MODULE_TAG}${pageId}`;
|
|
73
|
+
}
|
|
74
|
+
export function readPhiPageReference(value) {
|
|
75
|
+
if (typeof value !== "string" || !value.startsWith(PHI_PAGE_REFERENCE_VERSION)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const tag = value.slice(PHI_PAGE_REFERENCE_VERSION.length, PHI_PAGE_REFERENCE_VERSION.length + 1);
|
|
79
|
+
const body = value.slice(PHI_PAGE_REFERENCE_VERSION.length + 1);
|
|
80
|
+
if (tag === PHI_PAGE_REFERENCE_SITE_TAG) {
|
|
81
|
+
const pageScopeId = PHI_PAGE_SCOPE_ID_PATTERN.test(body) ? Number(body) : NaN;
|
|
82
|
+
return isPositiveInteger(pageScopeId)
|
|
83
|
+
? { reference: value, target: { kind: "site", pageScopeId } }
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
if (tag === PHI_PAGE_REFERENCE_MODULE_TAG) {
|
|
87
|
+
const pageId = readPresetPageId(body);
|
|
88
|
+
return pageId
|
|
89
|
+
? { reference: value, target: { kind: "module", pageId } }
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
export function createPhiPageUri(reference, fragment) {
|
|
95
|
+
if (!readPhiPageReference(reference)) {
|
|
96
|
+
throw new Error("Invalid Phi Page reference.");
|
|
97
|
+
}
|
|
98
|
+
const normalizedFragment = fragment?.replace(/^#/u, "").trim();
|
|
99
|
+
return `${PHIS_INTERNAL_PAGE_SCHEME}${reference}${normalizedFragment ? `#${encodeURIComponent(normalizedFragment)}` : ""}`;
|
|
100
|
+
}
|
|
101
|
+
export function createPhiAssetUri(assetId) {
|
|
102
|
+
if (!isPositiveInteger(assetId)) {
|
|
103
|
+
throw new Error("Phi Asset ids must be positive integers.");
|
|
104
|
+
}
|
|
105
|
+
return `${PHIS_INTERNAL_ASSET_SCHEME}${assetId}`;
|
|
106
|
+
}
|
|
107
|
+
export function readPhiInternalReference(value) {
|
|
108
|
+
if (typeof value !== "string") {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (value.startsWith(PHIS_INTERNAL_ASSET_SCHEME)) {
|
|
112
|
+
const id = value.slice(PHIS_INTERNAL_ASSET_SCHEME.length);
|
|
113
|
+
return /^[1-9]\d*$/u.test(id) && Number.isSafeInteger(Number(id))
|
|
114
|
+
? { kind: "asset", assetId: Number(id) }
|
|
115
|
+
: null;
|
|
116
|
+
}
|
|
117
|
+
if (!value.startsWith(PHIS_INTERNAL_PAGE_SCHEME)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const [encodedReference, ...fragmentParts] = value.slice(PHIS_INTERNAL_PAGE_SCHEME.length).split("#");
|
|
121
|
+
const parsed = readPhiPageReference(encodedReference);
|
|
122
|
+
if (!parsed) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const fragment = fragmentParts.length > 0 ? decodeURIComponent(fragmentParts.join("#")) : null;
|
|
127
|
+
return { kind: "page", ...parsed, fragment };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The field names a stored link target may stand under, and nothing else identifies one in persisted
|
|
135
|
+
* config.
|
|
136
|
+
*
|
|
137
|
+
* The Asset rule is the precedent and the reason: the server's reference collector reads persisted JSON,
|
|
138
|
+
* not the Widget catalogue, so it cannot ask a plugin which of its fields hold targets. A Widget that
|
|
139
|
+
* keeps one under another name authors a reference the delete guard cannot see, and the Page it points
|
|
140
|
+
* at can be removed while the Widget still draws a link to it.
|
|
141
|
+
*
|
|
142
|
+
* One plain name for the single-link case and a suffix for the rest, so a Card's second link is
|
|
143
|
+
* `actionLinkTarget` and is found by the same rule that finds the first.
|
|
144
|
+
*/
|
|
145
|
+
export const PHI_LINK_TARGET_CONFIG_KEY = "linkTarget";
|
|
146
|
+
const PHI_LINK_TARGET_CONFIG_KEY_SUFFIX = "LinkTarget";
|
|
147
|
+
export function isPhiLinkTargetConfigKey(key) {
|
|
148
|
+
return key === PHI_LINK_TARGET_CONFIG_KEY
|
|
149
|
+
|| (key.endsWith(PHI_LINK_TARGET_CONFIG_KEY_SUFFIX) && key.length > PHI_LINK_TARGET_CONFIG_KEY_SUFFIX.length);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Whether a string may be kept as an external target.
|
|
153
|
+
*
|
|
154
|
+
* Absolute, or an anchor on the document the reader already has. Everything else is refused, and the
|
|
155
|
+
* refusal is the whole point rather than strictness for its own sake: a stored `/pricing` is a Page link
|
|
156
|
+
* that has thrown its identity away, and one of them is enough to give the Site a second internal format
|
|
157
|
+
* -- one the resolver cannot resolve, the index cannot see, and the delete guard cannot warn about.
|
|
158
|
+
*
|
|
159
|
+
* The reserved scheme is rejected here in its plain form only. Normalising the encodings it can hide in
|
|
160
|
+
* belongs to the server's sanitizer, which sees the whole document; this is the near guard, so a target
|
|
161
|
+
* never carries an unresolved `phis:` URI in the field where a literal URL belongs.
|
|
162
|
+
*/
|
|
163
|
+
const PHI_STORABLE_EXTERNAL_HREF_PATTERN = /^(?:https?:\/\/|\/\/|mailto:|tel:|#)/iu;
|
|
164
|
+
export function isPhiStorableExternalHref(value) {
|
|
165
|
+
const trimmed = value.trim();
|
|
166
|
+
return trimmed.length > 0
|
|
167
|
+
&& !trimmed.toLowerCase().startsWith("phis:")
|
|
168
|
+
&& PHI_STORABLE_EXTERNAL_HREF_PATTERN.test(trimmed);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A stored link target, read back under the contract rather than trusted.
|
|
172
|
+
*
|
|
173
|
+
* Anything that is not one of the two shapes answers `null`, and a Control that gets `null` draws no
|
|
174
|
+
* link at all. That is the same answer the contract gives for a Page reference that no longer resolves:
|
|
175
|
+
* non-interactive text beats a link that goes somewhere nobody chose.
|
|
176
|
+
*/
|
|
177
|
+
const PHI_ROUTE_PARAM_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/u;
|
|
178
|
+
/** Route parameter values as a target stores them: names a route can declare, non-empty string values. */
|
|
179
|
+
function readPhiRouteParams(value) {
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const entries = Object.entries(value).flatMap(([name, entry]) => PHI_ROUTE_PARAM_NAME_PATTERN.test(name) && typeof entry === "string" && entry.trim()
|
|
184
|
+
? [[name, entry.trim()]]
|
|
185
|
+
: []);
|
|
186
|
+
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A route pattern with its parameters filled, or `null` while one is still open.
|
|
190
|
+
*
|
|
191
|
+
* Each value is one path segment, encoded as one: a slug with a slash in it cannot step into another
|
|
192
|
+
* route. A pattern without parameters comes back as it is.
|
|
193
|
+
*/
|
|
194
|
+
export function fillPhiRouteParams(pattern, params) {
|
|
195
|
+
let open = false;
|
|
196
|
+
const filled = pattern.split("/").map((segment) => {
|
|
197
|
+
if (!segment.startsWith(":"))
|
|
198
|
+
return segment;
|
|
199
|
+
const value = params?.[segment.slice(1)];
|
|
200
|
+
if (!value) {
|
|
201
|
+
open = true;
|
|
202
|
+
return segment;
|
|
203
|
+
}
|
|
204
|
+
return encodeURIComponent(value);
|
|
205
|
+
}).join("/");
|
|
206
|
+
return open ? null : filled;
|
|
207
|
+
}
|
|
208
|
+
export function readPhiLinkTarget(value) {
|
|
209
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const record = value;
|
|
213
|
+
const newTab = record.newTab === true ? { newTab: true } : {};
|
|
214
|
+
if (record.kind === "page") {
|
|
215
|
+
const parsed = readPhiPageReference(record.reference);
|
|
216
|
+
if (!parsed) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const fragment = typeof record.fragment === "string" ? record.fragment.replace(/^#/u, "").trim() : "";
|
|
220
|
+
/*
|
|
221
|
+
* An Area that is not one is dropped rather than kept: the alternative is a stored value that asks
|
|
222
|
+
* a question nowhere can answer, and a link drawing nothing with no way to see why. Falling back to
|
|
223
|
+
* the asking Area is the behaviour of every link that names none, which is the honest default.
|
|
224
|
+
*/
|
|
225
|
+
const area = isPhiCmsAreaKey(record.area) ? { area: record.area } : {};
|
|
226
|
+
const params = readPhiRouteParams(record.params);
|
|
227
|
+
return {
|
|
228
|
+
kind: "page",
|
|
229
|
+
reference: parsed.reference,
|
|
230
|
+
...area,
|
|
231
|
+
...(params ? { params } : {}),
|
|
232
|
+
...(fragment ? { fragment } : {}),
|
|
233
|
+
...newTab,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (record.kind === "external") {
|
|
237
|
+
const href = typeof record.href === "string" ? record.href.trim() : "";
|
|
238
|
+
return isPhiStorableExternalHref(href) ? { kind: "external", href, ...newTab } : null;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
package/dist/resource-links.d.ts
CHANGED
|
@@ -59,8 +59,9 @@ export type PhisResourceLink = {
|
|
|
59
59
|
*
|
|
60
60
|
* Bounded twice: to this Add-on's own links, and to subjects the acting user may reach. Writing needs
|
|
61
61
|
* an actor -- a link is a person saying these two things belong together, and it is what later lets the
|
|
62
|
-
* Add-on act with no person there. A hook or a job may read the links it wrote and may
|
|
63
|
-
*
|
|
62
|
+
* Add-on act with no person there. A hook or a job may read the links it wrote and may make new ones only
|
|
63
|
+
* on what it opened itself: a thread it owns -- or a ticket or task whose thread it owns -- it reaches
|
|
64
|
+
* already, so a link there grants nothing that needed anybody's consent.
|
|
64
65
|
*/
|
|
65
66
|
export type PhisResourceLinksCapabilityV1 = {
|
|
66
67
|
/** This Add-on's links on one subject, or none where the subject is out of reach. */
|
|
@@ -68,8 +69,10 @@ export type PhisResourceLinksCapabilityV1 = {
|
|
|
68
69
|
/**
|
|
69
70
|
* Records a reference, or hands back the one that is already there.
|
|
70
71
|
*
|
|
71
|
-
* Asking twice is how an id is learned, the way a declared group is: `(type, externalId)` is
|
|
72
|
-
* within this Add-on and this Site, so a second call answers with the first
|
|
72
|
+
* Asking twice is how an id is learned, the way a declared group is: `(subject, type, externalId)` is
|
|
73
|
+
* unique within this Add-on and this Site, so a second call for the same subject answers with the first
|
|
74
|
+
* link instead of failing. One external record may be linked from several subjects -- one issue that
|
|
75
|
+
* several tickets wait on -- and each of those links is its own.
|
|
73
76
|
*/
|
|
74
77
|
link(input: {
|
|
75
78
|
subject: PhisResourceLinkSubject;
|
|
@@ -86,9 +89,12 @@ export type PhisResourceLinksCapabilityV1 = {
|
|
|
86
89
|
}): Promise<boolean>;
|
|
87
90
|
/** Removes a link, and with it whatever actorless reach it granted. */
|
|
88
91
|
unlink(linkId: number): Promise<boolean>;
|
|
89
|
-
/**
|
|
90
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Every link of this Add-on to one external record, oldest first: what an event about that record has
|
|
94
|
+
* to reach. Empty where none is recorded.
|
|
95
|
+
*/
|
|
96
|
+
listByExternalId(input: {
|
|
91
97
|
type: number;
|
|
92
98
|
externalId: string;
|
|
93
|
-
}): Promise<PhisResourceLink
|
|
99
|
+
}): Promise<PhisResourceLink[]>;
|
|
94
100
|
};
|
package/dist/schema.d.ts
CHANGED
|
@@ -235,6 +235,15 @@ export type PhisAddonAssetSlotDescriptor = {
|
|
|
235
235
|
* newest of several would be a fact nobody asked for.
|
|
236
236
|
*/
|
|
237
237
|
mirrorColumn?: string;
|
|
238
|
+
/**
|
|
239
|
+
* A role this Add-on declares, which the actor must hold to put a file in this slot, take one out,
|
|
240
|
+
* or list what it holds -- the same guard `requiresRole` is on a declared query, refused loudly.
|
|
241
|
+
*
|
|
242
|
+
* On an owner- or group-scoped table it narrows: the row's own authority still has to agree. On a
|
|
243
|
+
* table with neither, Core has no row to judge, so a slot there must state one, and the role is then
|
|
244
|
+
* the whole authority -- which is what lets a category icon or a shop logo carry a file at all.
|
|
245
|
+
*/
|
|
246
|
+
requiresRole?: string;
|
|
238
247
|
};
|
|
239
248
|
export type PhisAddonTableDescriptor = {
|
|
240
249
|
name: string;
|
package/dist/signals.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ export declare function isPhiSignalAddressSegment(value: string): boolean;
|
|
|
78
78
|
*/
|
|
79
79
|
export declare function isPhiControllerPluginKey(value: string): boolean;
|
|
80
80
|
export declare function isPhiControllerSignalAddress(address: unknown): address is PhiControllerSignalAddress;
|
|
81
|
+
export declare function readPhiControllerSignalAddress(value: unknown): PhiControllerSignalAddress | undefined;
|
|
81
82
|
/** The instance a `cms:` address names, or null when the string is not one. */
|
|
82
83
|
export declare function readPhiCmsSignalAddressInstanceId(address: string): PhiCmsInstanceId | null;
|
|
83
84
|
export declare function isPhiRegionSignalAddress(address: string): boolean;
|
package/dist/signals.js
CHANGED
|
@@ -146,6 +146,9 @@ export function isPhiControllerSignalAddress(address) {
|
|
|
146
146
|
isPhiSignalAddressSegment(namespacedType.slice(controllerSeparatorIndex + 1)) &&
|
|
147
147
|
isPhiSignalAddressSegment(instanceKey));
|
|
148
148
|
}
|
|
149
|
+
export function readPhiControllerSignalAddress(value) {
|
|
150
|
+
return isPhiControllerSignalAddress(value) ? value.trim() : undefined;
|
|
151
|
+
}
|
|
149
152
|
/** The instance a `cms:` address names, or null when the string is not one. */
|
|
150
153
|
export function readPhiCmsSignalAddressInstanceId(address) {
|
|
151
154
|
if (!address.startsWith("cms:")) {
|
package/dist/support.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PhisSecretsCapabilityV1, PhisSettingsCapabilityV1 } from "./capabilities.js";
|
|
1
2
|
/**
|
|
2
3
|
* The Support vocabulary Core and the Support Module must spell the same way.
|
|
3
4
|
*
|
|
@@ -44,6 +45,17 @@ export declare const PhisSupportTicketTypeFlag: {
|
|
|
44
45
|
*/
|
|
45
46
|
readonly Requestable: number;
|
|
46
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* What is true of one ticket, as bit flags (`support_tickets.flags`).
|
|
50
|
+
*
|
|
51
|
+
* `AwaitingDelivery`: the work is resolved and the fix is not in the requester's hands yet. An integration
|
|
52
|
+
* that tracks releases sets it when the merge resolves the ticket, and the requester window does not start
|
|
53
|
+
* until the release says the fix is out (`delivered_at`): a window that ran out before anybody could have
|
|
54
|
+
* the fix would close a ticket nobody had a chance to object to.
|
|
55
|
+
*/
|
|
56
|
+
export declare const PhisSupportTicketFlag: {
|
|
57
|
+
readonly AwaitingDelivery: number;
|
|
58
|
+
};
|
|
47
59
|
/**
|
|
48
60
|
* What is true of a Support queue, as bit flags.
|
|
49
61
|
*
|
|
@@ -327,6 +339,13 @@ export type PhisSupportCapabilityV1 = {
|
|
|
327
339
|
}): Promise<PhisSupportTicket>;
|
|
328
340
|
/** The work on one ticket, in the order the Site keeps it. */
|
|
329
341
|
listTasks(ticketId: number): Promise<PhisSupportTicketTask[]>;
|
|
342
|
+
/**
|
|
343
|
+
* One task, or null where its ticket is out of reach or it is absent.
|
|
344
|
+
*
|
|
345
|
+
* The way back from a link: an integration that hears about its external record finds the link, the
|
|
346
|
+
* link names a task, and the task names its ticket -- whose conversation a comment belongs in.
|
|
347
|
+
*/
|
|
348
|
+
getTask(taskId: number): Promise<PhisSupportTicketTask | null>;
|
|
330
349
|
/** Records a piece of work, which is what a linked issue hangs on. */
|
|
331
350
|
addTask(input: {
|
|
332
351
|
ticketId: number;
|
|
@@ -339,6 +358,19 @@ export type PhisSupportCapabilityV1 = {
|
|
|
339
358
|
taskId: number;
|
|
340
359
|
status: number;
|
|
341
360
|
}): Promise<PhisSupportTicketTask>;
|
|
361
|
+
/**
|
|
362
|
+
* Holds a resolved ticket's requester window until the fix is delivered.
|
|
363
|
+
*
|
|
364
|
+
* For an integration that tracks releases: the merge resolved the ticket, and the requester cannot have
|
|
365
|
+
* the fix until a release carries it. False where the ticket is not resolved -- there is no window to
|
|
366
|
+
* hold. Reopening or resolving again lets go of it.
|
|
367
|
+
*/
|
|
368
|
+
awaitDelivery(ticketId: number): Promise<boolean>;
|
|
369
|
+
/**
|
|
370
|
+
* Says the fix is out: the requester window of a resolved ticket starts now. False where the ticket is
|
|
371
|
+
* not resolved or was already delivered.
|
|
372
|
+
*/
|
|
373
|
+
markDelivered(ticketId: number): Promise<boolean>;
|
|
342
374
|
/**
|
|
343
375
|
* What one person may still ask for, of one type.
|
|
344
376
|
*
|
|
@@ -388,3 +420,155 @@ export type PhisSupportCapabilityV1 = {
|
|
|
388
420
|
reference: string;
|
|
389
421
|
}): Promise<PhisSupportAllowanceGrant | null>;
|
|
390
422
|
};
|
|
423
|
+
/**
|
|
424
|
+
* What Core hands an implementation of `@phis/server/service/support-forwarding` when it builds one.
|
|
425
|
+
*
|
|
426
|
+
* Built per Site and per forward, so the context names the Site; what the implementation talks to and how
|
|
427
|
+
* it signs in is its own, which is why what it reads from Core is its own configuration and nothing else.
|
|
428
|
+
*/
|
|
429
|
+
export type PhisSupportForwardingServiceContext = {
|
|
430
|
+
/** The Add-on implementing the kind. */
|
|
431
|
+
providerId: string;
|
|
432
|
+
siteKey: string;
|
|
433
|
+
/**
|
|
434
|
+
* The implementation's own Site secrets and settings, the same two capabilities its hooks and jobs
|
|
435
|
+
* have: where the work goes (a repository, an installation) is per-Site configuration, and so are the
|
|
436
|
+
* credentials it goes with.
|
|
437
|
+
*/
|
|
438
|
+
secrets: PhisSecretsCapabilityV1;
|
|
439
|
+
settings: PhisSettingsCapabilityV1;
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* One place an implementation can send work to: a repository, a project, a desk.
|
|
443
|
+
*
|
|
444
|
+
* `key` is the implementation's own name for it and comes back unchanged in `PhisSupportForwardRequest`;
|
|
445
|
+
* `label` is shown to a Supporter as it is ("GitHub acme/app").
|
|
446
|
+
*/
|
|
447
|
+
export type PhisSupportForwardingTarget = {
|
|
448
|
+
key: string;
|
|
449
|
+
label: string;
|
|
450
|
+
};
|
|
451
|
+
/**
|
|
452
|
+
* Whether this implementation can take work on this Site right now, and where.
|
|
453
|
+
*
|
|
454
|
+
* Asked before a Supporter is offered anything. One implementation may offer several targets -- every
|
|
455
|
+
* repository its installation reaches, say -- and a Supporter chooses one per forward. `findings` say in an
|
|
456
|
+
* operator's terms what does not answer when `usable` is false or a target is left out.
|
|
457
|
+
*/
|
|
458
|
+
export type PhisSupportForwardingProbe = {
|
|
459
|
+
usable: boolean;
|
|
460
|
+
targets: PhisSupportForwardingTarget[];
|
|
461
|
+
findings: string[];
|
|
462
|
+
};
|
|
463
|
+
/**
|
|
464
|
+
* One ticket's work, handed over.
|
|
465
|
+
*
|
|
466
|
+
* `mirror` is the ticket type's `External`, decided by Core and not by the implementation: where it is
|
|
467
|
+
* false the external record is opened from the title alone and nothing of the conversation follows it.
|
|
468
|
+
* Where it is true, `body` is what opens it -- the ticket's first message, and only if the person who
|
|
469
|
+
* wrote it agreed to it leaving the Site. What follows afterwards reaches the implementation as thread
|
|
470
|
+
* events, through the synced link Core writes for it, and Core withholds there what may not leave.
|
|
471
|
+
*/
|
|
472
|
+
export type PhisSupportForwardRequest = {
|
|
473
|
+
/** The target the Supporter chose, by the key the probe gave it. */
|
|
474
|
+
target: string;
|
|
475
|
+
ticket: {
|
|
476
|
+
id: number;
|
|
477
|
+
threadId: number;
|
|
478
|
+
subject: string;
|
|
479
|
+
typeKey: string;
|
|
480
|
+
};
|
|
481
|
+
task: {
|
|
482
|
+
title: string;
|
|
483
|
+
};
|
|
484
|
+
mirror: boolean;
|
|
485
|
+
body: string | null;
|
|
486
|
+
/** Who forwarded it, for the external record's attribution. */
|
|
487
|
+
actor: {
|
|
488
|
+
userId: number;
|
|
489
|
+
displayName: string | null;
|
|
490
|
+
};
|
|
491
|
+
signal: AbortSignal;
|
|
492
|
+
};
|
|
493
|
+
/**
|
|
494
|
+
* The external record the work now lives in.
|
|
495
|
+
*
|
|
496
|
+
* `type` and `state` are the implementation's own vocabulary -- an issue, a pull request, open -- and Core
|
|
497
|
+
* stores them on the resource link it writes for the forward without interpreting either, as it does for
|
|
498
|
+
* every link.
|
|
499
|
+
*/
|
|
500
|
+
export type PhisSupportForwardResult = {
|
|
501
|
+
type: number;
|
|
502
|
+
externalId: string;
|
|
503
|
+
url: string | null;
|
|
504
|
+
state: number | null;
|
|
505
|
+
};
|
|
506
|
+
/**
|
|
507
|
+
* A record that already exists in the other system, named by a Supporter: what they pasted, as it is.
|
|
508
|
+
*
|
|
509
|
+
* `reference` is whatever the implementation's own people call one -- a URL, `owner/name#12` -- and each
|
|
510
|
+
* implementation reads only its own. Looking one up changes nothing anywhere: whether the link is made is
|
|
511
|
+
* Core's to decide afterwards, and the record hears of it only once it stands (`linked`).
|
|
512
|
+
*/
|
|
513
|
+
export type PhisSupportLinkLookup = {
|
|
514
|
+
reference: string;
|
|
515
|
+
signal: AbortSignal;
|
|
516
|
+
};
|
|
517
|
+
/**
|
|
518
|
+
* What an implementation answers about a reference.
|
|
519
|
+
*
|
|
520
|
+
* - `unknown`: not one of its own; Core asks the next implementation.
|
|
521
|
+
* - `refused`: its own, but no work can go there -- closed, out of the Site's reach, gone. `reason` is
|
|
522
|
+
* shown to the Supporter as it is.
|
|
523
|
+
* - `linkable`: the record, as `forward` would have answered it, and what it is called there, which
|
|
524
|
+
* becomes the engineering task's title.
|
|
525
|
+
*/
|
|
526
|
+
export type PhisSupportLinkResult = {
|
|
527
|
+
status: "unknown";
|
|
528
|
+
} | {
|
|
529
|
+
status: "refused";
|
|
530
|
+
reason: string;
|
|
531
|
+
} | {
|
|
532
|
+
status: "linkable";
|
|
533
|
+
record: PhisSupportForwardResult;
|
|
534
|
+
title: string;
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* A link that now stands, so the record can say where it is linked from, as a forwarded one does in its
|
|
538
|
+
* body. Nothing of the conversation travels with it: a link hands nothing over by itself.
|
|
539
|
+
*/
|
|
540
|
+
export type PhisSupportLinkedNotice = {
|
|
541
|
+
record: PhisSupportForwardResult;
|
|
542
|
+
ticket: {
|
|
543
|
+
id: number;
|
|
544
|
+
threadId: number;
|
|
545
|
+
subject: string;
|
|
546
|
+
typeKey: string;
|
|
547
|
+
};
|
|
548
|
+
/** Who linked it, for the record's attribution. */
|
|
549
|
+
actor: {
|
|
550
|
+
userId: number;
|
|
551
|
+
displayName: string | null;
|
|
552
|
+
};
|
|
553
|
+
signal: AbortSignal;
|
|
554
|
+
};
|
|
555
|
+
/**
|
|
556
|
+
* The interface of `@phis/server/service/support-forwarding`, version 1.
|
|
557
|
+
*
|
|
558
|
+
* Whether it can take work, taking it, and linking work that is already there -- an issue somebody opened
|
|
559
|
+
* in the other system before or beside the ticket: `lookup` says what a reference is, Core records the link,
|
|
560
|
+
* and `linked` tells the record. A linked record is followed exactly as a forwarded one is; only how it
|
|
561
|
+
* came to exist differs, and a `linked` that fails leaves the link standing.
|
|
562
|
+
*
|
|
563
|
+
* Everything after the hand-over is the capabilities every Add-on has -- `support:v1` to finish the engineering task the forward created,
|
|
564
|
+
* `threads:v1` to answer in the conversation, `resource-links:v1` to move the link's state -- so that what
|
|
565
|
+
* happens to a ticket stays Core's to decide.
|
|
566
|
+
*/
|
|
567
|
+
export interface PhisSupportForwardingAdapterV1 {
|
|
568
|
+
probe(input: {
|
|
569
|
+
signal: AbortSignal;
|
|
570
|
+
}): Promise<PhisSupportForwardingProbe>;
|
|
571
|
+
forward(input: PhisSupportForwardRequest): Promise<PhisSupportForwardResult>;
|
|
572
|
+
lookup(input: PhisSupportLinkLookup): Promise<PhisSupportLinkResult>;
|
|
573
|
+
linked(input: PhisSupportLinkedNotice): Promise<void>;
|
|
574
|
+
}
|
package/dist/support.js
CHANGED
|
@@ -44,6 +44,17 @@ export const PhisSupportTicketTypeFlag = {
|
|
|
44
44
|
*/
|
|
45
45
|
Requestable: 1 << 2,
|
|
46
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* What is true of one ticket, as bit flags (`support_tickets.flags`).
|
|
49
|
+
*
|
|
50
|
+
* `AwaitingDelivery`: the work is resolved and the fix is not in the requester's hands yet. An integration
|
|
51
|
+
* that tracks releases sets it when the merge resolves the ticket, and the requester window does not start
|
|
52
|
+
* until the release says the fix is out (`delivered_at`): a window that ran out before anybody could have
|
|
53
|
+
* the fix would close a ticket nobody had a chance to object to.
|
|
54
|
+
*/
|
|
55
|
+
export const PhisSupportTicketFlag = {
|
|
56
|
+
AwaitingDelivery: 1 << 0,
|
|
57
|
+
};
|
|
47
58
|
/**
|
|
48
59
|
* What is true of a Support queue, as bit flags.
|
|
49
60
|
*
|
package/dist/threads.d.ts
CHANGED
|
@@ -278,6 +278,11 @@ export type PhisThreadsCapabilityV1 = {
|
|
|
278
278
|
*
|
|
279
279
|
* `messageFlags` is normalized rather than trusted: `Internal` requires an actor who is Staff, and
|
|
280
280
|
* `Imported` is Core's to set on what arrives through an integration.
|
|
281
|
+
*
|
|
282
|
+
* One exception, and only in the narrowing direction: without an actor, an Add-on may mark a message it
|
|
283
|
+
* imports -- one carrying `externalRef` -- as `Internal`. What it brings in from outside is its own to
|
|
284
|
+
* keep from the requester, as a maintainer's comment on an issue is; nothing it writes can be made
|
|
285
|
+
* readable to more people this way, only to fewer.
|
|
281
286
|
*/
|
|
282
287
|
create(input: {
|
|
283
288
|
kind: PhisThreadKindValue;
|
|
@@ -321,6 +326,7 @@ export type PhisThreadsCapabilityV1 = {
|
|
|
321
326
|
*
|
|
322
327
|
* Idempotent on `externalRef` where one is given: the same reference answers with the thread as it
|
|
323
328
|
* stands instead of writing a second message, which is what a webhook delivered twice needs.
|
|
329
|
+
* `messageFlags` follow the terms `create` states, its exception for an imported `Internal` included.
|
|
324
330
|
*/
|
|
325
331
|
reply(input: {
|
|
326
332
|
threadId: number;
|
|
@@ -360,4 +366,27 @@ export type PhisThreadsCapabilityV1 = {
|
|
|
360
366
|
* or `reply`, so it finds its way back to a conversation it already knows about and to no other.
|
|
361
367
|
*/
|
|
362
368
|
findByExternalRef(externalRef: string): Promise<PhisThreadDetail | null>;
|
|
369
|
+
/**
|
|
370
|
+
* Records that a message of a reachable thread was written out to the other system, under that
|
|
371
|
+
* system's id for it. True when recorded now, false when it already was -- an event delivered twice
|
|
372
|
+
* finds its first export and posts nothing.
|
|
373
|
+
*
|
|
374
|
+
* An exported message is never imported back: what this Add-on wrote out and then hears about again is
|
|
375
|
+
* recognised by the reference and left alone.
|
|
376
|
+
*/
|
|
377
|
+
recordExport(input: {
|
|
378
|
+
messageId: number;
|
|
379
|
+
externalRef: string;
|
|
380
|
+
}): Promise<boolean>;
|
|
381
|
+
/** The other system's id for a message this Add-on exported, or null. */
|
|
382
|
+
findExport(messageId: number): Promise<string | null>;
|
|
383
|
+
/**
|
|
384
|
+
* Changes a message this Add-on imported, as its source changed it: new text marks it `Edited`, `null`
|
|
385
|
+
* withdraws it -- the text is gone and the message stays, `Redacted`, so the conversation still reads.
|
|
386
|
+
* False where this Add-on imported nothing under that reference.
|
|
387
|
+
*/
|
|
388
|
+
amendImported(input: {
|
|
389
|
+
externalRef: string;
|
|
390
|
+
message: string | null;
|
|
391
|
+
}): Promise<boolean>;
|
|
363
392
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Timeline shows, the same for a Widget's own entries and for a Timeline Provider's answer.
|
|
3
|
+
*
|
|
4
|
+
* A Provider may run in a Module of ours, in an outside Module, or answer from an Add-on behind a Site
|
|
5
|
+
* route, so the entry is spelled here rather than in the UI package: every end that hands entries over
|
|
6
|
+
* reads and writes this shape.
|
|
7
|
+
*/
|
|
8
|
+
/** What an entry's mark says about it: a word about meaning, so the Theme decides the colour. */
|
|
9
|
+
export declare const PHI_TIMELINE_TONES: readonly ["secondary", "success", "warning", "danger"];
|
|
10
|
+
export type PhiTimelineTone = (typeof PHI_TIMELINE_TONES)[number];
|
|
11
|
+
export type PhiTimelineEntry = {
|
|
12
|
+
/** Stable within the list; the entry's identity when the list is drawn again. */
|
|
13
|
+
key: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
content: string;
|
|
16
|
+
/**
|
|
17
|
+
* When it happened, as an ISO 8601 date or date-time. The Widget formats it for the reader's locale.
|
|
18
|
+
* An entry that states `time` as well shows `time`.
|
|
19
|
+
*/
|
|
20
|
+
at?: string;
|
|
21
|
+
/**
|
|
22
|
+
* When it happened, already in words: "Spring 2026", "v1.2", "Yesterday". Shown as written, and
|
|
23
|
+
* preferred over `at` where an entry states both.
|
|
24
|
+
*/
|
|
25
|
+
time?: string;
|
|
26
|
+
/** A Phi icon name, drawn in place of the mark. */
|
|
27
|
+
icon?: string;
|
|
28
|
+
tone?: PhiTimelineTone;
|
|
29
|
+
/** Still under way: the mark spins. */
|
|
30
|
+
pending?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** A Timeline Provider's answer. */
|
|
33
|
+
export type PhiTimelineProviderResult = {
|
|
34
|
+
entries: PhiTimelineEntry[];
|
|
35
|
+
};
|
|
36
|
+
export declare function isPhiTimelineTone(value: unknown): value is PhiTimelineTone;
|
|
37
|
+
/** One entry, or `null` for anything that is not one. An entry without a key or text is not one. */
|
|
38
|
+
export declare function readPhiTimelineEntry(value: unknown): PhiTimelineEntry | null;
|
|
39
|
+
export declare function readPhiTimelineEntries(value: unknown): PhiTimelineEntry[];
|