@ysk-kit/cli 1.2.1 → 1.2.2
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/add-capability.js +2 -2
- package/dist/add-capability.js.map +1 -1
- package/dist/add-module.js +7 -7
- package/dist/add-module.js.map +1 -1
- package/dist/apply-capability.d.ts +1 -1
- package/dist/apply-capability.d.ts.map +1 -1
- package/dist/apply-capability.js +3 -3
- package/dist/apply-capability.js.map +1 -1
- package/dist/capability-catalog.d.ts +1 -1
- package/dist/capability-catalog.d.ts.map +1 -1
- package/dist/capability-catalog.js +11 -3
- package/dist/capability-catalog.js.map +1 -1
- package/dist/capability-patches.d.ts.map +1 -1
- package/dist/capability-patches.js +29 -46
- package/dist/capability-patches.js.map +1 -1
- package/dist/check-agent-guidance.d.ts.map +1 -1
- package/dist/check-agent-guidance.js +2 -1
- package/dist/check-agent-guidance.js.map +1 -1
- package/dist/check-agent.d.ts.map +1 -1
- package/dist/check-agent.js +2 -1
- package/dist/check-agent.js.map +1 -1
- package/dist/doctor.js +3 -3
- package/dist/doctor.js.map +1 -1
- package/dist/help.js +1 -1
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +111 -86
- package/dist/index.js.map +1 -1
- package/dist/upgrade.js +1 -1
- package/dist/upgrade.js.map +1 -1
- package/package.json +4 -4
- package/templates/agent/rules/api-modules.mdc +1 -1
- package/templates/agent/rules/desktop.mdc +9 -0
- package/templates/agent/rules/security.mdc +9 -0
- package/templates/agent/skills/db-migration/SKILL.md +17 -0
- package/templates/agent/skills/desktop-electron/SKILL.md +18 -0
- package/templates/agent/skills/security-review/SKILL.md +20 -0
- package/templates/agent/skills/webhook-handling/SKILL.md +18 -0
- package/templates/agent/ysk-kit.mdc +1 -1
- package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.test.ts +56 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +71 -18
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/stripe-webhook-event.ts +12 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/webhook-event-repository.ts +19 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +172 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-webhook-event-repository.ts +31 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-webhook-event-repository.ts +39 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +3 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +34 -7
- package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +2 -2
- package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +1 -1
- package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +1 -1
- package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.test.ts +58 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +63 -36
- package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +1 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +49 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +6 -3
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +5 -0
- package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +2 -2
- package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +8 -2
- package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +2 -2
- package/templates/capabilities/push/packages/contracts/src/api/devices.ts +2 -2
- package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +1 -1
- package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +2 -2
- package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +1 -1
- package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +1 -1
- package/templates/module/api.ts.tmpl +3 -3
- package/templates/module/dto.ts.tmpl +1 -1
- package/templates/module/sdk-resource.ts.tmpl +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-review
|
|
3
|
+
description: >
|
|
4
|
+
Review YSK Kit authz, tenant isolation, secrets, logging redaction, token
|
|
5
|
+
storage, webhook signatures, and LLM input against hexagonal modules and
|
|
6
|
+
the {ok,data}/{ok,error} envelope. Optional lightweight threat-model mode.
|
|
7
|
+
Use when the user asks for a security review or audit; the diff touches
|
|
8
|
+
auth, apikey, organizations, billing, files, crypto, webhooks, env/secrets,
|
|
9
|
+
or a new capability; or the plan's security section is not "none".
|
|
10
|
+
中文:安全審查、權限、跨租戶、密鑰、日誌遮罩、webhook 簽名。
|
|
11
|
+
Do not use for pure UI styling, docs typos, or generic dependency bumps.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Read `docs/skills/security-review.md`. Law: `AGENTS.md`.
|
|
15
|
+
|
|
16
|
+
1. Diff is the report scope; the repo is research. HIGH confidence only.
|
|
17
|
+
2. AuthZ in `application/` (membership + role). Cross-tenant memory tests.
|
|
18
|
+
3. No secrets in logs. Webhook signatures. Client token stores.
|
|
19
|
+
4. Threat-model sub-mode only when the user asks or a new trust boundary lands.
|
|
20
|
+
Full steps: `docs/skills/security-review.md`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webhook-handling
|
|
3
|
+
description: >
|
|
4
|
+
Handle inbound webhooks in YSK Kit: verify signature on the raw body, ack
|
|
5
|
+
fast, idempotency by event id, out-of-order events, retries, replay tests.
|
|
6
|
+
Stripe is the main example; the same rules apply to other providers.
|
|
7
|
+
Use when adding or changing /v1/*/webhook, adding billing, or debugging
|
|
8
|
+
"paid but not activated" / duplicate fulfilments.
|
|
9
|
+
中文:webhook、驗簽、冪等、Stripe 回調、重送。
|
|
10
|
+
Do not use for outbound HTTP or a general security audit with no webhook route.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Read `docs/skills/webhook-handling.md`. Law: `AGENTS.md`.
|
|
14
|
+
|
|
15
|
+
1. Mount raw body before JSON parsers (Express + Fastify). Verify signature.
|
|
16
|
+
2. Persist event id, return `{ ok: true, data: { received: true } }`, fulfil via jobs.
|
|
17
|
+
3. Do not trust event order. Replay tests in memory; never call Stripe in CI.
|
|
18
|
+
Full steps: `docs/skills/webhook-handling.md`.
|
|
@@ -3,4 +3,4 @@ description: YSK Kit agent law — follow root AGENTS.md
|
|
|
3
3
|
alwaysApply: true
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Follow `AGENTS.md` at the repo root (Chinese: `AGENTS.zh.md`). Do not copy that file here. Procedures: `docs/skills/`. Plan first when required: `pnpm ysk-kit plan <slug>` → `docs/plans/<yyyy-mm-dd>-<slug>.md`. New HTTP features start with `pnpm ysk-kit add module <kebab-name> --prisma --web`. Verify with `pnpm layers && pnpm typecheck && pnpm test && pnpm gen:openapi && pnpm ysk-kit check agent`.
|
|
6
|
+
Follow `AGENTS.md` at the repo root (Chinese: `AGENTS.zh.md`). Do not copy that file here. Procedures: `docs/skills/`. Plan first when required: `pnpm ysk-kit plan <slug>` → `docs/plans/<yyyy-mm-dd>-<slug>.md`. New HTTP features start with `pnpm ysk-kit add module <kebab-name> --prisma --web`. Verify with `pnpm layers && pnpm typecheck && pnpm test && pnpm gen:openapi && pnpm ysk-kit check agent`. Security / Prisma / webhooks / Electron: `docs/skills/security-review.md`, `db-migration.md`, `webhook-handling.md`, `desktop-electron.md`.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMemoryOrganizationRepository } from '../../organizations/infra/memory-organization-repository';
|
|
3
|
+
import { createLogBilling } from '../infra/log-billing';
|
|
4
|
+
import { createMemorySubscriptionRepository } from '../infra/memory-subscription-repository';
|
|
5
|
+
import { createMemoryWebhookEventRepository } from '../infra/memory-webhook-event-repository';
|
|
6
|
+
import { createBillingService } from './billing-service';
|
|
7
|
+
|
|
8
|
+
describe('createBillingService webhooks', () => {
|
|
9
|
+
it('activates once and skips a replay plus a stale event', async () => {
|
|
10
|
+
const orgs = createMemoryOrganizationRepository();
|
|
11
|
+
const subscriptions = createMemorySubscriptionRepository();
|
|
12
|
+
const service = createBillingService({
|
|
13
|
+
subscriptions,
|
|
14
|
+
billing: createLogBilling(),
|
|
15
|
+
orgs,
|
|
16
|
+
webhookEvents: createMemoryWebhookEventRepository(),
|
|
17
|
+
});
|
|
18
|
+
const owner = await orgs.createWithOwner({ name: 'Pay', ownerUserId: 'user-1' });
|
|
19
|
+
await service.handleStripeEvent({
|
|
20
|
+
id: 'evt_new',
|
|
21
|
+
type: 'checkout.session.completed',
|
|
22
|
+
created: 100,
|
|
23
|
+
data: {
|
|
24
|
+
object: {
|
|
25
|
+
customer: 'cus_1',
|
|
26
|
+
metadata: { organizationId: owner.id, planCode: 'pro', seatCount: '4' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
await service.handleStripeEvent({
|
|
31
|
+
id: 'evt_new',
|
|
32
|
+
type: 'checkout.session.completed',
|
|
33
|
+
created: 100,
|
|
34
|
+
data: {
|
|
35
|
+
object: {
|
|
36
|
+
customer: 'cus_1',
|
|
37
|
+
metadata: { organizationId: owner.id, planCode: 'pro', seatCount: '4' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
await service.handleStripeEvent({
|
|
42
|
+
id: 'evt_old',
|
|
43
|
+
type: 'checkout.session.completed',
|
|
44
|
+
created: 50,
|
|
45
|
+
data: {
|
|
46
|
+
object: {
|
|
47
|
+
customer: 'cus_1',
|
|
48
|
+
metadata: { organizationId: owner.id, planCode: 'pro', seatCount: '1' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const row = await subscriptions.findByOrganizationId(owner.id);
|
|
53
|
+
expect(row?.seatCount).toBe(4);
|
|
54
|
+
expect(row?.status).toBe('active');
|
|
55
|
+
});
|
|
56
|
+
});
|
package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { BILLING_PLANS, orgRoleCan } from '@ysk-kit/contracts';
|
|
|
3
3
|
import { AppError } from '@ysk-kit/domain-kernel';
|
|
4
4
|
import type { IOrganizationRepository } from '../../organizations/domain/organization-repository';
|
|
5
5
|
import type { IBillingPort } from '../domain/billing-port';
|
|
6
|
+
import type { StripeWebhookEvent } from '../domain/stripe-webhook-event';
|
|
6
7
|
import type { ISubscriptionRepository } from '../domain/subscription-repository';
|
|
8
|
+
import type { IProcessedWebhookRepository } from '../domain/webhook-event-repository';
|
|
7
9
|
|
|
8
10
|
const PERIOD_MS = 1000 * 60 * 60 * 24 * 30;
|
|
9
11
|
|
|
@@ -30,6 +32,7 @@ export const createBillingService = (opts: {
|
|
|
30
32
|
subscriptions: ISubscriptionRepository;
|
|
31
33
|
billing: IBillingPort;
|
|
32
34
|
orgs: IOrganizationRepository;
|
|
35
|
+
webhookEvents: IProcessedWebhookRepository;
|
|
33
36
|
now?: () => Date;
|
|
34
37
|
}) => {
|
|
35
38
|
const now = () => opts.now?.() ?? new Date();
|
|
@@ -44,6 +47,25 @@ export const createBillingService = (opts: {
|
|
|
44
47
|
return org;
|
|
45
48
|
};
|
|
46
49
|
|
|
50
|
+
const activate = async (
|
|
51
|
+
organizationId: string,
|
|
52
|
+
planCode: SubscriptionDto['planCode'],
|
|
53
|
+
customerId?: string,
|
|
54
|
+
seatCount = 1,
|
|
55
|
+
) => {
|
|
56
|
+
if (planCode === 'free') return;
|
|
57
|
+
const org = await opts.orgs.findById(organizationId);
|
|
58
|
+
if (!org) throw new AppError('NOT_FOUND');
|
|
59
|
+
await opts.subscriptions.upsert({
|
|
60
|
+
organizationId,
|
|
61
|
+
planCode,
|
|
62
|
+
status: 'active',
|
|
63
|
+
currentPeriodEnd: new Date(now().getTime() + PERIOD_MS),
|
|
64
|
+
seatCount,
|
|
65
|
+
});
|
|
66
|
+
if (customerId) await opts.orgs.setStripeCustomerId(organizationId, customerId);
|
|
67
|
+
};
|
|
68
|
+
|
|
47
69
|
return {
|
|
48
70
|
plans: () => BILLING_PLANS,
|
|
49
71
|
subscription: async (userId: string, organizationId: string): Promise<SubscriptionDto> => {
|
|
@@ -64,24 +86,7 @@ export const createBillingService = (opts: {
|
|
|
64
86
|
invoiceId,
|
|
65
87
|
});
|
|
66
88
|
},
|
|
67
|
-
activate
|
|
68
|
-
organizationId: string,
|
|
69
|
-
planCode: SubscriptionDto['planCode'],
|
|
70
|
-
customerId?: string,
|
|
71
|
-
seatCount = 1,
|
|
72
|
-
) => {
|
|
73
|
-
if (planCode === 'free') return;
|
|
74
|
-
const org = await opts.orgs.findById(organizationId);
|
|
75
|
-
if (!org) throw new AppError('NOT_FOUND');
|
|
76
|
-
await opts.subscriptions.upsert({
|
|
77
|
-
organizationId,
|
|
78
|
-
planCode,
|
|
79
|
-
status: 'active',
|
|
80
|
-
currentPeriodEnd: new Date(now().getTime() + PERIOD_MS),
|
|
81
|
-
seatCount,
|
|
82
|
-
});
|
|
83
|
-
if (customerId) await opts.orgs.setStripeCustomerId(organizationId, customerId);
|
|
84
|
-
},
|
|
89
|
+
activate,
|
|
85
90
|
portal: async (userId: string, organizationId: string, returnUrl: string) => {
|
|
86
91
|
const org = await requireBiller(userId, organizationId);
|
|
87
92
|
const customerId = org.stripeCustomerId;
|
|
@@ -120,6 +125,54 @@ export const createBillingService = (opts: {
|
|
|
120
125
|
}
|
|
121
126
|
return { url: result.url };
|
|
122
127
|
},
|
|
128
|
+
handleStripeEvent: async (event: StripeWebhookEvent) => {
|
|
129
|
+
if (!event.id) throw new AppError('VALIDATION_FAILED', 'Stripe event id required');
|
|
130
|
+
const organizationId = event.data.object.metadata?.organizationId;
|
|
131
|
+
const eventCreatedAt = new Date((Number.isFinite(event.created) ? event.created : 0) * 1000);
|
|
132
|
+
const claim = await opts.webhookEvents.tryClaim({
|
|
133
|
+
provider: 'stripe',
|
|
134
|
+
eventId: event.id,
|
|
135
|
+
eventType: event.type,
|
|
136
|
+
eventCreatedAt,
|
|
137
|
+
...(organizationId ? { organizationId } : {}),
|
|
138
|
+
});
|
|
139
|
+
if (claim === 'duplicate') return { received: true as const };
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
if (organizationId) {
|
|
143
|
+
const latest = await opts.webhookEvents.maxEventCreatedAt(
|
|
144
|
+
'stripe',
|
|
145
|
+
organizationId,
|
|
146
|
+
event.id,
|
|
147
|
+
);
|
|
148
|
+
if (latest && eventCreatedAt < latest) return { received: true as const };
|
|
149
|
+
}
|
|
150
|
+
if (event.type === 'checkout.session.completed') {
|
|
151
|
+
const planCode = event.data.object.metadata?.planCode;
|
|
152
|
+
const seatRaw = Number(event.data.object.metadata?.seatCount ?? '1');
|
|
153
|
+
const seatCount = Number.isFinite(seatRaw) && seatRaw >= 1 ? Math.floor(seatRaw) : 1;
|
|
154
|
+
if (organizationId && (planCode === 'pro' || planCode === 'free')) {
|
|
155
|
+
const customer = event.data.object.customer;
|
|
156
|
+
await activate(
|
|
157
|
+
organizationId,
|
|
158
|
+
planCode,
|
|
159
|
+
typeof customer === 'string' ? customer : undefined,
|
|
160
|
+
seatCount,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { received: true as const };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (
|
|
167
|
+
error instanceof AppError &&
|
|
168
|
+
(error.code === 'NOT_FOUND' || error.code === 'VALIDATION_FAILED')
|
|
169
|
+
) {
|
|
170
|
+
return { received: true as const };
|
|
171
|
+
}
|
|
172
|
+
await opts.webhookEvents.release('stripe', event.id);
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
123
176
|
cancel: async (userId: string, organizationId: string) => {
|
|
124
177
|
await requireBiller(userId, organizationId);
|
|
125
178
|
const row = await opts.subscriptions.findByOrganizationId(organizationId);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ProcessedWebhookClaim = 'claimed' | 'duplicate';
|
|
2
|
+
|
|
3
|
+
export type ProcessedWebhookRecord = {
|
|
4
|
+
provider: string;
|
|
5
|
+
eventId: string;
|
|
6
|
+
eventType: string;
|
|
7
|
+
eventCreatedAt: Date;
|
|
8
|
+
organizationId?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface IProcessedWebhookRepository {
|
|
12
|
+
tryClaim(input: ProcessedWebhookRecord): Promise<ProcessedWebhookClaim>;
|
|
13
|
+
maxEventCreatedAt(
|
|
14
|
+
provider: string,
|
|
15
|
+
organizationId: string,
|
|
16
|
+
exceptEventId: string,
|
|
17
|
+
): Promise<Date | null>;
|
|
18
|
+
release(provider: string, eventId: string): Promise<void>;
|
|
19
|
+
}
|
package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts
CHANGED
|
@@ -2,10 +2,12 @@ import { createHmac } from 'node:crypto';
|
|
|
2
2
|
import request from 'supertest';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { createApp } from '../../../app';
|
|
5
|
+
import { createFastifyApp } from '../../../app-fastify';
|
|
5
6
|
import { createMemoryInput } from '../../../create-memory-input';
|
|
6
7
|
import { createBillingService } from '../application/billing-service';
|
|
7
8
|
import { createLogBilling } from './log-billing';
|
|
8
9
|
import { createMemorySubscriptionRepository } from './memory-subscription-repository';
|
|
10
|
+
import { createMemoryWebhookEventRepository } from './memory-webhook-event-repository';
|
|
9
11
|
import { createStripeBilling } from './stripe-billing';
|
|
10
12
|
|
|
11
13
|
describe('billing http', () => {
|
|
@@ -34,7 +36,9 @@ describe('billing http', () => {
|
|
|
34
36
|
.send({ name: 'Pay' });
|
|
35
37
|
const organizationId = org.body.data.id as string;
|
|
36
38
|
const payload = JSON.stringify({
|
|
39
|
+
id: 'evt_checkout_1',
|
|
37
40
|
type: 'checkout.session.completed',
|
|
41
|
+
created: Math.floor(Date.now() / 1000),
|
|
38
42
|
data: {
|
|
39
43
|
object: {
|
|
40
44
|
customer: 'cus_1',
|
|
@@ -73,6 +77,7 @@ describe('billing http', () => {
|
|
|
73
77
|
fetchImpl: (async () => new Response('{}', { status: 500 })) as typeof fetch,
|
|
74
78
|
}),
|
|
75
79
|
orgs: mem.orgs,
|
|
80
|
+
webhookEvents: createMemoryWebhookEventRepository(),
|
|
76
81
|
});
|
|
77
82
|
const stripeApp = createApp({ ...mem.input, billingService });
|
|
78
83
|
const registered = await request(stripeApp).post('/v1/auth/register').send({
|
|
@@ -251,6 +256,7 @@ describe('billing http', () => {
|
|
|
251
256
|
getInvoicePdf: async () => ({ url: 'https://files.stripe.com/x.pdf' }),
|
|
252
257
|
},
|
|
253
258
|
orgs: mem.orgs,
|
|
259
|
+
webhookEvents: createMemoryWebhookEventRepository(),
|
|
254
260
|
});
|
|
255
261
|
const pdfApp = createApp({ ...mem.input, billingService });
|
|
256
262
|
const registered = await request(pdfApp).post('/v1/auth/register').send({
|
|
@@ -270,4 +276,170 @@ describe('billing http', () => {
|
|
|
270
276
|
expect(pdf.status).toBe(302);
|
|
271
277
|
expect(pdf.headers.location).toBe('https://files.stripe.com/x.pdf');
|
|
272
278
|
});
|
|
279
|
+
|
|
280
|
+
const sign = (secret: string, payload: string): string => {
|
|
281
|
+
const t = String(Math.floor(Date.now() / 1000));
|
|
282
|
+
const v1 = createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex');
|
|
283
|
+
return `t=${t},v1=${v1}`;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
it('acks a replayed Stripe event without applying it twice', async () => {
|
|
287
|
+
const mem = createMemoryInput();
|
|
288
|
+
const secret = 'whsec_replay';
|
|
289
|
+
const app = createApp({ ...mem.input, stripeWebhookSecret: secret });
|
|
290
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
291
|
+
email: 'replay@ysk.hk',
|
|
292
|
+
password: 'password1',
|
|
293
|
+
displayName: 'Replay',
|
|
294
|
+
});
|
|
295
|
+
const jwt = registered.body.data.accessToken as string;
|
|
296
|
+
const org = await request(app)
|
|
297
|
+
.post('/v1/organizations')
|
|
298
|
+
.set('authorization', `Bearer ${jwt}`)
|
|
299
|
+
.send({ name: 'Replay Co' });
|
|
300
|
+
const organizationId = org.body.data.id as string;
|
|
301
|
+
const payload = JSON.stringify({
|
|
302
|
+
id: 'evt_replay_1',
|
|
303
|
+
type: 'checkout.session.completed',
|
|
304
|
+
created: 1_800_000_000,
|
|
305
|
+
data: {
|
|
306
|
+
object: {
|
|
307
|
+
customer: 'cus_replay',
|
|
308
|
+
metadata: { organizationId, planCode: 'pro', seatCount: '3' },
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
const header = sign(secret, payload);
|
|
313
|
+
const first = await request(app)
|
|
314
|
+
.post('/v1/billing/webhook')
|
|
315
|
+
.set('content-type', 'application/json')
|
|
316
|
+
.set('stripe-signature', header)
|
|
317
|
+
.send(payload);
|
|
318
|
+
const second = await request(app)
|
|
319
|
+
.post('/v1/billing/webhook')
|
|
320
|
+
.set('content-type', 'application/json')
|
|
321
|
+
.set('stripe-signature', header)
|
|
322
|
+
.send(payload);
|
|
323
|
+
expect(first.status).toBe(200);
|
|
324
|
+
expect(second.status).toBe(200);
|
|
325
|
+
expect(second.body).toEqual({ ok: true, data: { received: true } });
|
|
326
|
+
const sub = await request(app)
|
|
327
|
+
.get(`/v1/billing/subscription?organizationId=${organizationId}`)
|
|
328
|
+
.set('authorization', `Bearer ${jwt}`);
|
|
329
|
+
expect(sub.body.data.seatCount).toBe(3);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('ignores an older Stripe event after a newer one for the same org', async () => {
|
|
333
|
+
const mem = createMemoryInput();
|
|
334
|
+
const secret = 'whsec_order';
|
|
335
|
+
const app = createApp({ ...mem.input, stripeWebhookSecret: secret });
|
|
336
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
337
|
+
email: 'order@ysk.hk',
|
|
338
|
+
password: 'password1',
|
|
339
|
+
displayName: 'Order',
|
|
340
|
+
});
|
|
341
|
+
const jwt = registered.body.data.accessToken as string;
|
|
342
|
+
const org = await request(app)
|
|
343
|
+
.post('/v1/organizations')
|
|
344
|
+
.set('authorization', `Bearer ${jwt}`)
|
|
345
|
+
.send({ name: 'Order Co' });
|
|
346
|
+
const organizationId = org.body.data.id as string;
|
|
347
|
+
const newer = JSON.stringify({
|
|
348
|
+
id: 'evt_new',
|
|
349
|
+
type: 'checkout.session.completed',
|
|
350
|
+
created: 1_800_000_100,
|
|
351
|
+
data: {
|
|
352
|
+
object: {
|
|
353
|
+
customer: 'cus_new',
|
|
354
|
+
metadata: { organizationId, planCode: 'pro', seatCount: '5' },
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
const older = JSON.stringify({
|
|
359
|
+
id: 'evt_old',
|
|
360
|
+
type: 'checkout.session.completed',
|
|
361
|
+
created: 1_800_000_001,
|
|
362
|
+
data: {
|
|
363
|
+
object: {
|
|
364
|
+
customer: 'cus_old',
|
|
365
|
+
metadata: { organizationId, planCode: 'pro', seatCount: '2' },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
const first = await request(app)
|
|
370
|
+
.post('/v1/billing/webhook')
|
|
371
|
+
.set('content-type', 'application/json')
|
|
372
|
+
.set('stripe-signature', sign(secret, newer))
|
|
373
|
+
.send(newer);
|
|
374
|
+
const second = await request(app)
|
|
375
|
+
.post('/v1/billing/webhook')
|
|
376
|
+
.set('content-type', 'application/json')
|
|
377
|
+
.set('stripe-signature', sign(secret, older))
|
|
378
|
+
.send(older);
|
|
379
|
+
expect(first.status).toBe(200);
|
|
380
|
+
expect(second.status).toBe(200);
|
|
381
|
+
const sub = await request(app)
|
|
382
|
+
.get(`/v1/billing/subscription?organizationId=${organizationId}`)
|
|
383
|
+
.set('authorization', `Bearer ${jwt}`);
|
|
384
|
+
expect(sub.body.data.seatCount).toBe(5);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('handles a signed Stripe webhook on Fastify', async () => {
|
|
388
|
+
const mem = createMemoryInput();
|
|
389
|
+
const secret = 'whsec_fastify';
|
|
390
|
+
const app = await createFastifyApp({ ...mem.input, stripeWebhookSecret: secret });
|
|
391
|
+
const registered = await app.inject({
|
|
392
|
+
method: 'POST',
|
|
393
|
+
url: '/v1/auth/register',
|
|
394
|
+
payload: { email: 'ff@ysk.hk', password: 'password1', displayName: 'Ff' },
|
|
395
|
+
});
|
|
396
|
+
const jwt = registered.json().data.accessToken as string;
|
|
397
|
+
const org = await app.inject({
|
|
398
|
+
method: 'POST',
|
|
399
|
+
url: '/v1/organizations',
|
|
400
|
+
headers: { authorization: `Bearer ${jwt}` },
|
|
401
|
+
payload: { name: 'Ff Co' },
|
|
402
|
+
});
|
|
403
|
+
const organizationId = org.json().data.id as string;
|
|
404
|
+
const payload = JSON.stringify({
|
|
405
|
+
id: 'evt_fastify',
|
|
406
|
+
type: 'checkout.session.completed',
|
|
407
|
+
created: 1_800_000_200,
|
|
408
|
+
data: {
|
|
409
|
+
object: {
|
|
410
|
+
customer: 'cus_ff',
|
|
411
|
+
metadata: { organizationId, planCode: 'pro', seatCount: '2' },
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
const unsigned = await app.inject({
|
|
416
|
+
method: 'POST',
|
|
417
|
+
url: '/v1/billing/webhook',
|
|
418
|
+
headers: { 'content-type': 'application/json' },
|
|
419
|
+
payload,
|
|
420
|
+
});
|
|
421
|
+
expect(unsigned.statusCode).toBe(401);
|
|
422
|
+
const signed = await app.inject({
|
|
423
|
+
method: 'POST',
|
|
424
|
+
url: '/v1/billing/webhook',
|
|
425
|
+
headers: {
|
|
426
|
+
'content-type': 'application/json',
|
|
427
|
+
'stripe-signature': sign(secret, payload),
|
|
428
|
+
},
|
|
429
|
+
payload,
|
|
430
|
+
});
|
|
431
|
+
expect(signed.statusCode).toBe(200);
|
|
432
|
+
expect(signed.json()).toEqual({ ok: true, data: { received: true } });
|
|
433
|
+
const replay = await app.inject({
|
|
434
|
+
method: 'POST',
|
|
435
|
+
url: '/v1/billing/webhook',
|
|
436
|
+
headers: {
|
|
437
|
+
'content-type': 'application/json',
|
|
438
|
+
'stripe-signature': sign(secret, payload),
|
|
439
|
+
},
|
|
440
|
+
payload,
|
|
441
|
+
});
|
|
442
|
+
expect(replay.statusCode).toBe(200);
|
|
443
|
+
await app.close();
|
|
444
|
+
});
|
|
273
445
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IProcessedWebhookRepository,
|
|
3
|
+
ProcessedWebhookRecord,
|
|
4
|
+
} from '../domain/webhook-event-repository';
|
|
5
|
+
|
|
6
|
+
const keyOf = (provider: string, eventId: string): string => `${provider}:${eventId}`;
|
|
7
|
+
|
|
8
|
+
export const createMemoryWebhookEventRepository = (): IProcessedWebhookRepository => {
|
|
9
|
+
const rows = new Map<string, ProcessedWebhookRecord>();
|
|
10
|
+
return {
|
|
11
|
+
async tryClaim(input) {
|
|
12
|
+
const key = keyOf(input.provider, input.eventId);
|
|
13
|
+
if (rows.has(key)) return 'duplicate';
|
|
14
|
+
rows.set(key, input);
|
|
15
|
+
return 'claimed';
|
|
16
|
+
},
|
|
17
|
+
async maxEventCreatedAt(provider, organizationId, exceptEventId) {
|
|
18
|
+
let latest: Date | null = null;
|
|
19
|
+
for (const row of rows.values()) {
|
|
20
|
+
if (row.provider !== provider) continue;
|
|
21
|
+
if (row.organizationId !== organizationId) continue;
|
|
22
|
+
if (row.eventId === exceptEventId) continue;
|
|
23
|
+
if (!latest || row.eventCreatedAt > latest) latest = row.eventCreatedAt;
|
|
24
|
+
}
|
|
25
|
+
return latest;
|
|
26
|
+
},
|
|
27
|
+
async release(provider, eventId) {
|
|
28
|
+
rows.delete(keyOf(provider, eventId));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PrismaClient } from '../../../generated/prisma/client';
|
|
2
|
+
import type { IProcessedWebhookRepository } from '../domain/webhook-event-repository';
|
|
3
|
+
|
|
4
|
+
export const createPrismaWebhookEventRepository = (
|
|
5
|
+
prisma: PrismaClient,
|
|
6
|
+
): IProcessedWebhookRepository => ({
|
|
7
|
+
async tryClaim(input) {
|
|
8
|
+
try {
|
|
9
|
+
await prisma.processedWebhookEvent.create({
|
|
10
|
+
data: {
|
|
11
|
+
provider: input.provider,
|
|
12
|
+
eventId: input.eventId,
|
|
13
|
+
eventType: input.eventType,
|
|
14
|
+
eventCreatedAt: input.eventCreatedAt,
|
|
15
|
+
...(input.organizationId ? { organizationId: input.organizationId } : {}),
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
return 'claimed';
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const code =
|
|
21
|
+
error && typeof error === 'object' && 'code' in error
|
|
22
|
+
? String((error as { code?: unknown }).code)
|
|
23
|
+
: '';
|
|
24
|
+
if (code === 'P2002') return 'duplicate';
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
async maxEventCreatedAt(provider, organizationId, exceptEventId) {
|
|
29
|
+
const row = await prisma.processedWebhookEvent.findFirst({
|
|
30
|
+
where: { provider, organizationId, eventId: { not: exceptEventId } },
|
|
31
|
+
orderBy: { eventCreatedAt: 'desc' },
|
|
32
|
+
select: { eventCreatedAt: true },
|
|
33
|
+
});
|
|
34
|
+
return row?.eventCreatedAt ?? null;
|
|
35
|
+
},
|
|
36
|
+
async release(provider, eventId) {
|
|
37
|
+
await prisma.processedWebhookEvent.deleteMany({ where: { provider, eventId } });
|
|
38
|
+
},
|
|
39
|
+
});
|
package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts
CHANGED
|
@@ -110,7 +110,9 @@ describe('stripe billing', () => {
|
|
|
110
110
|
const secret = 'whsec_test';
|
|
111
111
|
const payload = Buffer.from(
|
|
112
112
|
JSON.stringify({
|
|
113
|
+
id: 'evt_1',
|
|
113
114
|
type: 'checkout.session.completed',
|
|
115
|
+
created: 1_700_000_000,
|
|
114
116
|
data: { object: { metadata: { organizationId: 'o1', planCode: 'pro', seatCount: '2' } } },
|
|
115
117
|
}),
|
|
116
118
|
);
|
|
@@ -120,6 +122,7 @@ describe('stripe billing', () => {
|
|
|
120
122
|
.digest('hex');
|
|
121
123
|
const event = verifyStripeSignature(payload, `t=${t},v1=${v1}`, secret, 1_700_000_000_000);
|
|
122
124
|
expect(event.type).toBe('checkout.session.completed');
|
|
125
|
+
expect(event.id).toBe('evt_1');
|
|
123
126
|
expect(() =>
|
|
124
127
|
verifyStripeSignature(payload, `t=${t},v1=dead`, secret, 1_700_000_000_000),
|
|
125
128
|
).toThrow();
|
|
@@ -2,16 +2,16 @@ import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
|
2
2
|
import type { InvoiceDto } from '@ysk-kit/contracts';
|
|
3
3
|
import { AppError } from '@ysk-kit/domain-kernel';
|
|
4
4
|
import type { IBillingPort } from '../domain/billing-port';
|
|
5
|
+
import type { StripeWebhookEvent } from '../domain/stripe-webhook-event';
|
|
6
|
+
|
|
7
|
+
export type { StripeWebhookEvent };
|
|
5
8
|
|
|
6
9
|
export const verifyStripeSignature = (
|
|
7
10
|
raw: Buffer,
|
|
8
11
|
header: string,
|
|
9
12
|
secret: string,
|
|
10
13
|
nowMs = Date.now(),
|
|
11
|
-
): {
|
|
12
|
-
type: string;
|
|
13
|
-
data: { object: { metadata?: Record<string, string>; customer?: string } };
|
|
14
|
-
} => {
|
|
14
|
+
): StripeWebhookEvent => {
|
|
15
15
|
const parts = Object.fromEntries(
|
|
16
16
|
header.split(',').map((item) => {
|
|
17
17
|
const [key, ...rest] = item.split('=');
|
|
@@ -33,12 +33,39 @@ export const verifyStripeSignature = (
|
|
|
33
33
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
34
34
|
throw new AppError('UNAUTHENTICATED', 'Invalid Stripe signature');
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
const parsed = JSON.parse(raw.toString('utf8')) as Partial<StripeWebhookEvent>;
|
|
37
|
+
if (!parsed.id || typeof parsed.id !== 'string' || typeof parsed.type !== 'string') {
|
|
38
|
+
throw new AppError('VALIDATION_FAILED', 'Stripe event id required');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
id: parsed.id,
|
|
42
|
+
type: parsed.type,
|
|
43
|
+
created: typeof parsed.created === 'number' ? parsed.created : 0,
|
|
44
|
+
data: {
|
|
45
|
+
object: {
|
|
46
|
+
...(parsed.data?.object?.metadata ? { metadata: parsed.data.object.metadata } : {}),
|
|
47
|
+
...(typeof parsed.data?.object?.customer === 'string'
|
|
48
|
+
? { customer: parsed.data.object.customer }
|
|
49
|
+
: {}),
|
|
50
|
+
...(typeof parsed.data?.object?.payment_status === 'string'
|
|
51
|
+
? { payment_status: parsed.data.object.payment_status }
|
|
52
|
+
: {}),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
39
55
|
};
|
|
40
56
|
};
|
|
41
57
|
|
|
58
|
+
export const applyVerifiedStripeWebhook = async (
|
|
59
|
+
raw: Buffer,
|
|
60
|
+
header: string,
|
|
61
|
+
secret: string,
|
|
62
|
+
billing: { handleStripeEvent: (event: StripeWebhookEvent) => Promise<unknown> },
|
|
63
|
+
nowMs = Date.now(),
|
|
64
|
+
): Promise<void> => {
|
|
65
|
+
const event = verifyStripeSignature(raw, header, secret, nowMs);
|
|
66
|
+
await billing.handleStripeEvent(event);
|
|
67
|
+
};
|
|
68
|
+
|
|
42
69
|
export const createStripeBilling = (opts: {
|
|
43
70
|
secretKey: string;
|
|
44
71
|
pricePro: string;
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
OrganizationIdQuerySchema,
|
|
10
10
|
PortalCommandSchema,
|
|
11
11
|
SubscriptionDtoSchema,
|
|
12
|
-
} from '../dto/billing';
|
|
13
|
-
import { ErrSchema, OkSchema } from '../errors/envelope';
|
|
12
|
+
} from '../dto/billing.js';
|
|
13
|
+
import { ErrSchema, OkSchema } from '../errors/envelope.js';
|
|
14
14
|
|
|
15
15
|
const c = initContract();
|
|
16
16
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { BillingPlanCodeSchema, SubscriptionStatusSchema } from '../enums/billing-plan';
|
|
2
|
+
import { BillingPlanCodeSchema, SubscriptionStatusSchema } from '../enums/billing-plan.js';
|
|
3
3
|
|
|
4
4
|
export const BillingPlanDtoSchema = z.object({
|
|
5
5
|
code: BillingPlanCodeSchema,
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
PortalCommand,
|
|
8
8
|
SubscriptionDto,
|
|
9
9
|
} from '@ysk-kit/contracts';
|
|
10
|
-
import type { HttpClient } from '../http';
|
|
10
|
+
import type { HttpClient } from '../http.js';
|
|
11
11
|
|
|
12
12
|
const withOrg = (path: string, organizationId: string): string =>
|
|
13
13
|
`${path}?organizationId=${encodeURIComponent(organizationId)}`;
|