@ysk-kit/cli 1.0.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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/dist/add-capability.d.ts +4 -0
  3. package/dist/add-capability.d.ts.map +1 -0
  4. package/dist/add-capability.js +104 -0
  5. package/dist/add-capability.js.map +1 -0
  6. package/dist/add-module.d.ts +5 -0
  7. package/dist/add-module.d.ts.map +1 -0
  8. package/dist/add-module.js +210 -0
  9. package/dist/add-module.js.map +1 -0
  10. package/dist/agent-stubs.d.ts +3 -0
  11. package/dist/agent-stubs.d.ts.map +1 -0
  12. package/dist/agent-stubs.js +25 -0
  13. package/dist/agent-stubs.js.map +1 -0
  14. package/dist/apply-capability.d.ts +3 -0
  15. package/dist/apply-capability.d.ts.map +1 -0
  16. package/dist/apply-capability.js +189 -0
  17. package/dist/apply-capability.js.map +1 -0
  18. package/dist/capability-catalog.d.ts +25 -0
  19. package/dist/capability-catalog.d.ts.map +1 -0
  20. package/dist/capability-catalog.js +123 -0
  21. package/dist/capability-catalog.js.map +1 -0
  22. package/dist/capability-patches.d.ts +40 -0
  23. package/dist/capability-patches.d.ts.map +1 -0
  24. package/dist/capability-patches.js +616 -0
  25. package/dist/capability-patches.js.map +1 -0
  26. package/dist/check-agent.d.ts +9 -0
  27. package/dist/check-agent.d.ts.map +1 -0
  28. package/dist/check-agent.js +85 -0
  29. package/dist/check-agent.js.map +1 -0
  30. package/dist/generate-openapi.d.ts +2 -0
  31. package/dist/generate-openapi.d.ts.map +1 -0
  32. package/dist/generate-openapi.js +52 -0
  33. package/dist/generate-openapi.js.map +1 -0
  34. package/dist/help.d.ts +2 -0
  35. package/dist/help.d.ts.map +1 -0
  36. package/dist/help.js +54 -0
  37. package/dist/help.js.map +1 -0
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +59 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/merge-prisma.d.ts +15 -0
  43. package/dist/merge-prisma.d.ts.map +1 -0
  44. package/dist/merge-prisma.js +90 -0
  45. package/dist/merge-prisma.js.map +1 -0
  46. package/dist/patch-text.d.ts +17 -0
  47. package/dist/patch-text.d.ts.map +1 -0
  48. package/dist/patch-text.js +72 -0
  49. package/dist/patch-text.js.map +1 -0
  50. package/dist/upgrade.d.ts +16 -0
  51. package/dist/upgrade.d.ts.map +1 -0
  52. package/dist/upgrade.js +110 -0
  53. package/dist/upgrade.js.map +1 -0
  54. package/package.json +41 -0
  55. package/templates/agent/skills/add-capability/SKILL.md +8 -0
  56. package/templates/agent/skills/add-module/SKILL.md +8 -0
  57. package/templates/agent/skills/envelope-api/SKILL.md +8 -0
  58. package/templates/agent/skills/fix-layers/SKILL.md +8 -0
  59. package/templates/agent/skills/new-product/SKILL.md +8 -0
  60. package/templates/agent/skills/verify-change/SKILL.md +8 -0
  61. package/templates/agent/ysk-kit.mdc +6 -0
  62. package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +140 -0
  63. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/billing-port.ts +23 -0
  64. package/templates/capabilities/billing/apps/api/src/modules/billing/domain/subscription-repository.ts +22 -0
  65. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing-router.ts +85 -0
  66. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +273 -0
  67. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/log-billing.ts +31 -0
  68. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-subscription-repository.ts +27 -0
  69. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-subscription-repository.ts +52 -0
  70. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +127 -0
  71. package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +160 -0
  72. package/templates/capabilities/billing/apps/web/src/features/orgs/billing-page.tsx +183 -0
  73. package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +85 -0
  74. package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +59 -0
  75. package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +38 -0
  76. package/templates/capabilities/billing/packages/web-sdk/src/billing-hooks.ts +56 -0
  77. package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +48 -0
  78. package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +10 -0
  79. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm-router.ts +88 -0
  80. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +89 -0
  81. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +13 -0
  82. package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +17 -0
  83. package/templates/capabilities/llm/apps/web/src/features/llm/llm-page.tsx +53 -0
  84. package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +26 -0
  85. package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +33 -0
  86. package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +68 -0
  87. package/templates/capabilities/llm/packages/web-sdk/src/llm-hooks.ts +12 -0
  88. package/templates/capabilities/push/apps/api/src/modules/devices/application/device-service.ts +42 -0
  89. package/templates/capabilities/push/apps/api/src/modules/devices/domain/device-repository.ts +22 -0
  90. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device-router.ts +42 -0
  91. package/templates/capabilities/push/apps/api/src/modules/devices/infra/device.app.test.ts +34 -0
  92. package/templates/capabilities/push/apps/api/src/modules/devices/infra/memory-device-repository.ts +33 -0
  93. package/templates/capabilities/push/apps/api/src/modules/devices/infra/prisma-device-repository.ts +42 -0
  94. package/templates/capabilities/push/apps/mobile/src/adapters/push.ts +21 -0
  95. package/templates/capabilities/push/packages/contracts/src/api/devices.ts +33 -0
  96. package/templates/capabilities/push/packages/contracts/src/dto/device.ts +18 -0
  97. package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +10 -0
  98. package/templates/capabilities/team/apps/api/src/modules/organizations/application/organization-service.ts +272 -0
  99. package/templates/capabilities/team/apps/api/src/modules/organizations/domain/organization-repository.ts +58 -0
  100. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/memory-organization-repository.ts +141 -0
  101. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization-router.ts +130 -0
  102. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization.app.test.ts +213 -0
  103. package/templates/capabilities/team/apps/api/src/modules/organizations/infra/prisma-organization-repository.ts +142 -0
  104. package/templates/capabilities/team/apps/mobile/src/screens/invite-screen.tsx +40 -0
  105. package/templates/capabilities/team/apps/mobile/src/screens/org-detail-screen.tsx +154 -0
  106. package/templates/capabilities/team/apps/mobile/src/screens/orgs-screen.tsx +53 -0
  107. package/templates/capabilities/team/apps/web/src/features/orgs/invite-page.tsx +77 -0
  108. package/templates/capabilities/team/apps/web/src/features/orgs/org-detail-page.tsx +138 -0
  109. package/templates/capabilities/team/apps/web/src/features/orgs/orgs-page.tsx +73 -0
  110. package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +154 -0
  111. package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +58 -0
  112. package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +53 -0
  113. package/templates/capabilities/team/packages/web-sdk/src/organizations-hooks.ts +80 -0
  114. package/templates/module/api.ts.tmpl +32 -0
  115. package/templates/module/application-service.ts.tmpl +9 -0
  116. package/templates/module/domain-repository.ts.tmpl +15 -0
  117. package/templates/module/dto.ts.tmpl +21 -0
  118. package/templates/module/memory-repository.ts.tmpl +36 -0
  119. package/templates/module/model.prisma.tmpl +8 -0
  120. package/templates/module/prisma-repository.ts.tmpl +38 -0
  121. package/templates/module/router.ts.tmpl +32 -0
  122. package/templates/module/sdk-resource.ts.tmpl +17 -0
  123. package/templates/module/service.test.ts.tmpl +33 -0
  124. package/templates/module/web-hooks.ts.tmpl +24 -0
  125. package/templates/module/web-page.tsx.tmpl +69 -0
@@ -0,0 +1,17 @@
1
+ import type { PrismaClient } from '../../../generated/prisma/client';
2
+ import type { ILlmUsageRepository } from '../domain/usage-repository';
3
+
4
+ export const createPrismaLlmUsageRepository = (prisma: PrismaClient): ILlmUsageRepository => ({
5
+ async create(input) {
6
+ await prisma.llmUsage.create({
7
+ data: {
8
+ userId: input.userId,
9
+ model: input.model,
10
+ promptTokens: input.usage.promptTokens,
11
+ completionTokens: input.usage.completionTokens,
12
+ totalTokens: input.usage.totalTokens,
13
+ ...(input.requestId ? { requestId: input.requestId } : {}),
14
+ },
15
+ });
16
+ },
17
+ });
@@ -0,0 +1,53 @@
1
+ import { Button, Input } from '@ysk-kit/ui';
2
+ import { createLlmHooks } from '@ysk-kit/web-sdk';
3
+ import { type FormEvent, useState } from 'react';
4
+ import { api } from '../../lib/client';
5
+
6
+ const llmHooks = createLlmHooks(api);
7
+
8
+ export function LlmPage() {
9
+ const complete = llmHooks.useLlmComplete();
10
+ const [prompt, setPrompt] = useState('');
11
+ const [formError, setFormError] = useState<string | null>(null);
12
+
13
+ const onSubmit = (event: FormEvent) => {
14
+ event.preventDefault();
15
+ if (prompt.trim().length === 0) return;
16
+ complete.mutate(
17
+ { messages: [{ role: 'user', content: prompt.trim() }] },
18
+ { onError: (error) => setFormError(error.message) },
19
+ );
20
+ };
21
+
22
+ return (
23
+ <section className="space-y-4">
24
+ <h1 className="text-2xl font-semibold">LLM</h1>
25
+ <p className="text-sm text-zinc-600">
26
+ One-shot complete via SpaceXAI-compatible /v1/llm/complete.
27
+ </p>
28
+ <form onSubmit={onSubmit} className="grid gap-3">
29
+ <label className="grid gap-1 text-sm" htmlFor="llm-prompt">
30
+ Prompt
31
+ <Input
32
+ id="llm-prompt"
33
+ value={prompt}
34
+ onChange={(e) => setPrompt(e.target.value)}
35
+ required
36
+ />
37
+ </label>
38
+ <Button type="submit" disabled={complete.isPending}>
39
+ Send
40
+ </Button>
41
+ </form>
42
+ {formError ? <p className="text-sm text-red-600">{formError}</p> : null}
43
+ {complete.data ? (
44
+ <div className="space-y-2 rounded-md border border-zinc-200 bg-white p-3 text-sm">
45
+ <p className="whitespace-pre-wrap">{complete.data.text}</p>
46
+ <p className="text-zinc-500">
47
+ {complete.data.model} · {complete.data.usage.totalTokens} tokens
48
+ </p>
49
+ </div>
50
+ ) : null}
51
+ </section>
52
+ );
53
+ }
@@ -0,0 +1,26 @@
1
+ import { initContract } from '@ts-rest/core';
2
+ import { LlmCompleteCommandSchema, LlmCompleteDtoSchema, LlmModelsDtoSchema } from '../dto/llm';
3
+ import { ErrSchema, OkSchema } from '../errors/envelope';
4
+
5
+ const c = initContract();
6
+
7
+ export const llmContract = c.router({
8
+ complete: {
9
+ method: 'POST',
10
+ path: '/v1/llm/complete',
11
+ body: LlmCompleteCommandSchema,
12
+ responses: {
13
+ 200: OkSchema(LlmCompleteDtoSchema),
14
+ 401: ErrSchema,
15
+ 403: ErrSchema,
16
+ 503: ErrSchema,
17
+ },
18
+ summary: 'One-shot chat completion',
19
+ },
20
+ models: {
21
+ method: 'GET',
22
+ path: '/v1/llm/models',
23
+ responses: { 200: OkSchema(LlmModelsDtoSchema), 401: ErrSchema, 403: ErrSchema },
24
+ summary: 'Configured models',
25
+ },
26
+ });
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { LlmRoleSchema } from '../enums/llm-role';
3
+
4
+ export const LlmMessageSchema = z.object({
5
+ role: LlmRoleSchema,
6
+ content: z.string().min(1).max(32_000),
7
+ });
8
+ export type LlmMessage = z.infer<typeof LlmMessageSchema>;
9
+
10
+ export const LlmCompleteCommandSchema = z.object({
11
+ messages: z.array(LlmMessageSchema).min(1).max(50),
12
+ temperature: z.number().min(0).max(2).optional(),
13
+ });
14
+ export type LlmCompleteCommand = z.infer<typeof LlmCompleteCommandSchema>;
15
+
16
+ export const LlmUsageSchema = z.object({
17
+ promptTokens: z.number().int().nonnegative(),
18
+ completionTokens: z.number().int().nonnegative(),
19
+ totalTokens: z.number().int().nonnegative(),
20
+ });
21
+ export type LlmUsage = z.infer<typeof LlmUsageSchema>;
22
+
23
+ export const LlmCompleteDtoSchema = z.object({
24
+ text: z.string(),
25
+ model: z.string(),
26
+ usage: LlmUsageSchema,
27
+ });
28
+ export type LlmCompleteDto = z.infer<typeof LlmCompleteDtoSchema>;
29
+
30
+ export const LlmModelsDtoSchema = z.object({
31
+ items: z.array(z.object({ id: z.string() })),
32
+ });
33
+ export type LlmModelsDto = z.infer<typeof LlmModelsDtoSchema>;
@@ -0,0 +1,68 @@
1
+ import type {
2
+ LlmCompleteCommand,
3
+ LlmCompleteDto,
4
+ LlmModelsDto,
5
+ LlmUsage,
6
+ Platform,
7
+ } from '@ysk-kit/contracts';
8
+ import type { HttpClient } from '../http';
9
+ import type { TokenStore } from '../token-store';
10
+
11
+ export const llmResource = (
12
+ http: HttpClient,
13
+ ctx: { baseUrl: string; platform: Platform; tokenStore: TokenStore; fetchImpl?: typeof fetch },
14
+ ) => ({
15
+ complete: (body: LlmCompleteCommand) =>
16
+ http.request<LlmCompleteDto>('/v1/llm/complete', {
17
+ method: 'POST',
18
+ body: JSON.stringify(body),
19
+ }),
20
+ models: () => http.request<LlmModelsDto>('/v1/llm/models'),
21
+ stream: async (
22
+ body: LlmCompleteCommand,
23
+ handlers: {
24
+ onDelta: (delta: string) => void;
25
+ onDone: (usage?: LlmUsage) => void;
26
+ onError?: (message: string) => void;
27
+ },
28
+ ) => {
29
+ const fetchImpl = ctx.fetchImpl ?? fetch;
30
+ const token = await ctx.tokenStore.get();
31
+ const headers = new Headers({
32
+ 'content-type': 'application/json',
33
+ 'x-ysk-platform': ctx.platform,
34
+ });
35
+ if (token) headers.set('authorization', `Bearer ${token}`);
36
+ const res = await fetchImpl(`${ctx.baseUrl}/v1/llm/stream`, {
37
+ method: 'POST',
38
+ headers,
39
+ body: JSON.stringify(body),
40
+ });
41
+ if (!res.ok || !res.body) {
42
+ handlers.onError?.(`HTTP ${res.status}`);
43
+ return;
44
+ }
45
+ const reader = res.body.getReader();
46
+ const decoder = new TextDecoder();
47
+ let buffer = '';
48
+ while (true) {
49
+ const { value, done } = await reader.read();
50
+ if (done) break;
51
+ buffer += decoder.decode(value, { stream: true });
52
+ const parts = buffer.split('\n\n');
53
+ buffer = parts.pop() ?? '';
54
+ for (const part of parts) {
55
+ const eventLine = part.split('\n').find((line) => line.startsWith('event:'));
56
+ const dataLine = part.split('\n').find((line) => line.startsWith('data:'));
57
+ if (!dataLine) continue;
58
+ const json = JSON.parse(dataLine.slice(5).trim()) as {
59
+ delta?: string;
60
+ done?: boolean;
61
+ usage?: LlmUsage;
62
+ };
63
+ if (eventLine?.includes('done') || json.done) handlers.onDone(json.usage);
64
+ else if (json.delta) handlers.onDelta(json.delta);
65
+ }
66
+ }
67
+ },
68
+ });
@@ -0,0 +1,12 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import type { LlmCompleteCommand } from '@ysk-kit/contracts';
3
+ import type { YskClient } from '@ysk-kit/sdk';
4
+
5
+ export function createLlmHooks(client: YskClient) {
6
+ return {
7
+ useLlmComplete: () =>
8
+ useMutation({
9
+ mutationFn: (body: LlmCompleteCommand) => client.llm.complete(body),
10
+ }),
11
+ };
12
+ }
@@ -0,0 +1,42 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { DeviceDto, RegisterDeviceCommand } from '@ysk-kit/contracts';
3
+ import { AppError } from '@ysk-kit/domain-kernel';
4
+ import type { IDeviceRepository } from '../domain/device-repository';
5
+
6
+ export const hashDeviceToken = (token: string): string =>
7
+ createHash('sha256').update(token).digest('hex');
8
+
9
+ const toDto = (row: {
10
+ id: string;
11
+ platform: DeviceDto['platform'];
12
+ token: string;
13
+ createdAt: Date;
14
+ }): DeviceDto => ({
15
+ id: row.id,
16
+ platform: row.platform,
17
+ tokenSuffix: row.token.slice(-8),
18
+ createdAt: row.createdAt.toISOString(),
19
+ });
20
+
21
+ export const createDeviceService = (devices: IDeviceRepository) => ({
22
+ list: async (userId: string): Promise<DeviceDto[]> => {
23
+ const rows = await devices.listForUser(userId);
24
+ return rows.map(toDto);
25
+ },
26
+ register: async (userId: string, body: RegisterDeviceCommand): Promise<DeviceDto> => {
27
+ const row = await devices.upsert({
28
+ userId,
29
+ platform: body.platform,
30
+ tokenHash: hashDeviceToken(body.token),
31
+ token: body.token,
32
+ });
33
+ return toDto(row);
34
+ },
35
+ remove: async (id: string, userId: string) => {
36
+ const ok = await devices.deleteOwn(id, userId);
37
+ if (!ok) throw new AppError('NOT_FOUND');
38
+ return { removed: true as const };
39
+ },
40
+ });
41
+
42
+ export type DeviceService = ReturnType<typeof createDeviceService>;
@@ -0,0 +1,22 @@
1
+ import type { DevicePlatform } from '@ysk-kit/contracts';
2
+
3
+ export type DeviceRecord = {
4
+ id: string;
5
+ userId: string;
6
+ platform: DevicePlatform;
7
+ tokenHash: string;
8
+ token: string;
9
+ createdAt: Date;
10
+ };
11
+
12
+ export interface IDeviceRepository {
13
+ upsert(input: {
14
+ userId: string;
15
+ platform: DevicePlatform;
16
+ tokenHash: string;
17
+ token: string;
18
+ }): Promise<DeviceRecord>;
19
+ listForUser(userId: string): Promise<DeviceRecord[]>;
20
+ deleteOwn(id: string, userId: string): Promise<boolean>;
21
+ deleteByTokenHash(tokenHash: string): Promise<void>;
22
+ }
@@ -0,0 +1,42 @@
1
+ import { type HttpHandler, mountContract } from '@ysk-kit/api-express';
2
+ import { appContract, type RegisterDeviceCommand } from '@ysk-kit/contracts';
3
+ import { AppError } from '@ysk-kit/domain-kernel';
4
+ import type { Express } from 'express';
5
+ import type { DeviceService } from '../application/device-service';
6
+
7
+ export const deviceHandlers = (service: DeviceService): Record<string, HttpHandler> => ({
8
+ list: {
9
+ auth: 'required',
10
+ handle: async ({ auth }) => {
11
+ if (!auth) throw new AppError('UNAUTHENTICATED');
12
+ return { status: 200, body: { ok: true, data: await service.list(auth.sub) } };
13
+ },
14
+ },
15
+ register: {
16
+ auth: 'required',
17
+ handle: async ({ body, auth }) => {
18
+ if (!auth) throw new AppError('UNAUTHENTICATED');
19
+ return {
20
+ status: 200,
21
+ body: { ok: true, data: await service.register(auth.sub, body as RegisterDeviceCommand) },
22
+ };
23
+ },
24
+ },
25
+ remove: {
26
+ auth: 'required',
27
+ handle: async ({ params, auth }) => {
28
+ if (!auth) throw new AppError('UNAUTHENTICATED');
29
+ return {
30
+ status: 200,
31
+ body: {
32
+ ok: true,
33
+ data: await service.remove((params as { id: string }).id, auth.sub),
34
+ },
35
+ };
36
+ },
37
+ },
38
+ });
39
+
40
+ export const registerDeviceRoutes = (app: Express, service: DeviceService): void => {
41
+ mountContract(app, appContract.devices, deviceHandlers(service));
42
+ };
@@ -0,0 +1,34 @@
1
+ import request from 'supertest';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createApp } from '../../../app';
4
+ import { createMemoryInput } from '../../../create-memory-input';
5
+
6
+ describe('devices http', () => {
7
+ it('registers a device without exposing the full token and pushes on notify', async () => {
8
+ const mem = createMemoryInput();
9
+ const app = createApp(mem.input);
10
+ const registered = await request(app).post('/v1/auth/register').send({
11
+ email: 'dev@ysk.hk',
12
+ password: 'password1',
13
+ displayName: 'Ki',
14
+ });
15
+ const token = registered.body.data.accessToken as string;
16
+ const created = await request(app)
17
+ .put('/v1/me/devices')
18
+ .set('authorization', `Bearer ${token}`)
19
+ .send({ token: 'ExponentPushToken[abcdefghijklmnop]', platform: 'ios' });
20
+ expect(created.status).toBe(200);
21
+ expect(created.body.data.tokenSuffix).toHaveLength(8);
22
+ expect(JSON.stringify(created.body)).not.toContain('ExponentPushToken');
23
+ const list = await request(app).get('/v1/me/devices').set('authorization', `Bearer ${token}`);
24
+ expect(list.body.data).toHaveLength(1);
25
+ await request(app).post('/v1/auth/forgot').send({ email: 'dev@ysk.hk' });
26
+ expect(mem.push.sink.some((row) => row.title === '重設密碼')).toBe(true);
27
+ });
28
+
29
+ it('rejects devices without a token', async () => {
30
+ const app = createApp(createMemoryInput().input);
31
+ const res = await request(app).get('/v1/me/devices');
32
+ expect(res.status).toBe(401);
33
+ });
34
+ });
@@ -0,0 +1,33 @@
1
+ import type { DeviceRecord, IDeviceRepository } from '../domain/device-repository';
2
+
3
+ export const createMemoryDeviceRepository = (): IDeviceRepository => {
4
+ const rows: DeviceRecord[] = [];
5
+ return {
6
+ async upsert(input) {
7
+ const existing = rows.find(
8
+ (row) => row.userId === input.userId && row.tokenHash === input.tokenHash,
9
+ );
10
+ if (existing) {
11
+ existing.token = input.token;
12
+ existing.platform = input.platform;
13
+ return existing;
14
+ }
15
+ const row: DeviceRecord = { id: crypto.randomUUID(), createdAt: new Date(), ...input };
16
+ rows.push(row);
17
+ return row;
18
+ },
19
+ async listForUser(userId) {
20
+ return rows.filter((row) => row.userId === userId);
21
+ },
22
+ async deleteOwn(id, userId) {
23
+ const index = rows.findIndex((row) => row.id === id && row.userId === userId);
24
+ if (index < 0) return false;
25
+ rows.splice(index, 1);
26
+ return true;
27
+ },
28
+ async deleteByTokenHash(tokenHash) {
29
+ const index = rows.findIndex((row) => row.tokenHash === tokenHash);
30
+ if (index >= 0) rows.splice(index, 1);
31
+ },
32
+ };
33
+ };
@@ -0,0 +1,42 @@
1
+ import type { DevicePlatform } from '@ysk-kit/contracts';
2
+ import type { PrismaClient } from '../../../generated/prisma/client';
3
+ import type { DeviceRecord, IDeviceRepository } from '../domain/device-repository';
4
+
5
+ const toRecord = (row: {
6
+ id: string;
7
+ userId: string;
8
+ platform: string;
9
+ tokenHash: string;
10
+ token: string;
11
+ createdAt: Date;
12
+ }): DeviceRecord => ({
13
+ ...row,
14
+ platform: row.platform as DevicePlatform,
15
+ });
16
+
17
+ export const createPrismaDeviceRepository = (prisma: PrismaClient): IDeviceRepository => ({
18
+ async upsert(input) {
19
+ const row = await prisma.device.upsert({
20
+ where: { userId_tokenHash: { userId: input.userId, tokenHash: input.tokenHash } },
21
+ create: input,
22
+ update: { token: input.token, platform: input.platform },
23
+ });
24
+ return toRecord(row);
25
+ },
26
+ async listForUser(userId) {
27
+ const rows = await prisma.device.findMany({
28
+ where: { userId },
29
+ orderBy: { createdAt: 'desc' },
30
+ });
31
+ return rows.map(toRecord);
32
+ },
33
+ async deleteOwn(id, userId) {
34
+ const row = await prisma.device.findFirst({ where: { id, userId } });
35
+ if (!row) return false;
36
+ await prisma.device.delete({ where: { id } });
37
+ return true;
38
+ },
39
+ async deleteByTokenHash(tokenHash) {
40
+ await prisma.device.deleteMany({ where: { tokenHash } });
41
+ },
42
+ });
@@ -0,0 +1,21 @@
1
+ import type { DevicePort, YskClient } from '@ysk-kit/sdk';
2
+
3
+ export const createDevicePort = (opts: {
4
+ api: YskClient;
5
+ platform: 'ios' | 'android';
6
+ }): DevicePort => ({
7
+ platform: opts.platform,
8
+ registerPushToken: async (token) => {
9
+ await opts.api.devices.register({ token, platform: opts.platform });
10
+ },
11
+ });
12
+
13
+ export const enablePush = async (
14
+ port: DevicePort,
15
+ getToken: () => Promise<string | null>,
16
+ ): Promise<boolean> => {
17
+ const token = await getToken();
18
+ if (!token) return false;
19
+ await port.registerPushToken(token);
20
+ return true;
21
+ };
@@ -0,0 +1,33 @@
1
+ import { initContract } from '@ts-rest/core';
2
+ import { z } from 'zod';
3
+ import { DeviceDtoSchema, RegisterDeviceCommandSchema } from '../dto/device';
4
+ import { ErrSchema, OkSchema } from '../errors/envelope';
5
+
6
+ const c = initContract();
7
+
8
+ export const devicesContract = c.router({
9
+ list: {
10
+ method: 'GET',
11
+ path: '/v1/me/devices',
12
+ responses: { 200: OkSchema(z.array(DeviceDtoSchema)), 401: ErrSchema },
13
+ summary: 'List my devices',
14
+ },
15
+ register: {
16
+ method: 'PUT',
17
+ path: '/v1/me/devices',
18
+ body: RegisterDeviceCommandSchema,
19
+ responses: { 200: OkSchema(DeviceDtoSchema), 401: ErrSchema, 422: ErrSchema },
20
+ summary: 'Register or refresh a push token',
21
+ },
22
+ remove: {
23
+ method: 'DELETE',
24
+ path: '/v1/me/devices/:id',
25
+ pathParams: z.object({ id: z.string().uuid() }),
26
+ responses: {
27
+ 200: OkSchema(z.object({ removed: z.literal(true) })),
28
+ 401: ErrSchema,
29
+ 404: ErrSchema,
30
+ },
31
+ summary: 'Remove a device',
32
+ },
33
+ });
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+
3
+ export const DevicePlatformSchema = z.enum(['ios', 'android']);
4
+ export type DevicePlatform = z.infer<typeof DevicePlatformSchema>;
5
+
6
+ export const RegisterDeviceCommandSchema = z.object({
7
+ token: z.string().min(8).max(4096),
8
+ platform: DevicePlatformSchema,
9
+ });
10
+ export type RegisterDeviceCommand = z.infer<typeof RegisterDeviceCommandSchema>;
11
+
12
+ export const DeviceDtoSchema = z.object({
13
+ id: z.string().uuid(),
14
+ platform: DevicePlatformSchema,
15
+ tokenSuffix: z.string().min(1).max(8),
16
+ createdAt: z.iso.datetime(),
17
+ });
18
+ export type DeviceDto = z.infer<typeof DeviceDtoSchema>;
@@ -0,0 +1,10 @@
1
+ import type { DeviceDto, RegisterDeviceCommand } from '@ysk-kit/contracts';
2
+ import type { HttpClient } from '../http';
3
+
4
+ export const devicesResource = (http: HttpClient) => ({
5
+ list: () => http.request<DeviceDto[]>('/v1/me/devices'),
6
+ register: (body: RegisterDeviceCommand) =>
7
+ http.request<DeviceDto>('/v1/me/devices', { method: 'PUT', body: JSON.stringify(body) }),
8
+ remove: (id: string) =>
9
+ http.request<{ removed: true }>(`/v1/me/devices/${id}`, { method: 'DELETE' }),
10
+ });