@agentforge-io/core 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/billing/billing-adapter.interface.d.ts +41 -0
- package/dist/adapters/billing/billing-adapter.interface.js +5 -0
- package/dist/adapters/billing/stripe/stripe.adapter.d.ts +30 -0
- package/dist/adapters/billing/stripe/stripe.adapter.js +122 -0
- package/dist/adapters/email/email-adapter.interface.d.ts +25 -0
- package/dist/adapters/email/email-adapter.interface.js +6 -0
- package/dist/adapters/email/noop.adapter.d.ts +10 -0
- package/dist/adapters/email/noop.adapter.js +15 -0
- package/dist/adapters/email/resend.adapter.d.ts +8 -0
- package/dist/adapters/email/resend.adapter.js +39 -0
- package/dist/adapters/job-queue/in-memory.d.ts +43 -0
- package/dist/adapters/job-queue/in-memory.js +154 -0
- package/dist/adapters/job-queue/job-queue.types.d.ts +76 -0
- package/dist/adapters/job-queue/job-queue.types.js +5 -0
- package/dist/adapters/prepared-stream/prepared-stream.types.d.ts +23 -0
- package/dist/adapters/prepared-stream/prepared-stream.types.js +5 -0
- package/dist/adapters/rate-limiter/in-memory.d.ts +19 -0
- package/dist/adapters/rate-limiter/in-memory.js +63 -0
- package/dist/adapters/rate-limiter/rate-limiter.types.d.ts +42 -0
- package/dist/adapters/rate-limiter/rate-limiter.types.js +5 -0
- package/dist/adapters/rate-limiter/redis.d.ts +31 -0
- package/dist/adapters/rate-limiter/redis.js +47 -0
- package/dist/adapters/upload/noop.adapter.d.ts +9 -0
- package/dist/adapters/upload/noop.adapter.js +14 -0
- package/dist/adapters/upload/s3.adapter.d.ts +38 -0
- package/dist/adapters/upload/s3.adapter.js +69 -0
- package/dist/adapters/upload/upload-adapter.interface.d.ts +37 -0
- package/dist/adapters/upload/upload-adapter.interface.js +15 -0
- package/dist/ai/index.d.ts +15 -0
- package/dist/ai/index.js +43 -0
- package/dist/billing/index.d.ts +12 -0
- package/dist/billing/index.js +28 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +8 -0
- package/dist/domain/agent.d.ts +59 -0
- package/dist/domain/agent.js +2 -0
- package/dist/domain/api-key.d.ts +28 -0
- package/dist/domain/api-key.js +2 -0
- package/dist/domain/auth-identity.d.ts +10 -0
- package/dist/domain/auth-identity.js +2 -0
- package/dist/domain/chat-token.d.ts +39 -0
- package/dist/domain/chat-token.js +2 -0
- package/dist/domain/connector-auth.d.ts +42 -0
- package/dist/domain/connector-auth.js +2 -0
- package/dist/domain/connector.d.ts +52 -0
- package/dist/domain/connector.js +2 -0
- package/dist/domain/conversation.d.ts +26 -0
- package/dist/domain/conversation.js +2 -0
- package/dist/domain/email-token.d.ts +11 -0
- package/dist/domain/email-token.js +2 -0
- package/dist/domain/external-user.d.ts +23 -0
- package/dist/domain/external-user.js +2 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.js +24 -0
- package/dist/domain/mcp-server.d.ts +33 -0
- package/dist/domain/mcp-server.js +2 -0
- package/dist/domain/plan.d.ts +20 -0
- package/dist/domain/plan.js +2 -0
- package/dist/domain/platform-secret.d.ts +24 -0
- package/dist/domain/platform-secret.js +8 -0
- package/dist/domain/refresh-token.d.ts +15 -0
- package/dist/domain/refresh-token.js +2 -0
- package/dist/domain/subscription.d.ts +21 -0
- package/dist/domain/subscription.js +2 -0
- package/dist/domain/tenant.d.ts +21 -0
- package/dist/domain/tenant.js +2 -0
- package/dist/domain/usage-record.d.ts +15 -0
- package/dist/domain/usage-record.js +2 -0
- package/dist/domain/user.d.ts +43 -0
- package/dist/domain/user.js +2 -0
- package/dist/factory.d.ts +68 -0
- package/dist/factory.js +56 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +59 -0
- package/dist/repositories/in-memory.d.ts +30 -0
- package/dist/repositories/in-memory.js +82 -0
- package/dist/repositories/index.d.ts +67 -0
- package/dist/repositories/index.js +16 -0
- package/dist/services/agent-config.service.d.ts +45 -0
- package/dist/services/agent-config.service.js +114 -0
- package/dist/services/agent-job.worker.d.ts +32 -0
- package/dist/services/agent-job.worker.js +97 -0
- package/dist/services/agent-runner.service.d.ts +35 -0
- package/dist/services/agent-runner.service.js +224 -0
- package/dist/services/agent.service.d.ts +171 -0
- package/dist/services/agent.service.js +329 -0
- package/dist/services/api-key.service.d.ts +41 -0
- package/dist/services/api-key.service.js +80 -0
- package/dist/services/auth.service.d.ts +133 -0
- package/dist/services/auth.service.js +411 -0
- package/dist/services/billing.service.d.ts +67 -0
- package/dist/services/billing.service.js +254 -0
- package/dist/services/chat-token.service.d.ts +29 -0
- package/dist/services/chat-token.service.js +113 -0
- package/dist/services/connector-registry.service.d.ts +156 -0
- package/dist/services/connector-registry.service.js +278 -0
- package/dist/services/conversation.service.d.ts +47 -0
- package/dist/services/conversation.service.js +101 -0
- package/dist/services/email-templates.d.ts +18 -0
- package/dist/services/email-templates.js +39 -0
- package/dist/services/email.service.d.ts +26 -0
- package/dist/services/email.service.js +42 -0
- package/dist/services/errors.d.ts +7 -0
- package/dist/services/errors.js +27 -0
- package/dist/services/in-memory-prepared-stream.store.d.ts +13 -0
- package/dist/services/in-memory-prepared-stream.store.js +35 -0
- package/dist/services/index.d.ts +13 -0
- package/dist/services/index.js +40 -0
- package/dist/services/mcp-client.service.d.ts +64 -0
- package/dist/services/mcp-client.service.js +157 -0
- package/dist/services/mcp-server.service.d.ts +44 -0
- package/dist/services/mcp-server.service.js +147 -0
- package/dist/services/oauth.service.d.ts +73 -0
- package/dist/services/oauth.service.js +174 -0
- package/dist/services/oauth2.service.d.ts +57 -0
- package/dist/services/oauth2.service.js +82 -0
- package/dist/services/orchestrator.service.d.ts +45 -0
- package/dist/services/orchestrator.service.js +180 -0
- package/dist/services/plan.service.d.ts +54 -0
- package/dist/services/plan.service.js +120 -0
- package/dist/services/prepared-stream.service.d.ts +23 -0
- package/dist/services/prepared-stream.service.js +43 -0
- package/dist/services/refresh-token.service.d.ts +38 -0
- package/dist/services/refresh-token.service.js +73 -0
- package/dist/services/secrets/crypto.d.ts +37 -0
- package/dist/services/secrets/crypto.js +110 -0
- package/dist/services/secrets/known-keys.d.ts +38 -0
- package/dist/services/secrets/known-keys.js +50 -0
- package/dist/services/secrets.service.d.ts +91 -0
- package/dist/services/secrets.service.js +193 -0
- package/dist/services/tenant-billing.service.d.ts +121 -0
- package/dist/services/tenant-billing.service.js +290 -0
- package/dist/services/tenant.service.d.ts +54 -0
- package/dist/services/tenant.service.js +96 -0
- package/dist/services/tool-registry.service.d.ts +42 -0
- package/dist/services/tool-registry.service.js +101 -0
- package/dist/services/upload.service.d.ts +37 -0
- package/dist/services/upload.service.js +84 -0
- package/dist/services/usage.service.d.ts +34 -0
- package/dist/services/usage.service.js +108 -0
- package/dist/types/agent.types.d.ts +160 -0
- package/dist/types/agent.types.js +2 -0
- package/dist/types/billing.types.d.ts +82 -0
- package/dist/types/billing.types.js +3 -0
- package/dist/types/config.types.d.ts +127 -0
- package/dist/types/config.types.js +9 -0
- package/dist/types/hooks.d.ts +85 -0
- package/dist/types/hooks.js +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.js +19 -0
- package/package.json +36 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BillingError = exports.BillingService = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Billing for the B2C path: the end user is the payer. Mirrors
|
|
6
|
+
* TenantBillingService, scoped to individual users.
|
|
7
|
+
*/
|
|
8
|
+
class BillingService {
|
|
9
|
+
constructor(users, subscriptions, usageRecords, adapter, usageService, opts) {
|
|
10
|
+
this.users = users;
|
|
11
|
+
this.subscriptions = subscriptions;
|
|
12
|
+
this.usageRecords = usageRecords;
|
|
13
|
+
this.adapter = adapter;
|
|
14
|
+
this.usageService = usageService;
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
}
|
|
17
|
+
// ─── Plans ──────────────────────────────────────────────────────────────
|
|
18
|
+
async getPlans() {
|
|
19
|
+
return this.opts.plans.list();
|
|
20
|
+
}
|
|
21
|
+
async getPlan(planId) {
|
|
22
|
+
const plan = await this.opts.plans.getPlan(planId);
|
|
23
|
+
if (!plan)
|
|
24
|
+
throw new BillingError('plan_not_found', `Plan "${planId}" not found`);
|
|
25
|
+
return plan;
|
|
26
|
+
}
|
|
27
|
+
async getDefaultPlanId() {
|
|
28
|
+
return this.opts.plans.getDefaultId();
|
|
29
|
+
}
|
|
30
|
+
// ─── Checkout ───────────────────────────────────────────────────────────
|
|
31
|
+
async createCheckout(input) {
|
|
32
|
+
const user = await this.users.findById(input.userId);
|
|
33
|
+
if (!user)
|
|
34
|
+
throw new BillingError('not_found', `User "${input.userId}" not found`);
|
|
35
|
+
const plan = await this.getPlan(input.planId);
|
|
36
|
+
if (!plan.stripePriceId) {
|
|
37
|
+
throw new BillingError('plan_not_purchasable', `Plan "${input.planId}" has no stripePriceId`);
|
|
38
|
+
}
|
|
39
|
+
// Lazy-create the Stripe customer for the user.
|
|
40
|
+
let customerId = user.providerCustomerId;
|
|
41
|
+
if (!customerId) {
|
|
42
|
+
customerId = await this.adapter.createCustomer({
|
|
43
|
+
email: user.email,
|
|
44
|
+
name: user.name,
|
|
45
|
+
metadata: { userId: user.id },
|
|
46
|
+
});
|
|
47
|
+
await this.users.update(user.id, { providerCustomerId: customerId });
|
|
48
|
+
}
|
|
49
|
+
const appUrl = input.appUrl ?? this.opts.billing?.appUrl ?? 'http://localhost:3000';
|
|
50
|
+
const successUrl = input.successUrl ??
|
|
51
|
+
this.opts.billing?.successUrl ??
|
|
52
|
+
`${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`;
|
|
53
|
+
const cancelUrl = input.cancelUrl ?? this.opts.billing?.cancelUrl ?? `${appUrl}/billing/cancel`;
|
|
54
|
+
const session = await this.adapter.createCheckoutSession({
|
|
55
|
+
userId: input.userId,
|
|
56
|
+
planId: input.planId,
|
|
57
|
+
stripePriceId: plan.stripePriceId,
|
|
58
|
+
successUrl,
|
|
59
|
+
cancelUrl,
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
sessionId: session.sessionId,
|
|
63
|
+
url: session.url,
|
|
64
|
+
planId: input.planId,
|
|
65
|
+
userId: input.userId,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ─── Portal ─────────────────────────────────────────────────────────────
|
|
69
|
+
async getPortalUrl(userId, returnUrl) {
|
|
70
|
+
const user = await this.users.findById(userId);
|
|
71
|
+
if (!user?.providerCustomerId) {
|
|
72
|
+
throw new BillingError('no_customer', 'User does not have a billing account');
|
|
73
|
+
}
|
|
74
|
+
const portalReturn = returnUrl ??
|
|
75
|
+
this.opts.billing?.portalReturnUrl ??
|
|
76
|
+
`${this.opts.billing?.appUrl ?? 'http://localhost:3000'}/billing`;
|
|
77
|
+
const url = await this.adapter.getPortalUrl(user.providerCustomerId, portalReturn);
|
|
78
|
+
return { url };
|
|
79
|
+
}
|
|
80
|
+
// ─── Webhook ────────────────────────────────────────────────────────────
|
|
81
|
+
async handleWebhook(payload, signature) {
|
|
82
|
+
const event = await this.adapter.handleWebhook(payload, signature);
|
|
83
|
+
this.opts.logger?.log(`Processing webhook event: ${event.type}`);
|
|
84
|
+
switch (event.type) {
|
|
85
|
+
case 'checkout.session.completed':
|
|
86
|
+
await this.onCheckoutCompleted(event.data);
|
|
87
|
+
break;
|
|
88
|
+
case 'customer.subscription.updated':
|
|
89
|
+
await this.onSubscriptionUpdated(event.data);
|
|
90
|
+
break;
|
|
91
|
+
case 'customer.subscription.deleted':
|
|
92
|
+
await this.onSubscriptionDeleted(event.data);
|
|
93
|
+
break;
|
|
94
|
+
case 'invoice.payment_failed':
|
|
95
|
+
await this.onPaymentFailed(event.data);
|
|
96
|
+
break;
|
|
97
|
+
default:
|
|
98
|
+
this.opts.logger?.debug?.(`Unhandled webhook event: ${event.type}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Same as handleWebhook but accepts an already-verified event. Used by
|
|
103
|
+
* routers that want to dispatch a single event to both the tenant and the
|
|
104
|
+
* user-centric handlers without re-verifying the signature.
|
|
105
|
+
*/
|
|
106
|
+
async handleWebhookEvent(event) {
|
|
107
|
+
switch (event.type) {
|
|
108
|
+
case 'checkout.session.completed':
|
|
109
|
+
await this.onCheckoutCompleted(event.data);
|
|
110
|
+
break;
|
|
111
|
+
case 'customer.subscription.updated':
|
|
112
|
+
await this.onSubscriptionUpdated(event.data);
|
|
113
|
+
break;
|
|
114
|
+
case 'customer.subscription.deleted':
|
|
115
|
+
await this.onSubscriptionDeleted(event.data);
|
|
116
|
+
break;
|
|
117
|
+
case 'invoice.payment_failed':
|
|
118
|
+
await this.onPaymentFailed(event.data);
|
|
119
|
+
break;
|
|
120
|
+
default:
|
|
121
|
+
this.opts.logger?.debug?.(`Unhandled webhook event: ${event.type}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async onCheckoutCompleted(data) {
|
|
125
|
+
const { metadata, subscription: subscriptionId, customer } = data;
|
|
126
|
+
if (!metadata?.userId || !metadata?.planId)
|
|
127
|
+
return;
|
|
128
|
+
const plan = await this.opts.plans.getPlan(metadata.planId);
|
|
129
|
+
if (!plan)
|
|
130
|
+
return;
|
|
131
|
+
// 30 days default; refined when we can query the provider.
|
|
132
|
+
let periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
133
|
+
if (subscriptionId) {
|
|
134
|
+
const sub = await this.adapter.getSubscription(subscriptionId);
|
|
135
|
+
periodEnd = sub.currentPeriodEnd;
|
|
136
|
+
}
|
|
137
|
+
const existing = await this.subscriptions.findByUserAndPlan(metadata.userId, metadata.planId);
|
|
138
|
+
if (existing) {
|
|
139
|
+
await this.subscriptions.update(existing.id, {
|
|
140
|
+
status: 'active',
|
|
141
|
+
providerSubscriptionId: subscriptionId,
|
|
142
|
+
providerCustomerId: customer,
|
|
143
|
+
currentPeriodEnd: periodEnd,
|
|
144
|
+
cancelAtPeriodEnd: false,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
await this.subscriptions.create({
|
|
149
|
+
userId: metadata.userId,
|
|
150
|
+
planId: metadata.planId,
|
|
151
|
+
status: 'active',
|
|
152
|
+
providerSubscriptionId: subscriptionId,
|
|
153
|
+
providerCustomerId: customer,
|
|
154
|
+
currentPeriodStart: new Date(),
|
|
155
|
+
currentPeriodEnd: periodEnd,
|
|
156
|
+
cancelAtPeriodEnd: false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
await this.users.update(metadata.userId, {
|
|
160
|
+
currentPlanId: metadata.planId,
|
|
161
|
+
providerCustomerId: customer,
|
|
162
|
+
});
|
|
163
|
+
this.opts.logger?.log(`User ${metadata.userId} activated plan "${metadata.planId}"`);
|
|
164
|
+
}
|
|
165
|
+
async onSubscriptionUpdated(data) {
|
|
166
|
+
const { id, status, current_period_end, cancel_at_period_end, metadata, items } = data;
|
|
167
|
+
if (!metadata?.userId)
|
|
168
|
+
return;
|
|
169
|
+
const periodEndTs = current_period_end ?? items?.data?.[0]?.current_period_end;
|
|
170
|
+
const periodEnd = typeof periodEndTs === 'number' && Number.isFinite(periodEndTs)
|
|
171
|
+
? new Date(periodEndTs * 1000)
|
|
172
|
+
: undefined;
|
|
173
|
+
await this.subscriptions.updateByProviderId(id, {
|
|
174
|
+
status: status,
|
|
175
|
+
cancelAtPeriodEnd: cancel_at_period_end ?? false,
|
|
176
|
+
...(periodEnd ? { currentPeriodEnd: periodEnd } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async onSubscriptionDeleted(data) {
|
|
180
|
+
const { id, metadata } = data;
|
|
181
|
+
await this.subscriptions.updateByProviderId(id, { status: 'canceled' });
|
|
182
|
+
if (metadata?.userId) {
|
|
183
|
+
const defaultPlanId = await this.getDefaultPlanId();
|
|
184
|
+
await this.users.update(metadata.userId, { currentPlanId: defaultPlanId });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async onPaymentFailed(data) {
|
|
188
|
+
const { subscription } = data;
|
|
189
|
+
if (subscription) {
|
|
190
|
+
await this.subscriptions.updateByProviderId(subscription, { status: 'past_due' });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// ─── Overview ───────────────────────────────────────────────────────────
|
|
194
|
+
async getOverview(userId) {
|
|
195
|
+
const user = await this.users.findById(userId);
|
|
196
|
+
const subscription = await this.subscriptions.findActiveForUser(userId);
|
|
197
|
+
const usage = await this.usageService.getSummary(userId);
|
|
198
|
+
const planId = user?.currentPlanId ?? (await this.getDefaultPlanId());
|
|
199
|
+
const plan = await this.opts.plans.getPlan(planId);
|
|
200
|
+
return {
|
|
201
|
+
subscription: subscription
|
|
202
|
+
? {
|
|
203
|
+
id: subscription.id,
|
|
204
|
+
userId,
|
|
205
|
+
planId: subscription.planId,
|
|
206
|
+
status: subscription.status,
|
|
207
|
+
providerSubscriptionId: subscription.providerSubscriptionId,
|
|
208
|
+
providerCustomerId: subscription.providerCustomerId,
|
|
209
|
+
currentPeriodStart: subscription.currentPeriodStart ?? new Date(),
|
|
210
|
+
currentPeriodEnd: subscription.currentPeriodEnd ?? new Date(),
|
|
211
|
+
trialEnd: subscription.trialEnd,
|
|
212
|
+
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
|
213
|
+
createdAt: subscription.createdAt,
|
|
214
|
+
updatedAt: subscription.updatedAt,
|
|
215
|
+
}
|
|
216
|
+
: null,
|
|
217
|
+
usage,
|
|
218
|
+
plan: {
|
|
219
|
+
id: planId,
|
|
220
|
+
name: plan?.name ?? planId,
|
|
221
|
+
features: plan?.features ?? [],
|
|
222
|
+
limits: {
|
|
223
|
+
requestsPerMonth: plan?.limits.requestsPerMonth ?? -1,
|
|
224
|
+
tokensPerMonth: plan?.limits.tokensPerMonth ?? -1,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// ─── Cancel ─────────────────────────────────────────────────────────────
|
|
230
|
+
async cancelSubscription(userId, immediately = false) {
|
|
231
|
+
const subscription = await this.subscriptions.findActiveForUser(userId);
|
|
232
|
+
if (!subscription?.providerSubscriptionId) {
|
|
233
|
+
throw new BillingError('no_active_subscription', 'No active subscription found');
|
|
234
|
+
}
|
|
235
|
+
await this.adapter.cancelSubscription(subscription.providerSubscriptionId, !immediately);
|
|
236
|
+
await this.subscriptions.update(subscription.id, {
|
|
237
|
+
cancelAtPeriodEnd: !immediately,
|
|
238
|
+
status: immediately ? 'canceled' : subscription.status,
|
|
239
|
+
});
|
|
240
|
+
if (immediately) {
|
|
241
|
+
const defaultPlanId = await this.getDefaultPlanId();
|
|
242
|
+
await this.users.update(userId, { currentPlanId: defaultPlanId });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
exports.BillingService = BillingService;
|
|
247
|
+
class BillingError extends Error {
|
|
248
|
+
constructor(code, message) {
|
|
249
|
+
super(message);
|
|
250
|
+
this.code = code;
|
|
251
|
+
this.status = code === 'not_found' ? 404 : 400;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
exports.BillingError = BillingError;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ChatToken, ChatTokenPatch } from '../domain/chat-token';
|
|
2
|
+
import type { ChatTokenRepository } from '../repositories';
|
|
3
|
+
export declare class ChatTokenError extends Error {
|
|
4
|
+
status: number;
|
|
5
|
+
code: 'not_found' | 'invalid' | 'origin_not_allowed' | 'revoked';
|
|
6
|
+
constructor(code: ChatTokenError['code'], message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface CreateChatTokenInput {
|
|
9
|
+
tenantId: string;
|
|
10
|
+
agentId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
allowedOrigins?: string[];
|
|
13
|
+
createdByUserId?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare class ChatTokenService {
|
|
16
|
+
private readonly repo;
|
|
17
|
+
constructor(repo: ChatTokenRepository);
|
|
18
|
+
create(input: CreateChatTokenInput): Promise<ChatToken>;
|
|
19
|
+
listForAgent(tenantId: string, agentId: string): Promise<ChatToken[]>;
|
|
20
|
+
deactivate(token: string): Promise<void>;
|
|
21
|
+
update(token: string, patch: ChatTokenPatch): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Public-path entry point. Returns the resolved token row, or throws with
|
|
24
|
+
* a code the HTTP layer maps to 403/404. Also pings `last_used_at` so the
|
|
25
|
+
* admin UI can show which tokens are actually live traffic.
|
|
26
|
+
*/
|
|
27
|
+
authorize(rawToken: string, origin: string | undefined): Promise<ChatToken>;
|
|
28
|
+
private generateToken;
|
|
29
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ChatTokenService = exports.ChatTokenError = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
class ChatTokenError extends Error {
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status =
|
|
10
|
+
code === 'not_found' ? 404 : code === 'origin_not_allowed' ? 403 : code === 'revoked' ? 403 : 400;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
exports.ChatTokenError = ChatTokenError;
|
|
14
|
+
/** Public token prefix to make stolen ones easy to spot in logs. 32 hex
|
|
15
|
+
* bytes ≈ 128 bits of entropy — plenty for a public-but-rotatable secret. */
|
|
16
|
+
const TOKEN_PREFIX = 'aft_';
|
|
17
|
+
const TOKEN_BYTES = 32;
|
|
18
|
+
class ChatTokenService {
|
|
19
|
+
constructor(repo) {
|
|
20
|
+
this.repo = repo;
|
|
21
|
+
}
|
|
22
|
+
async create(input) {
|
|
23
|
+
if (!input.tenantId)
|
|
24
|
+
throw new ChatTokenError('invalid', 'tenantId is required');
|
|
25
|
+
if (!input.agentId)
|
|
26
|
+
throw new ChatTokenError('invalid', 'agentId is required');
|
|
27
|
+
if (!input.name?.trim())
|
|
28
|
+
throw new ChatTokenError('invalid', 'name is required');
|
|
29
|
+
const origins = (input.allowedOrigins ?? [])
|
|
30
|
+
.map((o) => o.trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
for (const o of origins) {
|
|
33
|
+
if (/\s/.test(o)) {
|
|
34
|
+
throw new ChatTokenError('invalid', `Invalid origin: "${o}"`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const record = {
|
|
38
|
+
token: this.generateToken(),
|
|
39
|
+
tenantId: input.tenantId,
|
|
40
|
+
agentId: input.agentId,
|
|
41
|
+
name: input.name.trim(),
|
|
42
|
+
allowedOrigins: origins.length > 0 ? origins : undefined,
|
|
43
|
+
isActive: true,
|
|
44
|
+
createdByUserId: input.createdByUserId,
|
|
45
|
+
};
|
|
46
|
+
return this.repo.create(record);
|
|
47
|
+
}
|
|
48
|
+
async listForAgent(tenantId, agentId) {
|
|
49
|
+
return this.repo.listForAgent(tenantId, agentId);
|
|
50
|
+
}
|
|
51
|
+
async deactivate(token) {
|
|
52
|
+
await this.repo.deactivate(token);
|
|
53
|
+
}
|
|
54
|
+
async update(token, patch) {
|
|
55
|
+
await this.repo.update(token, patch);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Public-path entry point. Returns the resolved token row, or throws with
|
|
59
|
+
* a code the HTTP layer maps to 403/404. Also pings `last_used_at` so the
|
|
60
|
+
* admin UI can show which tokens are actually live traffic.
|
|
61
|
+
*/
|
|
62
|
+
async authorize(rawToken, origin) {
|
|
63
|
+
if (!rawToken)
|
|
64
|
+
throw new ChatTokenError('not_found', 'Missing chat token');
|
|
65
|
+
const row = await this.repo.findByToken(rawToken);
|
|
66
|
+
if (!row)
|
|
67
|
+
throw new ChatTokenError('not_found', 'Chat token not found');
|
|
68
|
+
if (!row.isActive)
|
|
69
|
+
throw new ChatTokenError('revoked', 'Chat token has been revoked');
|
|
70
|
+
if (row.allowedOrigins && row.allowedOrigins.length > 0) {
|
|
71
|
+
if (!origin) {
|
|
72
|
+
throw new ChatTokenError('origin_not_allowed', 'This widget can only be used from an allowed origin (Origin header missing).');
|
|
73
|
+
}
|
|
74
|
+
if (!matchesAllowedOrigin(origin, row.allowedOrigins)) {
|
|
75
|
+
throw new ChatTokenError('origin_not_allowed', `Origin "${origin}" is not allowed for this widget.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Fire-and-forget: don't make the request wait on this write.
|
|
79
|
+
this.repo.touch(rawToken).catch(() => { });
|
|
80
|
+
return row;
|
|
81
|
+
}
|
|
82
|
+
generateToken() {
|
|
83
|
+
return `${TOKEN_PREFIX}${(0, crypto_1.randomBytes)(TOKEN_BYTES).toString('hex')}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
exports.ChatTokenService = ChatTokenService;
|
|
87
|
+
/**
|
|
88
|
+
* Origin matching: each allowed entry can be either a full origin
|
|
89
|
+
* ("https://acme.com") or just a host ("acme.com"). Exact match only — no
|
|
90
|
+
* wildcards on purpose.
|
|
91
|
+
*/
|
|
92
|
+
function matchesAllowedOrigin(origin, allowed) {
|
|
93
|
+
const reqHost = hostnameOf(origin);
|
|
94
|
+
if (!reqHost)
|
|
95
|
+
return false;
|
|
96
|
+
for (const entry of allowed) {
|
|
97
|
+
const allowedHost = hostnameOf(entry) ?? entry.toLowerCase();
|
|
98
|
+
if (allowedHost === reqHost)
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
function hostnameOf(value) {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(value).hostname.toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Bare hostname like "acme.com" — URL ctor rejects it without scheme.
|
|
109
|
+
if (/^[a-z0-9.-]+$/i.test(value))
|
|
110
|
+
return value.toLowerCase();
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ConnectorDefinition } from '../domain';
|
|
2
|
+
import type { ConnectorAuthRepository } from '../repositories';
|
|
3
|
+
import type { OAuth2Service } from './oauth2.service';
|
|
4
|
+
import type { ToolRegistryService } from './tool-registry.service';
|
|
5
|
+
import type { AgentToolDefinition } from '../types';
|
|
6
|
+
export declare class ConnectorError extends Error {
|
|
7
|
+
status: number;
|
|
8
|
+
code: 'unknown_connector' | 'not_connected' | 'not_configured' | 'invalid_state' | 'token_exchange_failed';
|
|
9
|
+
constructor(code: ConnectorError['code'], message: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Host-supplied symmetric cipher for encrypting OAuth tokens at rest.
|
|
13
|
+
* Implemented in the platform's SecretsModule (AES-256-GCM with MASTER_KEY).
|
|
14
|
+
* Kept as an interface so @agentforge-io/core stays infrastructure-agnostic.
|
|
15
|
+
*/
|
|
16
|
+
export interface TokenCipher {
|
|
17
|
+
encrypt(plaintext: string): Buffer;
|
|
18
|
+
decrypt(blob: Buffer): string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Transient state stashed between `startAuthorize` and the OAuth callback.
|
|
22
|
+
* Persisted in the store so the callback (which arrives as a separate HTTP
|
|
23
|
+
* request) can pick up the connector id and the user it's for.
|
|
24
|
+
*/
|
|
25
|
+
export interface AuthorizeState {
|
|
26
|
+
userId: string;
|
|
27
|
+
connectorId: string;
|
|
28
|
+
pkceVerifier?: string;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Pluggable storage for the short-lived state map. Defaults to an
|
|
33
|
+
* in-memory implementation; production deployments behind multiple
|
|
34
|
+
* server instances should plug in a Redis-backed store.
|
|
35
|
+
*/
|
|
36
|
+
export interface AuthorizeStateStore {
|
|
37
|
+
set(state: string, value: AuthorizeState): Promise<void> | void;
|
|
38
|
+
consume(state: string): Promise<AuthorizeState | null> | AuthorizeState | null;
|
|
39
|
+
}
|
|
40
|
+
export declare class InMemoryAuthorizeStateStore implements AuthorizeStateStore {
|
|
41
|
+
private readonly map;
|
|
42
|
+
set(state: string, value: AuthorizeState): void;
|
|
43
|
+
consume(state: string): AuthorizeState | null;
|
|
44
|
+
}
|
|
45
|
+
export interface ConnectorStatus {
|
|
46
|
+
connectorId: string;
|
|
47
|
+
name: string;
|
|
48
|
+
description: string;
|
|
49
|
+
category?: string;
|
|
50
|
+
iconUrl?: string;
|
|
51
|
+
/** `true` when the operator has supplied client creds for this provider.
|
|
52
|
+
* When `false`, the user cannot click Connect — the card shows a "Needs
|
|
53
|
+
* admin setup" hint instead. Kept distinct from `connected` so the
|
|
54
|
+
* Directory can render the full catalog even on a freshly-installed
|
|
55
|
+
* platform. */
|
|
56
|
+
configured: boolean;
|
|
57
|
+
connected: boolean;
|
|
58
|
+
accountLabel?: string;
|
|
59
|
+
connectedAt?: Date;
|
|
60
|
+
}
|
|
61
|
+
interface ConnectorRegistryDeps {
|
|
62
|
+
oauth: OAuth2Service;
|
|
63
|
+
authRepo: ConnectorAuthRepository;
|
|
64
|
+
cipher: TokenCipher;
|
|
65
|
+
toolRegistry: ToolRegistryService;
|
|
66
|
+
stateStore?: AuthorizeStateStore;
|
|
67
|
+
/** Refresh access tokens this many seconds before they expire so a
|
|
68
|
+
* tool call started right at the edge doesn't 401 mid-flight. */
|
|
69
|
+
refreshSkewSeconds?: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Central registry for OAuth-based connectors. Holds the in-memory map of
|
|
73
|
+
* `ConnectorDefinition`s, drives the OAuth dance via `OAuth2Service`,
|
|
74
|
+
* encrypts/persists tokens via `cipher` + `authRepo`, and on each agent
|
|
75
|
+
* run synthesises per-user tool definitions whose handlers receive a
|
|
76
|
+
* fresh access token.
|
|
77
|
+
*
|
|
78
|
+
* The lifecycle is intentionally not "register tools globally at boot" —
|
|
79
|
+
* doing so would leak one user's tools to another. Instead, callers ask
|
|
80
|
+
* the registry for `toolsForUser(userId)` and merge the result into the
|
|
81
|
+
* ToolRegistry's per-call view.
|
|
82
|
+
*/
|
|
83
|
+
export declare class ConnectorRegistryService {
|
|
84
|
+
private readonly deps;
|
|
85
|
+
private readonly defs;
|
|
86
|
+
/**
|
|
87
|
+
* Connectors whose client creds are present. The registry treats the
|
|
88
|
+
* "catalog" (which providers we *support*) as separate from the
|
|
89
|
+
* "configured" set (which ones can actually run an OAuth flow). Hosts
|
|
90
|
+
* call `setConfigured(id, def)` when secrets land and `markUnconfigured`
|
|
91
|
+
* when they're cleared — neither call removes the definition from the
|
|
92
|
+
* catalog, so the Directory always shows the full grid.
|
|
93
|
+
*/
|
|
94
|
+
private readonly configured;
|
|
95
|
+
private readonly stateStore;
|
|
96
|
+
private readonly refreshSkewSeconds;
|
|
97
|
+
constructor(deps: ConnectorRegistryDeps);
|
|
98
|
+
/**
|
|
99
|
+
* Register a connector into the catalog. Catalog registration is one-shot
|
|
100
|
+
* (per-id) and unrelated to credential availability — call this at boot
|
|
101
|
+
* for every provider the platform supports. To then mark it as ready for
|
|
102
|
+
* OAuth flows, follow up with `setConfigured()` once creds resolve.
|
|
103
|
+
*/
|
|
104
|
+
register(def: ConnectorDefinition): void;
|
|
105
|
+
/**
|
|
106
|
+
* Mark a registered connector as configured AND refresh its definition.
|
|
107
|
+
* The definition is replaced because OAuth `clientId` / `clientSecret`
|
|
108
|
+
* live inside it — when the operator rotates creds the def itself
|
|
109
|
+
* changes. Existing per-user `ConnectorAuth` rows keep working: tokens
|
|
110
|
+
* are provider-issued, not tied to the client secret.
|
|
111
|
+
*/
|
|
112
|
+
setConfigured(def: ConnectorDefinition): void;
|
|
113
|
+
/**
|
|
114
|
+
* Mark a connector as unconfigured. The definition stays in the catalog
|
|
115
|
+
* (so the card keeps rendering with a "Needs admin setup" hint) but
|
|
116
|
+
* `startAuthorize` will refuse and per-user tools stop surfacing — we
|
|
117
|
+
* can't refresh tokens without client creds either, so silently dropping
|
|
118
|
+
* tools is friendlier than letting them 401 mid-call.
|
|
119
|
+
*/
|
|
120
|
+
markUnconfigured(connectorId: string): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Hard-remove a connector from the catalog. Rarely needed — used by
|
|
123
|
+
* tests or to retire a deprecated provider. Per-user rows are NOT
|
|
124
|
+
* deleted automatically; the caller is responsible for that cleanup.
|
|
125
|
+
*/
|
|
126
|
+
unregister(connectorId: string): boolean;
|
|
127
|
+
isConfigured(connectorId: string): boolean;
|
|
128
|
+
list(): ConnectorDefinition[];
|
|
129
|
+
get(connectorId: string): ConnectorDefinition;
|
|
130
|
+
startAuthorize(connectorId: string, userId: string, redirectUri: string): Promise<{
|
|
131
|
+
url: string;
|
|
132
|
+
}>;
|
|
133
|
+
/**
|
|
134
|
+
* Complete the OAuth callback. Verifies the `state`, exchanges the code
|
|
135
|
+
* for tokens, encrypts them, and upserts the `ConnectorAuth` row.
|
|
136
|
+
*
|
|
137
|
+
* Returns the resolved connector id so the caller can redirect the user
|
|
138
|
+
* back to the directory page with confirmation.
|
|
139
|
+
*/
|
|
140
|
+
completeAuthorize(state: string, code: string, redirectUri: string): Promise<{
|
|
141
|
+
connectorId: string;
|
|
142
|
+
userId: string;
|
|
143
|
+
}>;
|
|
144
|
+
listForUser(userId: string): Promise<ConnectorStatus[]>;
|
|
145
|
+
disconnect(userId: string, connectorId: string): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Returns the toolbelt for `userId`: one `AgentToolDefinition` per tool
|
|
148
|
+
* of every connector the user has authorized. Each handler is bound to a
|
|
149
|
+
* `ConnectorToolContext` whose `getAccessToken` lazy-refreshes when the
|
|
150
|
+
* cached token is close to expiry.
|
|
151
|
+
*/
|
|
152
|
+
toolsForUser(userId: string): Promise<AgentToolDefinition[]>;
|
|
153
|
+
private upsertAuth;
|
|
154
|
+
private getAccessToken;
|
|
155
|
+
}
|
|
156
|
+
export {};
|