@phis/contracts 0.5.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 CHANGED
@@ -6,6 +6,7 @@ and change at different moments.
6
6
 
7
7
  ```
8
8
  @phis/contracts/addon what phis and a separately shipped Add-on promise each other
9
+ @phis/contracts/payments the approved Payment consumer, Provider and merchant-profile types
9
10
  @phis/contracts/access the authorization vocabulary phis and @phis/ui both evaluate
10
11
  @phis/contracts/signals the signal vocabulary and address grammar the UI declares in and phis validates
11
12
  @phis/contracts/catalog the Module categories an Add-on declares, the UI groups by and a market filters on
@@ -45,7 +46,7 @@ The types and constants an Add-on declares or implements. Types and constants on
45
46
  `server-only`, no React, no Next.
46
47
 
47
48
  An Add-on is compiled to a single ESM artifact and installed into a running phis through the `phis` CLI;
48
- it is never built into the server. This subpath is the only thing an Add-on compiles against, and it must
49
+ it is never built into the server. This subpath is the shared Add-on entrypoint it compiles against, and it must
49
50
  not depend on phis itself. For an Add-on author it is a build-time dependency: everything it exports is
50
51
  erased at compile time apart from a handful of constants.
51
52
 
@@ -61,10 +62,10 @@ What it covers:
61
62
  (`PhisMediaStorageAdapter`, upload plans, object I/O) and Directory. `PHIS_SERVICE_KINDS` names them
62
63
  and `PHIS_SERVICE_INTERFACE_DIGESTS` holds the digest of each kind's current interface; Core refuses an
63
64
  Add-on built against a different digest at install.
64
- - **Core capabilities** -- `PHIS_CORE_CAPABILITIES`, the capability ids an Add-on may require. Declare
65
- against these constants rather than spelling the ids out. The list is the vocabulary and may reserve a
66
- name before Core delivers it (`resource-links`, `support`); an Add-on that requires a capability this
67
- release does not deliver is refused rather than handed a missing object.
65
+ - **Core capabilities** -- `PHIS_CORE_CAPABILITIES`, the catalogue ids an Add-on may require. Declare
66
+ against these constants rather than spelling the ids out. Core separately validates delivery to
67
+ Add-ons; a catalogue id such as `authentication` may serve Site Modules without a corresponding
68
+ Add-on context field. Payment ids accompany the implemented Core capability/service/event catalogue.
68
69
 
69
70
  `PHIS_ADDON_ABI_VERSION` is the single number phis checks an Add-on against.
70
71
 
@@ -72,6 +73,23 @@ How an Add-on is laid out, compiled, installed, activated and upgraded is descri
72
73
  [`ADDON_HOWTO.md`](https://github.com/phis-dev/phis-server/blob/main/ADDON_HOWTO.md); the rules behind it
73
74
  are in [`SERVER_ADDONS.md`](https://github.com/phis-dev/phis-server/blob/main/SERVER_ADDONS.md).
74
75
 
76
+ ## `/payments`
77
+
78
+ The approved v1 consumer capability, Provider adapter, checkout plans, normalized money,
79
+ payment/operation/refund records, merchant profile/grant types and owning-consumer event facts.
80
+ All exports are types; there is no secret accessor implementation, checkout SDK, exchange-rate
81
+ conversion or Payment engine here. The same types are re-exported by `/addon`.
82
+
83
+ Consumers own their prices in CHF, EUR, USD or another supported currency. Every payment uses one
84
+ currency; capture and refunds inherit it. Core never infers conversion or settlement currency.
85
+
86
+ Core implements Payment execution separately. Its catalogue exports `payments`, `paymentNotifications`,
87
+ the Payment service kind/interface digest and `payment.changed`. Notification ingress is available
88
+ only in a bound hook context. The Add-on event context discriminates thread and Payment subjects;
89
+ thread producers remain explicitly typed as thread events.
90
+ See the normative [Payment contract](https://github.com/phis-dev/phis-server/blob/main/PAYMENTS.md)
91
+ and [Stripe/profile contract](https://github.com/phis-dev/phis-server/blob/main/PAYMENTS_STRIPE.md).
92
+
75
93
  ## `/access`
76
94
 
77
95
  Claim shapes, policy shapes, and the evaluator that decides them. Unlike `/addon`, this is runtime code
@@ -3,6 +3,7 @@ export * from "./assets.js";
3
3
  export * from "./capabilities.js";
4
4
  export * from "./core.js";
5
5
  export * from "./media-storage.js";
6
+ export * from "./payments.js";
6
7
  export * from "./queries.js";
7
8
  export * from "./resource-links.js";
8
9
  export * from "./schema.js";
@@ -3,6 +3,7 @@ export * from "./assets.js";
3
3
  export * from "./capabilities.js";
4
4
  export * from "./core.js";
5
5
  export * from "./media-storage.js";
6
+ export * from "./payments.js";
6
7
  export * from "./queries.js";
7
8
  export * from "./resource-links.js";
8
9
  export * from "./schema.js";
package/dist/addon.d.ts CHANGED
@@ -65,7 +65,9 @@ export type PhisAddonHookDescriptor = {
65
65
  *
66
66
  * Absent is not a lesser variant of present: an unverified hook writes nothing at all -- not through
67
67
  * `data:v1`, not by opening a thread -- because nothing distinguishes its caller from anybody who
68
- * found the address. A hook that needs to write declares how it can be believed.
68
+ * found the address. A hook that needs ambient write authority declares how it can be believed.
69
+ * Target v1 Payment notification ingress instead verifies through its bound Core capability and
70
+ * reconciles Core-owned payment state without granting ambient hook write authority.
69
71
  */
70
72
  verification?: PhisAddonHookVerificationDescriptor;
71
73
  /**
@@ -12,7 +12,8 @@
12
12
  * at the point where it made the mistake.
13
13
  */
14
14
  import type { PhisAssetsCapabilityV1 } from "./assets.js";
15
- import type { PhisAddonEventId } from "./core.js";
15
+ import type { PhisAddonThreadEventId } from "./core.js";
16
+ import type { PhisPaymentChangedEventFacts, PhisPaymentNotificationsCapabilityV1, PhisPaymentsCapabilityV1 } from "./payments.js";
16
17
  import type { PhisDataCapabilityV1 } from "./queries.js";
17
18
  import type { PhisResourceLinksCapabilityV1 } from "./resource-links.js";
18
19
  import type { PhisSupportCapabilityV1 } from "./support.js";
@@ -102,6 +103,10 @@ export type PhisAddonCapabilities = {
102
103
  support?: PhisSupportCapabilityV1;
103
104
  settings?: PhisSettingsCapabilityV1;
104
105
  roles?: PhisRolesCapabilityV1;
106
+ /** Bound to the declaring consumer and Site when required. */
107
+ payments?: PhisPaymentsCapabilityV1;
108
+ /** Supplied only to an original, bound Payment Provider hook request. */
109
+ paymentNotifications?: PhisPaymentNotificationsCapabilityV1;
105
110
  };
106
111
  /**
107
112
  * The roles capability: `@phis/server/roles:v1`.
@@ -206,9 +211,19 @@ export type PhisStorageCapabilityV1 = {
206
211
  * own would be a package that could plant one.
207
212
  */
208
213
  export type PhisSecretsCapabilityV1 = {
209
- /** The value, or null when the operator has not configured it. */
210
- read(name: string): Promise<string | null>;
214
+ /**
215
+ * The value, or null when the operator has not configured it.
216
+ *
217
+ * `scope` says which of the two an operator can set: `"site"` (the default) is the one set with
218
+ * `--site` for the Site this call runs on; `"addon"` is the one set without `--site`, shared by every
219
+ * Site the Add-on runs on -- one credential for one external account, such as a single app
220
+ * registration. The two are never substituted for each other: a Site secret that is missing is
221
+ * `null`, not the shared one.
222
+ */
223
+ read(name: string, scope?: PhisSecretScope): Promise<string | null>;
211
224
  };
225
+ /** Which secret `PhisSecretsCapabilityV1.read` names: the Site's own, or the one shared by all Sites. */
226
+ export type PhisSecretScope = "site" | "addon";
212
227
  /**
213
228
  * What an Add-on may learn about a person.
214
229
  *
@@ -357,24 +372,26 @@ export type PhisAddonJobContext = {
357
372
  * this Site something, an event is this Site telling an Add-on something, and both run as the Add-on
358
373
  * rather than as a person.
359
374
  *
360
- * Delivery is bounded by the same link. An Add-on is told about a thread it is synced with and about no
361
- * other, because a Site-wide feed of everything anybody writes is the listing `threads:v1` refuses,
362
- * arriving by push instead of by pull.
375
+ * Thread delivery is bounded by a synced link. The Payment event reaches only its stored
376
+ * consuming Add-on. The subject kind and event discriminate those two ownership models; approved
377
+ * Payment facts are delivered through Core's durable owner-directed outbox.
363
378
  */
364
- export type PhisAddonEventContext = {
379
+ export type PhisAddonEventFacts = {
380
+ event: PhisAddonThreadEventId;
381
+ subject: {
382
+ kind: "thread";
383
+ threadId: number;
384
+ messageId: number | null;
385
+ };
386
+ } | PhisPaymentChangedEventFacts;
387
+ export type PhisAddonEventContext = PhisAddonEventFacts & {
365
388
  addonId: string;
366
- event: PhisAddonEventId;
367
389
  /** This delivery, stable across retries, so a handler can recognise one it already settled. */
368
390
  deliveryId: string;
369
391
  site: {
370
392
  id: number;
371
393
  key: string;
372
394
  };
373
- /** The thread the event happened in, and the message where the event is about one. */
374
- subject: {
375
- threadId: number;
376
- messageId: number | null;
377
- };
378
395
  capabilities: PhisAddonCapabilities;
379
396
  signal: AbortSignal;
380
397
  };
package/dist/cms.d.ts CHANGED
@@ -73,7 +73,9 @@ export declare const PhiCmsVisibilityContext: {
73
73
  * The flags a CMS row carries, in the one numbering both sides read them from.
74
74
  *
75
75
  * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
76
- * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
76
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds. The
77
+ * same numbering serves a config's own `flags`, where a Region's and an Overlay's behaviour live
78
+ * (`Sticky`, `NoCloseButton`, ...): a yes-or-no answer in a stored record is a bit, never a boolean.
77
79
  *
78
80
  * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
79
81
  * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
@@ -88,6 +90,26 @@ export declare const PhiCmsFlags: {
88
90
  readonly Collapsed: number;
89
91
  readonly NoTranslate: number;
90
92
  readonly NoIndex: number;
93
+ /** Stays in view while the page scrolls (Header and Sider families). */
94
+ readonly Sticky: number;
95
+ /** A Sider that takes the viewport height below its offset. */
96
+ readonly FullHeight: number;
97
+ /** A Sider the viewer can fold to its collapsed width. */
98
+ readonly Collapsible: number;
99
+ /** The Header chrome renders no close button. */
100
+ readonly NoCloseButton: number;
101
+ /** Escape does not request dismissal. */
102
+ readonly NoEscapeClose: number;
103
+ /** A Modal centred in the viewport. */
104
+ readonly Centered: number;
105
+ /** A Drawer the viewer can resize. */
106
+ readonly Resizable: number;
107
+ /** A Drawer that pushes a nested Drawer aside (`pushDistance` states how far). */
108
+ readonly Push: number;
109
+ /** Pointer input reaches the page behind the Overlay. */
110
+ readonly MaskPassesPointer: number;
111
+ /** An outside pointer action does not request dismissal. */
112
+ readonly MaskKeepsOpen: number;
91
113
  };
92
114
  export declare const PhiCmsRevisionFlags: {
93
115
  readonly Draft: number;
package/dist/cms.js CHANGED
@@ -81,7 +81,9 @@ export const PhiCmsVisibilityContext = {
81
81
  * The flags a CMS row carries, in the one numbering both sides read them from.
82
82
  *
83
83
  * Not every flag reaches every kind of row -- `Collapsed` is a Region's answer and `MobileOnly` a
84
- * renderable's -- but they share one space, because a row is stored as a row whatever it holds.
84
+ * renderable's -- but they share one space, because a row is stored as a row whatever it holds. The
85
+ * same numbering serves a config's own `flags`, where a Region's and an Overlay's behaviour live
86
+ * (`Sticky`, `NoCloseButton`, ...): a yes-or-no answer in a stored record is a bit, never a boolean.
85
87
  *
86
88
  * `NoIndex` is a Page's, and it is the Site's answer rather than the preset's: a Module states what a
87
89
  * Page it ships starts out as, and the Builder's switch is what decides from then on. That is why it
@@ -96,6 +98,35 @@ export const PhiCmsFlags = {
96
98
  Collapsed: 1 << 4,
97
99
  NoTranslate: 1 << 5,
98
100
  NoIndex: 1 << 6,
101
+ /*
102
+ * A Region's behaviour, carried in its config's `flags`: a Region stored on a Page is a config under
103
+ * the Page's `layoutConfig` and has no row of its own, so the config is the one place both kinds have.
104
+ */
105
+ /** Stays in view while the page scrolls (Header and Sider families). */
106
+ Sticky: 1 << 7,
107
+ /** A Sider that takes the viewport height below its offset. */
108
+ FullHeight: 1 << 8,
109
+ /** A Sider the viewer can fold to its collapsed width. */
110
+ Collapsible: 1 << 9,
111
+ /*
112
+ * An Overlay's behaviour, carried in its config's `flags` the same way. The bits name the departure
113
+ * from the Overlay's defaults, so an unset flag set is the plain Overlay: a close button, Escape
114
+ * closes, the mask stops the pointer and closes on a click.
115
+ */
116
+ /** The Header chrome renders no close button. */
117
+ NoCloseButton: 1 << 10,
118
+ /** Escape does not request dismissal. */
119
+ NoEscapeClose: 1 << 11,
120
+ /** A Modal centred in the viewport. */
121
+ Centered: 1 << 12,
122
+ /** A Drawer the viewer can resize. */
123
+ Resizable: 1 << 13,
124
+ /** A Drawer that pushes a nested Drawer aside (`pushDistance` states how far). */
125
+ Push: 1 << 14,
126
+ /** Pointer input reaches the page behind the Overlay. */
127
+ MaskPassesPointer: 1 << 15,
128
+ /** An outside pointer action does not request dismissal. */
129
+ MaskKeepsOpen: 1 << 16,
99
130
  };
100
131
  export const PhiCmsRevisionFlags = {
101
132
  Draft: 1 << 0,
@@ -26,7 +26,15 @@ export type PhiControlOption<TValue extends string | number = string> = {
26
26
  * come, so the order has to make sense without the headings.
27
27
  */
28
28
  group?: string;
29
+ /**
30
+ * A line drawn between this option and the one before or after it, where the Control can draw one --
31
+ * a Select does, in its menu. It sets an option apart from the rest without a heading to name it: the
32
+ * Button's "Custom" above the actions it is not one of. A Control that cannot draw it lists the
33
+ * options as they come.
34
+ */
35
+ separator?: PhiControlOptionSeparator;
29
36
  };
37
+ export type PhiControlOptionSeparator = "before" | "after";
30
38
  export type PhiControlOptionPreview = PhiControlOptionBackgroundPreview | PhiControlOptionFontPreview;
31
39
  /**
32
40
  * The option's label set in the typeface it names, so a list of fonts shows the fonts.
package/dist/controls.js CHANGED
@@ -125,6 +125,7 @@ export function readPhiControlOptions(value) {
125
125
  icon: readString(record.icon),
126
126
  preview: readOptionPreview(record.preview),
127
127
  group: readString(record.group),
128
+ separator: record.separator === "before" || record.separator === "after" ? record.separator : undefined,
128
129
  };
129
130
  })
130
131
  .filter((item) => item != null);
package/dist/core.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PhisPaymentChangedEventFacts } from "./payments.js";
1
2
  /**
2
3
  * What Core offers, named once so both sides can spell it the same way.
3
4
  *
@@ -45,6 +46,8 @@ export declare const PHIS_CORE_CAPABILITIES: {
45
46
  readonly settings: "@phis/server/settings:v1";
46
47
  /** Whether the acting user holds one of this Add-on's own declared roles. */
47
48
  readonly roles: "@phis/server/roles:v1";
49
+ readonly payments: "@phis/server/payments:v1";
50
+ readonly paymentNotifications: "@phis/server/payment-notifications:v1";
48
51
  };
49
52
  export type PhisCoreCapabilityId = (typeof PHIS_CORE_CAPABILITIES)[keyof typeof PHIS_CORE_CAPABILITIES];
50
53
  /**
@@ -67,8 +70,11 @@ export declare const PHIS_ADDON_EVENTS: {
67
70
  readonly threadStatusChanged: "thread.status.changed";
68
71
  /** The link itself is gone; what follows from that is the Add-on's own business. */
69
72
  readonly threadLinkRemoved: "thread.link.removed";
73
+ readonly paymentChanged: "payment.changed";
70
74
  };
71
- export type PhisAddonEventId = (typeof PHIS_ADDON_EVENTS)[keyof typeof PHIS_ADDON_EVENTS];
75
+ export type PhisAddonThreadEventId = Exclude<(typeof PHIS_ADDON_EVENTS)[keyof typeof PHIS_ADDON_EVENTS], "payment.changed">;
76
+ /** Delivered v1 event vocabulary, discriminated by its owned subject. */
77
+ export type PhisAddonEventId = PhisAddonThreadEventId | PhisPaymentChangedEventFacts["event"];
72
78
  /**
73
79
  * The Core-owned service kinds an Add-on may implement.
74
80
  *
@@ -78,6 +84,7 @@ export type PhisAddonEventId = (typeof PHIS_ADDON_EVENTS)[keyof typeof PHIS_ADDO
78
84
  */
79
85
  export declare const PHIS_SERVICE_KINDS: {
80
86
  readonly mediaStorage: "@phis/server/service/media-storage";
87
+ readonly payments: "@phis/server/service/payments";
81
88
  readonly directory: "@phis/server/service/directory";
82
89
  /**
83
90
  * Turning text in one language into text in another.
@@ -88,6 +95,15 @@ export declare const PHIS_SERVICE_KINDS: {
88
95
  * operator selects which one an installation uses.
89
96
  */
90
97
  readonly translation: "@phis/server/service/translation";
98
+ /**
99
+ * Handing a Support ticket's work to a system outside the Site -- an issue tracker, a ticket desk.
100
+ *
101
+ * A kind because Core owns the question: which ticket, which task, what may leave and what comes back.
102
+ * What the implementation talks to is its own business, and several may be installed at once; a
103
+ * Supporter chooses one per forward. The interface is `PhisSupportForwardingAdapterV1` in
104
+ * `@phis/contracts/support`.
105
+ */
106
+ readonly supportForwarding: "@phis/server/service/support-forwarding";
91
107
  };
92
108
  export type PhisServiceKind = (typeof PHIS_SERVICE_KINDS)[keyof typeof PHIS_SERVICE_KINDS];
93
109
  /**
package/dist/core.js CHANGED
@@ -45,6 +45,8 @@ export const PHIS_CORE_CAPABILITIES = {
45
45
  settings: "@phis/server/settings:v1",
46
46
  /** Whether the acting user holds one of this Add-on's own declared roles. */
47
47
  roles: "@phis/server/roles:v1",
48
+ payments: "@phis/server/payments:v1",
49
+ paymentNotifications: "@phis/server/payment-notifications:v1",
48
50
  };
49
51
  /**
50
52
  * What Core tells an Add-on about after the fact.
@@ -66,6 +68,7 @@ export const PHIS_ADDON_EVENTS = {
66
68
  threadStatusChanged: "thread.status.changed",
67
69
  /** The link itself is gone; what follows from that is the Add-on's own business. */
68
70
  threadLinkRemoved: "thread.link.removed",
71
+ paymentChanged: "payment.changed",
69
72
  };
70
73
  /**
71
74
  * The Core-owned service kinds an Add-on may implement.
@@ -76,6 +79,7 @@ export const PHIS_ADDON_EVENTS = {
76
79
  */
77
80
  export const PHIS_SERVICE_KINDS = {
78
81
  mediaStorage: "@phis/server/service/media-storage",
82
+ payments: "@phis/server/service/payments",
79
83
  directory: "@phis/server/service/directory",
80
84
  /**
81
85
  * Turning text in one language into text in another.
@@ -86,6 +90,15 @@ export const PHIS_SERVICE_KINDS = {
86
90
  * operator selects which one an installation uses.
87
91
  */
88
92
  translation: "@phis/server/service/translation",
93
+ /**
94
+ * Handing a Support ticket's work to a system outside the Site -- an issue tracker, a ticket desk.
95
+ *
96
+ * A kind because Core owns the question: which ticket, which task, what may leave and what comes back.
97
+ * What the implementation talks to is its own business, and several may be installed at once; a
98
+ * Supporter chooses one per forward. The interface is `PhisSupportForwardingAdapterV1` in
99
+ * `@phis/contracts/support`.
100
+ */
101
+ supportForwarding: "@phis/server/service/support-forwarding",
89
102
  };
90
103
  /**
91
104
  * The digest of the interface each service kind currently has.
@@ -107,11 +120,15 @@ export const PHIS_SERVICE_KINDS = {
107
120
  */
108
121
  export const PHIS_SERVICE_INTERFACE_VERSIONS = {
109
122
  [PHIS_SERVICE_KINDS.mediaStorage]: 1,
123
+ [PHIS_SERVICE_KINDS.payments]: 1,
110
124
  [PHIS_SERVICE_KINDS.directory]: 1,
111
125
  [PHIS_SERVICE_KINDS.translation]: 1,
126
+ [PHIS_SERVICE_KINDS.supportForwarding]: 1,
112
127
  };
113
128
  export const PHIS_SERVICE_INTERFACE_DIGESTS = {
129
+ [PHIS_SERVICE_KINDS.payments]: "6b106818d5edbfeddd484e9f571509695a1a8dbb5b82435338a094216410fcf7",
114
130
  [PHIS_SERVICE_KINDS.mediaStorage]: "45a871522511f7d77841ca8dd0113b423b3cfd4cb921f143cd1d3d116cab6952",
115
131
  [PHIS_SERVICE_KINDS.directory]: "2cac7397c7beb1b8e71fc67f5cae3be89182dddf096895570a15deafa1adbfc9",
116
132
  [PHIS_SERVICE_KINDS.translation]: "9bae2e7b92ded5cdde9321939a3bb8cd589f6e1ef8bca7bc5f65608313b3207d",
133
+ [PHIS_SERVICE_KINDS.supportForwarding]: "e9d01711a5ebdfa37ea4c6828952c87935b6e755b98ab8e69cc397933dfbed6f",
117
134
  };
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The approved v1 Payment agreement. Core owns execution; consumers own prices and commerce.
3
+ *
4
+ * Execution belongs to Core; its implemented capability, service and event identities live in core.ts.
5
+ * These declarations contain no SDK, credentials, persistence or exchange-rate conversion. See phis-server's
6
+ * PAYMENTS.md and PAYMENTS_STRIPE.md for authority, durability and Provider mapping requirements.
7
+ */
8
+ export type PhisPaymentId = string;
9
+ export type PhisPaymentOperationId = string;
10
+ export type PhisPaymentRefundId = string;
11
+ export type PhisPaymentMoney = {
12
+ /** Uppercase ISO currency code; one agreed currency for the whole payment. */
13
+ currency: string;
14
+ /** Canonical non-negative integer string of minor units, never floating-point money. */
15
+ minor: string;
16
+ };
17
+ export type PhisPaymentCaptureMode = "automatic" | "manual";
18
+ export type PhisPaymentCheckoutKind = "redirect" | "provider-ui";
19
+ export type PhisPaymentFeatures = {
20
+ captureModes: readonly PhisPaymentCaptureMode[];
21
+ checkoutKinds: readonly PhisPaymentCheckoutKind[];
22
+ canPartialCapture: boolean;
23
+ canCancel: boolean;
24
+ canRefund: boolean;
25
+ canPartialRefund: boolean;
26
+ };
27
+ export type PhisPaymentMethod = {
28
+ profileKey: string;
29
+ methodKey: string | null;
30
+ title: string;
31
+ features: PhisPaymentFeatures;
32
+ };
33
+ export type PhisPaymentCheckoutPlan = {
34
+ kind: "redirect";
35
+ url: string;
36
+ expiresAt: string | null;
37
+ } | {
38
+ kind: "provider-ui";
39
+ moduleId: string;
40
+ adapterKey: string;
41
+ parameters: Readonly<Record<string, string>>;
42
+ expiresAt: string | null;
43
+ };
44
+ export type PhisPaymentStatus = "created" | "awaiting_customer" | "processing" | "authorized" | "captured" | "canceled" | "expired" | "failed";
45
+ export type PhisPayment = {
46
+ id: PhisPaymentId;
47
+ reference: string;
48
+ profileKey: string;
49
+ mode: "test" | "live";
50
+ amount: PhisPaymentMoney;
51
+ captureMode: PhisPaymentCaptureMode;
52
+ status: PhisPaymentStatus;
53
+ authorizedMinor: string;
54
+ capturedMinor: string;
55
+ refundedMinor: string;
56
+ authorizationExpiresAt: string | null;
57
+ checkout: PhisPaymentCheckoutPlan | null;
58
+ reconciliationRequired: boolean;
59
+ version: number;
60
+ lastObservedAt: string | null;
61
+ };
62
+ export type PhisPaymentErrorCode = "invalid_input" | "not_found" | "forbidden" | "profile_unavailable" | "method_unavailable" | "unsupported_operation" | "invalid_state" | "active_attempt_exists" | "idempotency_conflict" | "payment_declined" | "provider_rejected" | "provider_unavailable" | "reconciliation_required";
63
+ export type PhisPaymentOperation = {
64
+ id: PhisPaymentOperationId;
65
+ paymentId: PhisPaymentId;
66
+ action: "create" | "complete" | "capture" | "cancel" | "refund";
67
+ state: "reserved" | "pending" | "succeeded" | "failed" | "indeterminate";
68
+ errorCode: PhisPaymentErrorCode | null;
69
+ };
70
+ export type PhisPaymentRefund = {
71
+ id: PhisPaymentRefundId;
72
+ paymentId: PhisPaymentId;
73
+ amount: PhisPaymentMoney;
74
+ status: "pending" | "succeeded" | "failed" | "canceled";
75
+ };
76
+ export type PhisPaymentCommandResult = {
77
+ operation: PhisPaymentOperation;
78
+ payment: PhisPayment;
79
+ refund: PhisPaymentRefund | null;
80
+ };
81
+ export type PhisPaymentCreateInput = {
82
+ profileKey: string;
83
+ reference: string;
84
+ idempotencyKey: string;
85
+ amount: PhisPaymentMoney;
86
+ description: string;
87
+ captureMode: PhisPaymentCaptureMode;
88
+ methodKey: string | null;
89
+ checkoutKind: PhisPaymentCheckoutKind;
90
+ returnPath: string;
91
+ };
92
+ export type PhisPaymentCommandInput = {
93
+ paymentId: PhisPaymentId;
94
+ idempotencyKey: string;
95
+ };
96
+ export interface PhisPaymentsCapabilityV1 {
97
+ listMethods(input: {
98
+ profileKey: string;
99
+ amount: PhisPaymentMoney;
100
+ captureMode: PhisPaymentCaptureMode;
101
+ checkoutKind: PhisPaymentCheckoutKind;
102
+ }): Promise<readonly PhisPaymentMethod[]>;
103
+ /** Core binds Site/consumer/profile; the consumer checks access to its business resource. */
104
+ create(input: PhisPaymentCreateInput): Promise<PhisPaymentCommandResult>;
105
+ get(paymentId: PhisPaymentId): Promise<PhisPayment | null>;
106
+ getOperation(operationId: PhisPaymentOperationId): Promise<PhisPaymentOperation | null>;
107
+ getRefund(refundId: PhisPaymentRefundId): Promise<PhisPaymentRefund | null>;
108
+ /** Customer continuation; never captures a manually authorized payment. */
109
+ complete(input: PhisPaymentCommandInput): Promise<PhisPaymentCommandResult>;
110
+ /** Explicit bounded reconciliation that may update state and enqueue a consumer event. */
111
+ refresh(paymentId: PhisPaymentId): Promise<PhisPayment | null>;
112
+ /** One final merchant-authorized capture; minor units inherit the stored currency. */
113
+ capture(input: PhisPaymentCommandInput & {
114
+ minor: string;
115
+ }): Promise<PhisPaymentCommandResult>;
116
+ cancel(input: PhisPaymentCommandInput): Promise<PhisPaymentCommandResult>;
117
+ /** Merchant-authorized refund; pending/unknown amounts reserve the captured balance. */
118
+ refund(input: PhisPaymentCommandInput & {
119
+ minor: string;
120
+ reason: string;
121
+ }): Promise<PhisPaymentCommandResult>;
122
+ }
123
+ export type PhisPaymentProviderConfigV1 = {
124
+ profileKey: string;
125
+ mode: "test" | "live";
126
+ merchantIdentity: string | null;
127
+ };
128
+ export type PhisPaymentProviderContextV1 = {
129
+ providerKey: string;
130
+ readCredentials(): Promise<Readonly<Record<string, string>> | null>;
131
+ };
132
+ export type PhisPaymentProviderBinding = {
133
+ reference: string;
134
+ data: Readonly<Record<string, string>>;
135
+ };
136
+ export type PhisPaymentProviderProbe = {
137
+ merchantIdentity: string;
138
+ mode: "test" | "live";
139
+ methods: readonly Omit<PhisPaymentMethod, "profileKey">[];
140
+ };
141
+ export type PhisPaymentProviderObservation = {
142
+ binding: PhisPaymentProviderBinding;
143
+ merchantIdentity: string;
144
+ mode: "test" | "live";
145
+ status: PhisPaymentStatus;
146
+ amount: PhisPaymentMoney;
147
+ authorizedMinor: string;
148
+ capturedMinor: string;
149
+ authorizationExpiresAt: string | null;
150
+ checkout: PhisPaymentCheckoutPlan | null;
151
+ /** Complete refund observations, including remote refunds created outside Core. */
152
+ refunds: readonly {
153
+ operationId: PhisPaymentOperationId | null;
154
+ binding: PhisPaymentProviderBinding;
155
+ minor: string;
156
+ status: PhisPaymentRefund["status"];
157
+ }[];
158
+ };
159
+ export type PhisPaymentProviderCommand = {
160
+ operationId: PhisPaymentOperationId;
161
+ paymentId: PhisPaymentId;
162
+ providerIdempotencyKey: string;
163
+ signal: AbortSignal;
164
+ } & ({
165
+ action: "create";
166
+ amount: PhisPaymentMoney;
167
+ description: string;
168
+ captureMode: PhisPaymentCaptureMode;
169
+ methodKey: string | null;
170
+ checkoutKind: PhisPaymentCheckoutKind;
171
+ returnUrl: string;
172
+ notificationUrl: string;
173
+ } | {
174
+ action: "complete" | "cancel";
175
+ binding: PhisPaymentProviderBinding;
176
+ } | {
177
+ action: "capture";
178
+ binding: PhisPaymentProviderBinding;
179
+ minor: string;
180
+ } | {
181
+ action: "refund";
182
+ binding: PhisPaymentProviderBinding;
183
+ minor: string;
184
+ reason: string;
185
+ });
186
+ export type PhisPaymentProviderExecution = {
187
+ outcome: "applied" | "pending";
188
+ observation: PhisPaymentProviderObservation;
189
+ } | {
190
+ outcome: "rejected";
191
+ code: PhisPaymentErrorCode;
192
+ } | {
193
+ outcome: "unknown";
194
+ };
195
+ export type PhisPaymentProviderNotification = {
196
+ outcome: "verified";
197
+ deliveryId: string;
198
+ reference: string;
199
+ } | {
200
+ outcome: "lookup-required";
201
+ reference: string;
202
+ } | {
203
+ outcome: "irrelevant";
204
+ } | {
205
+ outcome: "rejected";
206
+ } | {
207
+ outcome: "unavailable";
208
+ };
209
+ export interface PhisPaymentProviderV1 {
210
+ probe(input: {
211
+ amount: PhisPaymentMoney;
212
+ captureMode: PhisPaymentCaptureMode;
213
+ checkoutKind: PhisPaymentCheckoutKind;
214
+ signal: AbortSignal;
215
+ }): Promise<PhisPaymentProviderProbe>;
216
+ execute(input: PhisPaymentProviderCommand): Promise<PhisPaymentProviderExecution>;
217
+ observe(input: {
218
+ binding: PhisPaymentProviderBinding;
219
+ signal: AbortSignal;
220
+ }): Promise<PhisPaymentProviderObservation>;
221
+ /** Recover the same command; never replace its key, merchant, mode or Provider. */
222
+ recover(input: {
223
+ command: PhisPaymentProviderCommand;
224
+ binding: PhisPaymentProviderBinding | null;
225
+ }): Promise<PhisPaymentProviderExecution>;
226
+ verifyNotification(input: {
227
+ method: string;
228
+ headers: Readonly<Record<string, string>>;
229
+ body: Uint8Array;
230
+ signal: AbortSignal;
231
+ }): Promise<PhisPaymentProviderNotification>;
232
+ }
233
+ export interface PhisPaymentNotificationsCapabilityV1 {
234
+ receive(input: {
235
+ profileKey: string;
236
+ }): Promise<{
237
+ outcome: "processed" | "duplicate" | "ignored";
238
+ }>;
239
+ }
240
+ export type PhisPaymentGrantedOperation = "read" | "create" | "complete" | "refresh" | "capture" | "cancel" | "refund";
241
+ export type PhisPaymentMerchantActionPolicy = {
242
+ roles: readonly string[];
243
+ jobs: readonly string[];
244
+ events: readonly string[];
245
+ };
246
+ export type PhisPaymentProfileGrant = {
247
+ consumerAddonId: string;
248
+ operations: readonly PhisPaymentGrantedOperation[];
249
+ merchantActions: {
250
+ capture: PhisPaymentMerchantActionPolicy;
251
+ cancel: PhisPaymentMerchantActionPolicy;
252
+ refund: PhisPaymentMerchantActionPolicy;
253
+ };
254
+ };
255
+ /** Operator-controlled references and grants; contains no credential values. */
256
+ export type PhisPaymentProfileDraft = {
257
+ key: string;
258
+ providerKey: string;
259
+ mode: "test" | "live";
260
+ credentialReferences: Readonly<Record<string, string>>;
261
+ grants: readonly PhisPaymentProfileGrant[];
262
+ };
263
+ /** A payment event reaches only the consuming Add-on stored on that payment. */
264
+ export type PhisPaymentChangedEventFacts = {
265
+ event: "payment.changed";
266
+ subject: {
267
+ kind: "payment";
268
+ paymentId: PhisPaymentId;
269
+ reference: string;
270
+ version: number;
271
+ operationId: PhisPaymentOperationId | null;
272
+ refundId: PhisPaymentRefundId | null;
273
+ };
274
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The approved v1 Payment agreement. Core owns execution; consumers own prices and commerce.
3
+ *
4
+ * Execution belongs to Core; its implemented capability, service and event identities live in core.ts.
5
+ * These declarations contain no SDK, credentials, persistence or exchange-rate conversion. See phis-server's
6
+ * PAYMENTS.md and PAYMENTS_STRIPE.md for authority, durability and Provider mapping requirements.
7
+ */
8
+ export {};
@@ -98,6 +98,15 @@ export type PhiLinkTarget = {
98
98
  * goes. Whether the reader may follow it is still the target Area's own answer.
99
99
  */
100
100
  area?: PhiCmsAreaKey;
101
+ /**
102
+ * The values of the Page's route parameters, where its route has any (`/news/:slug`).
103
+ *
104
+ * A reference names the route and not one address of it, so a link to one entry of a family of
105
+ * Pages states which: `{ slug: "launch" }`. The resolver answers the route's pattern, and the link
106
+ * fills it from here and from what its caller adds -- a list that links each of its entries states
107
+ * the route once and the value per entry. A parameter nobody fills leaves no link at all.
108
+ */
109
+ params?: Readonly<Record<string, string>>;
101
110
  fragment?: string | null;
102
111
  newTab?: boolean;
103
112
  } | {
@@ -121,10 +130,10 @@ export declare const PHI_LINK_TARGET_CONFIG_KEY = "linkTarget";
121
130
  export declare function isPhiLinkTargetConfigKey(key: string): boolean;
122
131
  export declare function isPhiStorableExternalHref(value: string): boolean;
123
132
  /**
124
- * A stored link target, read back under the contract rather than trusted.
133
+ * A route pattern with its parameters filled, or `null` while one is still open.
125
134
  *
126
- * Anything that is not one of the two shapes answers `null`, and a Control that gets `null` draws no
127
- * link at all. That is the same answer the contract gives for a Page reference that no longer resolves:
128
- * non-interactive text beats a link that goes somewhere nobody chose.
135
+ * Each value is one path segment, encoded as one: a slug with a slash in it cannot step into another
136
+ * route. A pattern without parameters comes back as it is.
129
137
  */
138
+ export declare function fillPhiRouteParams(pattern: string, params: Readonly<Record<string, string>> | null | undefined): string | null;
130
139
  export declare function readPhiLinkTarget(value: unknown): PhiLinkTarget | null;
@@ -174,6 +174,37 @@ export function isPhiStorableExternalHref(value) {
174
174
  * link at all. That is the same answer the contract gives for a Page reference that no longer resolves:
175
175
  * non-interactive text beats a link that goes somewhere nobody chose.
176
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
+ }
177
208
  export function readPhiLinkTarget(value) {
178
209
  if (!value || typeof value !== "object" || Array.isArray(value)) {
179
210
  return null;
@@ -192,10 +223,12 @@ export function readPhiLinkTarget(value) {
192
223
  * the asking Area is the behaviour of every link that names none, which is the honest default.
193
224
  */
194
225
  const area = isPhiCmsAreaKey(record.area) ? { area: record.area } : {};
226
+ const params = readPhiRouteParams(record.params);
195
227
  return {
196
228
  kind: "page",
197
229
  reference: parsed.reference,
198
230
  ...area,
231
+ ...(params ? { params } : {}),
199
232
  ...(fragment ? { fragment } : {}),
200
233
  ...newTab,
201
234
  };
@@ -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 not make new
63
- * ones.
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 unique
72
- * within this Add-on and this Site, so a second call answers with the first link instead of failing.
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
- /** One link of this Add-on by the external id it recorded, or null. */
90
- findByExternalId(input: {
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 | null>;
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/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[];
@@ -0,0 +1,53 @@
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 const PHI_TIMELINE_TONES = ["secondary", "success", "warning", "danger"];
10
+ function readString(value) {
11
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
12
+ }
13
+ export function isPhiTimelineTone(value) {
14
+ return typeof value === "string" && PHI_TIMELINE_TONES.includes(value);
15
+ }
16
+ /**
17
+ * The `at` of an entry, when it is a date this reader can place. A string that is not one is dropped
18
+ * rather than shown: an unreadable instant would print as "Invalid Date" in the reader's language.
19
+ */
20
+ function readInstant(value) {
21
+ const text = readString(value);
22
+ return text && !Number.isNaN(Date.parse(text)) ? text : undefined;
23
+ }
24
+ /** One entry, or `null` for anything that is not one. An entry without a key or text is not one. */
25
+ export function readPhiTimelineEntry(value) {
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
27
+ return null;
28
+ }
29
+ const record = value;
30
+ const key = readString(record.key);
31
+ if (!key) {
32
+ return null;
33
+ }
34
+ const title = readString(record.title);
35
+ const at = readInstant(record.at);
36
+ const time = readString(record.time);
37
+ const icon = readString(record.icon);
38
+ return {
39
+ key,
40
+ content: typeof record.content === "string" ? record.content : "",
41
+ ...(title ? { title } : {}),
42
+ ...(at ? { at } : {}),
43
+ ...(time ? { time } : {}),
44
+ ...(icon ? { icon } : {}),
45
+ ...(isPhiTimelineTone(record.tone) ? { tone: record.tone } : {}),
46
+ ...(record.pending === true ? { pending: true } : {}),
47
+ };
48
+ }
49
+ export function readPhiTimelineEntries(value) {
50
+ return Array.isArray(value)
51
+ ? value.map(readPhiTimelineEntry).filter((entry) => entry != null)
52
+ : [];
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phis/contracts",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -15,6 +15,11 @@
15
15
  "prepack": "npm run build"
16
16
  },
17
17
  "exports": {
18
+ "./payments": {
19
+ "types": "./dist/payments.d.ts",
20
+ "import": "./dist/payments.js",
21
+ "default": "./dist/payments.js"
22
+ },
18
23
  "./references": {
19
24
  "types": "./dist/references.d.ts",
20
25
  "import": "./dist/references.js",
@@ -114,6 +119,11 @@
114
119
  "types": "./dist/layout.d.ts",
115
120
  "import": "./dist/layout.js",
116
121
  "default": "./dist/layout.js"
122
+ },
123
+ "./timeline": {
124
+ "types": "./dist/timeline.d.ts",
125
+ "import": "./dist/timeline.js",
126
+ "default": "./dist/timeline.js"
117
127
  }
118
128
  },
119
129
  "files": [