@opengeni/db 0.13.1 → 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
  };
@@ -4772,6 +4789,52 @@ function connectionSubjectVisibility(subjectId?: string | null): SQL {
4772
4789
  : isNull(schema.connections.subjectId);
4773
4790
  }
4774
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
+
4775
4838
  export async function createConnection(
4776
4839
  db: Database,
4777
4840
  input: CreateConnectionInput,
@@ -4780,29 +4843,10 @@ export async function createConnection(
4780
4843
  db,
4781
4844
  { accountId: input.accountId, workspaceId: input.workspaceId },
4782
4845
  async (scopedDb) => {
4783
- const [row] = await scopedDb
4784
- .insert(schema.connections)
4785
- .values({
4786
- accountId: input.accountId,
4787
- workspaceId: input.workspaceId,
4788
- subjectId: input.subjectId ?? null,
4789
- providerDomain: input.providerDomain,
4790
- kind: input.kind,
4791
- status: input.status ?? "active",
4792
- credentialEncrypted: input.credentialEncrypted,
4793
- grantedScopes: input.grantedScopes ?? [],
4794
- expiresAt: input.expiresAt ?? null,
4795
- verifiedInstallAt: input.verifiedInstallAt ?? null,
4796
- verifiedInstallVersion: input.verifiedInstallVersion ?? null,
4797
- metadata: input.metadata ?? {},
4798
- createdBySubjectId: input.createdBySubjectId ?? null,
4799
- updatedBySubjectId: input.updatedBySubjectId ?? input.createdBySubjectId ?? null,
4800
- })
4801
- .returning(connectionMetadataColumns);
4802
- if (!row) {
4803
- throw new Error("Failed to create connection");
4846
+ if (input.subjectId) {
4847
+ await setSubjectRlsContext(scopedDb, input.subjectId);
4804
4848
  }
4805
- return mapConnectionMetadata(row);
4849
+ return await createConnectionInScope(scopedDb, input);
4806
4850
  },
4807
4851
  );
4808
4852
  }
@@ -4812,7 +4856,7 @@ export async function listConnectionsMetadata(
4812
4856
  workspaceId: string,
4813
4857
  subjectId?: string | null,
4814
4858
  ): Promise<ConnectionMetadataWithVerification[]> {
4815
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4859
+ return await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
4816
4860
  const rows = await scopedDb
4817
4861
  .select(connectionMetadataColumns)
4818
4862
  .from(schema.connections)
@@ -4833,7 +4877,7 @@ export async function getConnectionMetadata(
4833
4877
  connectionId: string,
4834
4878
  subjectId?: string | null,
4835
4879
  ): Promise<ConnectionMetadataWithVerification | null> {
4836
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4880
+ return await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
4837
4881
  const [row] = await scopedDb
4838
4882
  .select(connectionMetadataColumns)
4839
4883
  .from(schema.connections)
@@ -4849,53 +4893,102 @@ export async function getConnectionMetadata(
4849
4893
  });
4850
4894
  }
4851
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
+
4852
4943
  export async function updateConnection(
4853
4944
  db: Database,
4854
4945
  input: UpdateConnectionInput,
4855
4946
  ): Promise<ConnectionMetadataWithVerification | null> {
4856
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
4857
- 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,
4858
4977
  updatedAt: new Date(),
4859
- ...(input.providerDomain !== undefined ? { providerDomain: input.providerDomain } : {}),
4860
- ...(input.subjectId !== undefined ? { subjectId: input.subjectId } : {}),
4861
- ...(input.kind !== undefined ? { kind: input.kind } : {}),
4862
- ...(input.status !== undefined ? { status: input.status } : {}),
4863
- ...(input.credentialEncrypted !== undefined
4864
- ? {
4865
- credentialEncrypted: input.credentialEncrypted,
4866
- version: sql`${schema.connections.version} + 1`,
4867
- lastError: null,
4868
- }
4869
- : {}),
4870
- ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
4871
- ...(input.expiresAt !== undefined ? { expiresAt: input.expiresAt } : {}),
4872
- ...(input.verifiedInstallAt !== undefined
4873
- ? { verifiedInstallAt: input.verifiedInstallAt }
4874
- : {}),
4875
- ...(input.verifiedInstallVersion !== undefined
4876
- ? { verifiedInstallVersion: input.verifiedInstallVersion }
4877
- : {}),
4878
- ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
4879
- ...(input.updatedBySubjectId !== undefined
4880
- ? { updatedBySubjectId: input.updatedBySubjectId }
4881
- : {}),
4882
- };
4883
- const [row] = await scopedDb
4884
- .update(schema.connections)
4885
- .set(set)
4886
- .where(
4887
- and(
4888
- eq(schema.connections.workspaceId, input.workspaceId),
4889
- eq(schema.connections.id, input.connectionId),
4890
- connectionSubjectVisibility(input.visibleToSubjectId),
4891
- ...(input.expectedVersion !== undefined
4892
- ? [eq(schema.connections.version, input.expectedVersion)]
4893
- : []),
4894
- ),
4895
- )
4896
- .returning(connectionMetadataColumns);
4897
- return row ? mapConnectionMetadata(row) : null;
4898
- });
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;
4899
4992
  }
4900
4993
 
4901
4994
  export async function revokeConnection(
@@ -4904,39 +4997,244 @@ export async function revokeConnection(
4904
4997
  connectionId: string,
4905
4998
  updatedBySubjectId?: string | null,
4906
4999
  ): Promise<ConnectionMetadataWithVerification | null> {
4907
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
4908
- const [row] = await scopedDb
4909
- .update(schema.connections)
4910
- .set({
4911
- status: "revoked",
4912
- // The version bump invalidates any in-flight refresh's (id, version) CAS,
4913
- // so a racing refresh cannot commit and flip the row back to active.
4914
- version: sql`${schema.connections.version} + 1`,
4915
- // Status-only revocation does not replace the verified credential or bot
4916
- // identity. Carry the marker to the same new CAS version so the dedicated
4917
- // reinstall path can still recognize (but not use) the inactive row.
4918
- verifiedInstallVersion: sql`case
4919
- when ${schema.connections.verifiedInstallAt} is null then null
4920
- else ${schema.connections.version} + 1
4921
- end`,
4922
- updatedBySubjectId: updatedBySubjectId ?? null,
4923
- updatedAt: new Date(),
4924
- })
4925
- .where(
4926
- and(
4927
- eq(schema.connections.workspaceId, workspaceId),
4928
- eq(schema.connections.id, connectionId),
4929
- // Same visibility rule as get/update: shared rows plus the caller's own
4930
- // subject rows. Cross-subject revocation (admin janitorial) arrives with
4931
- // the subject-connections UX in I5, deliberately not before.
4932
- connectionSubjectVisibility(updatedBySubjectId),
4933
- ),
4934
- )
4935
- .returning(connectionMetadataColumns);
4936
- 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;
4937
5126
  });
4938
5127
  }
4939
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
+
4940
5238
  export type ClaimSlackBotPostOperationResult =
4941
5239
  | { kind: "claimed" | "in_progress" | "completed"; operation: SlackBotPostOperation }
4942
5240
  | { kind: "conflict" | "connection_not_found" };
@@ -5215,6 +5513,9 @@ export async function loadConnectionCredentialForBroker(
5215
5513
  allowSubjectOwned?: boolean;
5216
5514
  },
5217
5515
  ): Promise<ConnectionCredentialForBroker | null> {
5516
+ if (input.allowSubjectOwned && !input.subjectId) {
5517
+ return null;
5518
+ }
5218
5519
  const key = environmentsEncryptionKeyBytes(settings);
5219
5520
  if (!key) {
5220
5521
  throw new Error(
@@ -5222,7 +5523,7 @@ export async function loadConnectionCredentialForBroker(
5222
5523
  );
5223
5524
  }
5224
5525
  const subjectPredicate = input.allowSubjectOwned
5225
- ? connectionSubjectVisibility(input.subjectId)
5526
+ ? connectionExactSubject(input.subjectId)
5226
5527
  : isNull(schema.connections.subjectId);
5227
5528
  const conditions: SQL[] = [
5228
5529
  eq(schema.connections.workspaceId, input.workspaceId),
@@ -5236,49 +5537,54 @@ export async function loadConnectionCredentialForBroker(
5236
5537
  conditions.push(eq(schema.connections.kind, input.kind));
5237
5538
  }
5238
5539
  }
5239
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
5240
- // Prefer active rows: a revoke bumps updatedAt, so recency alone would let a
5241
- // freshly revoked connection shadow an active replacement for the provider.
5242
- const [row] = await scopedDb
5243
- .select()
5244
- .from(schema.connections)
5245
- .where(and(...conditions))
5246
- .orderBy(
5247
- desc(sql`(${schema.connections.status} = 'active')`),
5248
- desc(schema.connections.updatedAt),
5249
- )
5250
- .limit(1);
5251
- if (!row) {
5252
- return null;
5253
- }
5254
- let credential: unknown;
5255
- try {
5256
- credential = JSON.parse(decryptEnvironmentValue(key, row.credentialEncrypted));
5257
- } catch (error) {
5258
- throw new Error(
5259
- `connection credential could not be decrypted for ${row.id}: ${error instanceof Error ? error.message : String(error)}`,
5260
- { cause: error },
5261
- );
5262
- }
5263
- if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
5264
- throw new Error(`connection credential bundle for ${row.id} is not a JSON object`);
5265
- }
5266
- return {
5267
- id: row.id,
5268
- accountId: row.accountId,
5269
- workspaceId: row.workspaceId,
5270
- subjectId: row.subjectId,
5271
- providerDomain: row.providerDomain,
5272
- kind: row.kind as ConnectionKind,
5273
- status: row.status as ConnectionStatus,
5274
- credential: credential as Record<string, unknown>,
5275
- grantedScopes: row.grantedScopes,
5276
- expiresAt: row.expiresAt,
5277
- lastRefreshAt: row.lastRefreshAt,
5278
- version: row.version,
5279
- metadata: row.metadata,
5280
- };
5281
- });
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
+ );
5282
5588
  }
5283
5589
 
5284
5590
  export async function recordConnectionTokenRefresh(
@@ -5291,35 +5597,42 @@ export async function recordConnectionTokenRefresh(
5291
5597
  expiresAt: Date | null;
5292
5598
  grantedScopes?: string[];
5293
5599
  lastRefreshAt: Date;
5600
+ subjectId?: string | null;
5294
5601
  },
5295
5602
  ): Promise<boolean> {
5296
- return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
5297
- const set = {
5298
- credentialEncrypted: input.credentialEncrypted,
5299
- expiresAt: input.expiresAt,
5300
- lastRefreshAt: input.lastRefreshAt,
5301
- status: "active",
5302
- lastError: null,
5303
- version: sql`${schema.connections.version} + 1`,
5304
- updatedAt: new Date(),
5305
- ...(input.grantedScopes !== undefined ? { grantedScopes: input.grantedScopes } : {}),
5306
- };
5307
- const updated = await scopedDb
5308
- .update(schema.connections)
5309
- .set(set)
5310
- .where(
5311
- and(
5312
- eq(schema.connections.id, input.id),
5313
- eq(schema.connections.workspaceId, input.workspaceId),
5314
- eq(schema.connections.version, input.version),
5315
- // A refresh may only ever renew a live credential; revoked/errored rows
5316
- // stay dead even if a status change somewhere forgot to bump version.
5317
- eq(schema.connections.status, "active"),
5318
- ),
5319
- )
5320
- .returning({ id: schema.connections.id });
5321
- return updated.length > 0;
5322
- });
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
+ );
5323
5636
  }
5324
5637
 
5325
5638
  export async function setConnectionStatus(
@@ -5327,9 +5640,9 @@ export async function setConnectionStatus(
5327
5640
  workspaceId: string,
5328
5641
  status: ConnectionStatus,
5329
5642
  lastError: string | null,
5330
- guard: { id: string; version: number },
5643
+ guard: { id: string; version: number; subjectId?: string | null },
5331
5644
  ): Promise<boolean> {
5332
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5645
+ return await withConnectionSubjectRls(db, workspaceId, guard.subjectId, async (scopedDb) => {
5333
5646
  const updated = await scopedDb
5334
5647
  .update(schema.connections)
5335
5648
  .set({
@@ -5347,6 +5660,7 @@ export async function setConnectionStatus(
5347
5660
  eq(schema.connections.workspaceId, workspaceId),
5348
5661
  eq(schema.connections.id, guard.id),
5349
5662
  eq(schema.connections.version, guard.version),
5663
+ connectionExactSubject(guard.subjectId),
5350
5664
  ),
5351
5665
  )
5352
5666
  .returning({ id: schema.connections.id });
@@ -5358,8 +5672,9 @@ export async function recordConnectionUsed(
5358
5672
  db: Database,
5359
5673
  workspaceId: string,
5360
5674
  connectionId: string,
5675
+ subjectId?: string | null,
5361
5676
  ): Promise<void> {
5362
- await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
5677
+ await withConnectionSubjectRls(db, workspaceId, subjectId, async (scopedDb) => {
5363
5678
  await scopedDb
5364
5679
  .update(schema.connections)
5365
5680
  .set({
@@ -5370,6 +5685,7 @@ export async function recordConnectionUsed(
5370
5685
  and(
5371
5686
  eq(schema.connections.workspaceId, workspaceId),
5372
5687
  eq(schema.connections.id, connectionId),
5688
+ connectionExactSubject(subjectId),
5373
5689
  ),
5374
5690
  );
5375
5691
  });
@@ -13559,6 +13875,7 @@ export type SessionCreateInput = {
13559
13875
  initialMessage: string;
13560
13876
  initialTurnInstructions?: string | null;
13561
13877
  resources: ResourceRef[];
13878
+ skills?: SessionSkill[];
13562
13879
  tools?: ToolRef[];
13563
13880
  toolPolicy?: SessionToolPolicy | null;
13564
13881
  metadata: Record<string, unknown>;
@@ -13958,6 +14275,7 @@ async function createSessionInTransaction(
13958
14275
  initialMessage: input.initialMessage,
13959
14276
  initialTurnInstructions: input.initialTurnInstructions ?? null,
13960
14277
  resources: input.resources,
14278
+ skills: input.skills ?? [],
13961
14279
  tools: input.tools ?? [],
13962
14280
  toolPolicy: input.toolPolicy ?? null,
13963
14281
  metadata: input.metadata,
@@ -15971,13 +16289,16 @@ type LineageIdRow = {
15971
16289
  parentSessionId: string | null;
15972
16290
  depth: number;
15973
16291
  path: string[];
16292
+ cycle: boolean;
15974
16293
  };
15975
16294
 
15976
16295
  /**
15977
16296
  * Read the full lineage slice around a session. Every recursive step carries
15978
16297
  * workspace_id as a hard predicate; a foreign parent/child id is invisible even
15979
- * before RLS is considered. Ancestors are capped at 10 and returned root-first.
15980
- * 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.
15981
16302
  */
15982
16303
  export async function getSessionLineage(
15983
16304
  db: Database,
@@ -15999,25 +16320,39 @@ export async function getSessionLineage(
15999
16320
  return null;
16000
16321
  }
16001
16322
 
16002
- const ancestorRows = (await scopedDb.execute(sql<LineageIdRow>`
16003
- with recursive ancestors(id, parent_session_id, depth, path) as (
16004
- 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
16005
16326
  from ${schema.sessions}
16006
16327
  where ${schema.sessions.workspaceId} = ${workspaceId}
16007
16328
  and ${schema.sessions.id} = ${sessionId}
16008
16329
  union all
16009
- 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)
16010
16336
  from ${schema.sessions} parent
16011
16337
  join ancestors on ancestors.parent_session_id = parent.id
16012
16338
  where parent.workspace_id = ${workspaceId}
16013
- and ancestors.depth < 10
16014
- and not parent.id = any(ancestors.path)
16339
+ and not ancestors.cycle
16340
+ and ancestors.depth < 64
16015
16341
  )
16016
- select id, parent_session_id as "parentSessionId", depth, path
16342
+ select id, parent_session_id as "parentSessionId", depth, path, cycle
16017
16343
  from ancestors
16018
- where depth > 0
16019
16344
  order by depth desc
16020
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);
16021
16356
 
16022
16357
  const childRows = (await scopedDb.execute(sql<LineageIdRow>`
16023
16358
  with recursive descendants(id, parent_session_id, depth, path) as (
@@ -16033,7 +16368,7 @@ export async function getSessionLineage(
16033
16368
  and descendants.depth < 5
16034
16369
  and not child.id = any(descendants.path)
16035
16370
  )
16036
- select id, parent_session_id as "parentSessionId", depth, path
16371
+ select id, parent_session_id as "parentSessionId", depth, path, false as cycle
16037
16372
  from descendants
16038
16373
  order by path
16039
16374
  limit ${descendantLimit + 1}
@@ -29588,6 +29923,12 @@ export async function claimSessionWorkForAttempt(
29588
29923
  and(
29589
29924
  eq(schema.sessionAttemptInterruptions.workspaceId, workspaceId),
29590
29925
  eq(schema.sessionAttemptInterruptions.sessionId, sessionId),
29926
+ inArray(schema.sessionAttemptInterruptions.state, [
29927
+ "pending",
29928
+ "delivered",
29929
+ "acknowledged",
29930
+ "settled",
29931
+ ]),
29591
29932
  isNull(schema.sessionTurnAttempts.quiescedAt),
29592
29933
  ),
29593
29934
  )
@@ -29879,20 +30220,27 @@ export async function claimSessionWorkForAttempt(
29879
30220
  )
29880
30221
  .limit(1)
29881
30222
  .for("update");
29882
- const rows = pendingAgentSteer
29883
- ? []
29884
- : await rawRows<{
29885
- id: string;
29886
- trigger_event_id: string;
29887
- metadata: Record<string, unknown>;
29888
- }>(
29889
- tx as unknown as Database,
29890
- 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
29891
30235
  where workspace_id = ${workspaceId} and session_id = ${sessionId}
29892
30236
  and status = 'queued' and source in ('user', 'api')
30237
+ and (
30238
+ ${Boolean(pendingAgentSteer)} = false
30239
+ or metadata->>'delivery' = 'steer'
30240
+ )
29893
30241
  order by position asc, created_at asc, id asc
29894
30242
  limit 1`,
29895
- );
30243
+ );
29896
30244
  const queuedTurnPreview = rows[0];
29897
30245
  const queuedLocks = queuedTurnPreview
29898
30246
  ? await lockSessionEventWriteRows(tx as unknown as Database, {
@@ -33455,7 +33803,9 @@ export async function getSessionQueueSnapshot(
33455
33803
  version: session.queueVersion,
33456
33804
  effectiveControl: serializeEffectiveSessionControl(effectiveControl),
33457
33805
  stoppingPreviousAttempt:
33458
- latestInterruption !== null && latestInterruption.quiescedAt === null,
33806
+ latestInterruption !== null &&
33807
+ latestInterruption.interruptionState !== "rejected_stale" &&
33808
+ latestInterruption.quiescedAt === null,
33459
33809
  items: rows.map(mapSessionTurn),
33460
33810
  };
33461
33811
  });
@@ -35210,6 +35560,7 @@ function mapSession(
35210
35560
  titleSource: (row.titleSource as "user" | "agent" | null) ?? null,
35211
35561
  instructions: row.instructions ?? null,
35212
35562
  resources: row.resources as ResourceRef[],
35563
+ skills: (row.skills as SessionSkill[]) ?? [],
35213
35564
  tools: row.tools as ToolRef[],
35214
35565
  toolPolicy: (row.toolPolicy as SessionToolPolicy | null) ?? {
35215
35566
  mode: "legacy",