@voyant-travel/apps 0.1.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 (64) hide show
  1. package/dist/access-boundary.d.ts +29 -0
  2. package/dist/access-boundary.js +33 -0
  3. package/dist/api-runtime.d.ts +10 -0
  4. package/dist/api-runtime.js +8 -0
  5. package/dist/app-api-contracts.d.ts +51 -0
  6. package/dist/app-api-contracts.js +50 -0
  7. package/dist/app-api-routes.d.ts +26 -0
  8. package/dist/app-api-routes.js +133 -0
  9. package/dist/app-api-service.d.ts +1263 -0
  10. package/dist/app-api-service.js +231 -0
  11. package/dist/app-api-service.test.d.ts +1 -0
  12. package/dist/app-api-service.test.js +105 -0
  13. package/dist/compiler.d.ts +38 -0
  14. package/dist/compiler.js +118 -0
  15. package/dist/compiler.test.d.ts +1 -0
  16. package/dist/compiler.test.js +99 -0
  17. package/dist/consent.d.ts +15 -0
  18. package/dist/consent.js +50 -0
  19. package/dist/consent.test.d.ts +1 -0
  20. package/dist/consent.test.js +80 -0
  21. package/dist/contracts.d.ts +263 -0
  22. package/dist/contracts.js +273 -0
  23. package/dist/index.d.ts +13 -0
  24. package/dist/index.js +12 -0
  25. package/dist/ingestion.d.ts +15 -0
  26. package/dist/ingestion.js +232 -0
  27. package/dist/ingestion.test.d.ts +1 -0
  28. package/dist/ingestion.test.js +47 -0
  29. package/dist/installation-reconciliation.d.ts +18 -0
  30. package/dist/installation-reconciliation.js +254 -0
  31. package/dist/installation-service.d.ts +351 -0
  32. package/dist/installation-service.js +328 -0
  33. package/dist/installation-state.d.ts +15 -0
  34. package/dist/installation-state.js +17 -0
  35. package/dist/installation-state.test.d.ts +1 -0
  36. package/dist/installation-state.test.js +38 -0
  37. package/dist/oauth-crypto.d.ts +8 -0
  38. package/dist/oauth-crypto.js +23 -0
  39. package/dist/oauth-crypto.test.d.ts +1 -0
  40. package/dist/oauth-crypto.test.js +11 -0
  41. package/dist/oauth-service.d.ts +65 -0
  42. package/dist/oauth-service.js +381 -0
  43. package/dist/oauth-service.test.d.ts +1 -0
  44. package/dist/oauth-service.test.js +8 -0
  45. package/dist/routes.d.ts +17 -0
  46. package/dist/routes.js +131 -0
  47. package/dist/schema.d.ts +2937 -0
  48. package/dist/schema.js +342 -0
  49. package/dist/service.d.ts +95 -0
  50. package/dist/service.js +172 -0
  51. package/dist/service.test.d.ts +1 -0
  52. package/dist/service.test.js +178 -0
  53. package/dist/test-fixtures.d.ts +80 -0
  54. package/dist/test-fixtures.js +78 -0
  55. package/dist/voyant.d.ts +2 -0
  56. package/dist/voyant.js +115 -0
  57. package/dist/webhook-delivery.d.ts +69 -0
  58. package/dist/webhook-delivery.js +262 -0
  59. package/migrations/20260717000100_app_registry_foundation.sql +87 -0
  60. package/migrations/20260717111938_breezy_karma.sql +136 -0
  61. package/migrations/20260717123000_app_webhook_delivery_health.sql +4 -0
  62. package/migrations/20260717143000_app_oauth_authorization.sql +47 -0
  63. package/migrations/meta/_journal.json +34 -0
  64. package/package.json +159 -0
@@ -0,0 +1,351 @@
1
+ import type { EventBus } from "@voyant-travel/core/events";
2
+ import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
3
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ import { type AppInstallation } from "./schema.js";
5
+ export type AppInstallationStatus = AppInstallation["status"];
6
+ export type AppInstallationUpdatePolicy = AppInstallation["updatePolicy"];
7
+ type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
8
+ export declare const APP_INSTALLATION_ACTIVE_EVENT: "app.installation.active";
9
+ export declare const APP_INSTALLATION_PAUSED_EVENT: "app.installation.paused";
10
+ export declare const APP_INSTALLATION_UNINSTALLED_EVENT: "app.installation.uninstalled";
11
+ export declare const APP_INSTALLATION_UPGRADED_EVENT: "app.installation.upgraded";
12
+ export declare const APP_INSTALLATION_UPGRADE_PENDING_EVENT: "app.installation.upgrade_pending";
13
+ export interface AppInstallationServiceOptions {
14
+ eventBus?: EventBus;
15
+ customFields?: CustomFieldsService;
16
+ platformApiVersion?: string;
17
+ deploymentId?: string;
18
+ }
19
+ export interface InstallAppInput {
20
+ appId: string;
21
+ releaseId: string;
22
+ deploymentId?: string;
23
+ actorId: string;
24
+ updatePolicy?: AppInstallationUpdatePolicy;
25
+ grantedOptionalScopes?: readonly string[];
26
+ credential?: AppCredentialInput;
27
+ }
28
+ export interface UpgradeAppInput {
29
+ installationId: string;
30
+ releaseId: string;
31
+ actorId: string;
32
+ credential?: AppCredentialInput;
33
+ }
34
+ export interface AppCredentialInput {
35
+ credentialHash: string;
36
+ encryptedMetadata: Record<string, unknown>;
37
+ expiresAt?: Date | null;
38
+ }
39
+ export interface LifecycleActionInput {
40
+ installationId: string;
41
+ actorId: string;
42
+ }
43
+ export interface PurgePreviewInput {
44
+ installationId: string;
45
+ actorId: string;
46
+ }
47
+ export interface ResolvedActiveInstallation {
48
+ installation: AppInstallation;
49
+ effectiveScopes: readonly string[];
50
+ }
51
+ export declare function createAppInstallationService(options?: AppInstallationServiceOptions): {
52
+ install: (db: PostgresJsDatabase, input: InstallAppInput) => Promise<{
53
+ installation: {
54
+ id: string;
55
+ appId: string;
56
+ deploymentId: string;
57
+ releaseId: string;
58
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
59
+ namespace: string;
60
+ installedBy: string;
61
+ credentialGeneration: number;
62
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
63
+ lastCompatibleReleaseCheckAt: Date | null;
64
+ pendingReleaseId: string | null;
65
+ pendingReason: string | null;
66
+ installedAt: Date;
67
+ authorizedAt: Date | null;
68
+ activatedAt: Date | null;
69
+ pausedAt: Date | null;
70
+ degradedAt: Date | null;
71
+ revokedAt: Date | null;
72
+ uninstalledAt: Date | null;
73
+ purgedAt: Date | null;
74
+ createdAt: Date;
75
+ updatedAt: Date;
76
+ };
77
+ outcome: "unchanged";
78
+ } | {
79
+ installation: {
80
+ appId: string;
81
+ deploymentId: string;
82
+ id: string;
83
+ createdAt: Date;
84
+ updatedAt: Date;
85
+ releaseId: string;
86
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
87
+ namespace: string;
88
+ installedBy: string;
89
+ credentialGeneration: number;
90
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
91
+ lastCompatibleReleaseCheckAt: Date | null;
92
+ pendingReleaseId: string | null;
93
+ pendingReason: string | null;
94
+ installedAt: Date;
95
+ authorizedAt: Date | null;
96
+ activatedAt: Date | null;
97
+ pausedAt: Date | null;
98
+ degradedAt: Date | null;
99
+ revokedAt: Date | null;
100
+ uninstalledAt: Date | null;
101
+ purgedAt: Date | null;
102
+ };
103
+ outcome: "created" | "reinstalled";
104
+ }>;
105
+ upgrade: (db: PostgresJsDatabase, input: UpgradeAppInput) => Promise<{
106
+ installation: {
107
+ id: string;
108
+ appId: string;
109
+ deploymentId: string;
110
+ releaseId: string;
111
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
112
+ namespace: string;
113
+ installedBy: string;
114
+ credentialGeneration: number;
115
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
116
+ lastCompatibleReleaseCheckAt: Date | null;
117
+ pendingReleaseId: string | null;
118
+ pendingReason: string | null;
119
+ installedAt: Date;
120
+ authorizedAt: Date | null;
121
+ activatedAt: Date | null;
122
+ pausedAt: Date | null;
123
+ degradedAt: Date | null;
124
+ revokedAt: Date | null;
125
+ uninstalledAt: Date | null;
126
+ purgedAt: Date | null;
127
+ createdAt: Date;
128
+ updatedAt: Date;
129
+ };
130
+ outcome: "pending_consent";
131
+ missingScopes: string[];
132
+ } | {
133
+ installation: {
134
+ id: string;
135
+ appId: string;
136
+ deploymentId: string;
137
+ releaseId: string;
138
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
139
+ namespace: string;
140
+ installedBy: string;
141
+ credentialGeneration: number;
142
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
143
+ lastCompatibleReleaseCheckAt: Date | null;
144
+ pendingReleaseId: string | null;
145
+ pendingReason: string | null;
146
+ installedAt: Date;
147
+ authorizedAt: Date | null;
148
+ activatedAt: Date | null;
149
+ pausedAt: Date | null;
150
+ degradedAt: Date | null;
151
+ revokedAt: Date | null;
152
+ uninstalledAt: Date | null;
153
+ purgedAt: Date | null;
154
+ createdAt: Date;
155
+ updatedAt: Date;
156
+ };
157
+ outcome: "upgraded";
158
+ missingScopes: never[];
159
+ }>;
160
+ pause: (db: PostgresJsDatabase, input: LifecycleActionInput) => Promise<{
161
+ installation: {
162
+ id: string;
163
+ appId: string;
164
+ deploymentId: string;
165
+ releaseId: string;
166
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
167
+ namespace: string;
168
+ installedBy: string;
169
+ credentialGeneration: number;
170
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
171
+ lastCompatibleReleaseCheckAt: Date | null;
172
+ pendingReleaseId: string | null;
173
+ pendingReason: string | null;
174
+ installedAt: Date;
175
+ authorizedAt: Date | null;
176
+ activatedAt: Date | null;
177
+ pausedAt: Date | null;
178
+ degradedAt: Date | null;
179
+ revokedAt: Date | null;
180
+ uninstalledAt: Date | null;
181
+ purgedAt: Date | null;
182
+ createdAt: Date;
183
+ updatedAt: Date;
184
+ };
185
+ outcome: "unchanged";
186
+ } | {
187
+ installation: {
188
+ id: string;
189
+ appId: string;
190
+ deploymentId: string;
191
+ releaseId: string;
192
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
193
+ namespace: string;
194
+ installedBy: string;
195
+ credentialGeneration: number;
196
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
197
+ lastCompatibleReleaseCheckAt: Date | null;
198
+ pendingReleaseId: string | null;
199
+ pendingReason: string | null;
200
+ installedAt: Date;
201
+ authorizedAt: Date | null;
202
+ activatedAt: Date | null;
203
+ pausedAt: Date | null;
204
+ degradedAt: Date | null;
205
+ revokedAt: Date | null;
206
+ uninstalledAt: Date | null;
207
+ purgedAt: Date | null;
208
+ createdAt: Date;
209
+ updatedAt: Date;
210
+ };
211
+ outcome: "updated";
212
+ }>;
213
+ resume: (db: PostgresJsDatabase, input: LifecycleActionInput) => Promise<{
214
+ installation: {
215
+ id: string;
216
+ appId: string;
217
+ deploymentId: string;
218
+ releaseId: string;
219
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
220
+ namespace: string;
221
+ installedBy: string;
222
+ credentialGeneration: number;
223
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
224
+ lastCompatibleReleaseCheckAt: Date | null;
225
+ pendingReleaseId: string | null;
226
+ pendingReason: string | null;
227
+ installedAt: Date;
228
+ authorizedAt: Date | null;
229
+ activatedAt: Date | null;
230
+ pausedAt: Date | null;
231
+ degradedAt: Date | null;
232
+ revokedAt: Date | null;
233
+ uninstalledAt: Date | null;
234
+ purgedAt: Date | null;
235
+ createdAt: Date;
236
+ updatedAt: Date;
237
+ };
238
+ outcome: "unchanged";
239
+ } | {
240
+ installation: {
241
+ id: string;
242
+ appId: string;
243
+ deploymentId: string;
244
+ releaseId: string;
245
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
246
+ namespace: string;
247
+ installedBy: string;
248
+ credentialGeneration: number;
249
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
250
+ lastCompatibleReleaseCheckAt: Date | null;
251
+ pendingReleaseId: string | null;
252
+ pendingReason: string | null;
253
+ installedAt: Date;
254
+ authorizedAt: Date | null;
255
+ activatedAt: Date | null;
256
+ pausedAt: Date | null;
257
+ degradedAt: Date | null;
258
+ revokedAt: Date | null;
259
+ uninstalledAt: Date | null;
260
+ purgedAt: Date | null;
261
+ createdAt: Date;
262
+ updatedAt: Date;
263
+ };
264
+ outcome: "updated";
265
+ }>;
266
+ uninstall: (db: PostgresJsDatabase, input: LifecycleActionInput) => Promise<{
267
+ installation: {
268
+ id: string;
269
+ appId: string;
270
+ deploymentId: string;
271
+ releaseId: string;
272
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
273
+ namespace: string;
274
+ installedBy: string;
275
+ credentialGeneration: number;
276
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
277
+ lastCompatibleReleaseCheckAt: Date | null;
278
+ pendingReleaseId: string | null;
279
+ pendingReason: string | null;
280
+ installedAt: Date;
281
+ authorizedAt: Date | null;
282
+ activatedAt: Date | null;
283
+ pausedAt: Date | null;
284
+ degradedAt: Date | null;
285
+ revokedAt: Date | null;
286
+ uninstalledAt: Date | null;
287
+ purgedAt: Date | null;
288
+ createdAt: Date;
289
+ updatedAt: Date;
290
+ };
291
+ outcome: "unchanged";
292
+ } | {
293
+ installation: {
294
+ id: string;
295
+ appId: string;
296
+ deploymentId: string;
297
+ releaseId: string;
298
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
299
+ namespace: string;
300
+ installedBy: string;
301
+ credentialGeneration: number;
302
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
303
+ lastCompatibleReleaseCheckAt: Date | null;
304
+ pendingReleaseId: string | null;
305
+ pendingReason: string | null;
306
+ installedAt: Date;
307
+ authorizedAt: Date | null;
308
+ activatedAt: Date | null;
309
+ pausedAt: Date | null;
310
+ degradedAt: Date | null;
311
+ revokedAt: Date | null;
312
+ uninstalledAt: Date | null;
313
+ purgedAt: Date | null;
314
+ createdAt: Date;
315
+ updatedAt: Date;
316
+ };
317
+ outcome: "updated";
318
+ }>;
319
+ purgePreview: (db: PostgresJsDatabase, input: PurgePreviewInput) => Promise<{
320
+ installation: {
321
+ id: string;
322
+ appId: string;
323
+ deploymentId: string;
324
+ releaseId: string;
325
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
326
+ namespace: string;
327
+ installedBy: string;
328
+ credentialGeneration: number;
329
+ updatePolicy: "patch" | "manual" | "compatible" | "pinned";
330
+ lastCompatibleReleaseCheckAt: Date | null;
331
+ pendingReleaseId: string | null;
332
+ pendingReason: string | null;
333
+ installedAt: Date;
334
+ authorizedAt: Date | null;
335
+ activatedAt: Date | null;
336
+ pausedAt: Date | null;
337
+ degradedAt: Date | null;
338
+ revokedAt: Date | null;
339
+ uninstalledAt: Date | null;
340
+ purgedAt: Date | null;
341
+ createdAt: Date;
342
+ updatedAt: Date;
343
+ };
344
+ grants: number;
345
+ credentials: number;
346
+ extensions: number;
347
+ webhooks: number;
348
+ }>;
349
+ resolveActiveInstallation: (db: PostgresJsDatabase, installationId: string) => Promise<ResolvedActiveInstallation | null>;
350
+ };
351
+ export {};
@@ -0,0 +1,328 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import { and, eq, sql } from "drizzle-orm";
3
+ import { audit, countInstallationRows, deactivateResolvedRegistrations, deactivateRuntimeState, markAppDefinitionsInactive, newlyRequiredScopes, reconcileGrants, reconcileRelease, rotateCredential, } from "./installation-reconciliation.js";
4
+ import { canInstallOver, invalidTransition, planLifecycleTransition } from "./installation-state.js";
5
+ import { appAccessCredentials, appExtensionInstallations, appGrants, appInstallations, appReleases, apps, appWebhookSubscriptions, } from "./schema.js";
6
+ export const APP_INSTALLATION_ACTIVE_EVENT = "app.installation.active";
7
+ export const APP_INSTALLATION_PAUSED_EVENT = "app.installation.paused";
8
+ export const APP_INSTALLATION_UNINSTALLED_EVENT = "app.installation.uninstalled";
9
+ export const APP_INSTALLATION_UPGRADED_EVENT = "app.installation.upgraded";
10
+ export const APP_INSTALLATION_UPGRADE_PENDING_EVENT = "app.installation.upgrade_pending";
11
+ export function createAppInstallationService(options = {}) {
12
+ const deploymentId = (input) => {
13
+ const resolved = input?.deploymentId ?? options.deploymentId;
14
+ if (!resolved) {
15
+ throw new ApiHttpError("Deployment identity is required", {
16
+ status: 400,
17
+ code: "app_deployment_required",
18
+ });
19
+ }
20
+ return resolved;
21
+ };
22
+ async function install(db, input) {
23
+ const resolvedDeploymentId = deploymentId(input);
24
+ return db.transaction(async (tx) => {
25
+ const release = await requireReleaseForApp(tx, input.appId, input.releaseId);
26
+ assertReleaseAvailable(release);
27
+ assertApiCompatible(release, options.platformApiVersion);
28
+ const app = await requireApp(tx, input.appId);
29
+ const existing = await selectInstallationByDeploymentApp(tx, resolvedDeploymentId, input.appId, true);
30
+ if (existing && !canInstallOver(existing.status)) {
31
+ if (existing.releaseId !== input.releaseId) {
32
+ throw invalidTransition(existing.status, "install_different_release");
33
+ }
34
+ await audit(tx, existing, input.actorId, "lifecycle", "install.idempotent", {});
35
+ return { installation: existing, outcome: "unchanged" };
36
+ }
37
+ const installation = existing
38
+ ? await reactivateInstallation(tx, existing, release, input)
39
+ : await createInstallation(tx, app, release, resolvedDeploymentId, input);
40
+ await reconcileRelease(tx, installation, release, input.actorId, "install", options);
41
+ await reconcileGrants(tx, installation, release, input.actorId, input.grantedOptionalScopes);
42
+ if (input.credential) {
43
+ await rotateCredential(tx, installation, input.credential, input.actorId);
44
+ }
45
+ await audit(tx, installation, input.actorId, "lifecycle", "install.active", {
46
+ transitions: ["pending", "authorizing", "active"],
47
+ releaseId: release.id,
48
+ });
49
+ await emitLifecycle(installation, "active");
50
+ return { installation, outcome: existing ? "reinstalled" : "created" };
51
+ });
52
+ }
53
+ async function upgrade(db, input) {
54
+ return db.transaction(async (tx) => {
55
+ const installation = await requireInstallation(tx, input.installationId, true);
56
+ if (!["active", "paused", "degraded"].includes(installation.status)) {
57
+ throw invalidTransition(installation.status, "upgrade");
58
+ }
59
+ const release = await requireReleaseForApp(tx, installation.appId, input.releaseId);
60
+ assertReleaseAvailable(release);
61
+ assertApiCompatible(release, options.platformApiVersion);
62
+ const missingScopes = await newlyRequiredScopes(tx, installation, release);
63
+ if (missingScopes.length > 0) {
64
+ const [pending] = await tx
65
+ .update(appInstallations)
66
+ .set({
67
+ pendingReleaseId: release.id,
68
+ pendingReason: `New required scopes need consent: ${missingScopes.join(", ")}`,
69
+ updatedAt: new Date(),
70
+ })
71
+ .where(eq(appInstallations.id, installation.id))
72
+ .returning();
73
+ const row = pending ?? installation;
74
+ await audit(tx, row, input.actorId, "grant", "upgrade.pending_consent", { missingScopes });
75
+ await emitLifecycle(row, "upgrade_pending");
76
+ return { installation: row, outcome: "pending_consent", missingScopes };
77
+ }
78
+ await deactivateResolvedRegistrations(tx, installation);
79
+ if (input.credential) {
80
+ await rotateCredential(tx, installation, input.credential, input.actorId);
81
+ }
82
+ const [upgraded] = await tx
83
+ .update(appInstallations)
84
+ .set({
85
+ releaseId: release.id,
86
+ pendingReleaseId: null,
87
+ pendingReason: null,
88
+ updatedAt: new Date(),
89
+ })
90
+ .where(eq(appInstallations.id, installation.id))
91
+ .returning();
92
+ const row = upgraded ?? installation;
93
+ await reconcileRelease(tx, row, release, input.actorId, "upgrade", options);
94
+ await reconcileGrants(tx, row, release, input.actorId, []);
95
+ await audit(tx, row, input.actorId, "lifecycle", "upgrade.active", { releaseId: release.id });
96
+ await emitLifecycle(row, "upgraded");
97
+ return { installation: row, outcome: "upgraded", missingScopes: [] };
98
+ });
99
+ }
100
+ async function pause(db, input) {
101
+ return transition(db, input, ["active", "degraded"], "paused", "pause", async (tx, row) => {
102
+ await deactivateRuntimeState(tx, row);
103
+ });
104
+ }
105
+ async function resume(db, input) {
106
+ return transition(db, input, ["paused"], "active", "resume", async (tx, row) => {
107
+ const release = await requireReleaseForApp(tx, row.appId, row.releaseId);
108
+ await reconcileRelease(tx, row, release, input.actorId, "resume", options);
109
+ });
110
+ }
111
+ async function uninstall(db, input) {
112
+ return transition(db, input, ["active", "paused", "degraded"], "uninstalled", "uninstall", async (tx, row) => {
113
+ await deactivateRuntimeState(tx, row);
114
+ await markAppDefinitionsInactive(tx, row, input.actorId);
115
+ });
116
+ }
117
+ async function purgePreview(db, input) {
118
+ const installation = await requireInstallation(db, input.installationId, false);
119
+ if (installation.status !== "uninstalled") {
120
+ throw invalidTransition(installation.status, "purge_preview");
121
+ }
122
+ const [grants, credentials, extensions, webhooks] = await Promise.all([
123
+ countInstallationRows(db, appGrants, installation.id),
124
+ countInstallationRows(db, appAccessCredentials, installation.id),
125
+ countInstallationRows(db, appExtensionInstallations, installation.id),
126
+ countInstallationRows(db, appWebhookSubscriptions, installation.id),
127
+ ]);
128
+ await audit(db, installation, input.actorId, "purge", "purge.preview", {
129
+ grants,
130
+ credentials,
131
+ extensions,
132
+ webhooks,
133
+ });
134
+ return { installation, grants, credentials, extensions, webhooks };
135
+ }
136
+ async function resolveActiveInstallation(db, installationId) {
137
+ const installation = await selectInstallationById(db, installationId, false);
138
+ if (installation?.status !== "active")
139
+ return null;
140
+ const grants = await db
141
+ .select({ scope: appGrants.scope })
142
+ .from(appGrants)
143
+ .where(and(eq(appGrants.installationId, installation.id), eq(appGrants.status, "granted")))
144
+ .orderBy(appGrants.scope);
145
+ return { installation, effectiveScopes: grants.map((grant) => grant.scope) };
146
+ }
147
+ return { install, upgrade, pause, resume, uninstall, purgePreview, resolveActiveInstallation };
148
+ async function emitLifecycle(installation, event) {
149
+ const data = {
150
+ appId: installation.appId,
151
+ installationId: installation.id,
152
+ deploymentId: installation.deploymentId,
153
+ };
154
+ const metadata = { category: "internal", source: "service" };
155
+ if (event === "active") {
156
+ await options.eventBus?.emit(APP_INSTALLATION_ACTIVE_EVENT, data, metadata);
157
+ }
158
+ else if (event === "paused") {
159
+ await options.eventBus?.emit(APP_INSTALLATION_PAUSED_EVENT, data, metadata);
160
+ }
161
+ else if (event === "uninstalled") {
162
+ await options.eventBus?.emit(APP_INSTALLATION_UNINSTALLED_EVENT, data, metadata);
163
+ }
164
+ else if (event === "upgraded") {
165
+ await options.eventBus?.emit(APP_INSTALLATION_UPGRADED_EVENT, data, metadata);
166
+ }
167
+ else {
168
+ await options.eventBus?.emit(APP_INSTALLATION_UPGRADE_PENDING_EVENT, data, metadata);
169
+ }
170
+ }
171
+ async function transition(db, input, from, to, action, sideEffect) {
172
+ return db.transaction(async (tx) => {
173
+ const current = await requireInstallation(tx, input.installationId, true);
174
+ const plan = planLifecycleTransition(current.status, from, to, action);
175
+ if (plan.outcome === "unchanged") {
176
+ await audit(tx, current, input.actorId, "lifecycle", `${action}.idempotent`, {});
177
+ return { installation: current, outcome: "unchanged" };
178
+ }
179
+ await sideEffect(tx, current);
180
+ const now = new Date();
181
+ const [updated] = await tx
182
+ .update(appInstallations)
183
+ .set(lifecyclePatch(to, now))
184
+ .where(eq(appInstallations.id, current.id))
185
+ .returning();
186
+ const row = updated ?? { ...current, status: to };
187
+ await audit(tx, row, input.actorId, "lifecycle", `${action}.${to}`, { from: current.status });
188
+ if (to === "active" || to === "paused" || to === "uninstalled") {
189
+ await emitLifecycle(row, to);
190
+ }
191
+ return { installation: row, outcome: "updated" };
192
+ });
193
+ }
194
+ }
195
+ async function createInstallation(db, app, release, deploymentId, input) {
196
+ const now = new Date();
197
+ const [row] = await db
198
+ .insert(appInstallations)
199
+ .values({
200
+ appId: app.id,
201
+ deploymentId,
202
+ releaseId: release.id,
203
+ status: "active",
204
+ namespace: app.platformNamespace,
205
+ installedBy: input.actorId,
206
+ updatePolicy: input.updatePolicy ?? "compatible",
207
+ authorizedAt: now,
208
+ activatedAt: now,
209
+ })
210
+ .returning();
211
+ if (!row)
212
+ throw writeFailed();
213
+ return row;
214
+ }
215
+ async function reactivateInstallation(db, existing, release, input) {
216
+ const now = new Date();
217
+ const [row] = await db
218
+ .update(appInstallations)
219
+ .set({
220
+ releaseId: release.id,
221
+ status: "active",
222
+ updatePolicy: input.updatePolicy ?? existing.updatePolicy,
223
+ pendingReleaseId: null,
224
+ pendingReason: null,
225
+ authorizedAt: now,
226
+ activatedAt: now,
227
+ uninstalledAt: null,
228
+ revokedAt: null,
229
+ updatedAt: now,
230
+ })
231
+ .where(eq(appInstallations.id, existing.id))
232
+ .returning();
233
+ return row ?? existing;
234
+ }
235
+ async function requireApp(db, appId) {
236
+ const [row] = await db.select().from(apps).where(eq(apps.id, appId)).for("update").limit(1);
237
+ if (!row)
238
+ throw new ApiHttpError("App registration not found", { status: 404, code: "app_not_found" });
239
+ return row;
240
+ }
241
+ async function requireReleaseForApp(db, appId, releaseId) {
242
+ const [row] = await db
243
+ .select()
244
+ .from(appReleases)
245
+ .where(and(eq(appReleases.id, releaseId), eq(appReleases.appId, appId)))
246
+ .for("update")
247
+ .limit(1);
248
+ if (!row)
249
+ throw new ApiHttpError("App release not found", { status: 404, code: "app_release_not_found" });
250
+ return row;
251
+ }
252
+ async function requireInstallation(db, id, lock) {
253
+ const row = await selectInstallationById(db, id, lock);
254
+ if (!row) {
255
+ throw new ApiHttpError("App installation not found", {
256
+ status: 404,
257
+ code: "app_installation_not_found",
258
+ });
259
+ }
260
+ return row;
261
+ }
262
+ async function selectInstallationById(db, id, lock) {
263
+ const query = db.select().from(appInstallations).where(eq(appInstallations.id, id));
264
+ const [row] = lock ? await query.for("update").limit(1) : await query.limit(1);
265
+ return row ?? null;
266
+ }
267
+ async function selectInstallationByDeploymentApp(db, deploymentId, appId, lock) {
268
+ const query = db
269
+ .select()
270
+ .from(appInstallations)
271
+ .where(and(eq(appInstallations.deploymentId, deploymentId), eq(appInstallations.appId, appId)));
272
+ const [row] = lock ? await query.for("update").limit(1) : await query.limit(1);
273
+ return row ?? null;
274
+ }
275
+ function assertReleaseAvailable(release) {
276
+ if (release.state !== "available") {
277
+ throw new ApiHttpError("App release is not available", {
278
+ status: 409,
279
+ code: "app_release_unavailable",
280
+ });
281
+ }
282
+ }
283
+ function assertApiCompatible(release, platformApiVersion) {
284
+ if (!platformApiVersion)
285
+ return;
286
+ const range = release.apiCompatibility;
287
+ if (platformApiVersion < range.min || platformApiVersion > range.max) {
288
+ throw new ApiHttpError("App release is not compatible with this platform API version", {
289
+ status: 409,
290
+ code: "app_release_incompatible",
291
+ });
292
+ }
293
+ }
294
+ function lifecyclePatch(to, now) {
295
+ const base = { status: to, updatedAt: now };
296
+ if (to === "active")
297
+ return { ...base, activatedAt: now };
298
+ if (to === "paused") {
299
+ return {
300
+ ...base,
301
+ pausedAt: now,
302
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
303
+ };
304
+ }
305
+ if (to === "degraded")
306
+ return { ...base, degradedAt: now };
307
+ if (to === "revoked") {
308
+ return {
309
+ ...base,
310
+ revokedAt: now,
311
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
312
+ };
313
+ }
314
+ if (to === "uninstalled") {
315
+ return {
316
+ ...base,
317
+ uninstalledAt: now,
318
+ credentialGeneration: sql `${appInstallations.credentialGeneration} + 1`,
319
+ };
320
+ }
321
+ return base;
322
+ }
323
+ function writeFailed() {
324
+ return new ApiHttpError("Could not write app installation", {
325
+ status: 500,
326
+ code: "app_installation_write_failed",
327
+ });
328
+ }
@@ -0,0 +1,15 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import type { AppInstallation } from "./schema.js";
3
+ type AppInstallationStatus = AppInstallation["status"];
4
+ export type LifecycleTransitionPlan = {
5
+ outcome: "unchanged";
6
+ status: AppInstallationStatus;
7
+ } | {
8
+ outcome: "updated";
9
+ from: AppInstallationStatus;
10
+ to: AppInstallationStatus;
11
+ };
12
+ export declare function planLifecycleTransition(current: AppInstallationStatus, allowedFrom: readonly AppInstallationStatus[], to: AppInstallationStatus, action: string): LifecycleTransitionPlan;
13
+ export declare function canInstallOver(status: AppInstallationStatus): boolean;
14
+ export declare function invalidTransition(from: string, action: string): ApiHttpError;
15
+ export {};