@voyant-travel/apps 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/dist/contracts.d.ts +8 -87
  3. package/dist/contracts.js +0 -50
  4. package/dist/index.d.ts +1 -5
  5. package/dist/index.js +1 -5
  6. package/dist/installation-reconciliation.d.ts +1 -1
  7. package/dist/installation-reconciliation.js +1 -5
  8. package/dist/installation-service.d.ts +14 -25
  9. package/dist/installation-service.js +7 -22
  10. package/dist/routes.d.ts +1 -8
  11. package/dist/routes.js +1 -67
  12. package/dist/schema.d.ts +4 -536
  13. package/dist/schema.js +1 -52
  14. package/dist/service.d.ts +18 -18
  15. package/migrations/meta/_journal.json +0 -7
  16. package/package.json +78 -116
  17. package/dist/access-boundary.d.ts +0 -29
  18. package/dist/access-boundary.js +0 -33
  19. package/dist/app-api-contracts.d.ts +0 -51
  20. package/dist/app-api-contracts.js +0 -50
  21. package/dist/app-api-routes.d.ts +0 -26
  22. package/dist/app-api-routes.js +0 -133
  23. package/dist/app-api-service.d.ts +0 -1263
  24. package/dist/app-api-service.js +0 -231
  25. package/dist/app-api-service.test.d.ts +0 -1
  26. package/dist/app-api-service.test.js +0 -105
  27. package/dist/consent.d.ts +0 -15
  28. package/dist/consent.js +0 -50
  29. package/dist/consent.test.d.ts +0 -1
  30. package/dist/consent.test.js +0 -80
  31. package/dist/oauth-crypto.d.ts +0 -8
  32. package/dist/oauth-crypto.js +0 -23
  33. package/dist/oauth-crypto.test.d.ts +0 -1
  34. package/dist/oauth-crypto.test.js +0 -11
  35. package/dist/oauth-service.d.ts +0 -65
  36. package/dist/oauth-service.js +0 -381
  37. package/dist/oauth-service.test.d.ts +0 -1
  38. package/dist/oauth-service.test.js +0 -8
  39. package/migrations/20260717143000_app_oauth_authorization.sql +0 -47
@@ -1,381 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,8 +0,0 @@
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
- });
@@ -1,47 +0,0 @@
1
- ALTER TYPE "public"."app_audit_event_kind" ADD VALUE IF NOT EXISTS 'consent';--> statement-breakpoint
2
- ALTER TYPE "public"."app_audit_event_kind" ADD VALUE IF NOT EXISTS 'token';--> statement-breakpoint
3
- CREATE TYPE "public"."app_access_token_mode" AS ENUM('offline', 'online');--> statement-breakpoint
4
- ALTER TABLE "app_installations" ADD COLUMN "credential_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
5
- ALTER TABLE "app_access_credentials" ADD COLUMN "token_mode" "app_access_token_mode" DEFAULT 'offline' NOT NULL;--> statement-breakpoint
6
- ALTER TABLE "app_access_credentials" ADD COLUMN "actor_id" text;--> statement-breakpoint
7
- ALTER TABLE "app_access_credentials" ADD COLUMN "viewer_id" text;--> statement-breakpoint
8
- DROP INDEX "public"."uidx_app_access_credentials_generation";--> statement-breakpoint
9
- CREATE INDEX "idx_app_access_credentials_generation" ON "app_access_credentials" USING btree ("installation_id","token_mode","generation");--> statement-breakpoint
10
- CREATE TABLE "app_oauth_authorization_codes" (
11
- "id" text PRIMARY KEY NOT NULL,
12
- "app_id" text NOT NULL,
13
- "installation_id" text NOT NULL,
14
- "release_id" text NOT NULL,
15
- "deployment_id" text NOT NULL,
16
- "code_hash" text NOT NULL,
17
- "state_hash" text NOT NULL,
18
- "redirect_uri" text NOT NULL,
19
- "code_challenge" text NOT NULL,
20
- "code_challenge_method" text NOT NULL,
21
- "requested_scopes" jsonb NOT NULL,
22
- "granted_scopes" jsonb NOT NULL,
23
- "denied_optional_scopes" jsonb NOT NULL,
24
- "actor_id" text NOT NULL,
25
- "expires_at" timestamp with time zone NOT NULL,
26
- "consumed_at" timestamp with time zone,
27
- "created_at" timestamp with time zone DEFAULT now() NOT NULL
28
- );--> statement-breakpoint
29
- CREATE TABLE "app_oauth_refresh_tokens" (
30
- "id" text PRIMARY KEY NOT NULL,
31
- "installation_id" text NOT NULL,
32
- "token_hash" text NOT NULL,
33
- "generation" integer NOT NULL,
34
- "status" "app_access_credential_status" DEFAULT 'active' NOT NULL,
35
- "rotated_from_id" text,
36
- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
37
- "expires_at" timestamp with time zone,
38
- "revoked_at" timestamp with time zone
39
- );--> statement-breakpoint
40
- ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
41
- ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_installation_id_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
42
- ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_authorization_codes_release_id_app_releases_id_fk" FOREIGN KEY ("release_id") REFERENCES "public"."app_releases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
43
- ALTER TABLE "app_oauth_refresh_tokens" ADD CONSTRAINT "app_oauth_refresh_tokens_installation_id_app_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."app_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
44
- CREATE UNIQUE INDEX "uidx_app_oauth_codes_hash" ON "app_oauth_authorization_codes" USING btree ("code_hash");--> statement-breakpoint
45
- CREATE INDEX "idx_app_oauth_codes_installation" ON "app_oauth_authorization_codes" USING btree ("installation_id","expires_at");--> statement-breakpoint
46
- CREATE UNIQUE INDEX "uidx_app_oauth_refresh_tokens_hash" ON "app_oauth_refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
47
- CREATE INDEX "idx_app_oauth_refresh_tokens_installation" ON "app_oauth_refresh_tokens" USING btree ("installation_id","status");