@nebutra/integration-vault 0.1.1 → 0.1.3
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -676
- package/dist/{chunk-6YC3QBSB.js → chunk-TQULRIQM.js} +7 -2
- package/dist/chunk-TQULRIQM.js.map +1 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +18 -7
- package/.turbo/turbo-build.log +0 -22
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-typecheck.log +0 -4
- package/dist/chunk-6YC3QBSB.js.map +0 -1
- package/examples/connect-token.ts +0 -34
- package/examples/invoke-consented.ts +0 -32
- package/examples/provider-fallback.ts +0 -26
- package/src/cli.ts +0 -57
- package/src/index.test.ts +0 -86
- package/src/index.ts +0 -407
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -11
package/src/index.ts
DELETED
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { CapabilityError } from "@nebutra/errors";
|
|
3
|
-
import { type EncryptedSecret, LocalProvider, type VaultProvider } from "@nebutra/vault";
|
|
4
|
-
|
|
5
|
-
export { CapabilityError } from "@nebutra/errors";
|
|
6
|
-
|
|
7
|
-
export interface OAuthStartRequest {
|
|
8
|
-
readonly app: string;
|
|
9
|
-
readonly tenantId: string;
|
|
10
|
-
readonly scopes?: readonly string[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface OAuthStartResult {
|
|
14
|
-
readonly provider: string;
|
|
15
|
-
readonly app: string;
|
|
16
|
-
readonly url: string;
|
|
17
|
-
readonly state: string;
|
|
18
|
-
readonly scopes: readonly string[];
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface ConnectTokenRequest {
|
|
22
|
-
readonly tenantId: string;
|
|
23
|
-
readonly app: string;
|
|
24
|
-
readonly accessToken: string;
|
|
25
|
-
readonly scopes: readonly string[];
|
|
26
|
-
readonly provider?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface InvokeRequest {
|
|
30
|
-
readonly tenantId: string;
|
|
31
|
-
readonly app: string;
|
|
32
|
-
readonly action: string;
|
|
33
|
-
readonly args: Record<string, unknown>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface InvokeResult {
|
|
37
|
-
readonly ok: boolean;
|
|
38
|
-
readonly provider?: string;
|
|
39
|
-
readonly result?: unknown;
|
|
40
|
-
readonly suggestion?: string;
|
|
41
|
-
readonly error?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface IntegrationConnection {
|
|
45
|
-
readonly id: string;
|
|
46
|
-
readonly tenantId: string;
|
|
47
|
-
readonly app: string;
|
|
48
|
-
readonly provider: string;
|
|
49
|
-
readonly tokenId: string;
|
|
50
|
-
readonly scopes: readonly string[];
|
|
51
|
-
readonly encrypted: EncryptedSecret;
|
|
52
|
-
readonly connectedAt: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface ListedConnection {
|
|
56
|
-
readonly id: string;
|
|
57
|
-
readonly app: string;
|
|
58
|
-
readonly provider: string;
|
|
59
|
-
readonly tokenId: string;
|
|
60
|
-
readonly scopes: readonly string[];
|
|
61
|
-
readonly connectedAt: string;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface IntegrationProvider {
|
|
65
|
-
readonly id: string;
|
|
66
|
-
supports(app: string): boolean;
|
|
67
|
-
startOAuth(request: OAuthStartRequest): Promise<OAuthStartResult>;
|
|
68
|
-
invoke(request: InvokeRequest, token: string, scopes: readonly string[]): Promise<unknown>;
|
|
69
|
-
doctor(): Promise<{ ok: boolean; provider: string; suggestion?: string }>;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface SaasConsentGrant {
|
|
73
|
-
readonly tenantId: string;
|
|
74
|
-
readonly app: string;
|
|
75
|
-
readonly action: string;
|
|
76
|
-
readonly scopes: readonly string[];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface SaasConsentStore {
|
|
80
|
-
grant(grant: SaasConsentGrant): Promise<void>;
|
|
81
|
-
hasConsent(grant: SaasConsentGrant): Promise<boolean>;
|
|
82
|
-
list(tenantId?: string): Promise<SaasConsentGrant[]>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface IntegrationVaultOptions {
|
|
86
|
-
readonly vault?: VaultProvider;
|
|
87
|
-
readonly providers?: readonly IntegrationProvider[];
|
|
88
|
-
readonly consent?: SaasConsentStore;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function requireTenant(tenantId: string): void {
|
|
92
|
-
if (!tenantId.trim()) {
|
|
93
|
-
throw new CapabilityError("integration-vault", "Tenant context is required", {
|
|
94
|
-
suggestion:
|
|
95
|
-
"Pass tenantId from the Sailor tenant context before starting OAuth or invoking SaaS tools.",
|
|
96
|
-
statusCode: 400,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function consentKey(grant: Omit<SaasConsentGrant, "scopes">): string {
|
|
102
|
-
return [grant.tenantId, grant.app, grant.action].join(":");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export class InMemorySaasConsentStore implements SaasConsentStore {
|
|
106
|
-
readonly #grants = new Map<string, SaasConsentGrant>();
|
|
107
|
-
|
|
108
|
-
async grant(grant: SaasConsentGrant): Promise<void> {
|
|
109
|
-
this.#grants.set(consentKey(grant), { ...grant, scopes: [...grant.scopes] });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async hasConsent(grant: SaasConsentGrant): Promise<boolean> {
|
|
113
|
-
const existing = this.#grants.get(consentKey(grant));
|
|
114
|
-
if (!existing) return false;
|
|
115
|
-
const scopes = new Set(existing.scopes);
|
|
116
|
-
return grant.scopes.every((scope) => scopes.has(scope));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async list(tenantId?: string): Promise<SaasConsentGrant[]> {
|
|
120
|
-
return Array.from(this.#grants.values()).filter(
|
|
121
|
-
(grant) => tenantId === undefined || grant.tenantId === tenantId,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export class InMemoryIntegrationProvider implements IntegrationProvider {
|
|
127
|
-
readonly id: string;
|
|
128
|
-
readonly #apps: ReadonlySet<string>;
|
|
129
|
-
|
|
130
|
-
constructor(options: { id: string; apps: readonly string[] }) {
|
|
131
|
-
this.id = options.id;
|
|
132
|
-
this.#apps = new Set(options.apps);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
supports(app: string): boolean {
|
|
136
|
-
return this.#apps.has(app) || this.#apps.has("*");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async startOAuth(request: OAuthStartRequest): Promise<OAuthStartResult> {
|
|
140
|
-
return {
|
|
141
|
-
provider: this.id,
|
|
142
|
-
app: request.app,
|
|
143
|
-
url: `http://127.0.0.1:8787/oauth/${request.app}?tenant=${request.tenantId}`,
|
|
144
|
-
state: `state_${request.tenantId}_${request.app}`,
|
|
145
|
-
scopes: request.scopes ?? [],
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async invoke(
|
|
150
|
-
request: InvokeRequest,
|
|
151
|
-
_token: string,
|
|
152
|
-
_scopes: readonly string[],
|
|
153
|
-
): Promise<unknown> {
|
|
154
|
-
return { app: request.app, action: request.action, ...request.args };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async doctor(): Promise<{ ok: boolean; provider: string }> {
|
|
158
|
-
return { ok: true, provider: this.id };
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export class HttpIntegrationProvider implements IntegrationProvider {
|
|
163
|
-
readonly id: string;
|
|
164
|
-
readonly #baseUrl: string;
|
|
165
|
-
readonly #apps: ReadonlySet<string>;
|
|
166
|
-
readonly #fetch: typeof fetch;
|
|
167
|
-
|
|
168
|
-
constructor(options: {
|
|
169
|
-
id: string;
|
|
170
|
-
baseUrl: string;
|
|
171
|
-
apps?: readonly string[];
|
|
172
|
-
fetch?: typeof fetch;
|
|
173
|
-
}) {
|
|
174
|
-
this.id = options.id;
|
|
175
|
-
this.#baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
176
|
-
this.#apps = new Set(options.apps ?? ["*"]);
|
|
177
|
-
this.#fetch = options.fetch ?? fetch;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
supports(app: string): boolean {
|
|
181
|
-
return this.#apps.has(app) || this.#apps.has("*");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async startOAuth(request: OAuthStartRequest): Promise<OAuthStartResult> {
|
|
185
|
-
return {
|
|
186
|
-
provider: this.id,
|
|
187
|
-
app: request.app,
|
|
188
|
-
url: `${this.#baseUrl}/oauth/${request.app}/start?tenant=${encodeURIComponent(request.tenantId)}`,
|
|
189
|
-
state: `state_${request.tenantId}_${request.app}`,
|
|
190
|
-
scopes: request.scopes ?? [],
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async invoke(request: InvokeRequest, token: string): Promise<unknown> {
|
|
195
|
-
const response = await this.#fetch(`${this.#baseUrl}/invoke`, {
|
|
196
|
-
method: "POST",
|
|
197
|
-
headers: {
|
|
198
|
-
authorization: `Bearer ${token}`,
|
|
199
|
-
"content-type": "application/json",
|
|
200
|
-
},
|
|
201
|
-
body: JSON.stringify(request),
|
|
202
|
-
});
|
|
203
|
-
if (!response.ok) {
|
|
204
|
-
throw new CapabilityError(
|
|
205
|
-
"integration-vault",
|
|
206
|
-
`Provider ${this.id} returned ${response.status}`,
|
|
207
|
-
{
|
|
208
|
-
suggestion: "Run `pnpm vault:doctor` and refresh or reconnect this SaaS app.",
|
|
209
|
-
metadata: { provider: this.id, app: request.app, status: response.status },
|
|
210
|
-
},
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
return response.json();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async doctor(): Promise<{ ok: boolean; provider: string; suggestion?: string }> {
|
|
217
|
-
return {
|
|
218
|
-
ok: true,
|
|
219
|
-
provider: this.id,
|
|
220
|
-
suggestion:
|
|
221
|
-
"Network health is checked on invoke; configure provider API keys in the sidecar environment.",
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
class InMemoryConnectionStore {
|
|
227
|
-
readonly #connections = new Map<string, IntegrationConnection>();
|
|
228
|
-
|
|
229
|
-
upsert(connection: IntegrationConnection): IntegrationConnection {
|
|
230
|
-
this.#connections.set(`${connection.tenantId}:${connection.app}`, connection);
|
|
231
|
-
return connection;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
get(tenantId: string, app: string): IntegrationConnection | undefined {
|
|
235
|
-
return this.#connections.get(`${tenantId}:${app}`);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
list(tenantId: string): ListedConnection[] {
|
|
239
|
-
return Array.from(this.#connections.values())
|
|
240
|
-
.filter((connection) => connection.tenantId === tenantId)
|
|
241
|
-
.map((connection) => ({
|
|
242
|
-
id: connection.id,
|
|
243
|
-
app: connection.app,
|
|
244
|
-
provider: connection.provider,
|
|
245
|
-
tokenId: connection.tokenId,
|
|
246
|
-
scopes: connection.scopes,
|
|
247
|
-
connectedAt: connection.connectedAt,
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export class IntegrationVault {
|
|
253
|
-
readonly #vault: VaultProvider;
|
|
254
|
-
readonly #providers: IntegrationProvider[];
|
|
255
|
-
readonly #connections = new InMemoryConnectionStore();
|
|
256
|
-
readonly #consent: SaasConsentStore;
|
|
257
|
-
|
|
258
|
-
constructor(options: IntegrationVaultOptions) {
|
|
259
|
-
this.#vault =
|
|
260
|
-
options.vault ??
|
|
261
|
-
new LocalProvider({
|
|
262
|
-
provider: "local",
|
|
263
|
-
masterKey: process.env.VAULT_LOCAL_MASTER_KEY ?? "integration-vault-local-development-key",
|
|
264
|
-
});
|
|
265
|
-
this.#providers =
|
|
266
|
-
options.providers && options.providers.length > 0
|
|
267
|
-
? [...options.providers]
|
|
268
|
-
: [new InMemoryIntegrationProvider({ id: "local", apps: ["*"] })];
|
|
269
|
-
this.#consent = options.consent ?? new InMemorySaasConsentStore();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
static local(options: IntegrationVaultOptions = {}): IntegrationVault {
|
|
273
|
-
return new IntegrationVault(options);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async startOAuth(request: OAuthStartRequest): Promise<OAuthStartResult> {
|
|
277
|
-
requireTenant(request.tenantId);
|
|
278
|
-
return this.#providerFor(request.app).startOAuth(request);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
async connectToken(request: ConnectTokenRequest): Promise<ListedConnection> {
|
|
282
|
-
requireTenant(request.tenantId);
|
|
283
|
-
const provider = request.provider
|
|
284
|
-
? this.#providers.find((candidate) => candidate.id === request.provider)
|
|
285
|
-
: this.#providerFor(request.app);
|
|
286
|
-
if (!provider) {
|
|
287
|
-
throw new CapabilityError("integration-vault", "Integration provider not found", {
|
|
288
|
-
suggestion: "Configure a provider adapter before connecting this app.",
|
|
289
|
-
metadata: { app: request.app, provider: request.provider },
|
|
290
|
-
statusCode: 404,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
const tokenId = `tok_${randomUUID()}`;
|
|
294
|
-
const encrypted = await this.#vault.encrypt(request.accessToken, {
|
|
295
|
-
id: tokenId,
|
|
296
|
-
tenantId: request.tenantId,
|
|
297
|
-
metadata: { name: `${request.app}:access-token`, type: "oauth_token" },
|
|
298
|
-
});
|
|
299
|
-
const connection = this.#connections.upsert({
|
|
300
|
-
id: `conn_${randomUUID()}`,
|
|
301
|
-
tenantId: request.tenantId,
|
|
302
|
-
app: request.app,
|
|
303
|
-
provider: provider.id,
|
|
304
|
-
tokenId,
|
|
305
|
-
scopes: [...request.scopes],
|
|
306
|
-
encrypted,
|
|
307
|
-
connectedAt: new Date().toISOString(),
|
|
308
|
-
});
|
|
309
|
-
return this.#redact(connection);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
async grantConsent(grant: SaasConsentGrant): Promise<void> {
|
|
313
|
-
requireTenant(grant.tenantId);
|
|
314
|
-
await this.#consent.grant(grant);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async invoke(request: InvokeRequest): Promise<InvokeResult> {
|
|
318
|
-
requireTenant(request.tenantId);
|
|
319
|
-
const connection = this.#connections.get(request.tenantId, request.app);
|
|
320
|
-
if (!connection) {
|
|
321
|
-
return {
|
|
322
|
-
ok: false,
|
|
323
|
-
error: "App is not connected",
|
|
324
|
-
suggestion: `Run \`pnpm vault:connect ${request.app}\` before invoking ${request.app}.${request.action}.`,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
const hasConsent = await this.#consent.hasConsent({
|
|
328
|
-
tenantId: request.tenantId,
|
|
329
|
-
app: request.app,
|
|
330
|
-
action: request.action,
|
|
331
|
-
scopes: connection.scopes,
|
|
332
|
-
});
|
|
333
|
-
if (!hasConsent) {
|
|
334
|
-
return {
|
|
335
|
-
ok: false,
|
|
336
|
-
error: "SaaS action is not authorized",
|
|
337
|
-
suggestion: `Run \`pnpm vault:connect ${request.app}\` and grant ${request.app}.${request.action} for this tenant.`,
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
try {
|
|
341
|
-
const token = await this.#vault.decrypt(connection.encrypted, { tenantId: request.tenantId });
|
|
342
|
-
const provider = this.#providerById(connection.provider);
|
|
343
|
-
const result = await provider.invoke(request, token, connection.scopes);
|
|
344
|
-
return { ok: true, provider: provider.id, result };
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return {
|
|
347
|
-
ok: false,
|
|
348
|
-
error: error instanceof Error ? error.message : String(error),
|
|
349
|
-
suggestion: "Refresh the connection or inspect provider health with `pnpm vault:doctor`.",
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async list(tenantId: string): Promise<ListedConnection[]> {
|
|
355
|
-
requireTenant(tenantId);
|
|
356
|
-
return this.#connections.list(tenantId);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async doctor(): Promise<{
|
|
360
|
-
ok: boolean;
|
|
361
|
-
providers: Array<{ ok: boolean; provider: string; suggestion?: string }>;
|
|
362
|
-
}> {
|
|
363
|
-
const providers = await Promise.all(this.#providers.map((provider) => provider.doctor()));
|
|
364
|
-
return { ok: providers.some((provider) => provider.ok), providers };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
async close(): Promise<void> {
|
|
368
|
-
await this.#vault.close();
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
#providerFor(app: string): IntegrationProvider {
|
|
372
|
-
const provider = this.#providers.find((candidate) => candidate.supports(app));
|
|
373
|
-
if (!provider) {
|
|
374
|
-
throw new CapabilityError("integration-vault", "No provider supports this app", {
|
|
375
|
-
suggestion: "Configure a provider adapter or local connector for this SaaS app.",
|
|
376
|
-
metadata: { app },
|
|
377
|
-
statusCode: 404,
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
return provider;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
#providerById(id: string): IntegrationProvider {
|
|
384
|
-
const provider = this.#providers.find((candidate) => candidate.id === id);
|
|
385
|
-
if (!provider) {
|
|
386
|
-
throw new CapabilityError("integration-vault", "Connection provider is unavailable", {
|
|
387
|
-
suggestion: "Reconnect this app or enable the provider adapter.",
|
|
388
|
-
metadata: { provider: id },
|
|
389
|
-
statusCode: 404,
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
return provider;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
#redact(connection: IntegrationConnection): ListedConnection {
|
|
396
|
-
return {
|
|
397
|
-
id: connection.id,
|
|
398
|
-
app: connection.app,
|
|
399
|
-
provider: connection.provider,
|
|
400
|
-
tokenId: connection.tokenId,
|
|
401
|
-
scopes: connection.scopes,
|
|
402
|
-
connectedAt: connection.connectedAt,
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
export const vault = IntegrationVault.local();
|
package/tsconfig.json
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../../tsconfig.base.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"target": "esnext",
|
|
7
|
-
"types": ["node"],
|
|
8
|
-
"incremental": false
|
|
9
|
-
},
|
|
10
|
-
"include": ["src", "examples"],
|
|
11
|
-
"exclude": ["node_modules", "dist"]
|
|
12
|
-
}
|
package/tsup.config.ts
DELETED