@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,17 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ export function planLifecycleTransition(current, allowedFrom, to, action) {
3
+ if (current === to)
4
+ return { outcome: "unchanged", status: current };
5
+ if (!allowedFrom.includes(current))
6
+ throw invalidTransition(current, action);
7
+ return { outcome: "updated", from: current, to };
8
+ }
9
+ export function canInstallOver(status) {
10
+ return status === "uninstalled" || status === "revoked";
11
+ }
12
+ export function invalidTransition(from, action) {
13
+ return new ApiHttpError(`Cannot ${action} app installation from ${from}`, {
14
+ status: 409,
15
+ code: "app_installation_invalid_transition",
16
+ });
17
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { canInstallOver, planLifecycleTransition } from "./installation-state.js";
3
+ describe("app installation lifecycle state", () => {
4
+ it("treats repeated pause, resume, and uninstall actions as idempotent", () => {
5
+ expect(planLifecycleTransition("paused", ["active", "degraded"], "paused", "pause")).toEqual({
6
+ outcome: "unchanged",
7
+ status: "paused",
8
+ });
9
+ expect(planLifecycleTransition("active", ["paused"], "active", "resume")).toEqual({
10
+ outcome: "unchanged",
11
+ status: "active",
12
+ });
13
+ expect(planLifecycleTransition("uninstalled", ["active", "paused", "degraded"], "uninstalled", "uninstall")).toEqual({ outcome: "unchanged", status: "uninstalled" });
14
+ });
15
+ it("allows the expected active, paused, resumed, and uninstalled path", () => {
16
+ expect(planLifecycleTransition("active", ["active", "degraded"], "paused", "pause")).toEqual({
17
+ outcome: "updated",
18
+ from: "active",
19
+ to: "paused",
20
+ });
21
+ expect(planLifecycleTransition("paused", ["paused"], "active", "resume")).toEqual({
22
+ outcome: "updated",
23
+ from: "paused",
24
+ to: "active",
25
+ });
26
+ expect(planLifecycleTransition("active", ["active", "paused", "degraded"], "uninstalled", "uninstall")).toEqual({ outcome: "updated", from: "active", to: "uninstalled" });
27
+ });
28
+ it("rejects invalid lifecycle transitions", () => {
29
+ expect(() => planLifecycleTransition("pending", ["active", "degraded"], "paused", "pause")).toThrow("Cannot pause app installation from pending");
30
+ expect(() => planLifecycleTransition("revoked", ["paused"], "active", "resume")).toThrow("Cannot resume app installation from revoked");
31
+ });
32
+ it("only reinstalls over terminal retained rows", () => {
33
+ expect(canInstallOver("uninstalled")).toBe(true);
34
+ expect(canInstallOver("revoked")).toBe(true);
35
+ expect(canInstallOver("active")).toBe(false);
36
+ expect(canInstallOver("paused")).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,8 @@
1
+ export declare const APP_ACCESS_TOKEN_PREFIX = "vapp_";
2
+ export declare const APP_REFRESH_TOKEN_PREFIX = "vappr_";
3
+ export declare const APP_AUTH_CODE_PREFIX = "vappc_";
4
+ export declare function randomToken(prefix: string, bytes?: number): string;
5
+ export declare function sha256Hex(value: string): string;
6
+ export declare function sha256Base64Url(value: string): string;
7
+ export declare function constantTimeEqual(left: string, right: string): boolean;
8
+ export declare function verifyPkceS256(verifier: string, challenge: string): boolean;
@@ -0,0 +1,23 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ export const APP_ACCESS_TOKEN_PREFIX = "vapp_";
3
+ export const APP_REFRESH_TOKEN_PREFIX = "vappr_";
4
+ export const APP_AUTH_CODE_PREFIX = "vappc_";
5
+ export function randomToken(prefix, bytes = 32) {
6
+ return `${prefix}${randomBytes(bytes).toString("base64url")}`;
7
+ }
8
+ export function sha256Hex(value) {
9
+ return createHash("sha256").update(value).digest("hex");
10
+ }
11
+ export function sha256Base64Url(value) {
12
+ return createHash("sha256").update(value).digest("base64url");
13
+ }
14
+ export function constantTimeEqual(left, right) {
15
+ const leftHash = Buffer.from(sha256Hex(left), "hex");
16
+ const rightHash = Buffer.from(sha256Hex(right), "hex");
17
+ return timingSafeEqual(leftHash, rightHash);
18
+ }
19
+ export function verifyPkceS256(verifier, challenge) {
20
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
21
+ return false;
22
+ return constantTimeEqual(sha256Base64Url(verifier), challenge);
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sha256Base64Url, verifyPkceS256 } from "./oauth-crypto.js";
3
+ describe("app OAuth PKCE", () => {
4
+ it("accepts only S256 challenges derived from the verifier", () => {
5
+ const verifier = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ_0123456789-1234567890";
6
+ const challenge = sha256Base64Url(verifier);
7
+ expect(verifyPkceS256(verifier, challenge)).toBe(true);
8
+ expect(verifyPkceS256(`${verifier}x`, challenge)).toBe(false);
9
+ expect(verifyPkceS256("too-short", challenge)).toBe(false);
10
+ });
11
+ });
@@ -0,0 +1,65 @@
1
+ import type { VoyantAuthContext } from "@voyant-travel/core";
2
+ import type { AccessCatalog } from "@voyant-travel/types/api-keys";
3
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ export interface AppOAuthServiceOptions {
5
+ accessCatalog: AccessCatalog;
6
+ deploymentId: string;
7
+ now?: () => Date;
8
+ }
9
+ export interface AuthorizeAppInput {
10
+ appId: string;
11
+ releaseId: string;
12
+ redirectUri: string;
13
+ state: string;
14
+ codeChallenge: string;
15
+ codeChallengeMethod: "S256";
16
+ actorId: string;
17
+ operatorGrantedScopes: readonly string[];
18
+ grantedOptionalScopes?: readonly string[];
19
+ }
20
+ export interface TokenCodeInput {
21
+ grantType: "authorization_code";
22
+ code: string;
23
+ redirectUri: string;
24
+ codeVerifier: string;
25
+ clientId: string;
26
+ clientSecret?: string;
27
+ }
28
+ export interface RefreshTokenInput {
29
+ grantType: "refresh_token";
30
+ refreshToken: string;
31
+ clientId: string;
32
+ clientSecret?: string;
33
+ }
34
+ export interface TokenExchangeInput {
35
+ grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
36
+ installationId: string;
37
+ viewerId: string;
38
+ viewerScopes: readonly string[];
39
+ contextualScopes?: readonly string[];
40
+ clientId: string;
41
+ clientSecret?: string;
42
+ }
43
+ export type AppTokenInput = TokenCodeInput | RefreshTokenInput | TokenExchangeInput;
44
+ export declare function createAppOAuthService(options: AppOAuthServiceOptions): {
45
+ authorize: (db: PostgresJsDatabase, input: AuthorizeAppInput) => Promise<{
46
+ code: string;
47
+ state: string;
48
+ redirectUri: string;
49
+ expiresAt: Date;
50
+ }>;
51
+ token: (db: PostgresJsDatabase, input: AppTokenInput) => Promise<{
52
+ refreshToken?: string | undefined;
53
+ accessToken: string;
54
+ tokenType: "Bearer";
55
+ expiresIn: number;
56
+ scope: string;
57
+ tokenMode: "offline" | "online";
58
+ }>;
59
+ resolveAccessToken: (db: PostgresJsDatabase, rawToken: string) => Promise<VoyantAuthContext | null>;
60
+ revokeInstallationCredentials: (db: PostgresJsDatabase, installationId: string, actorId: string) => Promise<{
61
+ installationId: string;
62
+ generation: number;
63
+ }>;
64
+ };
65
+ export declare function intersectAppTokenScopes(...sets: readonly (readonly string[])[]): string[];
@@ -0,0 +1,381 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { computeAppConsent } from "./consent.js";
4
+ import { APP_ACCESS_TOKEN_PREFIX, APP_AUTH_CODE_PREFIX, APP_REFRESH_TOKEN_PREFIX, constantTimeEqual, randomToken, sha256Hex, verifyPkceS256, } from "./oauth-crypto.js";
5
+ import { appAccessCredentials, appAuditEvents, appCredentials, appGrants, appInstallations, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleases, apps, } from "./schema.js";
6
+ const CODE_TTL_MS = 5 * 60 * 1000;
7
+ const OFFLINE_ACCESS_TTL_MS = 60 * 60 * 1000;
8
+ const ONLINE_ACCESS_TTL_MS = 10 * 60 * 1000;
9
+ const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
10
+ export function createAppOAuthService(options) {
11
+ const now = () => options.now?.() ?? new Date();
12
+ async function authorize(db, input) {
13
+ assertState(input.state);
14
+ if (input.codeChallengeMethod !== "S256") {
15
+ throw oauthError("invalid_request", "PKCE S256 is required");
16
+ }
17
+ const release = await requireRelease(db, input.appId, input.releaseId);
18
+ await requireExactRedirectUri(db, input.appId, input.redirectUri);
19
+ const consent = computeAppConsent({
20
+ release,
21
+ accessCatalog: options.accessCatalog,
22
+ operatorGrantedScopes: input.operatorGrantedScopes,
23
+ grantedOptionalScopes: input.grantedOptionalScopes,
24
+ });
25
+ const installation = await ensureAuthorizedInstallation(db, {
26
+ appId: input.appId,
27
+ releaseId: input.releaseId,
28
+ deploymentId: options.deploymentId,
29
+ actorId: input.actorId,
30
+ grantedScopes: consent.grantedScopes,
31
+ deniedOptionalScopes: consent.deniedOptionalScopes,
32
+ });
33
+ const code = randomToken(APP_AUTH_CODE_PREFIX);
34
+ const expiresAt = new Date(now().getTime() + CODE_TTL_MS);
35
+ await db.insert(appOAuthAuthorizationCodes).values({
36
+ appId: input.appId,
37
+ installationId: installation.id,
38
+ releaseId: input.releaseId,
39
+ deploymentId: options.deploymentId,
40
+ codeHash: sha256Hex(code),
41
+ stateHash: sha256Hex(input.state),
42
+ redirectUri: input.redirectUri,
43
+ codeChallenge: input.codeChallenge,
44
+ codeChallengeMethod: input.codeChallengeMethod,
45
+ requestedScopes: [...consent.requiredScopes, ...consent.optionalScopes].sort(),
46
+ grantedScopes: consent.grantedScopes,
47
+ deniedOptionalScopes: consent.deniedOptionalScopes,
48
+ actorId: input.actorId,
49
+ expiresAt,
50
+ });
51
+ await audit(db, installation, input.actorId, "consent", "oauth.consent.recorded", {
52
+ grantedScopes: consent.grantedScopes,
53
+ deniedOptionalScopes: consent.deniedOptionalScopes,
54
+ });
55
+ return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
56
+ }
57
+ async function token(db, input) {
58
+ await authenticateClient(db, input.clientId, input.clientSecret);
59
+ if (input.grantType === "authorization_code")
60
+ return exchangeCode(db, input);
61
+ if (input.grantType === "refresh_token")
62
+ return refresh(db, input);
63
+ return exchangeActorToken(db, input);
64
+ }
65
+ async function resolveAccessToken(db, rawToken) {
66
+ if (!rawToken.startsWith(APP_ACCESS_TOKEN_PREFIX))
67
+ return null;
68
+ const [credential] = await db
69
+ .select()
70
+ .from(appAccessCredentials)
71
+ .where(and(eq(appAccessCredentials.credentialHash, sha256Hex(rawToken)), eq(appAccessCredentials.status, "active")))
72
+ .limit(1);
73
+ if (!credential || (credential.expiresAt && credential.expiresAt <= now()))
74
+ return null;
75
+ const installation = await selectInstallation(db, credential.installationId);
76
+ if (!installation || !isInstallationUsable(installation, credential.generation))
77
+ return null;
78
+ const scopes = await grantedScopes(db, installation.id);
79
+ return {
80
+ callerType: "app",
81
+ actor: "staff",
82
+ audience: "staff",
83
+ appId: installation.appId,
84
+ appInstallationId: installation.id,
85
+ appReleaseId: installation.releaseId,
86
+ appCredentialGeneration: credential.generation,
87
+ appTokenMode: credential.tokenMode,
88
+ appViewerId: credential.viewerId ?? undefined,
89
+ scopes,
90
+ };
91
+ }
92
+ async function revokeInstallationCredentials(db, installationId, actorId) {
93
+ return db.transaction(async (tx) => {
94
+ const installation = await requireInstallation(tx, installationId);
95
+ const generation = installation.credentialGeneration + 1;
96
+ await tx
97
+ .update(appInstallations)
98
+ .set({ credentialGeneration: generation, updatedAt: now() })
99
+ .where(eq(appInstallations.id, installation.id));
100
+ await tx
101
+ .update(appAccessCredentials)
102
+ .set({ status: "revoked", deactivatedAt: now() })
103
+ .where(eq(appAccessCredentials.installationId, installation.id));
104
+ await tx
105
+ .update(appOAuthRefreshTokens)
106
+ .set({ status: "revoked", revokedAt: now() })
107
+ .where(eq(appOAuthRefreshTokens.installationId, installation.id));
108
+ await audit(tx, installation, actorId, "credential", "credential.revoked", { generation });
109
+ return { installationId: installation.id, generation };
110
+ });
111
+ }
112
+ return { authorize, token, resolveAccessToken, revokeInstallationCredentials };
113
+ async function exchangeCode(db, input) {
114
+ return db.transaction(async (tx) => {
115
+ const codeHash = sha256Hex(input.code);
116
+ const [code] = await tx
117
+ .select()
118
+ .from(appOAuthAuthorizationCodes)
119
+ .where(eq(appOAuthAuthorizationCodes.codeHash, codeHash))
120
+ .for("update")
121
+ .limit(1);
122
+ if (!code || code.consumedAt || code.expiresAt <= now()) {
123
+ throw oauthError("invalid_grant", "Authorization code is invalid, expired, or consumed");
124
+ }
125
+ if (code.appId !== input.clientId || code.redirectUri !== input.redirectUri) {
126
+ throw oauthError("invalid_grant", "Authorization code was issued to different client data");
127
+ }
128
+ if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
129
+ throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
130
+ }
131
+ await tx
132
+ .update(appOAuthAuthorizationCodes)
133
+ .set({ consumedAt: now() })
134
+ .where(eq(appOAuthAuthorizationCodes.id, code.id));
135
+ const installation = await requireInstallation(tx, code.installationId);
136
+ assertActiveInstallation(installation);
137
+ const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes);
138
+ await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
139
+ return tokens;
140
+ });
141
+ }
142
+ async function refresh(db, input) {
143
+ return db.transaction(async (tx) => {
144
+ const [tokenRow] = await tx
145
+ .select()
146
+ .from(appOAuthRefreshTokens)
147
+ .where(eq(appOAuthRefreshTokens.tokenHash, sha256Hex(input.refreshToken)))
148
+ .for("update")
149
+ .limit(1);
150
+ if (tokenRow?.status !== "active" || isExpired(tokenRow.expiresAt)) {
151
+ throw oauthError("invalid_grant", "Refresh token is invalid");
152
+ }
153
+ const installation = await requireInstallation(tx, tokenRow.installationId);
154
+ assertTokenClient(installation, input.clientId);
155
+ assertActiveInstallation(installation);
156
+ if (installation.credentialGeneration !== tokenRow.generation) {
157
+ throw oauthError("invalid_grant", "Refresh token generation was revoked");
158
+ }
159
+ await tx
160
+ .update(appOAuthRefreshTokens)
161
+ .set({ status: "inactive", revokedAt: now() })
162
+ .where(eq(appOAuthRefreshTokens.id, tokenRow.id));
163
+ const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), {
164
+ rotatedFromId: tokenRow.id,
165
+ });
166
+ await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
167
+ generation: installation.credentialGeneration,
168
+ });
169
+ return tokens;
170
+ });
171
+ }
172
+ async function exchangeActorToken(db, input) {
173
+ const installation = await requireInstallation(db, input.installationId);
174
+ assertTokenClient(installation, input.clientId);
175
+ assertActiveInstallation(installation);
176
+ const grants = await grantedScopes(db, installation.id);
177
+ const contextual = input.contextualScopes ?? grants;
178
+ const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
179
+ const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
180
+ const expiresAt = new Date(now().getTime() + ONLINE_ACCESS_TTL_MS);
181
+ await db.insert(appAccessCredentials).values({
182
+ installationId: installation.id,
183
+ generation: installation.credentialGeneration,
184
+ tokenMode: "online",
185
+ credentialHash: sha256Hex(accessToken),
186
+ encryptedMetadata: { scopeCount: scopes.length },
187
+ status: "active",
188
+ actorId: input.viewerId,
189
+ viewerId: input.viewerId,
190
+ expiresAt,
191
+ });
192
+ await audit(db, installation, input.viewerId, "token", "oauth.actor_token.exchanged", {
193
+ grantedScopes: scopes,
194
+ });
195
+ return tokenResponse(accessToken, null, expiresAt, scopes, "online");
196
+ }
197
+ async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
198
+ const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
199
+ const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
200
+ const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
201
+ const refreshExpiresAt = new Date(now().getTime() + REFRESH_TTL_MS);
202
+ const generation = installation.credentialGeneration;
203
+ await db.insert(appAccessCredentials).values({
204
+ installationId: installation.id,
205
+ generation,
206
+ tokenMode: "offline",
207
+ credentialHash: sha256Hex(accessToken),
208
+ encryptedMetadata: { scopeCount: scopes.length },
209
+ status: "active",
210
+ actorId,
211
+ viewerId,
212
+ expiresAt: accessExpiresAt,
213
+ });
214
+ await db.insert(appOAuthRefreshTokens).values({
215
+ installationId: installation.id,
216
+ tokenHash: sha256Hex(refreshToken),
217
+ generation,
218
+ rotatedFromId: refreshOptions.rotatedFromId,
219
+ expiresAt: refreshExpiresAt,
220
+ });
221
+ return tokenResponse(accessToken, refreshToken, accessExpiresAt, scopes, "offline");
222
+ }
223
+ function isExpired(expiresAt) {
224
+ return Boolean(expiresAt && expiresAt <= now());
225
+ }
226
+ }
227
+ function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
228
+ return {
229
+ accessToken,
230
+ tokenType: "Bearer",
231
+ expiresIn: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
232
+ scope: scopes.join(" "),
233
+ tokenMode,
234
+ ...(refreshToken ? { refreshToken } : {}),
235
+ };
236
+ }
237
+ async function authenticateClient(db, appId, clientSecret) {
238
+ const [secret] = await db
239
+ .select()
240
+ .from(appCredentials)
241
+ .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
242
+ .limit(1);
243
+ if (!secret)
244
+ return;
245
+ if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
246
+ throw oauthError("invalid_client", "Client authentication failed", 401);
247
+ }
248
+ const expected = secret.kmsKeyRef.slice("sha256:".length);
249
+ if (!constantTimeEqual(sha256Hex(clientSecret), expected)) {
250
+ throw oauthError("invalid_client", "Client authentication failed", 401);
251
+ }
252
+ }
253
+ async function requireRelease(db, appId, releaseId) {
254
+ const [release] = await db
255
+ .select()
256
+ .from(appReleases)
257
+ .where(and(eq(appReleases.id, releaseId), eq(appReleases.appId, appId)))
258
+ .limit(1);
259
+ if (!release)
260
+ throw oauthError("invalid_request", "App release not found", 404);
261
+ return release;
262
+ }
263
+ async function requireExactRedirectUri(db, appId, redirectUri) {
264
+ const [row] = await db
265
+ .select()
266
+ .from(appRedirectUris)
267
+ .where(and(eq(appRedirectUris.appId, appId), eq(appRedirectUris.redirectUri, redirectUri)))
268
+ .limit(1);
269
+ if (!row)
270
+ throw oauthError("invalid_request", "Redirect URI is not registered for this app");
271
+ }
272
+ async function ensureAuthorizedInstallation(db, input) {
273
+ return db.transaction(async (tx) => {
274
+ const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
275
+ if (!app)
276
+ throw oauthError("invalid_request", "App registration not found", 404);
277
+ const [existing] = await tx
278
+ .select()
279
+ .from(appInstallations)
280
+ .where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
281
+ .limit(1);
282
+ const installation = existing ??
283
+ (await tx
284
+ .insert(appInstallations)
285
+ .values({
286
+ appId: input.appId,
287
+ deploymentId: input.deploymentId,
288
+ releaseId: input.releaseId,
289
+ status: "active",
290
+ namespace: app.platformNamespace,
291
+ installedBy: input.actorId,
292
+ authorizedAt: new Date(),
293
+ activatedAt: new Date(),
294
+ })
295
+ .returning())[0];
296
+ if (!installation)
297
+ throw oauthError("server_error", "Could not create installation", 500);
298
+ for (const scope of input.grantedScopes) {
299
+ await tx
300
+ .insert(appGrants)
301
+ .values({
302
+ installationId: installation.id,
303
+ scope,
304
+ status: "granted",
305
+ optional: false,
306
+ grantedAt: new Date(),
307
+ })
308
+ .onConflictDoUpdate({
309
+ target: [appGrants.installationId, appGrants.scope],
310
+ set: { status: "granted", grantedAt: new Date(), revokedAt: null },
311
+ });
312
+ }
313
+ for (const scope of input.deniedOptionalScopes) {
314
+ await tx
315
+ .insert(appGrants)
316
+ .values({ installationId: installation.id, scope, status: "optional", optional: true })
317
+ .onConflictDoUpdate({
318
+ target: [appGrants.installationId, appGrants.scope],
319
+ set: { status: "optional", optional: true },
320
+ });
321
+ }
322
+ return installation;
323
+ });
324
+ }
325
+ async function requireInstallation(db, installationId) {
326
+ const installation = await selectInstallation(db, installationId);
327
+ if (!installation)
328
+ throw oauthError("invalid_grant", "App installation not found", 404);
329
+ return installation;
330
+ }
331
+ async function selectInstallation(db, installationId) {
332
+ const [installation] = await db
333
+ .select()
334
+ .from(appInstallations)
335
+ .where(eq(appInstallations.id, installationId))
336
+ .limit(1);
337
+ return installation ?? null;
338
+ }
339
+ async function grantedScopes(db, installationId) {
340
+ const rows = await db
341
+ .select({ scope: appGrants.scope })
342
+ .from(appGrants)
343
+ .where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
344
+ .orderBy(appGrants.scope);
345
+ return rows.map((row) => row.scope);
346
+ }
347
+ function assertActiveInstallation(installation) {
348
+ if (installation.status !== "active") {
349
+ throw oauthError("invalid_grant", "App installation is not active");
350
+ }
351
+ }
352
+ function isInstallationUsable(installation, generation) {
353
+ return installation?.status === "active" && installation.credentialGeneration === generation;
354
+ }
355
+ function assertTokenClient(installation, clientId) {
356
+ if (installation.appId !== clientId) {
357
+ throw oauthError("invalid_grant", "Token belongs to a different app");
358
+ }
359
+ }
360
+ export function intersectAppTokenScopes(...sets) {
361
+ const [first = [], ...rest] = sets;
362
+ return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
363
+ }
364
+ function assertState(state) {
365
+ if (!state.trim())
366
+ throw oauthError("invalid_request", "OAuth state is required");
367
+ }
368
+ function oauthError(error, description, status = 400) {
369
+ return new ApiHttpError(description, { status, code: error });
370
+ }
371
+ async function audit(db, installation, actorId, kind, action, details) {
372
+ await db.insert(appAuditEvents).values({
373
+ installationId: installation.id,
374
+ appId: installation.appId,
375
+ deploymentId: installation.deploymentId,
376
+ actorId,
377
+ kind,
378
+ action,
379
+ details,
380
+ });
381
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { intersectAppTokenScopes } from "./oauth-service.js";
3
+ describe("app OAuth online token scope intersection", () => {
4
+ it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
5
+ expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
6
+ expect(intersectAppTokenScopes(["bookings:read"], ["bookings:read", "invoices:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
7
+ });
8
+ });
@@ -0,0 +1,17 @@
1
+ import type { AccessCatalog } from "@voyant-travel/types/api-keys";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ import { Hono } from "hono";
4
+ import { type AppsServiceOptions } from "./service.js";
5
+ type Env = {
6
+ Variables: {
7
+ db: PostgresJsDatabase;
8
+ };
9
+ };
10
+ export interface AppsAdminRouteOptions extends AppsServiceOptions {
11
+ oauth?: {
12
+ accessCatalog: AccessCatalog;
13
+ deploymentId: string;
14
+ };
15
+ }
16
+ export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
17
+ export {};