@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
package/dist/schema.js ADDED
@@ -0,0 +1,342 @@
1
+ import { typeId } from "@voyant-travel/db/lib/typeid-column";
2
+ import { sql } from "drizzle-orm";
3
+ import { boolean, check, index, integer, jsonb, pgEnum, pgTable, text, timestamp, uniqueIndex, } from "drizzle-orm/pg-core";
4
+ export const appDistributionEnum = pgEnum("app_distribution", ["custom", "marketplace"]);
5
+ export const appLifecycleStateEnum = pgEnum("app_lifecycle_state", [
6
+ "active",
7
+ "suspended",
8
+ "deleted",
9
+ ]);
10
+ export const appCredentialKindEnum = pgEnum("app_credential_kind", ["client_secret", "signing_key"]);
11
+ export const appReleaseStateEnum = pgEnum("app_release_state", ["available", "suspended", "yanked"]);
12
+ export const appReleaseArtifactStateEnum = pgEnum("app_release_artifact_state", [
13
+ "available",
14
+ "unavailable",
15
+ ]);
16
+ export const appInstallationStatusEnum = pgEnum("app_installation_status", [
17
+ "pending",
18
+ "authorizing",
19
+ "active",
20
+ "paused",
21
+ "degraded",
22
+ "revoked",
23
+ "uninstalled",
24
+ ]);
25
+ export const appInstallationUpdatePolicyEnum = pgEnum("app_installation_update_policy", [
26
+ "manual",
27
+ "compatible",
28
+ "patch",
29
+ "pinned",
30
+ ]);
31
+ export const appGrantStatusEnum = pgEnum("app_grant_status", [
32
+ "requested",
33
+ "granted",
34
+ "optional",
35
+ "revoked",
36
+ ]);
37
+ export const appAccessCredentialStatusEnum = pgEnum("app_access_credential_status", [
38
+ "active",
39
+ "inactive",
40
+ "revoked",
41
+ ]);
42
+ export const appAccessTokenModeEnum = pgEnum("app_access_token_mode", ["offline", "online"]);
43
+ export const appInstallationRegistrationStatusEnum = pgEnum("app_installation_registration_status", ["active", "inactive"]);
44
+ export const appWebhookSubscriptionStatusEnum = pgEnum("app_webhook_subscription_status", [
45
+ "active",
46
+ "inactive",
47
+ "failed",
48
+ ]);
49
+ export const appAuditEventKindEnum = pgEnum("app_audit_event_kind", [
50
+ "lifecycle",
51
+ "grant",
52
+ "consent",
53
+ "credential",
54
+ "token",
55
+ "reconciliation",
56
+ "purge",
57
+ ]);
58
+ export const apps = pgTable("apps", {
59
+ id: typeId("apps"),
60
+ platformNamespace: text("platform_namespace").notNull(),
61
+ distribution: appDistributionEnum("distribution").notNull(),
62
+ ownerId: text("owner_id").notNull(),
63
+ displayName: text("display_name").notNull(),
64
+ slug: text("slug").notNull(),
65
+ lifecycleState: appLifecycleStateEnum("lifecycle_state").notNull().default("active"),
66
+ createdBy: text("created_by").notNull(),
67
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
68
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
69
+ }, (table) => [
70
+ uniqueIndex("uidx_apps_platform_namespace").on(table.platformNamespace),
71
+ index("idx_apps_owner").on(table.ownerId, table.distribution, table.lifecycleState),
72
+ // agent-quality: raw-sql reviewed -- owner: apps; identifier is a Drizzle-owned column and the literal prefix is static.
73
+ check("apps_platform_namespace_reserved", sql `${table.platformNamespace} LIKE 'app--%'`),
74
+ ]);
75
+ export const appRedirectUris = pgTable("app_redirect_uris", {
76
+ id: typeId("app_redirect_uris"),
77
+ appId: text("app_id")
78
+ .notNull()
79
+ .references(() => apps.id, { onDelete: "cascade" }),
80
+ redirectUri: text("redirect_uri").notNull(),
81
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
82
+ }, (table) => [
83
+ index("idx_app_redirect_uris_app").on(table.appId),
84
+ uniqueIndex("uidx_app_redirect_uris_exact").on(table.appId, table.redirectUri),
85
+ ]);
86
+ export const appCredentials = pgTable("app_credentials", {
87
+ id: typeId("app_credentials"),
88
+ appId: text("app_id")
89
+ .notNull()
90
+ .references(() => apps.id, { onDelete: "cascade" }),
91
+ kind: appCredentialKindEnum("kind").notNull(),
92
+ generation: integer("generation").notNull(),
93
+ kmsKeyRef: text("kms_key_ref").notNull(),
94
+ publicKeyRef: text("public_key_ref"),
95
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
96
+ retiredAt: timestamp("retired_at", { withTimezone: true }),
97
+ }, (table) => [
98
+ index("idx_app_credentials_app").on(table.appId, table.kind),
99
+ uniqueIndex("uidx_app_credentials_generation").on(table.appId, table.kind, table.generation),
100
+ ]);
101
+ export const appReleases = pgTable("app_releases", {
102
+ id: typeId("app_releases"),
103
+ appId: text("app_id")
104
+ .notNull()
105
+ .references(() => apps.id, { onDelete: "cascade" }),
106
+ releaseVersion: text("release_version").notNull(),
107
+ manifestSchemaVersion: text("manifest_schema_version").notNull(),
108
+ manifestDigest: text("manifest_digest").notNull(),
109
+ manifestSnapshot: jsonb("manifest_snapshot").$type().notNull(),
110
+ normalizedRecord: jsonb("normalized_record").$type().notNull(),
111
+ apiCompatibility: jsonb("api_compatibility").$type().notNull(),
112
+ defaultLocale: text("default_locale").notNull(),
113
+ supportedLocales: jsonb("supported_locales").$type().notNull(),
114
+ state: appReleaseStateEnum("state").notNull().default("available"),
115
+ createdBy: text("created_by").notNull(),
116
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
117
+ }, (table) => [
118
+ index("idx_app_releases_app").on(table.appId, table.createdAt),
119
+ uniqueIndex("uidx_app_releases_digest").on(table.appId, table.manifestDigest),
120
+ uniqueIndex("uidx_app_releases_version").on(table.appId, table.releaseVersion),
121
+ ]);
122
+ export const appReleaseArtifacts = pgTable("app_release_artifacts", {
123
+ id: typeId("app_release_artifacts"),
124
+ releaseId: text("release_id")
125
+ .notNull()
126
+ .references(() => appReleases.id, { onDelete: "cascade" }),
127
+ digest: text("digest").notNull(),
128
+ signature: text("signature"),
129
+ provenance: jsonb("provenance").$type().notNull(),
130
+ registryCoordinates: jsonb("registry_coordinates").$type(),
131
+ assetInventory: jsonb("asset_inventory").$type().notNull(),
132
+ state: appReleaseArtifactStateEnum("state").notNull().default("available"),
133
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
134
+ }, (table) => [
135
+ index("idx_app_release_artifacts_release").on(table.releaseId),
136
+ uniqueIndex("uidx_app_release_artifacts_digest").on(table.releaseId, table.digest),
137
+ ]);
138
+ export const appReleaseLocalizations = pgTable("app_release_localizations", {
139
+ id: typeId("app_release_localizations"),
140
+ releaseId: text("release_id")
141
+ .notNull()
142
+ .references(() => appReleases.id, { onDelete: "cascade" }),
143
+ locale: text("locale").notNull(),
144
+ surface: text("surface").notNull(),
145
+ messageKey: text("message_key").notNull(),
146
+ text: text("text").notNull(),
147
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
148
+ }, (table) => [
149
+ index("idx_app_release_localizations_release").on(table.releaseId, table.locale),
150
+ uniqueIndex("uidx_app_release_localizations_key").on(table.releaseId, table.locale, table.surface, table.messageKey),
151
+ ]);
152
+ export const appInstallations = pgTable("app_installations", {
153
+ id: typeId("app_installations"),
154
+ appId: text("app_id")
155
+ .notNull()
156
+ .references(() => apps.id, { onDelete: "cascade" }),
157
+ deploymentId: text("deployment_id").notNull(),
158
+ releaseId: text("release_id")
159
+ .notNull()
160
+ .references(() => appReleases.id, { onDelete: "restrict" }),
161
+ status: appInstallationStatusEnum("status").notNull().default("pending"),
162
+ namespace: text("namespace").notNull(),
163
+ installedBy: text("installed_by").notNull(),
164
+ credentialGeneration: integer("credential_generation").notNull().default(0),
165
+ updatePolicy: appInstallationUpdatePolicyEnum("update_policy").notNull().default("compatible"),
166
+ lastCompatibleReleaseCheckAt: timestamp("last_compatible_release_check_at", {
167
+ withTimezone: true,
168
+ }),
169
+ pendingReleaseId: text("pending_release_id"),
170
+ pendingReason: text("pending_reason"),
171
+ installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
172
+ authorizedAt: timestamp("authorized_at", { withTimezone: true }),
173
+ activatedAt: timestamp("activated_at", { withTimezone: true }),
174
+ pausedAt: timestamp("paused_at", { withTimezone: true }),
175
+ degradedAt: timestamp("degraded_at", { withTimezone: true }),
176
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
177
+ uninstalledAt: timestamp("uninstalled_at", { withTimezone: true }),
178
+ purgedAt: timestamp("purged_at", { withTimezone: true }),
179
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
180
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
181
+ }, (table) => [
182
+ index("idx_app_installations_app").on(table.appId, table.status),
183
+ index("idx_app_installations_deployment").on(table.deploymentId, table.status),
184
+ uniqueIndex("uidx_app_installations_deployment_app").on(table.deploymentId, table.appId),
185
+ // agent-quality: raw-sql reviewed -- owner: apps; identifier is a Drizzle-owned column and the literal prefix is static.
186
+ check("app_installations_namespace_reserved", sql `${table.namespace} LIKE 'app--%'`),
187
+ ]);
188
+ export const appGrants = pgTable("app_grants", {
189
+ id: typeId("app_grants"),
190
+ installationId: text("installation_id")
191
+ .notNull()
192
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
193
+ scope: text("scope").notNull(),
194
+ status: appGrantStatusEnum("status").notNull(),
195
+ optional: boolean("optional").notNull().default(false),
196
+ requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().defaultNow(),
197
+ grantedAt: timestamp("granted_at", { withTimezone: true }),
198
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
199
+ }, (table) => [
200
+ index("idx_app_grants_installation").on(table.installationId, table.status),
201
+ uniqueIndex("uidx_app_grants_scope").on(table.installationId, table.scope),
202
+ ]);
203
+ export const appAccessCredentials = pgTable("app_access_credentials", {
204
+ id: typeId("app_access_credentials"),
205
+ installationId: text("installation_id")
206
+ .notNull()
207
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
208
+ generation: integer("generation").notNull(),
209
+ tokenMode: appAccessTokenModeEnum("token_mode").notNull().default("offline"),
210
+ credentialHash: text("credential_hash").notNull(),
211
+ encryptedMetadata: jsonb("encrypted_metadata").$type().notNull(),
212
+ status: appAccessCredentialStatusEnum("status").notNull().default("active"),
213
+ actorId: text("actor_id"),
214
+ viewerId: text("viewer_id"),
215
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
216
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
217
+ deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
218
+ }, (table) => [
219
+ index("idx_app_access_credentials_installation").on(table.installationId, table.status),
220
+ index("idx_app_access_credentials_generation").on(table.installationId, table.tokenMode, table.generation),
221
+ ]);
222
+ export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes", {
223
+ id: typeId("app_oauth_authorization_codes"),
224
+ appId: text("app_id")
225
+ .notNull()
226
+ .references(() => apps.id, { onDelete: "cascade" }),
227
+ installationId: text("installation_id")
228
+ .notNull()
229
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
230
+ releaseId: text("release_id")
231
+ .notNull()
232
+ .references(() => appReleases.id, { onDelete: "cascade" }),
233
+ deploymentId: text("deployment_id").notNull(),
234
+ codeHash: text("code_hash").notNull(),
235
+ stateHash: text("state_hash").notNull(),
236
+ redirectUri: text("redirect_uri").notNull(),
237
+ codeChallenge: text("code_challenge").notNull(),
238
+ codeChallengeMethod: text("code_challenge_method").notNull(),
239
+ requestedScopes: jsonb("requested_scopes").$type().notNull(),
240
+ grantedScopes: jsonb("granted_scopes").$type().notNull(),
241
+ deniedOptionalScopes: jsonb("denied_optional_scopes").$type().notNull(),
242
+ actorId: text("actor_id").notNull(),
243
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
244
+ consumedAt: timestamp("consumed_at", { withTimezone: true }),
245
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
246
+ }, (table) => [
247
+ uniqueIndex("uidx_app_oauth_codes_hash").on(table.codeHash),
248
+ index("idx_app_oauth_codes_installation").on(table.installationId, table.expiresAt),
249
+ ]);
250
+ export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
251
+ id: typeId("app_oauth_refresh_tokens"),
252
+ installationId: text("installation_id")
253
+ .notNull()
254
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
255
+ tokenHash: text("token_hash").notNull(),
256
+ generation: integer("generation").notNull(),
257
+ status: appAccessCredentialStatusEnum("status").notNull().default("active"),
258
+ rotatedFromId: text("rotated_from_id"),
259
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
260
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
261
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
262
+ }, (table) => [
263
+ uniqueIndex("uidx_app_oauth_refresh_tokens_hash").on(table.tokenHash),
264
+ index("idx_app_oauth_refresh_tokens_installation").on(table.installationId, table.status),
265
+ ]);
266
+ export const appInstallationSettings = pgTable("app_installation_settings", {
267
+ id: typeId("app_installation_settings"),
268
+ installationId: text("installation_id")
269
+ .notNull()
270
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
271
+ settings: jsonb("settings").$type().notNull(),
272
+ schemaDigest: text("schema_digest").notNull(),
273
+ updatedBy: text("updated_by").notNull(),
274
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
275
+ }, (table) => [uniqueIndex("uidx_app_installation_settings_installation").on(table.installationId)]);
276
+ export const appSecretReferences = pgTable("app_secret_references", {
277
+ id: typeId("app_secret_references"),
278
+ installationId: text("installation_id")
279
+ .notNull()
280
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
281
+ key: text("key").notNull(),
282
+ secretRef: text("secret_ref").notNull(),
283
+ createdBy: text("created_by").notNull(),
284
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
285
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
286
+ }, (table) => [uniqueIndex("uidx_app_secret_references_key").on(table.installationId, table.key)]);
287
+ export const appExtensionInstallations = pgTable("app_extension_installations", {
288
+ id: typeId("app_extension_installations"),
289
+ installationId: text("installation_id")
290
+ .notNull()
291
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
292
+ releaseId: text("release_id")
293
+ .notNull()
294
+ .references(() => appReleases.id, { onDelete: "cascade" }),
295
+ extensionKey: text("extension_key").notNull(),
296
+ descriptor: jsonb("descriptor").$type().notNull(),
297
+ status: appInstallationRegistrationStatusEnum("status").notNull().default("active"),
298
+ installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
299
+ deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
300
+ }, (table) => [
301
+ index("idx_app_extension_installations_installation").on(table.installationId, table.status),
302
+ uniqueIndex("uidx_app_extension_installations_key").on(table.installationId, table.extensionKey),
303
+ ]);
304
+ export const appWebhookSubscriptions = pgTable("app_webhook_subscriptions", {
305
+ id: typeId("app_webhook_subscriptions"),
306
+ installationId: text("installation_id")
307
+ .notNull()
308
+ .references(() => appInstallations.id, { onDelete: "cascade" }),
309
+ releaseId: text("release_id")
310
+ .notNull()
311
+ .references(() => appReleases.id, { onDelete: "cascade" }),
312
+ eventType: text("event_type").notNull(),
313
+ eventVersion: text("event_version").notNull(),
314
+ endpointUrl: text("endpoint_url").notNull(),
315
+ status: appWebhookSubscriptionStatusEnum("status").notNull().default("active"),
316
+ externalSubscriptionId: text("external_subscription_id"),
317
+ signingKeyId: text("signing_key_id"),
318
+ lastDeliveryAt: timestamp("last_delivery_at", { withTimezone: true }),
319
+ failureCount: integer("failure_count").notNull().default(0),
320
+ pausedAt: timestamp("paused_at", { withTimezone: true }),
321
+ installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
322
+ deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
323
+ }, (table) => [
324
+ index("idx_app_webhook_subscriptions_installation").on(table.installationId, table.status),
325
+ uniqueIndex("uidx_app_webhook_subscriptions_event").on(table.installationId, table.eventType, table.eventVersion, table.endpointUrl),
326
+ ]);
327
+ export const appAuditEvents = pgTable("app_audit_events", {
328
+ id: typeId("app_audit_events"),
329
+ installationId: text("installation_id").references(() => appInstallations.id, {
330
+ onDelete: "set null",
331
+ }),
332
+ appId: text("app_id").notNull(),
333
+ deploymentId: text("deployment_id").notNull(),
334
+ actorId: text("actor_id").notNull(),
335
+ kind: appAuditEventKindEnum("kind").notNull(),
336
+ action: text("action").notNull(),
337
+ details: jsonb("details").$type().notNull(),
338
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
339
+ }, (table) => [
340
+ index("idx_app_audit_events_installation").on(table.installationId, table.createdAt),
341
+ index("idx_app_audit_events_app").on(table.appId, table.deploymentId, table.createdAt),
342
+ ]);
@@ -0,0 +1,95 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { type CompileAppManifestOptions } from "./compiler.js";
3
+ import type { AppListQuery, CreateCustomAppRegistrationInput, ReleaseManifestFetchInput, ReleaseManifestUploadInput } from "./contracts.js";
4
+ import { type ManifestFetchOptions } from "./ingestion.js";
5
+ export interface AppsServiceOptions extends CompileAppManifestOptions {
6
+ fetch?: ManifestFetchOptions["fetch"];
7
+ resolveHost?: ManifestFetchOptions["resolveHost"];
8
+ }
9
+ export declare function createAppsService(options?: AppsServiceOptions): {
10
+ createCustomApp: (db: PostgresJsDatabase, input: CreateCustomAppRegistrationInput) => Promise<{
11
+ id: string;
12
+ platformNamespace: string;
13
+ distribution: "custom" | "marketplace";
14
+ ownerId: string;
15
+ displayName: string;
16
+ slug: string;
17
+ lifecycleState: "active" | "suspended" | "deleted";
18
+ createdBy: string;
19
+ createdAt: Date;
20
+ updatedAt: Date;
21
+ }>;
22
+ list: (db: PostgresJsDatabase, query: AppListQuery) => Promise<{
23
+ data: {
24
+ id: string;
25
+ platformNamespace: string;
26
+ distribution: "custom" | "marketplace";
27
+ ownerId: string;
28
+ displayName: string;
29
+ slug: string;
30
+ lifecycleState: "active" | "suspended" | "deleted";
31
+ createdBy: string;
32
+ createdAt: Date;
33
+ updatedAt: Date;
34
+ }[];
35
+ total: number;
36
+ limit: number;
37
+ offset: number;
38
+ }>;
39
+ get: (db: PostgresJsDatabase, appId: string) => Promise<{
40
+ id: string;
41
+ platformNamespace: string;
42
+ distribution: "custom" | "marketplace";
43
+ ownerId: string;
44
+ displayName: string;
45
+ slug: string;
46
+ lifecycleState: "active" | "suspended" | "deleted";
47
+ createdBy: string;
48
+ createdAt: Date;
49
+ updatedAt: Date;
50
+ } | null>;
51
+ releaseFromUpload: (db: PostgresJsDatabase, appId: string, input: ReleaseManifestUploadInput) => Promise<{
52
+ release: {
53
+ appId: string;
54
+ id: string;
55
+ createdBy: string;
56
+ createdAt: Date;
57
+ releaseVersion: string;
58
+ manifestSchemaVersion: string;
59
+ manifestDigest: string;
60
+ manifestSnapshot: Record<string, unknown>;
61
+ normalizedRecord: Record<string, unknown>;
62
+ apiCompatibility: {
63
+ min: string;
64
+ max: string;
65
+ };
66
+ defaultLocale: string;
67
+ supportedLocales: string[];
68
+ state: "suspended" | "available" | "yanked";
69
+ };
70
+ created: boolean;
71
+ digest: string;
72
+ }>;
73
+ releaseFromFetch: (db: PostgresJsDatabase, appId: string, input: ReleaseManifestFetchInput) => Promise<{
74
+ release: {
75
+ appId: string;
76
+ id: string;
77
+ createdBy: string;
78
+ createdAt: Date;
79
+ releaseVersion: string;
80
+ manifestSchemaVersion: string;
81
+ manifestDigest: string;
82
+ manifestSnapshot: Record<string, unknown>;
83
+ normalizedRecord: Record<string, unknown>;
84
+ apiCompatibility: {
85
+ min: string;
86
+ max: string;
87
+ };
88
+ defaultLocale: string;
89
+ supportedLocales: string[];
90
+ state: "suspended" | "available" | "yanked";
91
+ };
92
+ created: boolean;
93
+ digest: string;
94
+ }>;
95
+ };
@@ -0,0 +1,172 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ApiHttpError } from "@voyant-travel/hono";
3
+ import { and, eq, sql } from "drizzle-orm";
4
+ import { compileAppManifest } from "./compiler.js";
5
+ import { fetchProtectedManifest } from "./ingestion.js";
6
+ import { appRedirectUris, appReleaseArtifacts, appReleaseLocalizations, appReleases, apps, } from "./schema.js";
7
+ export function createAppsService(options = {}) {
8
+ async function createCustomApp(db, input) {
9
+ return db.transaction(async (tx) => {
10
+ const app = await insertRegistrationWithNamespace(tx, input);
11
+ if (input.redirectUris.length > 0) {
12
+ await tx
13
+ .insert(appRedirectUris)
14
+ .values(input.redirectUris.map((redirectUri) => ({
15
+ appId: app.id,
16
+ redirectUri,
17
+ })))
18
+ .returning({ id: appRedirectUris.id });
19
+ }
20
+ return app;
21
+ });
22
+ }
23
+ async function list(db, query) {
24
+ const where = and(query.ownerId ? eq(apps.ownerId, query.ownerId) : undefined, query.distribution ? eq(apps.distribution, query.distribution) : undefined);
25
+ const [data, count] = await Promise.all([
26
+ db
27
+ .select()
28
+ .from(apps)
29
+ .where(where)
30
+ .limit(query.limit)
31
+ .offset(query.offset)
32
+ .orderBy(apps.createdAt),
33
+ db.select({ count: sql `count(*)::int` }).from(apps).where(where),
34
+ ]);
35
+ return { data, total: count[0]?.count ?? 0, limit: query.limit, offset: query.offset };
36
+ }
37
+ async function get(db, appId) {
38
+ const [row] = await db.select().from(apps).where(eq(apps.id, appId)).limit(1);
39
+ return row ?? null;
40
+ }
41
+ async function releaseFromUpload(db, appId, input) {
42
+ const compiled = compileAppManifest(input.manifest, options);
43
+ return createRelease(db, appId, {
44
+ createdBy: input.createdBy,
45
+ compiled,
46
+ artifactProvenance: input.provenance,
47
+ });
48
+ }
49
+ async function releaseFromFetch(db, appId, input) {
50
+ const fetched = await fetchProtectedManifest(input.manifestUrl, {
51
+ fetch: options.fetch,
52
+ resolveHost: options.resolveHost,
53
+ });
54
+ const compiled = compileAppManifest(fetched.body, options);
55
+ return createRelease(db, appId, {
56
+ createdBy: input.createdBy,
57
+ compiled,
58
+ artifactProvenance: {
59
+ source: "https-manifest",
60
+ url: fetched.url,
61
+ contentType: fetched.contentType,
62
+ bytes: fetched.bytes,
63
+ },
64
+ });
65
+ }
66
+ return { createCustomApp, list, get, releaseFromUpload, releaseFromFetch };
67
+ }
68
+ async function insertRegistrationWithNamespace(db, input) {
69
+ for (let attempt = 0; attempt < 8; attempt += 1) {
70
+ const [row] = await db
71
+ .insert(apps)
72
+ .values({
73
+ ownerId: input.ownerId,
74
+ displayName: input.displayName,
75
+ slug: input.slug,
76
+ createdBy: input.createdBy,
77
+ distribution: "custom",
78
+ platformNamespace: `app--${randomBytes(10).toString("hex")}`,
79
+ })
80
+ .onConflictDoNothing({ target: apps.platformNamespace })
81
+ .returning();
82
+ if (row)
83
+ return row;
84
+ }
85
+ throw new ApiHttpError("Could not assign a unique app namespace", {
86
+ status: 500,
87
+ code: "app_namespace_assignment_failed",
88
+ });
89
+ }
90
+ async function createRelease(db, appId, input) {
91
+ return db.transaction(async (tx) => {
92
+ const existingApp = await selectAppForUpdate(tx, appId);
93
+ if (!existingApp) {
94
+ throw new ApiHttpError("App registration not found", { status: 404, code: "app_not_found" });
95
+ }
96
+ const releaseVersion = input.compiled.manifest.releaseVersion;
97
+ const versionRow = await selectReleaseByVersion(tx, appId, releaseVersion);
98
+ if (versionRow &&
99
+ versionRow.releaseVersion === releaseVersion &&
100
+ versionRow.manifestDigest !== input.compiled.digest) {
101
+ throw new ApiHttpError(`Release version ${releaseVersion} already exists with different content; releases are immutable, publish a new version instead`, { status: 409, code: "app_release_version_conflict" });
102
+ }
103
+ const manifestSnapshot = JSON.parse(input.compiled.canonicalJson);
104
+ const normalizedRecord = JSON.parse(JSON.stringify(input.compiled.normalizedRelease));
105
+ const [created] = await tx
106
+ .insert(appReleases)
107
+ .values({
108
+ appId,
109
+ releaseVersion: input.compiled.manifest.releaseVersion,
110
+ manifestSchemaVersion: input.compiled.manifest.schemaVersion,
111
+ manifestDigest: input.compiled.digest,
112
+ manifestSnapshot,
113
+ normalizedRecord,
114
+ apiCompatibility: input.compiled.manifest.apiCompatibility,
115
+ defaultLocale: input.compiled.manifest.locales.default,
116
+ supportedLocales: [...input.compiled.normalizedRelease.supportedLocales],
117
+ createdBy: input.createdBy,
118
+ })
119
+ .onConflictDoNothing({ target: [appReleases.appId, appReleases.manifestDigest] })
120
+ .returning();
121
+ const release = created ?? (await selectReleaseByDigest(tx, appId, input.compiled.digest));
122
+ if (!release) {
123
+ throw new ApiHttpError("Could not resolve app release", {
124
+ status: 500,
125
+ code: "app_release_create_failed",
126
+ });
127
+ }
128
+ if (created) {
129
+ await tx
130
+ .insert(appReleaseArtifacts)
131
+ .values({
132
+ releaseId: created.id,
133
+ digest: input.compiled.digest,
134
+ signature: null,
135
+ provenance: input.artifactProvenance,
136
+ registryCoordinates: null,
137
+ assetInventory: { files: [] },
138
+ })
139
+ .returning({ id: appReleaseArtifacts.id });
140
+ if (input.compiled.normalizedRelease.localizations.length > 0) {
141
+ await tx
142
+ .insert(appReleaseLocalizations)
143
+ .values(input.compiled.normalizedRelease.localizations.map((localization) => ({
144
+ releaseId: created.id,
145
+ ...localization,
146
+ })))
147
+ .returning({ id: appReleaseLocalizations.id });
148
+ }
149
+ }
150
+ return { release, created: Boolean(created), digest: input.compiled.digest };
151
+ });
152
+ }
153
+ async function selectAppForUpdate(db, appId) {
154
+ const [row] = await db.select().from(apps).where(eq(apps.id, appId)).for("update").limit(1);
155
+ return row ?? null;
156
+ }
157
+ async function selectReleaseByVersion(db, appId, version) {
158
+ const [row] = await db
159
+ .select()
160
+ .from(appReleases)
161
+ .where(and(eq(appReleases.appId, appId), eq(appReleases.releaseVersion, version)))
162
+ .limit(1);
163
+ return row ?? null;
164
+ }
165
+ async function selectReleaseByDigest(db, appId, digest) {
166
+ const [row] = await db
167
+ .select()
168
+ .from(appReleases)
169
+ .where(and(eq(appReleases.appId, appId), eq(appReleases.manifestDigest, digest)))
170
+ .limit(1);
171
+ return row ?? null;
172
+ }
@@ -0,0 +1 @@
1
+ export {};