@portalshq/capability-interaction-orchestrator 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +247 -2
  2. package/dist/arbitration-engine.d.ts +20 -0
  3. package/dist/arbitration-engine.d.ts.map +1 -0
  4. package/dist/arbitration-engine.js +67 -0
  5. package/dist/billing-controller.d.ts +38 -0
  6. package/dist/billing-controller.d.ts.map +1 -0
  7. package/dist/billing-controller.js +239 -0
  8. package/dist/db/repository.d.ts +41 -0
  9. package/dist/db/repository.d.ts.map +1 -0
  10. package/dist/db/repository.js +385 -0
  11. package/dist/event-publisher.d.ts +30 -0
  12. package/dist/event-publisher.d.ts.map +1 -0
  13. package/dist/event-publisher.js +122 -0
  14. package/dist/execution-bridge.d.ts +27 -0
  15. package/dist/execution-bridge.d.ts.map +1 -0
  16. package/dist/execution-bridge.js +48 -0
  17. package/dist/index.d.ts +14 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +13 -0
  20. package/dist/moderation-gateway.d.ts +36 -0
  21. package/dist/moderation-gateway.d.ts.map +1 -0
  22. package/dist/moderation-gateway.js +167 -0
  23. package/dist/orchestrator.d.ts +36 -0
  24. package/dist/orchestrator.d.ts.map +1 -0
  25. package/dist/orchestrator.js +258 -0
  26. package/dist/policies/index.d.ts +33 -0
  27. package/dist/policies/index.d.ts.map +1 -0
  28. package/dist/policies/index.js +177 -0
  29. package/dist/submission-gateway.d.ts +26 -0
  30. package/dist/submission-gateway.d.ts.map +1 -0
  31. package/dist/submission-gateway.js +102 -0
  32. package/dist/types.d.ts +322 -0
  33. package/dist/types.d.ts.map +1 -0
  34. package/dist/types.js +8 -0
  35. package/dist/validation.d.ts +20 -0
  36. package/dist/validation.d.ts.map +1 -0
  37. package/dist/validation.js +221 -0
  38. package/dist/window-manager.d.ts +27 -0
  39. package/dist/window-manager.d.ts.map +1 -0
  40. package/dist/window-manager.js +57 -0
  41. package/package.json +54 -4
  42. package/sql/001_interaction_orchestrator_core.sql +143 -0
  43. package/sql/002_durable_lifecycle.sql +44 -0
package/README.md CHANGED
@@ -1,3 +1,248 @@
1
- # Temporary Holding Version
1
+ # @portalshq/capability-interaction-orchestrator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A durable interaction lifecycle for input windows, moderated submissions, selection,
4
+ credit-backed billing, execution handoff and replayable events. PostgreSQL owns
5
+ admission, quotas, idempotency, credit allocations, resolutions and event ordering.
6
+
7
+ ## Setup
8
+
9
+ Requires Node.js 22+, PostgreSQL, and the `@portalshq/monetization` entitlement schema
10
+ in the **same database**. Apply migrations with errors stopping the transaction:
11
+
12
+ ```sh
13
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction \
14
+ -f node_modules/@portalshq/monetization/sql/001_monetization.sql \
15
+ -f node_modules/@portalshq/capability-interaction-orchestrator/sql/001_interaction_orchestrator_core.sql \
16
+ -f node_modules/@portalshq/capability-interaction-orchestrator/sql/002_durable_lifecycle.sql
17
+ ```
18
+
19
+ Deploy the accompanying monetization update together with this package: both use
20
+ ID order for grant locks while consuming the oldest matching credits first.
21
+ The old `Stub*` exports have been removed. The executable classes now require their
22
+ actual dependencies; no implementation silently grants a payment or host approval.
23
+
24
+ ## Lifecycle interface
25
+
26
+ ```typescript
27
+ import { Pool } from 'pg';
28
+ import {
29
+ InteractionRepository,
30
+ InteractionOrchestrator,
31
+ } from '@portalshq/capability-interaction-orchestrator';
32
+
33
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
34
+ const repository = new InteractionRepository(pool);
35
+ const interactions = new InteractionOrchestrator(repository);
36
+
37
+ const window = await interactions.windows.createWindow({
38
+ worldId: 'channel-123',
39
+ branchId: 'canonical',
40
+ baseCommitHash: 'current-commit',
41
+ conflictKey: 'story',
42
+ allowedInputKinds: ['custom_prompt'],
43
+ arbitrationPolicy: 'fifo',
44
+ billingPolicy: 'prepaid_credits',
45
+ billingTerms: {
46
+ tenantId: 'creator-tenant',
47
+ entitlementKind: 'creator-tenant:story_guide',
48
+ amountCredits: 1,
49
+ amountCents: 100,
50
+ currency: 'USD',
51
+ // Set sessionId only when the actual entitlement scope names a session.
52
+ },
53
+ maxSubmissionsPerUser: 1,
54
+ maxSelectedInputs: 5,
55
+ opensAt: new Date().toISOString(),
56
+ closesAt: null,
57
+ contentRating: 'E',
58
+ });
59
+ await interactions.windows.openWindow(window.id);
60
+
61
+ const submission = await interactions.submissions.submitIntent({
62
+ windowId: window.id,
63
+ actorId: 'authenticated-consumer-id',
64
+ idempotencyKey: 'request-id-kept-across-retries',
65
+ kind: 'custom_prompt',
66
+ payload: { prompt: 'Explore the old lighthouse' },
67
+ });
68
+ if (submission.status !== 'eligible') {
69
+ // Rejected inputs and inputs awaiting human review do not enter selection.
70
+ console.log(submission.status);
71
+ }
72
+ ```
73
+
74
+ `actorId` must come from authenticated application identity. Authorize the caller
75
+ against the window's world before exposing submission, cancellation, review,
76
+ host-selection, refund or event methods over a network. This package is an internal
77
+ module; it does not authenticate HTTP requests. Window configuration, billing terms,
78
+ policy configuration and moderation context come from trusted application code.
79
+ Submission payloads and app metadata cannot set prices, rank or host authority.
80
+
81
+ The server reloads prices for `billing.quote(window, actorId)`; the quote is an
82
+ informational display, not a payment approval. All paid policies settle **credits
83
+ already granted by monetization**. Fiat checkout, Stripe authorization and monetary
84
+ refunds belong to monetization's purchase lifecycle. No synthetic Stripe holds are
85
+ created here.
86
+
87
+ Input JSON is limited to 64 KiB and 32 nesting levels; timestamps use ISO UTC with
88
+ `Z`. Prompts/messages require their text field; votes require an option or target ID
89
+ (and both must agree when present); mutations require target IDs and a parameter
90
+ object; object creation requires `payload.object`; branch creation requires a name.
91
+ Configure `allowedInputKinds` to operations implemented by your execution adapter.
92
+
93
+ ### Resolve, execute, commit
94
+
95
+ ```typescript
96
+ // For finite windows, closeWindow first. An always-open queue can resolve directly.
97
+ const resolution = await interactions.resolveWindow(window.id, {
98
+ idempotencyKey: 'generation-request-id',
99
+ });
100
+ const executable = await interactions.prepareExecution(resolution.id);
101
+ // Pass executable to your execution adapter to generate a private preview/artifact.
102
+ // Keep proposed world changes private until output moderation and commit succeed.
103
+ // Then call commitResolution(resolution.id, proposedOutput, resultingCommitHash).
104
+ ```
105
+
106
+ `resolveWindow` persists selection and losing-input refunds atomically. Selection
107
+ has a window-scoped retry key; reusing it with a different host selection fails.
108
+ Only one selected/executing resolution may exist per window. `prepareExecution`
109
+ checks backing grants and captures selection-dependent credits before execution.
110
+ `commitResolution(id, output, hash)` moderates the complete output, captures deferred
111
+ publication credits and records fulfillment and events together. A non-allow output
112
+ fails the resolution and releases/refunds its credits. For parallel branches, supply
113
+ `{ [selectedSubmissionId]: branchCommitHash }` instead of one hash; the canonical
114
+ head stays unchanged. Output and commit targets must match on a retry.
115
+
116
+ The owned execution adapter must enforce base-commit conflicts against its world
117
+ store. A hash supplied to this module identifies a private proposed artifact;
118
+ publication happens only after a committed resolution. PostgreSQL transactions do
119
+ not make an external provider call or world-store write atomic.
120
+
121
+ `failResolution(id, reason)` refunds selected work and records failure. Use it after
122
+ an execution failure or expired/revoked hold; retrying a failed resolution requires
123
+ a new submission. `cancelWindow(id)` cancels pending inputs and refunds their credits
124
+ in one transaction, refusing to interrupt an active resolution.
125
+
126
+ ## Selection policies
127
+
128
+ | Policy | Behavior |
129
+ | --- | --- |
130
+ | `fifo` | Admission order, with ID as the final deterministic tie-break |
131
+ | `first_accepted` | The first eligible input |
132
+ | `plurality` | Distinct actors per option/target, descending vote count, then admission order; selects one representative per winning target |
133
+ | `ranked` | Trusted `arbitrationPolicyConfig.priorities[submissionId]`, then admission order; ignores payload priority |
134
+ | `batch` | Admission order, combining nonoverlapping `targetEntityIds` up to capacity |
135
+ | `parallel_branches` | Admission order up to capacity, one stable branch destination per selected input |
136
+ | `host_selection` | Explicit `selectedSubmissionIds` passed to `resolveWindow`; rejects unknown, duplicate or excessive IDs |
137
+
138
+ All policies respect `maxSelectedInputs`. Custom policies must partition the eligible
139
+ inputs exactly once into selected and rejected IDs. The arbitration engine is a
140
+ pure selection module; its `resolutionSequence: 0` is an unpersisted proposal.
141
+ The orchestrator allocates the actual monotonic sequence under the window lock.
142
+
143
+ For always-open FIFO/parallel queues, overflow stays eligible for the next generation
144
+ and is recorded in `appMetadata.deferredSubmissionIds`. Finite-window losers are
145
+ marked `not_selected` and refunded. Prompt/message/vote inputs can follow a moving
146
+ queue head; state-changing inputs with an old base must be cancelled and resubmitted
147
+ after application revalidation. Active per-user quota counts pending, eligible,
148
+ selected and executing inputs; completed/rejected/cancelled inputs free that quota.
149
+ Scheduled windows can coexist, but opening two windows with the same world, branch
150
+ and conflict key fails.
151
+
152
+ ## Moderation
153
+
154
+ `ModerationGateway` checks every nested string in JSON input/output. Its defaults
155
+ provide configurable term rules, Unicode normalization, word edges, input length
156
+ 200 and output length 32,000. E blocks mild/strong profanity, explicit terms and
157
+ graphic violence terms; T permits mild profanity; T+ permits strong profanity and
158
+ graphic violence; M permits these categories. Blocked/prohibited terms apply at
159
+ every rating. These are text rules, not semantic or image/video classification.
160
+ Install application/model validators for contextual content decisions and non-text
161
+ media inspection. Version your configuration with `moderationPolicyVersion`.
162
+
163
+ Custom validators cannot bypass the built-in rules. Missing `validateOutput` falls
164
+ back to `validate` with the complete output in a synthetic submission. Validator
165
+ exceptions and malformed decisions stop qualification. `review` and `transform`
166
+ remain in `moderating`; only an explicit trusted
167
+ `submissions.reviewSubmission(id, 'allow' | 'reject', reasons)` completes them.
168
+ Transforms are never silently applied. `contextFor(window)` injects application
169
+ world rules; each request uses its own rating context.
170
+
171
+ ## Billing behavior
172
+
173
+ | Policy | Admission | Selection/execution | Commit |
174
+ | --- | --- | --- | --- |
175
+ | `free` | Zero-credit reservation | Verify lifecycle | No charge |
176
+ | `prepaid_credits` | Consume credits | Validate backing grants | Fulfill |
177
+ | `pay_to_generate` | Consume credits | Validate backing grants | Fulfill |
178
+ | `pay_for_guaranteed_slot` | Claim capacity and consume credits | Guaranteed FIFO/parallel selection | Fulfill |
179
+ | `capture_if_selected` | Deduct a credit hold from spendable balance | Capture selected holds; release losers | Fulfill |
180
+ | `pay_to_publish` | Deduct a credit hold | Generate a private preview | Capture only on allowed output |
181
+
182
+ Guaranteed slots require FIFO or parallel selection and cannot overbook capacity.
183
+ Reservations default to one hour; configure `reservationTtlMs` (1 second–24 hours).
184
+ A hold expires no later than its earliest backing grant. Captured prepaid credits
185
+ remain spent while queued. Cancellation, failed execution, non-allow output and
186
+ finite-window loss release/refund exactly once, restoring only surviving unexpired
187
+ original grants. A deleted/refunded purchase's grant is never recreated. Expired
188
+ credits cannot be made spendable by an interaction refund. Deferred captures check
189
+ expiry and revocation again at execution/publication.
190
+
191
+ ## Events and workers
192
+
193
+ `EventPublisher` accepts `createPostgresFanoutBus(pool)` or `InMemoryFanoutBus` through
194
+ the fanout seam. Set up `fanout_messages` as documented by
195
+ [`capability-realtime-fanout-postgres`](../capability-realtime-fanout-postgres).
196
+ Lifecycle writes enqueue events; call `drainOutbox()` from a worker. Publishing to
197
+ the transport is at least once, with expiring claims and token-checked acknowledgment.
198
+ Failed deliveries stay durable for retry. Each event has a stable ID and a
199
+ transactionally allocated world sequence. Topics use `interaction:${worldId}`.
200
+
201
+ `subscribe(worldId, afterSequence?, { signal? })` returns an async iterator. With a
202
+ cursor it replays all later events in bounded pages; without one it starts at the
203
+ current head. Fanout messages wake the iterator; the database remains authoritative
204
+ for ordering and deduplication. Polling recovers missed hints. Abort, iterator return
205
+ and exceptions release subscriptions and timers, including a read that is waiting.
206
+ Use `String(event.sequence)` for SSE `id`; validate and authorize `Last-Event-ID`.
207
+ `getEvents` returns at most 100 events per page. The iterator handles pagination.
208
+
209
+ Run these bounded jobs with error reporting and nonoverlapping invocations:
210
+
211
+ - `windows.startWindowCloser(5000)` closes due finite windows; always-open queues remain open.
212
+ - `billing.expireReservations()` releases expired, unselected holds in batches of 100.
213
+ - `submissions.resumeSubmission(id)` recovers a durable `received` admission after an outage. Find these through application recovery jobs or `listSubmissions(windowId, 'received')`.
214
+ - `events.drainOutbox()` delivers a bounded outbox batch; repeat while work remains.
215
+
216
+ Open scheduled windows explicitly at their scheduled time. Finish the active
217
+ always-open queue resolution before explicitly closing its intake. Stop/await the closer,
218
+ abort/return all event iterators, finish in-flight worker jobs and then close the
219
+ caller-owned fanout bus and pool during shutdown. A selected/executing expired hold
220
+ requires `failResolution`; it is never silently reassigned. Use trusted operator
221
+ `billing.refund` for completed work; active resolutions must fail first.
222
+
223
+ Repository transactions serialize admission/selection per window, world event writes
224
+ per world, and credit mutations in grant-ID order. Keep network calls outside these
225
+ transactions. The exported repository and `*InTransaction` methods are low-level
226
+ integration seams; use `InteractionOrchestrator` for lifecycle operations. Retain
227
+ outbox history for the promised replay period and monitor unpublished age, pending
228
+ qualification age, expiring holds and failed resolutions. This package does not
229
+ silently prune audit/replay data.
230
+
231
+ ## Validation and migration
232
+
233
+ ```sh
234
+ npm run build --workspace=packages/monetization
235
+ npm run build --workspace=packages/capability-interaction-orchestrator
236
+ npm run typecheck --workspace=packages/capability-interaction-orchestrator
237
+ npm run test:coverage --workspace=packages/capability-interaction-orchestrator
238
+ ```
239
+
240
+ Tests use embedded PostgreSQL (PGlite) by default, applying real migrations. To verify
241
+ multiple connections against a scratch PostgreSQL instance, set both
242
+ `INTERACTION_TEST_MODE=non-production` and `INTERACTION_TEST_DATABASE_URL`. Each suite
243
+ creates/drops only its randomly named schema. Coverage thresholds are 95% for lines,
244
+ statements, functions and branches and are enforced in the PostgreSQL CI job.
245
+
246
+ See the repository's
247
+ [migration guide](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/bears-live-app-migration.md)
248
+ and [advanced usage](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/advanced-usage.md).
@@ -0,0 +1,20 @@
1
+ import type { ArbitrationPolicyName, InputWindow, Resolution, Submission } from "./types.js";
2
+ export interface ArbitrationPolicy {
3
+ readonly name: ArbitrationPolicyName;
4
+ resolve(window: InputWindow, submissions: Submission[]): Resolution;
5
+ }
6
+ export interface IArbitrationEngine {
7
+ resolve(window: InputWindow, submissions: Submission[]): Promise<Resolution>;
8
+ registerPolicy(name: string, policy: ArbitrationPolicy): void;
9
+ setPolicyConfig(policyName: string, config: Record<string, unknown>): void;
10
+ }
11
+ /** Pure selection. Persistence and monotonic resolution sequences belong to the orchestrator. */
12
+ export declare class ArbitrationEngine implements IArbitrationEngine {
13
+ private readonly policies;
14
+ private readonly configs;
15
+ constructor();
16
+ registerPolicy(name: string, policy: ArbitrationPolicy): void;
17
+ setPolicyConfig(name: string, config: Record<string, unknown>): void;
18
+ resolve(window: InputWindow, submissions: Submission[]): Promise<Resolution>;
19
+ }
20
+ //# sourceMappingURL=arbitration-engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arbitration-engine.d.ts","sourceRoot":"","sources":["../src/arbitration-engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EACrB,WAAW,EACX,UAAU,EACV,UAAU,EACX,MAAM,YAAY,CAAC;AAkBpB,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACrC,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,UAAU,CAAC;CACrE;AACD,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7E,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC9D,eAAe,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5E;AACD,iGAAiG;AACjG,qBAAa,iBAAkB,YAAW,kBAAkB;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwC;IACjE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;;IAatE,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAS7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAS9D,OAAO,CACX,MAAM,EAAE,WAAW,EACnB,WAAW,EAAE,UAAU,EAAE,GACxB,OAAO,CAAC,UAAU,CAAC;CAuDvB"}
@@ -0,0 +1,67 @@
1
+ import { BatchPolicy, FIFOPolicy, FirstAcceptedPolicy, HostSelectionPolicy, ParallelBranchesPolicy, PluralityPolicy, RankedPolicy, } from "./policies/index.js";
2
+ import { integer, InteractionError, policyName, record, text, } from "./validation.js";
3
+ /** Pure selection. Persistence and monotonic resolution sequences belong to the orchestrator. */
4
+ export class ArbitrationEngine {
5
+ policies = new Map();
6
+ configs = new Map();
7
+ constructor() {
8
+ for (const policy of [
9
+ new FIFOPolicy(),
10
+ new FirstAcceptedPolicy(),
11
+ new PluralityPolicy(),
12
+ new RankedPolicy(),
13
+ new BatchPolicy(),
14
+ new ParallelBranchesPolicy(),
15
+ new HostSelectionPolicy(),
16
+ ])
17
+ this.registerPolicy(policy.name, policy);
18
+ }
19
+ registerPolicy(name, policy) {
20
+ policyName(name);
21
+ if (name !== policy.name || typeof policy.resolve !== "function")
22
+ throw new InteractionError("INVALID_INPUT", "Policy registration name must match policy.name");
23
+ this.policies.set(name, policy);
24
+ }
25
+ setPolicyConfig(name, config) {
26
+ if (!this.policies.has(name))
27
+ throw new InteractionError("INVALID_INPUT", `Unknown arbitration policy: ${name}`);
28
+ record(config, "policy config");
29
+ this.configs.set(name, structuredClone(config));
30
+ }
31
+ async resolve(window, submissions) {
32
+ const policy = this.policies.get(window.arbitrationPolicy);
33
+ if (!policy)
34
+ throw new InteractionError("INVALID_INPUT", `Unknown arbitration policy: ${window.arbitrationPolicy}`);
35
+ integer(window.maxSelectedInputs, "maxSelectedInputs");
36
+ if (new Set(submissions.map((s) => s.id)).size !== submissions.length ||
37
+ submissions.some((s) => s.windowId !== window.id ||
38
+ s.status !== "eligible" ||
39
+ !Number.isSafeInteger(s.admissionSequence) ||
40
+ s.admissionSequence < 1))
41
+ throw new InteractionError("INVALID_INPUT", "Arbitration requires distinct eligible submissions from this window");
42
+ const effective = {
43
+ ...window,
44
+ arbitrationPolicyConfig: {
45
+ ...this.configs.get(policy.name),
46
+ ...window.arbitrationPolicyConfig,
47
+ },
48
+ };
49
+ const resolution = policy.resolve(structuredClone(effective), structuredClone(submissions));
50
+ text(resolution.id, "resolution id");
51
+ text(resolution.policyVersion, "policyVersion");
52
+ record(resolution.appMetadata, "resolution metadata");
53
+ const selected = resolution.selectedSubmissionIds;
54
+ const rejected = resolution.rejectedSubmissionIds;
55
+ const all = [...selected, ...rejected];
56
+ if (resolution.windowId !== window.id ||
57
+ resolution.baseCommitHash !== window.baseCommitHash ||
58
+ resolution.arbitrationPolicy !== window.arbitrationPolicy ||
59
+ resolution.status !== "selected" ||
60
+ selected.length > window.maxSelectedInputs ||
61
+ all.length !== submissions.length ||
62
+ new Set(all).size !== all.length ||
63
+ all.some((id) => !submissions.some((s) => s.id === id)))
64
+ throw new InteractionError("INVALID_POLICY_RESULT", "Policy must partition submissions exactly once within capacity");
65
+ return resolution;
66
+ }
67
+ }
@@ -0,0 +1,38 @@
1
+ import type { InteractionRepository } from "./db/repository.js";
2
+ import type { EntitlementReservation, InputWindow, PriceQuote, Resolution, Submission } from "./types.js";
3
+ interface Allocation {
4
+ grantId: string;
5
+ amount: number;
6
+ }
7
+ export interface StoredReservation extends EntitlementReservation {
8
+ state: "reserved" | "captured" | "released" | "refunded";
9
+ allocations: Allocation[];
10
+ }
11
+ export interface IBillingController {
12
+ quote(window: InputWindow, actorId: string): Promise<PriceQuote>;
13
+ authorize(submission: Submission): Promise<EntitlementReservation>;
14
+ capture(resolution: Resolution): Promise<void>;
15
+ cancel(submission: Submission): Promise<void>;
16
+ refund(submission: Submission, reason: string): Promise<void>;
17
+ }
18
+ /** Credit-backed billing against monetization grants on the same PostgreSQL database.
19
+ * A hold deducts spendable balance once. Capture, release and refund are durable transitions.
20
+ */
21
+ export declare class BillingController implements IBillingController {
22
+ private readonly repository;
23
+ constructor(repository: InteractionRepository);
24
+ /** Lock every allocation a lifecycle transaction will touch, in database ID order. */
25
+ lockReservations(r: InteractionRepository, submissionIds: string[]): Promise<void>;
26
+ quote(window: InputWindow, actorId: string): Promise<PriceQuote>;
27
+ authorize(submission: Submission): Promise<EntitlementReservation>;
28
+ getReservation(id: string, r?: InteractionRepository): Promise<StoredReservation | null>;
29
+ authorizeInTransaction(r: InteractionRepository, s: Submission, w: InputWindow): Promise<StoredReservation>;
30
+ capture(resolution: Resolution): Promise<void>;
31
+ captureInTransaction(r: InteractionRepository, s: Submission, w: InputWindow): Promise<void>;
32
+ cancel(submission: Submission): Promise<void>;
33
+ refund(submission: Submission, reason: string): Promise<void>;
34
+ releaseInTransaction(r: InteractionRepository, s: Submission, w: InputWindow, reason: string): Promise<void>;
35
+ expireReservations(limit?: number): Promise<number>;
36
+ }
37
+ export {};
38
+ //# sourceMappingURL=billing-controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-controller.d.ts","sourceRoot":"","sources":["../src/billing-controller.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,KAAK,EACV,sBAAsB,EACtB,WAAW,EACX,UAAU,EACV,UAAU,EACV,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,iBAAkB,SAAQ,sBAAsB;IAC/D,KAAK,EAAE,UAAU,GAAG,UAAU,GAAG,UAAU,GAAG,UAAU,CAAC;IACzD,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B;AACD,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACjE,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACnE,OAAO,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AACD;;GAEG;AACH,qBAAa,iBAAkB,YAAW,kBAAkB;IAC9C,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,qBAAqB;IAC9D,sFAAsF;IAChF,gBAAgB,CACpB,CAAC,EAAE,qBAAqB,EACxB,aAAa,EAAE,MAAM,EAAE,GACtB,OAAO,CAAC,IAAI,CAAC;IAoBV,KAAK,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAmBhE,SAAS,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAKlE,cAAc,CAClB,EAAE,EAAE,MAAM,EACV,CAAC,wBAAkB,GAClB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC;IAiB9B,sBAAsB,CAC1B,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,GACb,OAAO,CAAC,iBAAiB,CAAC;IA+FvB,OAAO,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAkC9C,oBAAoB,CACxB,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,GACb,OAAO,CAAC,IAAI,CAAC;IAgDV,MAAM,CAAC,UAAU,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;IAoB7C,MAAM,CAAC,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAY7D,oBAAoB,CACxB,CAAC,EAAE,qBAAqB,EACxB,CAAC,EAAE,UAAU,EACb,CAAC,EAAE,WAAW,EACd,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC;IAiDV,kBAAkB,CAAC,KAAK,SAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAkCvD"}
@@ -0,0 +1,239 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { grantAppliesAt } from "@portalshq/monetization";
3
+ import { integer, InteractionError, requireValue, text } from "./validation.js";
4
+ /** Credit-backed billing against monetization grants on the same PostgreSQL database.
5
+ * A hold deducts spendable balance once. Capture, release and refund are durable transitions.
6
+ */
7
+ export class BillingController {
8
+ repository;
9
+ constructor(repository) {
10
+ this.repository = repository;
11
+ }
12
+ /** Lock every allocation a lifecycle transaction will touch, in database ID order. */
13
+ async lockReservations(r, submissionIds) {
14
+ const allocations = await r.query("SELECT allocations FROM interaction_reservations WHERE submission_id=ANY($1::text[])", [submissionIds]);
15
+ const ids = [
16
+ ...new Set(allocations.rows.flatMap((row) => row.allocations.map((allocation) => allocation.grantId))),
17
+ ];
18
+ if (ids.length)
19
+ await r.query("SELECT id FROM monetization_entitlements WHERE id=ANY($1::text[]) ORDER BY id FOR UPDATE", [ids]);
20
+ }
21
+ async quote(window, actorId) {
22
+ text(actorId, "actorId");
23
+ const current = requireValue(await this.repository.getWindow(window.id), "Window");
24
+ const terms = current.billingTerms;
25
+ if (current.billingPolicy !== "free" && !terms)
26
+ throw new InteractionError("INVALID_INPUT", "Paid window requires billingTerms");
27
+ return {
28
+ id: randomUUID(),
29
+ amountCents: current.billingPolicy === "free" ? 0 : terms.amountCents,
30
+ currency: terms?.currency ?? "USD",
31
+ expiresAt: new Date((await this.repository.now()) + 900000).toISOString(),
32
+ };
33
+ }
34
+ async authorize(submission) {
35
+ return this.repository.withSubmission(submission.id, async (r, s, w) => this.authorizeInTransaction(r, s, w));
36
+ }
37
+ async getReservation(id, r = this.repository) {
38
+ const row = (await r.query("SELECT * FROM interaction_reservations WHERE submission_id=$1", [id])).rows[0];
39
+ return row
40
+ ? {
41
+ id: row.id,
42
+ amountCredits: row.amount_credits,
43
+ expiresAt: new Date(row.expires_at).toISOString(),
44
+ state: row.state,
45
+ allocations: row.allocations,
46
+ }
47
+ : null;
48
+ }
49
+ async authorizeInTransaction(r, s, w) {
50
+ const existing = await this.getReservation(s.id, r);
51
+ if (existing) {
52
+ if (existing.state === "released" || existing.state === "refunded")
53
+ throw new InteractionError("INVALID_STATE", "Reservation was already released");
54
+ return existing;
55
+ }
56
+ if (s.status !== "awaiting_entitlement")
57
+ throw new InteractionError("INVALID_STATE", "Submission must pass moderation before billing");
58
+ const allocations = [];
59
+ const now = await r.now();
60
+ let expiresAt = now + (w.billingTerms?.reservationTtlMs ?? 3600000);
61
+ const terms = w.billingPolicy === "free"
62
+ ? undefined
63
+ : requireValue(w.billingTerms, "billingTerms");
64
+ if (terms) {
65
+ const site = {
66
+ channelId: w.worldId,
67
+ ...(terms.sessionId ? { sessionId: terms.sessionId } : {}),
68
+ ...(terms.date ? { date: terms.date } : {}),
69
+ };
70
+ const candidates = await r.query(`SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS created_order
71
+ FROM monetization_entitlements WHERE tenant_id=$1 AND consumer_id=$2 AND kind=$3 AND remaining>0 AND (expires_at IS NULL OR expires_at>clock_timestamp()) ORDER BY id FOR UPDATE`, [terms.tenantId, s.actorId, terms.entitlementKind]);
72
+ const applicable = candidates.rows
73
+ .filter((row) => grantAppliesAt(row.scope, site))
74
+ .sort((a, b) => a.created_order < b.created_order
75
+ ? -1
76
+ : a.created_order > b.created_order
77
+ ? 1
78
+ : 0);
79
+ if (applicable.reduce((sum, row) => sum + Number(row.remaining), 0) <
80
+ terms.amountCredits)
81
+ throw new InteractionError("INSUFFICIENT_CREDITS", "Insufficient credits");
82
+ let owed = terms.amountCredits;
83
+ for (const row of applicable) {
84
+ if (!owed)
85
+ break;
86
+ const amount = Math.min(owed, Number(row.remaining));
87
+ await r.query("UPDATE monetization_entitlements SET remaining=remaining-$2,updated_at=now() WHERE id=$1", [row.id, amount]);
88
+ allocations.push({ grantId: row.id, amount });
89
+ owed -= amount;
90
+ if (row.expires_at)
91
+ expiresAt = Math.min(expiresAt, new Date(row.expires_at).getTime());
92
+ }
93
+ }
94
+ const state = ["capture_if_selected", "pay_to_publish"].includes(w.billingPolicy)
95
+ ? "reserved"
96
+ : "captured";
97
+ const reservation = {
98
+ id: randomUUID(),
99
+ state,
100
+ amountCredits: terms?.amountCredits ?? 0,
101
+ allocations,
102
+ expiresAt: new Date(expiresAt).toISOString(),
103
+ };
104
+ await r.query("INSERT INTO interaction_reservations(id,submission_id,state,amount_credits,allocations,expires_at) VALUES($1,$2,$3,$4,$5,$6)", [
105
+ reservation.id,
106
+ s.id,
107
+ state,
108
+ reservation.amountCredits,
109
+ JSON.stringify(allocations),
110
+ reservation.expiresAt,
111
+ ]);
112
+ await r.emit(w, "billing_authorized", {
113
+ submissionId: s.id,
114
+ reservationId: reservation.id,
115
+ state,
116
+ amountCredits: reservation.amountCredits,
117
+ });
118
+ return reservation;
119
+ }
120
+ async capture(resolution) {
121
+ await this.repository.transaction(async (r) => {
122
+ const w = requireValue(await r.getWindow(resolution.windowId, true), "Window");
123
+ const stored = requireValue(await r.getResolution(resolution.id), "Resolution");
124
+ if (stored.windowId !== w.id ||
125
+ !["selected", "executing", "committed"].includes(stored.status))
126
+ throw new InteractionError("INVALID_STATE", "Resolution cannot be captured");
127
+ // Pay-to-publish capture is owned by commitResolution, after output moderation.
128
+ if (w.billingPolicy === "pay_to_publish")
129
+ throw new InteractionError("OUTPUT_REQUIRED", "Use commitResolution to capture after output moderation");
130
+ if (stored.status === "committed")
131
+ return;
132
+ await this.lockReservations(r, stored.selectedSubmissionIds);
133
+ for (const id of stored.selectedSubmissionIds)
134
+ await this.captureInTransaction(r, requireValue(await r.getSubmission(id, true), "Submission"), w);
135
+ });
136
+ }
137
+ async captureInTransaction(r, s, w) {
138
+ const reservation = requireValue(await this.getReservation(s.id, r), "Reservation");
139
+ if (reservation.state !== "reserved" && reservation.state !== "captured")
140
+ throw new InteractionError("INVALID_STATE", "Reservation is no longer payable");
141
+ const now = await r.now();
142
+ if (reservation.state === "reserved" &&
143
+ Date.parse(reservation.expiresAt) <= now)
144
+ throw new InteractionError("RESERVATION_EXPIRED", "Credit reservation expired");
145
+ await this.lockReservations(r, [s.id]);
146
+ for (const allocation of [...reservation.allocations].sort((a, b) => a.grantId.localeCompare(b.grantId))) {
147
+ const grant = (await r.query("SELECT id,expires_at FROM monetization_entitlements WHERE id=$1 FOR UPDATE", [allocation.grantId])).rows[0];
148
+ if (!grant ||
149
+ (grant.expires_at && new Date(grant.expires_at).getTime() <= now))
150
+ throw new InteractionError("ENTITLEMENT_REVOKED", "Backing credit grant was revoked or expired");
151
+ }
152
+ if (reservation.state === "captured")
153
+ return;
154
+ await r.query("UPDATE interaction_reservations SET state='captured',updated_at=now() WHERE id=$1", [reservation.id]);
155
+ await r.emit(w, "billing_captured", {
156
+ submissionId: s.id,
157
+ reservationId: reservation.id,
158
+ });
159
+ }
160
+ async cancel(submission) {
161
+ await this.repository.withSubmission(submission.id, async (r, s, w) => {
162
+ if (s.status === "cancelled")
163
+ return;
164
+ if (![
165
+ "received",
166
+ "moderating",
167
+ "awaiting_entitlement",
168
+ "eligible",
169
+ "rejected",
170
+ ].includes(s.status))
171
+ throw new InteractionError("INVALID_STATE", "Cannot cancel selected or completed billing");
172
+ await this.releaseInTransaction(r, s, w, "cancelled");
173
+ await r.saveSubmission(s, "cancelled");
174
+ });
175
+ }
176
+ async refund(submission, reason) {
177
+ text(reason, "refund reason");
178
+ await this.repository.withSubmission(submission.id, async (r, s, w) => {
179
+ if (s.status === "selected" || s.status === "executing")
180
+ throw new InteractionError("INVALID_STATE", "Fail the active resolution before refunding");
181
+ await this.releaseInTransaction(r, s, w, reason);
182
+ if (s.status !== "refunded")
183
+ await r.saveSubmission(s, "refunded");
184
+ });
185
+ }
186
+ async releaseInTransaction(r, s, w, reason) {
187
+ const reservation = await this.getReservation(s.id, r);
188
+ if (!reservation ||
189
+ reservation.state === "released" ||
190
+ reservation.state === "refunded")
191
+ return;
192
+ // Lock grants in the same order as capture. Deleted or expired grants cannot be recreated.
193
+ await this.lockReservations(r, [s.id]);
194
+ let restored = 0;
195
+ for (const allocation of [...reservation.allocations].sort((a, b) => a.grantId.localeCompare(b.grantId))) {
196
+ const row = (await r.query("SELECT remaining,quantity,expires_at FROM monetization_entitlements WHERE id=$1 FOR UPDATE", [allocation.grantId])).rows[0];
197
+ if (!row ||
198
+ (row.expires_at &&
199
+ new Date(row.expires_at).getTime() <= (await r.now())))
200
+ continue;
201
+ const amount = Math.min(allocation.amount, Number(row.quantity) - Number(row.remaining));
202
+ await r.query("UPDATE monetization_entitlements SET remaining=remaining+$2,updated_at=now() WHERE id=$1", [allocation.grantId, amount]);
203
+ restored += amount;
204
+ }
205
+ const state = reservation.state === "reserved" ? "released" : "refunded";
206
+ await r.query("UPDATE interaction_reservations SET state=$2,reason=$3,updated_at=now() WHERE id=$1", [reservation.id, state, reason]);
207
+ await r.emit(w, "billing_released", {
208
+ submissionId: s.id,
209
+ reservationId: reservation.id,
210
+ state,
211
+ restoredCredits: restored,
212
+ reason,
213
+ });
214
+ }
215
+ async expireReservations(limit = 100) {
216
+ integer(limit, "limit", 1, 1000);
217
+ const rows = await this.repository.query(`SELECT submission_id FROM interaction_reservations r JOIN submissions s ON s.id=r.submission_id
218
+ WHERE r.state='reserved' AND r.expires_at<=clock_timestamp() AND s.status IN ('received','moderating','awaiting_entitlement','eligible') ORDER BY r.expires_at LIMIT $1`, [limit]);
219
+ let expired = 0;
220
+ for (const row of rows.rows)
221
+ await this.repository.withSubmission(row.submission_id, async (r, s, w) => {
222
+ const reservation = await this.getReservation(s.id, r);
223
+ if (reservation?.state !== "reserved" ||
224
+ Date.parse(reservation.expiresAt) > (await r.now()))
225
+ return;
226
+ if (![
227
+ "received",
228
+ "moderating",
229
+ "awaiting_entitlement",
230
+ "eligible",
231
+ ].includes(s.status))
232
+ return;
233
+ await this.releaseInTransaction(r, s, w, "reservation_expired");
234
+ await r.saveSubmission(s, "failed");
235
+ expired++;
236
+ });
237
+ return expired;
238
+ }
239
+ }