@voyant-travel/apps 0.9.1 → 0.10.1

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.
@@ -0,0 +1,410 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ApiHttpError } from "@voyant-travel/hono";
3
+ import { and, eq, isNull } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { canonicalJsonStringify, compileAppManifest, } from "./compiler.js";
6
+ import { appCredentials, appInstallations, appRedirectUris, appReleaseArtifacts, appReleaseLocalizations, appReleases, apps, } from "./schema.js";
7
+ const httpsUrlSchema = z
8
+ .string()
9
+ .url()
10
+ .refine((value) => new URL(value).protocol === "https:", "URL must use HTTPS.");
11
+ /**
12
+ * Provider-neutral, host-verified input to the deployment-local app registry.
13
+ * A managed host adapts its private catalog model to this closed contract only
14
+ * after it has authenticated the deployment and verified artifact provenance.
15
+ */
16
+ export const hostVerifiedMarketplaceAcquisitionSchema = z
17
+ .object({
18
+ schemaVersion: z.literal("voyant.runtime-marketplace-acquisition.v1"),
19
+ acquisitionId: z.string().trim().min(1).max(200),
20
+ app: z
21
+ .object({
22
+ id: z.string().trim().min(1).max(200),
23
+ ownerId: z.string().trim().min(1).max(200),
24
+ displayName: z.string().trim().min(1).max(120),
25
+ slug: z
26
+ .string()
27
+ .trim()
28
+ .regex(/^[a-z0-9][a-z0-9-]{0,78}[a-z0-9]$/),
29
+ redirectUris: z.array(httpsUrlSchema).max(20),
30
+ oauthClient: z
31
+ .object({
32
+ method: z.literal("client_secret_post"),
33
+ /** Verifier for a high-entropy publisher-held secret; never the raw secret. */
34
+ secretSha256: z.string().regex(/^[a-f0-9]{64}$/),
35
+ })
36
+ .strict(),
37
+ })
38
+ .strict(),
39
+ release: z
40
+ .object({
41
+ id: z.string().trim().min(1).max(200),
42
+ manifest: z.unknown(),
43
+ digest: z.string().regex(/^sha256:[a-f0-9]{64}$/),
44
+ signature: z.string().trim().min(1).max(16_384).optional(),
45
+ provenance: z.record(z.string(), z.unknown()),
46
+ assetInventory: z.record(z.string(), z.unknown()).default({ files: [] }),
47
+ })
48
+ .strict(),
49
+ })
50
+ .strict();
51
+ export const resolveMarketplaceInstallIntentSchema = z
52
+ .object({ intent: z.string().trim().min(1).max(2048) })
53
+ .strict();
54
+ export const marketplaceInstallIntentResultSchema = z.object({
55
+ data: z
56
+ .object({
57
+ appId: z.string(),
58
+ releaseId: z.string(),
59
+ acquisitionId: z.string(),
60
+ created: z.boolean(),
61
+ })
62
+ .strict(),
63
+ });
64
+ export const marketplaceSetupHandoffResultSchema = z.object({
65
+ data: z.object({ redirectUrl: httpsUrlSchema }).strict(),
66
+ });
67
+ export const MARKETPLACE_SETUP_ASSERTION_TYPE = "voyant-marketplace-setup+jwt";
68
+ /** Claims signed by the managed host and verified by the remote app backend. */
69
+ export const marketplaceSetupAssertionClaimsSchema = z
70
+ .object({
71
+ schemaVersion: z.literal("voyant.marketplace-setup-assertion.v1"),
72
+ iss: z.string().trim().min(1),
73
+ aud: z.string().trim().min(1),
74
+ sub: z.string().trim().min(1),
75
+ jti: z.string().trim().min(1).max(200),
76
+ iat: z.number().int().nonnegative(),
77
+ exp: z.number().int().positive(),
78
+ installationId: z.string().trim().min(1).max(200),
79
+ appId: z.string().trim().min(1).max(200),
80
+ releaseId: z.string().trim().min(1).max(200),
81
+ authorizationUrl: httpsUrlSchema,
82
+ tokenUrl: httpsUrlSchema,
83
+ redirectUri: httpsUrlSchema,
84
+ })
85
+ .strict()
86
+ .superRefine((claims, context) => {
87
+ if (claims.sub !== claims.installationId) {
88
+ context.addIssue({
89
+ code: "custom",
90
+ path: ["sub"],
91
+ message: "Setup assertion subject must equal installationId.",
92
+ });
93
+ }
94
+ if (claims.aud !== claims.appId) {
95
+ context.addIssue({
96
+ code: "custom",
97
+ path: ["aud"],
98
+ message: "Setup assertion audience must equal appId.",
99
+ });
100
+ }
101
+ if (claims.exp <= claims.iat || claims.exp - claims.iat > 300) {
102
+ context.addIssue({
103
+ code: "custom",
104
+ path: ["exp"],
105
+ message: "Setup assertion lifetime must be positive and no longer than 300 seconds.",
106
+ });
107
+ }
108
+ });
109
+ export function createMarketplaceAcquisitionService(options) {
110
+ return {
111
+ async resolveAndAcquire(db, input) {
112
+ const raw = await options.resolveAcquisitionIntent({ intent: input.intent });
113
+ if (!raw) {
114
+ throw new ApiHttpError("Marketplace install intent was not found or has expired", {
115
+ status: 404,
116
+ code: "app_marketplace_intent_unavailable",
117
+ });
118
+ }
119
+ const acquisition = hostVerifiedMarketplaceAcquisitionSchema.parse(raw);
120
+ const compiled = compileAppManifest(acquisition.release.manifest, options);
121
+ if (compiled.digest !== acquisition.release.digest) {
122
+ throw new ApiHttpError("Marketplace release digest verification failed", {
123
+ status: 409,
124
+ code: "app_marketplace_digest_mismatch",
125
+ });
126
+ }
127
+ return db.transaction(async (tx) => {
128
+ const app = await acquireApp(tx, acquisition, input.actorId);
129
+ await reconcileOAuthClient(tx, app.id, acquisition.app.oauthClient.secretSha256);
130
+ await reconcileRedirectUris(tx, app.id, acquisition.app.redirectUris);
131
+ const release = await acquireRelease(tx, acquisition, compiled, input.actorId);
132
+ return {
133
+ appId: app.id,
134
+ releaseId: release.id,
135
+ acquisitionId: acquisition.acquisitionId,
136
+ created: release.created,
137
+ };
138
+ });
139
+ },
140
+ async createSetupHandoff(db, installationId) {
141
+ const [row] = await db
142
+ .select({
143
+ installationId: appInstallations.id,
144
+ appId: apps.id,
145
+ releaseId: appReleases.id,
146
+ distribution: apps.distribution,
147
+ status: appInstallations.status,
148
+ normalizedRecord: appReleases.normalizedRecord,
149
+ })
150
+ .from(appInstallations)
151
+ .innerJoin(apps, eq(apps.id, appInstallations.appId))
152
+ .innerJoin(appReleases, eq(appReleases.id, appInstallations.releaseId))
153
+ .where(eq(appInstallations.id, installationId))
154
+ .limit(1);
155
+ if (row?.distribution !== "marketplace" || row.status !== "active") {
156
+ throw new ApiHttpError("Active Marketplace installation not found", {
157
+ status: 404,
158
+ code: "app_marketplace_installation_not_found",
159
+ });
160
+ }
161
+ const setupUrl = readSetupUrl(row.normalizedRecord);
162
+ if (!setupUrl) {
163
+ throw new ApiHttpError("This app release has no setup handoff", {
164
+ status: 409,
165
+ code: "app_marketplace_setup_unavailable",
166
+ });
167
+ }
168
+ const handoff = await options.createSetupHandoff({
169
+ installationId: row.installationId,
170
+ appId: row.appId,
171
+ releaseId: row.releaseId,
172
+ });
173
+ const redirectUrl = parseTrustedSetupRedirect(handoff.redirectUrl, setupUrl);
174
+ return { redirectUrl };
175
+ },
176
+ };
177
+ }
178
+ async function acquireApp(db, acquisition, actorId) {
179
+ const existing = await selectAppForUpdate(db, acquisition.app.id);
180
+ if (existing) {
181
+ if (existing.distribution !== "marketplace" || existing.ownerId !== acquisition.app.ownerId) {
182
+ throw immutableIdentityConflict("Marketplace app identity conflicts with the local registry");
183
+ }
184
+ const [updated] = await db
185
+ .update(apps)
186
+ .set({
187
+ displayName: acquisition.app.displayName,
188
+ slug: acquisition.app.slug,
189
+ lifecycleState: "active",
190
+ updatedAt: new Date(),
191
+ })
192
+ .where(eq(apps.id, existing.id))
193
+ .returning();
194
+ return updated ?? existing;
195
+ }
196
+ const [created] = await db
197
+ .insert(apps)
198
+ .values({
199
+ id: acquisition.app.id,
200
+ ownerId: acquisition.app.ownerId,
201
+ displayName: acquisition.app.displayName,
202
+ slug: acquisition.app.slug,
203
+ distribution: "marketplace",
204
+ platformNamespace: `app--${randomBytes(10).toString("hex")}`,
205
+ createdBy: actorId,
206
+ })
207
+ .onConflictDoNothing()
208
+ .returning();
209
+ const row = created ?? (await selectAppForUpdate(db, acquisition.app.id));
210
+ if (row?.distribution !== "marketplace" || row.ownerId !== acquisition.app.ownerId) {
211
+ throw immutableIdentityConflict("Marketplace app identity could not be admitted");
212
+ }
213
+ return row;
214
+ }
215
+ async function reconcileOAuthClient(db, appId, secretSha256) {
216
+ const expectedVerifier = `sha256:${secretSha256}`;
217
+ const [active] = await db
218
+ .select()
219
+ .from(appCredentials)
220
+ .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret"), isNull(appCredentials.retiredAt)))
221
+ .limit(1);
222
+ if (active) {
223
+ if (active.kmsKeyRef !== expectedVerifier) {
224
+ throw immutableIdentityConflict("Marketplace OAuth client verifier conflicts with the admitted app");
225
+ }
226
+ return;
227
+ }
228
+ const generations = await db
229
+ .select({ generation: appCredentials.generation })
230
+ .from(appCredentials)
231
+ .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")));
232
+ await db.insert(appCredentials).values({
233
+ appId,
234
+ kind: "client_secret",
235
+ generation: Math.max(0, ...generations.map((credential) => credential.generation)) + 1,
236
+ kmsKeyRef: expectedVerifier,
237
+ });
238
+ }
239
+ async function reconcileRedirectUris(db, appId, redirectUris) {
240
+ const desired = [...new Set(redirectUris)].sort();
241
+ const current = await db
242
+ .select({ redirectUri: appRedirectUris.redirectUri })
243
+ .from(appRedirectUris)
244
+ .where(eq(appRedirectUris.appId, appId));
245
+ const present = current.map((row) => row.redirectUri).sort();
246
+ if (JSON.stringify(present) === JSON.stringify(desired))
247
+ return;
248
+ await db.delete(appRedirectUris).where(eq(appRedirectUris.appId, appId));
249
+ if (desired.length > 0) {
250
+ await db.insert(appRedirectUris).values(desired.map((redirectUri) => ({ appId, redirectUri })));
251
+ }
252
+ }
253
+ async function acquireRelease(db, acquisition, compiled, actorId) {
254
+ const byId = await selectReleaseByIdForUpdate(db, acquisition.release.id);
255
+ const byVersion = await selectReleaseByVersion(db, acquisition.app.id, compiled.manifest.releaseVersion);
256
+ const byDigest = await selectReleaseByDigest(db, acquisition.app.id, compiled.digest);
257
+ for (const existing of [byId, byVersion, byDigest]) {
258
+ if (existing &&
259
+ (existing.id !== acquisition.release.id ||
260
+ existing.appId !== acquisition.app.id ||
261
+ existing.releaseVersion !== compiled.manifest.releaseVersion ||
262
+ existing.manifestDigest !== compiled.digest)) {
263
+ throw immutableIdentityConflict("Marketplace release identity conflicts with an immutable local release");
264
+ }
265
+ }
266
+ const existing = byId ?? byVersion ?? byDigest;
267
+ if (existing) {
268
+ if (existing.state !== "available") {
269
+ throw new ApiHttpError("Marketplace release is not locally available", {
270
+ status: 409,
271
+ code: "app_marketplace_release_unavailable",
272
+ });
273
+ }
274
+ if (canonicalJsonStringify(existing.manifestSnapshot) !== compiled.canonicalJson) {
275
+ throw immutableIdentityConflict("Marketplace release snapshot conflicts with the admitted manifest");
276
+ }
277
+ const [artifact] = await db
278
+ .select()
279
+ .from(appReleaseArtifacts)
280
+ .where(eq(appReleaseArtifacts.releaseId, existing.id))
281
+ .limit(1);
282
+ const expectedProvenance = marketplaceArtifactProvenance(acquisition);
283
+ if (artifact?.digest !== compiled.digest ||
284
+ artifact.state !== "available" ||
285
+ artifact.signature !== (acquisition.release.signature ?? null) ||
286
+ canonicalJsonStringify(artifact.provenance) !== canonicalJsonStringify(expectedProvenance) ||
287
+ canonicalJsonStringify(artifact.assetInventory) !==
288
+ canonicalJsonStringify(acquisition.release.assetInventory)) {
289
+ throw immutableIdentityConflict("Marketplace release artifact conflicts with the admitted manifest");
290
+ }
291
+ return { ...existing, created: false };
292
+ }
293
+ const manifestSnapshot = JSON.parse(compiled.canonicalJson);
294
+ const normalizedRecord = JSON.parse(JSON.stringify(compiled.normalizedRelease));
295
+ const [created] = await db
296
+ .insert(appReleases)
297
+ .values({
298
+ id: acquisition.release.id,
299
+ appId: acquisition.app.id,
300
+ releaseVersion: compiled.manifest.releaseVersion,
301
+ manifestSchemaVersion: compiled.manifest.schemaVersion,
302
+ manifestDigest: compiled.digest,
303
+ manifestSnapshot,
304
+ normalizedRecord,
305
+ apiCompatibility: compiled.manifest.apiCompatibility,
306
+ defaultLocale: compiled.manifest.locales.default,
307
+ supportedLocales: [...compiled.normalizedRelease.supportedLocales],
308
+ state: "available",
309
+ createdBy: actorId,
310
+ })
311
+ .returning();
312
+ if (!created) {
313
+ throw new ApiHttpError("Marketplace release could not be admitted", {
314
+ status: 500,
315
+ code: "app_marketplace_release_write_failed",
316
+ });
317
+ }
318
+ await db.insert(appReleaseArtifacts).values({
319
+ releaseId: created.id,
320
+ digest: compiled.digest,
321
+ signature: acquisition.release.signature ?? null,
322
+ provenance: marketplaceArtifactProvenance(acquisition),
323
+ registryCoordinates: null,
324
+ assetInventory: acquisition.release.assetInventory,
325
+ state: "available",
326
+ });
327
+ if (compiled.normalizedRelease.localizations.length > 0) {
328
+ await db.insert(appReleaseLocalizations).values(compiled.normalizedRelease.localizations.map((localization) => ({
329
+ releaseId: created.id,
330
+ ...localization,
331
+ })));
332
+ }
333
+ return { ...created, created: true };
334
+ }
335
+ function marketplaceArtifactProvenance(acquisition) {
336
+ return {
337
+ ...acquisition.release.provenance,
338
+ acquisitionId: acquisition.acquisitionId,
339
+ source: "managed-marketplace",
340
+ };
341
+ }
342
+ async function selectAppForUpdate(db, appId) {
343
+ const [row] = await db.select().from(apps).where(eq(apps.id, appId)).for("update").limit(1);
344
+ return row ?? null;
345
+ }
346
+ async function selectReleaseByIdForUpdate(db, releaseId) {
347
+ const [row] = await db
348
+ .select()
349
+ .from(appReleases)
350
+ .where(eq(appReleases.id, releaseId))
351
+ .for("update")
352
+ .limit(1);
353
+ return row ?? null;
354
+ }
355
+ async function selectReleaseByVersion(db, appId, version) {
356
+ const [row] = await db
357
+ .select()
358
+ .from(appReleases)
359
+ .where(and(eq(appReleases.appId, appId), eq(appReleases.releaseVersion, version)))
360
+ .limit(1);
361
+ return row ?? null;
362
+ }
363
+ async function selectReleaseByDigest(db, appId, digest) {
364
+ const [row] = await db
365
+ .select()
366
+ .from(appReleases)
367
+ .where(and(eq(appReleases.appId, appId), eq(appReleases.manifestDigest, digest)))
368
+ .limit(1);
369
+ return row ?? null;
370
+ }
371
+ function immutableIdentityConflict(message) {
372
+ return new ApiHttpError(message, {
373
+ status: 409,
374
+ code: "app_marketplace_identity_conflict",
375
+ });
376
+ }
377
+ function readSetupUrl(normalizedRecord) {
378
+ const urls = normalizedRecord.urls;
379
+ if (!urls || typeof urls !== "object" || Array.isArray(urls))
380
+ return null;
381
+ const setup = urls.setup;
382
+ if (typeof setup !== "string")
383
+ return null;
384
+ try {
385
+ const parsed = new URL(setup);
386
+ return parsed.protocol === "https:" ? parsed : null;
387
+ }
388
+ catch {
389
+ return null;
390
+ }
391
+ }
392
+ function parseTrustedSetupRedirect(input, admittedSetupUrl) {
393
+ let redirect;
394
+ try {
395
+ redirect = new URL(input);
396
+ }
397
+ catch {
398
+ throw new ApiHttpError("Managed setup handoff returned an invalid redirect", {
399
+ status: 502,
400
+ code: "app_marketplace_setup_invalid",
401
+ });
402
+ }
403
+ if (redirect.protocol !== "https:" || redirect.origin !== admittedSetupUrl.origin) {
404
+ throw new ApiHttpError("Managed setup handoff returned an untrusted redirect", {
405
+ status: 502,
406
+ code: "app_marketplace_setup_invalid",
407
+ });
408
+ }
409
+ return redirect.toString();
410
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,191 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { compileAppManifest } from "./compiler.js";
4
+ import { createAppInstallationService } from "./installation-service.js";
5
+ import { createMarketplaceAcquisitionService, hostVerifiedMarketplaceAcquisitionSchema, marketplaceSetupAssertionClaimsSchema, } from "./marketplace-acquisition.js";
6
+ import { appCredentials, appRedirectUris, appReleaseArtifacts, appReleases, apps, } from "./schema.js";
7
+ import { validManifest } from "./test-fixtures.js";
8
+ const DB_AVAILABLE = !!process.env.TEST_DATABASE_URL;
9
+ function acquisition(overrides = {}) {
10
+ const digest = compileAppManifest(validManifest).digest;
11
+ return {
12
+ schemaVersion: "voyant.runtime-marketplace-acquisition.v1",
13
+ acquisitionId: "acquisition_1",
14
+ app: {
15
+ id: "marketplace_app_1",
16
+ ownerId: "publisher_1",
17
+ displayName: "Accounting Bridge",
18
+ slug: "accounting-bridge",
19
+ redirectUris: ["https://app.example.com/oauth/callback"],
20
+ oauthClient: {
21
+ method: "client_secret_post",
22
+ secretSha256: "a".repeat(64),
23
+ },
24
+ },
25
+ release: {
26
+ id: "marketplace_release_1",
27
+ manifest: validManifest,
28
+ digest,
29
+ signature: "host-verified-signature",
30
+ provenance: { publisherId: "publisher_1", reviewId: "review_1" },
31
+ assetInventory: { files: [] },
32
+ },
33
+ ...overrides,
34
+ };
35
+ }
36
+ describe("Marketplace OAuth client acquisition contract", () => {
37
+ it("admits only a publisher-held secret verifier, never a raw secret", () => {
38
+ const current = acquisition();
39
+ expect(hostVerifiedMarketplaceAcquisitionSchema.parse(current).app.oauthClient).toEqual({
40
+ method: "client_secret_post",
41
+ secretSha256: "a".repeat(64),
42
+ });
43
+ expect(() => hostVerifiedMarketplaceAcquisitionSchema.parse({
44
+ ...current,
45
+ app: {
46
+ ...current.app,
47
+ oauthClient: {
48
+ ...current.app.oauthClient,
49
+ secret: "must-not-cross-the-host-boundary",
50
+ },
51
+ },
52
+ })).toThrow();
53
+ });
54
+ });
55
+ describe("Marketplace setup assertion contract", () => {
56
+ const valid = {
57
+ schemaVersion: "voyant.marketplace-setup-assertion.v1",
58
+ iss: "https://cloud.example.com",
59
+ aud: "marketplace_app_1",
60
+ sub: "installation_1",
61
+ jti: "setup_nonce_1",
62
+ iat: 1_000,
63
+ exp: 1_300,
64
+ installationId: "installation_1",
65
+ appId: "marketplace_app_1",
66
+ releaseId: "marketplace_release_1",
67
+ authorizationUrl: "https://admin.example.com/apps/oauth/authorize",
68
+ tokenUrl: "https://admin.example.com/api/v1/admin/apps/oauth/token",
69
+ redirectUri: "https://app.example.com/oauth/callback",
70
+ };
71
+ it("binds subject, audience, identity, exact OAuth coordinates, and a five-minute lifetime", () => {
72
+ expect(marketplaceSetupAssertionClaimsSchema.parse(valid)).toEqual(valid);
73
+ expect(() => marketplaceSetupAssertionClaimsSchema.parse({ ...valid, sub: "another_installation" })).toThrow(/subject/i);
74
+ expect(() => marketplaceSetupAssertionClaimsSchema.parse({ ...valid, aud: "another_app" })).toThrow(/audience/i);
75
+ expect(() => marketplaceSetupAssertionClaimsSchema.parse({ ...valid, exp: 1_301 })).toThrow(/300 seconds/i);
76
+ });
77
+ it("rejects non-HTTPS coordinates", () => {
78
+ expect(() => marketplaceSetupAssertionClaimsSchema.parse({
79
+ ...valid,
80
+ tokenUrl: "http://admin.example.com/api/v1/admin/apps/oauth/token",
81
+ })).toThrow(/HTTPS/i);
82
+ });
83
+ });
84
+ describe.skipIf(!DB_AVAILABLE)("managed Marketplace acquisition", () => {
85
+ let db;
86
+ beforeAll(async () => {
87
+ const { createTestDb } = await import("@voyant-travel/db/test-utils");
88
+ db = createTestDb();
89
+ });
90
+ beforeEach(async () => {
91
+ const { cleanupTestDb } = await import("@voyant-travel/db/test-utils");
92
+ await cleanupTestDb(db);
93
+ });
94
+ it("idempotently admits a host-verified app and immutable available release", async () => {
95
+ const current = acquisition();
96
+ const resolveAcquisitionIntent = vi.fn(async () => current);
97
+ const service = createMarketplaceAcquisitionService({
98
+ resolveAcquisitionIntent,
99
+ createSetupHandoff: vi.fn(),
100
+ });
101
+ const first = await service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" });
102
+ const second = await service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" });
103
+ expect(first).toMatchObject({
104
+ appId: current.app.id,
105
+ releaseId: current.release.id,
106
+ acquisitionId: current.acquisitionId,
107
+ created: true,
108
+ });
109
+ expect(second).toMatchObject({ created: false });
110
+ expect(resolveAcquisitionIntent).toHaveBeenNthCalledWith(1, { intent: "opaque-1" });
111
+ const [app] = await db.select().from(apps).where(eq(apps.id, current.app.id));
112
+ const releases = await db
113
+ .select()
114
+ .from(appReleases)
115
+ .where(eq(appReleases.appId, current.app.id));
116
+ const artifacts = await db
117
+ .select()
118
+ .from(appReleaseArtifacts)
119
+ .where(eq(appReleaseArtifacts.releaseId, current.release.id));
120
+ const redirects = await db
121
+ .select()
122
+ .from(appRedirectUris)
123
+ .where(eq(appRedirectUris.appId, current.app.id));
124
+ const credentials = await db
125
+ .select()
126
+ .from(appCredentials)
127
+ .where(eq(appCredentials.appId, current.app.id));
128
+ expect(app).toMatchObject({
129
+ distribution: "marketplace",
130
+ ownerId: current.app.ownerId,
131
+ displayName: current.app.displayName,
132
+ });
133
+ expect(releases).toHaveLength(1);
134
+ expect(releases[0]).toMatchObject({
135
+ state: "available",
136
+ manifestDigest: current.release.digest,
137
+ });
138
+ expect(artifacts).toHaveLength(1);
139
+ expect(artifacts[0]?.registryCoordinates).toBeNull();
140
+ expect(redirects.map((row) => row.redirectUri)).toEqual(current.app.redirectUris);
141
+ expect(credentials).toHaveLength(1);
142
+ expect(credentials[0]).toMatchObject({
143
+ kind: "client_secret",
144
+ generation: 1,
145
+ kmsKeyRef: `sha256:${current.app.oauthClient.secretSha256}`,
146
+ });
147
+ current.release.signature = "different-signature";
148
+ await expect(service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" })).rejects.toMatchObject({ status: 409, code: "app_marketplace_identity_conflict" });
149
+ current.release.signature = "host-verified-signature";
150
+ current.app.oauthClient.secretSha256 = "b".repeat(64);
151
+ await expect(service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" })).rejects.toMatchObject({ status: 409, code: "app_marketplace_identity_conflict" });
152
+ });
153
+ it("rejects a digest that does not match the independently compiled manifest", async () => {
154
+ const service = createMarketplaceAcquisitionService({
155
+ resolveAcquisitionIntent: async () => acquisition({ release: { ...acquisition().release, digest: `sha256:${"0".repeat(64)}` } }),
156
+ createSetupHandoff: vi.fn(),
157
+ });
158
+ await expect(service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" })).rejects.toMatchObject({ status: 409, code: "app_marketplace_digest_mismatch" });
159
+ });
160
+ it("creates setup only from active installation identity and an admitted setup origin", async () => {
161
+ const current = acquisition();
162
+ const createSetupHandoff = vi.fn(async () => ({
163
+ redirectUrl: "https://app.example.com/setup?code=one-time-opaque",
164
+ }));
165
+ const service = createMarketplaceAcquisitionService({
166
+ resolveAcquisitionIntent: async () => current,
167
+ createSetupHandoff,
168
+ });
169
+ await service.resolveAndAcquire(db, { intent: "opaque-1", actorId: "user_1" });
170
+ const installed = await createAppInstallationService({ deploymentId: "deployment_1" }).install(db, {
171
+ appId: current.app.id,
172
+ releaseId: current.release.id,
173
+ actorId: "user_1",
174
+ });
175
+ await expect(service.createSetupHandoff(db, installed.installation.id)).resolves.toEqual({
176
+ redirectUrl: "https://app.example.com/setup?code=one-time-opaque",
177
+ });
178
+ expect(createSetupHandoff).toHaveBeenCalledWith({
179
+ installationId: installed.installation.id,
180
+ appId: current.app.id,
181
+ releaseId: current.release.id,
182
+ });
183
+ createSetupHandoff.mockResolvedValueOnce({
184
+ redirectUrl: "https://attacker.example/setup?code=stolen",
185
+ });
186
+ await expect(service.createSetupHandoff(db, installed.installation.id)).rejects.toMatchObject({
187
+ status: 502,
188
+ code: "app_marketplace_setup_invalid",
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,6 @@
1
+ export declare function buildAppOAuthCallbackUrl(input: {
2
+ redirectUri: string;
3
+ code: string;
4
+ state: string;
5
+ nonce?: string;
6
+ }): URL;
@@ -0,0 +1,8 @@
1
+ export function buildAppOAuthCallbackUrl(input) {
2
+ const redirectUrl = new URL(input.redirectUri);
3
+ redirectUrl.searchParams.set("code", input.code);
4
+ redirectUrl.searchParams.set("state", input.state);
5
+ if (input.nonce)
6
+ redirectUrl.searchParams.set("nonce", input.nonce);
7
+ return redirectUrl;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { appOAuthAuthorizeQuerySchema } from "./contracts.js";
3
+ import { buildAppOAuthCallbackUrl } from "./oauth-redirect.js";
4
+ const request = {
5
+ response_type: "code",
6
+ client_id: "app_smartbill",
7
+ release_id: "release_smartbill_1",
8
+ redirect_uri: "https://voyant-smartbill-app-dev.pixelmakers.io/v1/setup/oauth/callback",
9
+ state: "state_1",
10
+ code_challenge: "challenge_1",
11
+ code_challenge_method: "S256",
12
+ };
13
+ describe("app OAuth callback binding", () => {
14
+ it("accepts and echoes an app-generated nonce", () => {
15
+ const nonce = "n".repeat(43);
16
+ const parsed = appOAuthAuthorizeQuerySchema.parse({ ...request, nonce });
17
+ const redirect = buildAppOAuthCallbackUrl({
18
+ redirectUri: parsed.redirect_uri,
19
+ code: "authorization_code_1",
20
+ state: parsed.state,
21
+ nonce: parsed.nonce,
22
+ });
23
+ expect(redirect.searchParams.get("code")).toBe("authorization_code_1");
24
+ expect(redirect.searchParams.get("state")).toBe("state_1");
25
+ expect(redirect.searchParams.get("nonce")).toBe(nonce);
26
+ });
27
+ it("keeps nonce optional for OAuth clients that use state and PKCE only", () => {
28
+ const parsed = appOAuthAuthorizeQuerySchema.parse(request);
29
+ const redirect = buildAppOAuthCallbackUrl({
30
+ redirectUri: parsed.redirect_uri,
31
+ code: "authorization_code_1",
32
+ state: parsed.state,
33
+ });
34
+ expect(redirect.searchParams.has("nonce")).toBe(false);
35
+ });
36
+ it("rejects short nonce values", () => {
37
+ expect(() => appOAuthAuthorizeQuerySchema.parse({ ...request, nonce: "short" })).toThrow();
38
+ });
39
+ });
@@ -1,5 +1,5 @@
1
1
  import { ApiHttpError } from "@voyant-travel/hono";
2
- import { and, eq } from "drizzle-orm";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
3
  import { computeAppConsent } from "./consent.js";
4
4
  import { APP_ACCESS_TOKEN_PREFIX, APP_AUTH_CODE_PREFIX, APP_REFRESH_TOKEN_PREFIX, constantTimeEqual, randomToken, sha256Hex, verifyPkceS256, } from "./oauth-crypto.js";
5
5
  import { assertActiveInstallation, assertManagedBinding, assertManagedInstallationAuthority, assertTokenClient, ensureAuthorizedInstallation, grantedScopes, isInstallationUsable, managedBindingMatches, requireInstallation, selectInstallation, } from "./oauth-installation.js";
@@ -323,7 +323,7 @@ async function authenticateClient(db, appId, clientSecret, required) {
323
323
  const [secret] = await db
324
324
  .select()
325
325
  .from(appCredentials)
326
- .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
326
+ .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret"), isNull(appCredentials.retiredAt)))
327
327
  .limit(1);
328
328
  if (!secret) {
329
329
  if (required)