@opengeni/db 0.13.0 → 0.13.4

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.
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ import type {
59
59
  SessionHumanInputRequest,
60
60
  LineageNode,
61
61
  SessionMcpApprovalPolicy,
62
+ SessionSkill,
62
63
  SessionMcpServerMetadata,
63
64
  SessionStatus,
64
65
  SessionToolPolicy,
@@ -1338,6 +1339,24 @@ export async function bootstrapWorkspace(
1338
1339
  })
1339
1340
  .where(eq(schema.workspaceMemberships.id, membership.id));
1340
1341
  }
1342
+ // Access refreshes must retain workspaces created or granted after the
1343
+ // default workspace. Restore account-scoped RLS before listing them.
1344
+ await setRlsContext(tx as unknown as Database, {
1345
+ accountId: workspace.accountId,
1346
+ workspaceId: null,
1347
+ });
1348
+ const memberships = await tx
1349
+ .select({
1350
+ membership: schema.workspaceMemberships,
1351
+ workspace: schema.workspaces,
1352
+ })
1353
+ .from(schema.workspaceMemberships)
1354
+ .innerJoin(
1355
+ schema.workspaces,
1356
+ eq(schema.workspaceMemberships.workspaceId, schema.workspaces.id),
1357
+ )
1358
+ .where(eq(schema.workspaceMemberships.subjectId, input.subjectId))
1359
+ .orderBy(desc(schema.workspaces.createdAt));
1341
1360
  return {
1342
1361
  mode: input.accountExternalSource === "opengeni:local" ? "local" : "configured",
1343
1362
  subjectId: input.subjectId,
@@ -1351,15 +1370,13 @@ export async function bootstrapWorkspace(
1351
1370
  permissions: input.accountPermissions ?? allAccountPermissions,
1352
1371
  },
1353
1372
  ],
1354
- workspaceGrants: [
1355
- {
1356
- workspaceId: workspace.id,
1357
- accountId: account.id,
1358
- subjectId: input.subjectId,
1359
- ...(input.subjectLabel ? { subjectLabel: input.subjectLabel } : {}),
1360
- permissions: workspacePermissions,
1361
- },
1362
- ],
1373
+ workspaceGrants: memberships.map((row) => ({
1374
+ workspaceId: row.workspace.id,
1375
+ accountId: row.workspace.accountId,
1376
+ subjectId: input.subjectId,
1377
+ ...(input.subjectLabel ? { subjectLabel: input.subjectLabel } : {}),
1378
+ permissions: row.membership.permissions as Permission[],
1379
+ })),
1363
1380
  defaultAccountId: account.id,
1364
1381
  defaultWorkspaceId: workspace.id,
1365
1382
  };
@@ -3328,6 +3345,7 @@ export type RegistryCapabilityCatalogItemInput = {
3328
3345
  importBatchId: string;
3329
3346
  scopesHint?: string[];
3330
3347
  homepageUrl?: string | null;
3348
+ installUrl?: string | null;
3331
3349
  tags?: string[];
3332
3350
  metadata?: Record<string, unknown>;
3333
3351
  };
@@ -4196,7 +4214,7 @@ export async function upsertRegistryCapabilityCatalogItem(
4196
4214
  tags: input.tags ?? ["mcp", "integration", input.tier],
4197
4215
  homepageUrl: input.homepageUrl ?? `https://${input.providerDomain}`,
4198
4216
  endpointUrl: input.mcpUrl,
4199
- installUrl: input.homepageUrl ?? `https://${input.providerDomain}`,
4217
+ installUrl: input.installUrl ?? input.homepageUrl ?? `https://${input.providerDomain}`,
4200
4218
  authModel: input.authKind === "none" ? null : "credential_ref",
4201
4219
  providerDomain: input.providerDomain,
4202
4220
  surfaceType: "mcp",
@@ -4771,6 +4789,52 @@ function connectionSubjectVisibility(subjectId?: string | null): SQL {
4771
4789
  : isNull(schema.connections.subjectId);
4772
4790
  }
4773
4791
 
4792
+ function connectionExactSubject(subjectId?: string | null): SQL {
4793
+ return subjectId
4794
+ ? eq(schema.connections.subjectId, subjectId)
4795
+ : isNull(schema.connections.subjectId);
4796
+ }
4797
+
4798
+ async function withConnectionSubjectRls<T>(
4799
+ db: Database,
4800
+ workspaceId: string,
4801
+ subjectId: string | null | undefined,
4802
+ fn: (db: Database) => Promise<T>,
4803
+ ): Promise<T> {
4804
+ return subjectId
4805
+ ? await withWorkspaceSubjectRls(db, workspaceId, subjectId, fn)
4806
+ : await withWorkspaceRls(db, workspaceId, fn);
4807
+ }
4808
+
4809
+ async function createConnectionInScope(
4810
+ db: Database,
4811
+ input: CreateConnectionInput,
4812
+ ): Promise<ConnectionMetadataWithVerification> {
4813
+ const [row] = await db
4814
+ .insert(schema.connections)
4815
+ .values({
4816
+ accountId: input.accountId,
4817
+ workspaceId: input.workspaceId,
4818
+ subjectId: input.subjectId ?? null,
4819
+ providerDomain: input.providerDomain,
4820
+ kind: input.kind,
4821
+ status: input.status ?? "active",
4822
+ credentialEncrypted: input.credentialEncrypted,
4823
+ grantedScopes: input.grantedScopes ?? [],
4824
+ expiresAt: input.expiresAt ?? null,
4825
+ verifiedInstallAt: input.verifiedInstallAt ?? null,
4826
+ verifiedInstallVersion: input.verifiedInstallVersion ?? null,
4827
+ metadata: input.metadata ?? {},
4828
+ createdBySubjectId: input.createdBySubjectId ?? null,
4829
+ updatedBySubjectId: input.updatedBySubjectId ?? input.createdBySubjectId ?? null,
4830
+ })
4831
+ .returning(connectionMetadataColumns);
4832
+ if (!row) {
4833
+ throw new Error("Failed to create connection");
4834
+ }
4835
+ return mapConnectionMetadata(row);
4836
+ }
4837
+
4774
4838
  export async function createConnection(
4775
4839
  db: Database,
4776
4840
  input: CreateConnectionInput,
@@ -4779,29 +4843,10 @@ export async function createConnection(
4779
4843
  db,
4780
4844
  { accountId: input.accountId, workspaceId: input.workspaceId },
4781
4845
  async (scopedDb) => {
4782
- const [row] = await scopedDb
4783
- .insert(schema.connections)
4784
- .values({
4785
- accountId: input.accountId,
4786
- workspaceId: input.workspaceId,
4787
- subjectId: input.subjectId ?? null,
4788
- providerDomain: input.providerDomain,
4789
- kind: input.kind,
4790
- status: input.status ?? "active",
4791
- credentialEncrypted: input.credentialEncrypted,
4792
- grantedScopes: input.grantedScopes ?? [],
4793
- expiresAt: input.expiresAt ?? null,
4794
- verifiedInstallAt: input.verifiedInstallAt ?? null,
4795
- verifiedInstallVersion: input.verifiedInstallVersion ?? null,
4796
- metadata: input.metadata ?? {},
4797
- createdBySubjectId: input.createdBySubjectId ?? null,
4798
- updatedBySubjectId: input.updatedBySubjectId ?? input.createdBySubjectId ?? null,
4799
- })
4800
- .returning(connectionMetadataColumns);
4801
- if (!row) {
4802
- throw new Error("Failed to create connection");
4846
+ if (input.subjectId) {
4847
+ await setSubjectRlsContext(scopedDb, input.subjectId);
4803
4848
  }
4804
- return mapConnectionMetadata(row);
4849
+ return await createConnectionInScope(scopedDb, input);
4805
4850
  },
4806
4851
  );
4807
4852
  }
@@ -4811,7 +4856,7 @@ export async function listConnectionsMetadata(
4811
4856
  workspaceId: string,
4812
4857
  subjectId?: string | null,
4813
4858
  ): Promise<ConnectionMetadataWithVerification[]> {
4814
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4859
+ return await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
4815
4860
  const rows = await scopedDb
4816
4861
  .select(connectionMetadataColumns)
4817
4862
  .from(schema.connections)
@@ -4832,7 +4877,7 @@ export async function getConnectionMetadata(
4832
4877
  connectionId: string,
4833
4878
  subjectId?: string | null,
4834
4879
  ): Promise<ConnectionMetadataWithVerification | null> {
4835
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4880
+ return await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
4836
4881
  const [row] = await scopedDb
4837
4882
  .select(connectionMetadataColumns)
4838
4883
  .from(schema.connections)
@@ -4848,53 +4893,102 @@ export async function getConnectionMetadata(
4848
4893
  });
4849
4894
  }
4850
4895
 
4896
+ async function updateConnectionInScope(
4897
+ db: Database,
4898
+ input: UpdateConnectionInput,
4899
+ ): Promise<ConnectionMetadataWithVerification | null> {
4900
+ const set = {
4901
+ updatedAt: new Date(),
4902
+ ...(input.providerDomain !== undefined ? { providerDomain: input.providerDomain } : {}),
4903
+ ...(input.subjectId !== undefined ? { subjectId: input.subjectId } : {}),
4904
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
4905
+ ...(input.status !== undefined ? { status: input.status } : {}),
4906
+ ...(input.credentialEncrypted !== undefined
4907
+ ? {
4908
+ credentialEncrypted: input.credentialEncrypted,
4909
+ version: sql`${schema.connections.version} + 1`,
4910
+ lastError: null,
4911
+ }
4912
+ : {}),
4913
+ ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
4914
+ ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
4915
+ ...(input.verifiedInstallAt !== undefined
4916
+ ? { verifiedInstallAt: input.verifiedInstallAt }
4917
+ : {}),
4918
+ ...(input.verifiedInstallVersion !== undefined
4919
+ ? { verifiedInstallVersion: input.verifiedInstallVersion }
4920
+ : {}),
4921
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
4922
+ ...(input.updatedBySubjectId !== undefined
4923
+ ? { updatedBySubjectId: input.updatedBySubjectId }
4924
+ : {}),
4925
+ };
4926
+ const [row] = await db
4927
+ .update(schema.connections)
4928
+ .set(set)
4929
+ .where(
4930
+ and(
4931
+ eq(schema.connections.workspaceId, input.workspaceId),
4932
+ eq(schema.connections.id, input.connectionId),
4933
+ connectionSubjectVisibility(input.visibleToSubjectId),
4934
+ ...(input.expectedVersion !== undefined
4935
+ ? [eq(schema.connections.version, input.expectedVersion)]
4936
+ : []),
4937
+ ),
4938
+ )
4939
+ .returning(connectionMetadataColumns);
4940
+ return row ? mapConnectionMetadata(row) : null;
4941
+ }
4942
+
4851
4943
  export async function updateConnection(
4852
4944
  db: Database,
4853
4945
  input: UpdateConnectionInput,
4854
4946
  ): Promise<ConnectionMetadataWithVerification | null> {
4855
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
4856
- const set = {
4947
+ return await withConnectionSubjectRls(
4948
+ db,
4949
+ input.workspaceId,
4950
+ input.visibleToSubjectId,
4951
+ async (scopedDb) => await updateConnectionInScope(scopedDb, input),
4952
+ );
4953
+ }
4954
+
4955
+ async function revokeConnectionInScope(
4956
+ db: Database,
4957
+ workspaceId: string,
4958
+ connectionId: string,
4959
+ updatedBySubjectId?: string | null,
4960
+ expectedVersion?: number,
4961
+ ): Promise<ConnectionMetadataWithVerification | null> {
4962
+ const [row] = await db
4963
+ .update(schema.connections)
4964
+ .set({
4965
+ status: "revoked",
4966
+ // The version bump invalidates any in-flight refresh's (id, version) CAS,
4967
+ // so a racing refresh cannot commit and flip the row back to active.
4968
+ version: sql`${schema.connections.version} + 1`,
4969
+ // Status-only revocation does not replace the verified credential or bot
4970
+ // identity. Carry the marker to the same new CAS version so the dedicated
4971
+ // reinstall path can still recognize (but not use) the inactive row.
4972
+ verifiedInstallVersion: sql`case
4973
+ when ${schema.connections.verifiedInstallAt} is null then null
4974
+ else ${schema.connections.version} + 1
4975
+ end`,
4976
+ updatedBySubjectId: updatedBySubjectId ?? null,
4857
4977
  updatedAt: new Date(),
4858
- ...(input.providerDomain !== undefined ? { providerDomain: input.providerDomain } : {}),
4859
- ...(input.subjectId !== undefined ? { subjectId: input.subjectId } : {}),
4860
- ...(input.kind !== undefined ? { kind: input.kind } : {}),
4861
- ...(input.status !== undefined ? { status: input.status } : {}),
4862
- ...(input.credentialEncrypted !== undefined
4863
- ? {
4864
- credentialEncrypted: input.credentialEncrypted,
4865
- version: sql`${schema.connections.version} + 1`,
4866
- lastError: null,
4867
- }
4868
- : {}),
4869
- ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
4870
- ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
4871
- ...(input.verifiedInstallAt !== undefined
4872
- ? { verifiedInstallAt: input.verifiedInstallAt }
4873
- : {}),
4874
- ...(input.verifiedInstallVersion !== undefined
4875
- ? { verifiedInstallVersion: input.verifiedInstallVersion }
4876
- : {}),
4877
- ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
4878
- ...(input.updatedBySubjectId !== undefined
4879
- ? { updatedBySubjectId: input.updatedBySubjectId }
4880
- : {}),
4881
- };
4882
- const [row] = await scopedDb
4883
- .update(schema.connections)
4884
- .set(set)
4885
- .where(
4886
- and(
4887
- eq(schema.connections.workspaceId, input.workspaceId),
4888
- eq(schema.connections.id, input.connectionId),
4889
- connectionSubjectVisibility(input.visibleToSubjectId),
4890
- ...(input.expectedVersion !== undefined
4891
- ? [eq(schema.connections.version, input.expectedVersion)]
4892
- : []),
4893
- ),
4894
- )
4895
- .returning(connectionMetadataColumns);
4896
- return row ? mapConnectionMetadata(row) : null;
4897
- });
4978
+ })
4979
+ .where(
4980
+ and(
4981
+ eq(schema.connections.workspaceId, workspaceId),
4982
+ eq(schema.connections.id, connectionId),
4983
+ // Same visibility rule as get/update: shared rows plus the caller's own
4984
+ // subject rows. Cross-subject revocation (admin janitorial) arrives with
4985
+ // the subject-connections UX in I5, deliberately not before.
4986
+ connectionSubjectVisibility(updatedBySubjectId),
4987
+ ...(expectedVersion !== undefined ? [eq(schema.connections.version, expectedVersion)] : []),
4988
+ ),
4989
+ )
4990
+ .returning(connectionMetadataColumns);
4991
+ return row ? mapConnectionMetadata(row) : null;
4898
4992
  }
4899
4993
 
4900
4994
  export async function revokeConnection(
@@ -4903,39 +4997,244 @@ export async function revokeConnection(
4903
4997
  connectionId: string,
4904
4998
  updatedBySubjectId?: string | null,
4905
4999
  ): Promise<ConnectionMetadataWithVerification | null> {
4906
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4907
- const [row] = await scopedDb
4908
- .update(schema.connections)
4909
- .set({
4910
- status: "revoked",
4911
- // The version bump invalidates any in-flight refresh's (id, version) CAS,
4912
- // so a racing refresh cannot commit and flip the row back to active.
4913
- version: sql`${schema.connections.version} + 1`,
4914
- // Status-only revocation does not replace the verified credential or bot
4915
- // identity. Carry the marker to the same new CAS version so the dedicated
4916
- // reinstall path can still recognize (but not use) the inactive row.
4917
- verifiedInstallVersion: sql`case
4918
- when ${schema.connections.verifiedInstallAt} is null then null
4919
- else ${schema.connections.version} + 1
4920
- end`,
4921
- updatedBySubjectId: updatedBySubjectId ?? null,
4922
- updatedAt: new Date(),
4923
- })
4924
- .where(
4925
- and(
4926
- eq(schema.connections.workspaceId, workspaceId),
4927
- eq(schema.connections.id, connectionId),
4928
- // Same visibility rule as get/update: shared rows plus the caller's own
4929
- // subject rows. Cross-subject revocation (admin janitorial) arrives with
4930
- // the subject-connections UX in I5, deliberately not before.
4931
- connectionSubjectVisibility(updatedBySubjectId),
4932
- ),
4933
- )
4934
- .returning(connectionMetadataColumns);
4935
- return row ? mapConnectionMetadata(row) : null;
5000
+ return await withConnectionSubjectRls(
5001
+ db,
5002
+ workspaceId,
5003
+ updatedBySubjectId,
5004
+ async (scopedDb) =>
5005
+ await revokeConnectionInScope(scopedDb, workspaceId, connectionId, updatedBySubjectId),
5006
+ );
5007
+ }
5008
+
5009
+ export class SlackBotLifecycleSuccessAuditError extends Error {
5010
+ constructor() {
5011
+ super("OpenGeni Slack bot lifecycle success audit failed");
5012
+ this.name = "SlackBotLifecycleSuccessAuditError";
5013
+ }
5014
+ }
5015
+
5016
+ type SlackBotLifecycleSuccessAuditInput = {
5017
+ accountId: string;
5018
+ workspaceId: string;
5019
+ subjectId: string;
5020
+ credentialRole: string;
5021
+ credentialLabel: string;
5022
+ slackTeamId: string;
5023
+ };
5024
+
5025
+ async function insertSlackBotLifecycleSuccessAuditInScope(
5026
+ db: Database,
5027
+ input: SlackBotLifecycleSuccessAuditInput & {
5028
+ action: "slack_bot.connected" | "slack_bot.reinstalled" | "slack_bot.disconnected";
5029
+ connectionId: string;
5030
+ },
5031
+ ): Promise<void> {
5032
+ try {
5033
+ await db.insert(schema.auditEvents).values({
5034
+ accountId: input.accountId,
5035
+ workspaceId: input.workspaceId,
5036
+ subjectId: input.subjectId,
5037
+ action: input.action,
5038
+ targetType: "connection",
5039
+ targetId: input.connectionId,
5040
+ metadata: {
5041
+ credentialRole: input.credentialRole,
5042
+ credentialLabel: input.credentialLabel,
5043
+ connectionId: input.connectionId,
5044
+ slackTeamId: input.slackTeamId,
5045
+ outcome: "succeeded",
5046
+ },
5047
+ });
5048
+ } catch {
5049
+ // Do not leak a provider/database payload through the callback. Throwing from
5050
+ // the RLS transaction is what rolls the paired connection mutation back.
5051
+ throw new SlackBotLifecycleSuccessAuditError();
5052
+ }
5053
+ }
5054
+
5055
+ async function assertWorkspaceAccountPairInScope(
5056
+ db: Database,
5057
+ accountId: string,
5058
+ workspaceId: string,
5059
+ ): Promise<void> {
5060
+ const [workspace] = await db
5061
+ .select({ id: schema.workspaces.id })
5062
+ .from(schema.workspaces)
5063
+ .where(and(eq(schema.workspaces.id, workspaceId), eq(schema.workspaces.accountId, accountId)))
5064
+ .limit(1);
5065
+ if (!workspace) {
5066
+ throw new Error("Workspace does not belong to the expected account");
5067
+ }
5068
+ }
5069
+
5070
+ async function withSlackBotLifecycleRls<T>(
5071
+ db: Database,
5072
+ input: Pick<SlackBotLifecycleSuccessAuditInput, "accountId" | "workspaceId" | "subjectId">,
5073
+ fn: (db: Database) => Promise<T>,
5074
+ ): Promise<T> {
5075
+ return await withRlsContext(
5076
+ db,
5077
+ { accountId: input.accountId, workspaceId: input.workspaceId },
5078
+ async (scopedDb) => {
5079
+ await setSubjectRlsContext(scopedDb, input.subjectId);
5080
+ await assertWorkspaceAccountPairInScope(scopedDb, input.accountId, input.workspaceId);
5081
+ return await fn(scopedDb);
5082
+ },
5083
+ );
5084
+ }
5085
+
5086
+ export async function createConnectionWithSlackBotSuccessAudit(
5087
+ db: Database,
5088
+ input: SlackBotLifecycleSuccessAuditInput & { connection: CreateConnectionInput },
5089
+ ): Promise<ConnectionMetadataWithVerification> {
5090
+ if (
5091
+ input.connection.accountId !== input.accountId ||
5092
+ input.connection.workspaceId !== input.workspaceId
5093
+ ) {
5094
+ throw new Error("Slack bot connection and lifecycle audit tenant must match");
5095
+ }
5096
+ return await withSlackBotLifecycleRls(db, input, async (scopedDb) => {
5097
+ const connection = await createConnectionInScope(scopedDb, input.connection);
5098
+ await insertSlackBotLifecycleSuccessAuditInScope(scopedDb, {
5099
+ ...input,
5100
+ action: "slack_bot.connected",
5101
+ connectionId: connection.id,
5102
+ });
5103
+ return connection;
5104
+ });
5105
+ }
5106
+
5107
+ export async function updateConnectionWithSlackBotSuccessAudit(
5108
+ db: Database,
5109
+ input: SlackBotLifecycleSuccessAuditInput & { connection: UpdateConnectionInput },
5110
+ ): Promise<ConnectionMetadataWithVerification | null> {
5111
+ if (input.connection.workspaceId !== input.workspaceId) {
5112
+ throw new Error("Slack bot connection and lifecycle audit workspace must match");
5113
+ }
5114
+ return await withSlackBotLifecycleRls(db, input, async (scopedDb) => {
5115
+ const connection = await updateConnectionInScope(scopedDb, input.connection);
5116
+ if (!connection) return null;
5117
+ if (connection.accountId !== input.accountId) {
5118
+ throw new Error("Slack bot connection and lifecycle audit account must match");
5119
+ }
5120
+ await insertSlackBotLifecycleSuccessAuditInScope(scopedDb, {
5121
+ ...input,
5122
+ action: "slack_bot.reinstalled",
5123
+ connectionId: connection.id,
5124
+ });
5125
+ return connection;
4936
5126
  });
4937
5127
  }
4938
5128
 
5129
+ export async function revokeConnectionWithSlackBotSuccessAudit(
5130
+ db: Database,
5131
+ input: SlackBotLifecycleSuccessAuditInput & {
5132
+ connectionId: string;
5133
+ expectedVersion: number;
5134
+ },
5135
+ ): Promise<ConnectionMetadataWithVerification | null> {
5136
+ return await withSlackBotLifecycleRls(db, input, async (scopedDb) => {
5137
+ const connection = await revokeConnectionInScope(
5138
+ scopedDb,
5139
+ input.workspaceId,
5140
+ input.connectionId,
5141
+ input.subjectId,
5142
+ input.expectedVersion,
5143
+ );
5144
+ if (!connection) return null;
5145
+ if (connection.accountId !== input.accountId) {
5146
+ throw new Error("Slack bot connection and lifecycle audit account must match");
5147
+ }
5148
+ await insertSlackBotLifecycleSuccessAuditInScope(scopedDb, {
5149
+ ...input,
5150
+ action: "slack_bot.disconnected",
5151
+ connectionId: connection.id,
5152
+ });
5153
+ return connection;
5154
+ });
5155
+ }
5156
+
5157
+ export type SlackBotInstallCallbackFailureStage =
5158
+ | "permission_check"
5159
+ | "nonce_consume"
5160
+ | "provider_denial"
5161
+ | "code_exchange"
5162
+ | "credential_verification"
5163
+ | "permission_recheck"
5164
+ | "principal_validation"
5165
+ | "persistence";
5166
+
5167
+ export type SlackBotInstallCallbackFailureReason =
5168
+ | "permission_lost"
5169
+ | "state_replayed"
5170
+ | "provider_denied"
5171
+ | "missing_code"
5172
+ | "exchange_failed"
5173
+ | "scope_mismatch"
5174
+ | "identity_mismatch"
5175
+ | "credential_verification_failed"
5176
+ | "connection_conflict"
5177
+ | "principal_mismatch"
5178
+ | "persistence_failed"
5179
+ | "success_audit_failed";
5180
+
5181
+ export async function recordSlackBotInstallCallbackFailure(
5182
+ db: Database,
5183
+ input: {
5184
+ accountId: string;
5185
+ workspaceId: string;
5186
+ subjectId: string;
5187
+ callbackDigest: string;
5188
+ installMode: "connect" | "reinstall";
5189
+ stage: SlackBotInstallCallbackFailureStage;
5190
+ reason: SlackBotInstallCallbackFailureReason;
5191
+ },
5192
+ ): Promise<boolean> {
5193
+ if (!/^[a-f0-9]{64}$/.test(input.callbackDigest)) {
5194
+ throw new Error("Slack callback digest must be a lowercase SHA-256 value");
5195
+ }
5196
+ return await withRlsContext(
5197
+ db,
5198
+ { accountId: input.accountId, workspaceId: input.workspaceId },
5199
+ async (scopedDb) => {
5200
+ await setSubjectRlsContext(scopedDb, input.subjectId);
5201
+ await assertWorkspaceAccountPairInScope(scopedDb, input.accountId, input.workspaceId);
5202
+ await scopedDb.execute(
5203
+ sql`select pg_advisory_xact_lock(hashtextextended(${`slack-callback-failure:${input.workspaceId}:${input.callbackDigest}`}, 0))`,
5204
+ );
5205
+ const [existing] = await scopedDb
5206
+ .select({ id: schema.auditEvents.id })
5207
+ .from(schema.auditEvents)
5208
+ .where(
5209
+ and(
5210
+ eq(schema.auditEvents.accountId, input.accountId),
5211
+ eq(schema.auditEvents.workspaceId, input.workspaceId),
5212
+ eq(schema.auditEvents.action, "slack_bot.install.callback.failed"),
5213
+ eq(schema.auditEvents.targetType, "slack_oauth_callback"),
5214
+ eq(schema.auditEvents.targetId, input.callbackDigest),
5215
+ ),
5216
+ )
5217
+ .limit(1);
5218
+ if (existing) return false;
5219
+ await scopedDb.insert(schema.auditEvents).values({
5220
+ accountId: input.accountId,
5221
+ workspaceId: input.workspaceId,
5222
+ subjectId: input.subjectId,
5223
+ action: "slack_bot.install.callback.failed",
5224
+ targetType: "slack_oauth_callback",
5225
+ targetId: input.callbackDigest,
5226
+ metadata: {
5227
+ outcome: "failed",
5228
+ installMode: input.installMode,
5229
+ stage: input.stage,
5230
+ reason: input.reason,
5231
+ },
5232
+ });
5233
+ return true;
5234
+ },
5235
+ );
5236
+ }
5237
+
4939
5238
  export type ClaimSlackBotPostOperationResult =
4940
5239
  | { kind: "claimed" | "in_progress" | "completed"; operation: SlackBotPostOperation }
4941
5240
  | { kind: "conflict" | "connection_not_found" };
@@ -5214,6 +5513,9 @@ export async function loadConnectionCredentialForBroker(
5214
5513
  allowSubjectOwned?: boolean;
5215
5514
  },
5216
5515
  ): Promise<ConnectionCredentialForBroker | null> {
5516
+ if (input.allowSubjectOwned && !input.subjectId) {
5517
+ return null;
5518
+ }
5217
5519
  const key = environmentsEncryptionKeyBytes(settings);
5218
5520
  if (!key) {
5219
5521
  throw new Error(
@@ -5221,7 +5523,7 @@ export async function loadConnectionCredentialForBroker(
5221
5523
  );
5222
5524
  }
5223
5525
  const subjectPredicate = input.allowSubjectOwned
5224
- ? connectionSubjectVisibility(input.subjectId)
5526
+ ? connectionExactSubject(input.subjectId)
5225
5527
  : isNull(schema.connections.subjectId);
5226
5528
  const conditions: SQL[] = [
5227
5529
  eq(schema.connections.workspaceId, input.workspaceId),
@@ -5235,49 +5537,54 @@ export async function loadConnectionCredentialForBroker(
5235
5537
  conditions.push(eq(schema.connections.kind, input.kind));
5236
5538
  }
5237
5539
  }
5238
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
5239
- // Prefer active rows: a revoke bumps updatedAt, so recency alone would let a
5240
- // freshly revoked connection shadow an active replacement for the provider.
5241
- const [row] = await scopedDb
5242
- .select()
5243
- .from(schema.connections)
5244
- .where(and(...conditions))
5245
- .orderBy(
5246
- desc(sql`(${schema.connections.status} = 'active')`),
5247
- desc(schema.connections.updatedAt),
5248
- )
5249
- .limit(1);
5250
- if (!row) {
5251
- return null;
5252
- }
5253
- let credential: unknown;
5254
- try {
5255
- credential = JSON.parse(decryptEnvironmentValue(key, row.credentialEncrypted));
5256
- } catch (error) {
5257
- throw new Error(
5258
- `connection credential could not be decrypted for ${row.id}: ${error instanceof Error ? error.message : String(error)}`,
5259
- { cause: error },
5260
- );
5261
- }
5262
- if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
5263
- throw new Error(`connection credential bundle for ${row.id} is not a JSON object`);
5264
- }
5265
- return {
5266
- id: row.id,
5267
- accountId: row.accountId,
5268
- workspaceId: row.workspaceId,
5269
- subjectId: row.subjectId,
5270
- providerDomain: row.providerDomain,
5271
- kind: row.kind as ConnectionKind,
5272
- status: row.status as ConnectionStatus,
5273
- credential: credential as Record<string, unknown>,
5274
- grantedScopes: row.grantedScopes,
5275
- expiresAt: row.expiresAt,
5276
- lastRefreshAt: row.lastRefreshAt,
5277
- version: row.version,
5278
- metadata: row.metadata,
5279
- };
5280
- });
5540
+ return await withConnectionSubjectRls(
5541
+ db,
5542
+ input.workspaceId,
5543
+ input.allowSubjectOwned ? input.subjectId : null,
5544
+ async (scopedDb) => {
5545
+ // Prefer active rows: a revoke bumps updatedAt, so recency alone would let a
5546
+ // freshly revoked connection shadow an active replacement for the provider.
5547
+ const [row] = await scopedDb
5548
+ .select()
5549
+ .from(schema.connections)
5550
+ .where(and(...conditions))
5551
+ .orderBy(
5552
+ desc(sql`(${schema.connections.status} = 'active')`),
5553
+ desc(schema.connections.updatedAt),
5554
+ )
5555
+ .limit(1);
5556
+ if (!row) {
5557
+ return null;
5558
+ }
5559
+ let credential: unknown;
5560
+ try {
5561
+ credential = JSON.parse(decryptEnvironmentValue(key, row.credentialEncrypted));
5562
+ } catch (error) {
5563
+ throw new Error(
5564
+ `connection credential could not be decrypted for ${row.id}: ${error instanceof Error ? error.message : String(error)}`,
5565
+ { cause: error },
5566
+ );
5567
+ }
5568
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
5569
+ throw new Error(`connection credential bundle for ${row.id} is not a JSON object`);
5570
+ }
5571
+ return {
5572
+ id: row.id,
5573
+ accountId: row.accountId,
5574
+ workspaceId: row.workspaceId,
5575
+ subjectId: row.subjectId,
5576
+ providerDomain: row.providerDomain,
5577
+ kind: row.kind as ConnectionKind,
5578
+ status: row.status as ConnectionStatus,
5579
+ credential: credential as Record<string, unknown>,
5580
+ grantedScopes: row.grantedScopes,
5581
+ expiresAt: row.expiresAt,
5582
+ lastRefreshAt: row.lastRefreshAt,
5583
+ version: row.version,
5584
+ metadata: row.metadata,
5585
+ };
5586
+ },
5587
+ );
5281
5588
  }
5282
5589
 
5283
5590
  export async function recordConnectionTokenRefresh(
@@ -5290,35 +5597,42 @@ export async function recordConnectionTokenRefresh(
5290
5597
  expiresAt: Date | null;
5291
5598
  grantedScopes?: string[];
5292
5599
  lastRefreshAt: Date;
5600
+ subjectId?: string | null;
5293
5601
  },
5294
5602
  ): Promise<boolean> {
5295
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
5296
- const set = {
5297
- credentialEncrypted: input.credentialEncrypted,
5298
- expiresAt: input.expiresAt,
5299
- lastRefreshAt: input.lastRefreshAt,
5300
- status: "active",
5301
- lastError: null,
5302
- version: sql`${schema.connections.version} + 1`,
5303
- updatedAt: new Date(),
5304
- ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
5305
- };
5306
- const updated = await scopedDb
5307
- .update(schema.connections)
5308
- .set(set)
5309
- .where(
5310
- and(
5311
- eq(schema.connections.id, input.id),
5312
- eq(schema.connections.workspaceId, input.workspaceId),
5313
- eq(schema.connections.version, input.version),
5314
- // A refresh may only ever renew a live credential; revoked/errored rows
5315
- // stay dead even if a status change somewhere forgot to bump version.
5316
- eq(schema.connections.status, "active"),
5317
- ),
5318
- )
5319
- .returning({ id: schema.connections.id });
5320
- return updated.length > 0;
5321
- });
5603
+ return await withConnectionSubjectRls(
5604
+ db,
5605
+ input.workspaceId,
5606
+ input.subjectId,
5607
+ async (scopedDb) => {
5608
+ const set = {
5609
+ credentialEncrypted: input.credentialEncrypted,
5610
+ expiresAt: input.expiresAt,
5611
+ lastRefreshAt: input.lastRefreshAt,
5612
+ status: "active",
5613
+ lastError: null,
5614
+ version: sql`${schema.connections.version} + 1`,
5615
+ updatedAt: new Date(),
5616
+ ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
5617
+ };
5618
+ const updated = await scopedDb
5619
+ .update(schema.connections)
5620
+ .set(set)
5621
+ .where(
5622
+ and(
5623
+ eq(schema.connections.id, input.id),
5624
+ eq(schema.connections.workspaceId, input.workspaceId),
5625
+ eq(schema.connections.version, input.version),
5626
+ connectionExactSubject(input.subjectId),
5627
+ // A refresh may only ever renew a live credential; revoked/errored rows
5628
+ // stay dead even if a status change somewhere forgot to bump version.
5629
+ eq(schema.connections.status, "active"),
5630
+ ),
5631
+ )
5632
+ .returning({ id: schema.connections.id });
5633
+ return updated.length > 0;
5634
+ },
5635
+ );
5322
5636
  }
5323
5637
 
5324
5638
  export async function setConnectionStatus(
@@ -5326,9 +5640,9 @@ export async function setConnectionStatus(
5326
5640
  workspaceId: string,
5327
5641
  status: ConnectionStatus,
5328
5642
  lastError: string | null,
5329
- guard: { id: string; version: number },
5643
+ guard: { id: string; version: number; subjectId?: string | null },
5330
5644
  ): Promise<boolean> {
5331
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5645
+ return await withConnectionSubjectRls(db, workspaceId, guard.subjectId, async (scopedDb) => {
5332
5646
  const updated = await scopedDb
5333
5647
  .update(schema.connections)
5334
5648
  .set({
@@ -5346,6 +5660,7 @@ export async function setConnectionStatus(
5346
5660
  eq(schema.connections.workspaceId, workspaceId),
5347
5661
  eq(schema.connections.id, guard.id),
5348
5662
  eq(schema.connections.version, guard.version),
5663
+ connectionExactSubject(guard.subjectId),
5349
5664
  ),
5350
5665
  )
5351
5666
  .returning({ id: schema.connections.id });
@@ -5357,8 +5672,9 @@ export async function recordConnectionUsed(
5357
5672
  db: Database,
5358
5673
  workspaceId: string,
5359
5674
  connectionId: string,
5675
+ subjectId?: string | null,
5360
5676
  ): Promise<void> {
5361
- await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5677
+ await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
5362
5678
  await scopedDb
5363
5679
  .update(schema.connections)
5364
5680
  .set({
@@ -5369,6 +5685,7 @@ export async function recordConnectionUsed(
5369
5685
  and(
5370
5686
  eq(schema.connections.workspaceId, workspaceId),
5371
5687
  eq(schema.connections.id, connectionId),
5688
+ connectionExactSubject(subjectId),
5372
5689
  ),
5373
5690
  );
5374
5691
  });
@@ -13558,6 +13875,7 @@ export type SessionCreateInput = {
13558
13875
  initialMessage: string;
13559
13876
  initialTurnInstructions?: string | null;
13560
13877
  resources: ResourceRef[];
13878
+ skills?: SessionSkill[];
13561
13879
  tools?: ToolRef[];
13562
13880
  toolPolicy?: SessionToolPolicy | null;
13563
13881
  metadata: Record<string, unknown>;
@@ -13957,6 +14275,7 @@ async function createSessionInTransaction(
13957
14275
  initialMessage: input.initialMessage,
13958
14276
  initialTurnInstructions: input.initialTurnInstructions ?? null,
13959
14277
  resources: input.resources,
14278
+ skills: input.skills ?? [],
13960
14279
  tools: input.tools ?? [],
13961
14280
  toolPolicy: input.toolPolicy ?? null,
13962
14281
  metadata: input.metadata,
@@ -15970,13 +16289,16 @@ type LineageIdRow = {
15970
16289
  parentSessionId: string | null;
15971
16290
  depth: number;
15972
16291
  path: string[];
16292
+ cycle: boolean;
15973
16293
  };
15974
16294
 
15975
16295
  /**
15976
16296
  * Read the full lineage slice around a session. Every recursive step carries
15977
16297
  * workspace_id as a hard predicate; a foreign parent/child id is invisible even
15978
- * before RLS is considered. Ancestors are capped at 10 and returned root-first.
15979
- * Descendants are capped at depth 5 and 200 total rows, returned as a nested tree.
16298
+ * before RLS is considered. Up to 63 ancestors are returned root-first; an
16299
+ * invalid, cyclic, foreign, or deeper chain fails closed instead of presenting
16300
+ * a partial path as if it were rooted. Descendants are capped at depth 5 and
16301
+ * 200 total rows, returned as a nested tree.
15980
16302
  */
15981
16303
  export async function getSessionLineage(
15982
16304
  db: Database,
@@ -15998,25 +16320,39 @@ export async function getSessionLineage(
15998
16320
  return null;
15999
16321
  }
16000
16322
 
16001
- const ancestorRows = (await scopedDb.execute(sql<LineageIdRow>`
16002
- with recursive ancestors(id, parent_session_id, depth, path) as (
16003
- select ${schema.sessions.id}, ${schema.sessions.parentSessionId}, 0, array[${schema.sessions.id}]
16323
+ const ancestorLineageRows = (await scopedDb.execute(sql<LineageIdRow>`
16324
+ with recursive ancestors(id, parent_session_id, depth, path, cycle) as (
16325
+ select ${schema.sessions.id}, ${schema.sessions.parentSessionId}, 0, array[${schema.sessions.id}], false
16004
16326
  from ${schema.sessions}
16005
16327
  where ${schema.sessions.workspaceId} = ${workspaceId}
16006
16328
  and ${schema.sessions.id} = ${sessionId}
16007
16329
  union all
16008
- select parent.id, parent.parent_session_id, ancestors.depth + 1, ancestors.path || parent.id
16330
+ select
16331
+ parent.id,
16332
+ parent.parent_session_id,
16333
+ ancestors.depth + 1,
16334
+ ancestors.path || parent.id,
16335
+ parent.id = any(ancestors.path)
16009
16336
  from ${schema.sessions} parent
16010
16337
  join ancestors on ancestors.parent_session_id = parent.id
16011
16338
  where parent.workspace_id = ${workspaceId}
16012
- and ancestors.depth < 10
16013
- and not parent.id = any(ancestors.path)
16339
+ and not ancestors.cycle
16340
+ and ancestors.depth < 64
16014
16341
  )
16015
- select id, parent_session_id as "parentSessionId", depth, path
16342
+ select id, parent_session_id as "parentSessionId", depth, path, cycle
16016
16343
  from ancestors
16017
- where depth > 0
16018
16344
  order by depth desc
16019
16345
  `)) as LineageIdRow[];
16346
+ const frontier = ancestorLineageRows[0];
16347
+ if (
16348
+ !frontier ||
16349
+ frontier.cycle ||
16350
+ frontier.parentSessionId !== null ||
16351
+ Number(frontier.depth) >= 64
16352
+ ) {
16353
+ throw new Error(`session lineage for ${sessionId} has no valid workspace root`);
16354
+ }
16355
+ const ancestorRows = ancestorLineageRows.filter((row) => row.depth > 0);
16020
16356
 
16021
16357
  const childRows = (await scopedDb.execute(sql<LineageIdRow>`
16022
16358
  with recursive descendants(id, parent_session_id, depth, path) as (
@@ -16032,7 +16368,7 @@ export async function getSessionLineage(
16032
16368
  and descendants.depth < 5
16033
16369
  and not child.id = any(descendants.path)
16034
16370
  )
16035
- select id, parent_session_id as "parentSessionId", depth, path
16371
+ select id, parent_session_id as "parentSessionId", depth, path, false as cycle
16036
16372
  from descendants
16037
16373
  order by path
16038
16374
  limit ${descendantLimit + 1}
@@ -29587,6 +29923,12 @@ export async function claimSessionWorkForAttempt(
29587
29923
  and(
29588
29924
  eq(schema.sessionAttemptInterruptions.workspaceId, workspaceId),
29589
29925
  eq(schema.sessionAttemptInterruptions.sessionId, sessionId),
29926
+ inArray(schema.sessionAttemptInterruptions.state, [
29927
+ "pending",
29928
+ "delivered",
29929
+ "acknowledged",
29930
+ "settled",
29931
+ ]),
29590
29932
  isNull(schema.sessionTurnAttempts.quiescedAt),
29591
29933
  ),
29592
29934
  )
@@ -29878,20 +30220,27 @@ export async function claimSessionWorkForAttempt(
29878
30220
  )
29879
30221
  .limit(1)
29880
30222
  .for("update");
29881
- const rows = pendingAgentSteer
29882
- ? []
29883
- : await rawRows<{
29884
- id: string;
29885
- trigger_event_id: string;
29886
- metadata: Record<string, unknown>;
29887
- }>(
29888
- tx as unknown as Database,
29889
- sql`select id, trigger_event_id, metadata from session_turns
30223
+ // A human/API Steer is the newest explicit replacement direction. It
30224
+ // must claim next even when an older Agent Steer is pending; the
30225
+ // internal instruction is delivered as context on that same human turn
30226
+ // instead of manufacturing another system inference ahead of it.
30227
+ // Ordinary queued sends retain the established Agent-Steer priority.
30228
+ const rows = await rawRows<{
30229
+ id: string;
30230
+ trigger_event_id: string;
30231
+ metadata: Record<string, unknown>;
30232
+ }>(
30233
+ tx as unknown as Database,
30234
+ sql`select id, trigger_event_id, metadata from session_turns
29890
30235
  where workspace_id = ${workspaceId} and session_id = ${sessionId}
29891
30236
  and status = 'queued' and source in ('user', 'api')
30237
+ and (
30238
+ ${Boolean(pendingAgentSteer)} = false
30239
+ or metadata->>'delivery' = 'steer'
30240
+ )
29892
30241
  order by position asc, created_at asc, id asc
29893
30242
  limit 1`,
29894
- );
30243
+ );
29895
30244
  const queuedTurnPreview = rows[0];
29896
30245
  const queuedLocks = queuedTurnPreview
29897
30246
  ? await lockSessionEventWriteRows(tx as unknown as Database, {
@@ -33454,7 +33803,9 @@ export async function getSessionQueueSnapshot(
33454
33803
  version: session.queueVersion,
33455
33804
  effectiveControl: serializeEffectiveSessionControl(effectiveControl),
33456
33805
  stoppingPreviousAttempt:
33457
- latestInterruption !== null && latestInterruption.quiescedAt === null,
33806
+ latestInterruption !== null &&
33807
+ latestInterruption.interruptionState !== "rejected_stale" &&
33808
+ latestInterruption.quiescedAt === null,
33458
33809
  items: rows.map(mapSessionTurn),
33459
33810
  };
33460
33811
  });
@@ -35209,6 +35560,7 @@ function mapSession(
35209
35560
  titleSource: (row.titleSource as "user" | "agent" | null) ?? null,
35210
35561
  instructions: row.instructions ?? null,
35211
35562
  resources: row.resources as ResourceRef[],
35563
+ skills: (row.skills as SessionSkill[]) ?? [],
35212
35564
  tools: row.tools as ToolRef[],
35213
35565
  toolPolicy: (row.toolPolicy as SessionToolPolicy | null) ?? {
35214
35566
  mode: "legacy",