@wopr-network/platform-core 1.70.0 → 1.71.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.
@@ -18,6 +18,7 @@ import type { FleetManager } from "../fleet/fleet-manager.js";
18
18
  import type { IProfileStore } from "../fleet/profile-store.js";
19
19
  import type { IServiceKeyRepository } from "../gateway/service-key-repository.js";
20
20
  import type { ProductConfig } from "../product-config/repository-types.js";
21
+ import type { ProductConfigService } from "../product-config/service.js";
21
22
  import type { ProxyManagerInterface } from "../proxy/types.js";
22
23
  import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
23
24
  import type { OrgService } from "../tenancy/org-service.js";
@@ -68,6 +69,7 @@ export interface PlatformContainer {
68
69
  db: DrizzleDb;
69
70
  pool: Pool;
70
71
  productConfig: ProductConfig;
72
+ productConfigService: ProductConfigService;
71
73
  creditLedger: ILedger;
72
74
  orgMemberRepo: IOrgMemberRepository;
73
75
  orgService: OrgService;
@@ -36,7 +36,7 @@ export async function buildContainer(bootConfig) {
36
36
  await migrate(db, { migrationsFolder });
37
37
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
38
38
  const { platformBoot } = await import("../product-config/boot.js");
39
- const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
39
+ const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
40
40
  // 5. Credit ledger
41
41
  const { DrizzleLedger } = await import("../credits/ledger.js");
42
42
  const creditLedger = new DrizzleLedger(db);
@@ -120,6 +120,7 @@ export async function buildContainer(bootConfig) {
120
120
  db,
121
121
  pool,
122
122
  productConfig,
123
+ productConfigService,
123
124
  creditLedger,
124
125
  orgMemberRepo,
125
126
  orgService,
@@ -7,6 +7,7 @@
7
7
  * const c = createTestContainer();
8
8
  * const c2 = createTestContainer({ creditLedger: myCustomLedger });
9
9
  */
10
+ import { ProductConfigService } from "../product-config/service.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Stub factories (satisfy interface contracts with no-op implementations)
12
13
  // ---------------------------------------------------------------------------
@@ -76,6 +77,18 @@ function stubProductConfig() {
76
77
  billing: null,
77
78
  };
78
79
  }
80
+ function stubProductConfigService() {
81
+ const stubRepo = {
82
+ getBySlug: async () => stubProductConfig(),
83
+ listAll: async () => [],
84
+ upsertProduct: async () => stubProductConfig().product,
85
+ replaceNavItems: async () => { },
86
+ upsertFeatures: async () => { },
87
+ upsertFleetConfig: async () => { },
88
+ upsertBillingConfig: async () => { },
89
+ };
90
+ return new ProductConfigService(stubRepo);
91
+ }
79
92
  // ---------------------------------------------------------------------------
80
93
  // Public API
81
94
  // ---------------------------------------------------------------------------
@@ -88,6 +101,7 @@ export function createTestContainer(overrides) {
88
101
  db: {},
89
102
  pool: { end: async () => { } },
90
103
  productConfig: stubProductConfig(),
104
+ productConfigService: stubProductConfigService(),
91
105
  creditLedger: stubLedger(),
92
106
  orgMemberRepo: stubOrgMemberRepo(),
93
107
  orgService: {},
@@ -3,7 +3,10 @@ export { createAssertOrgAdminOrOwner } from "./auth-helpers.js";
3
3
  export { authSocialRouter } from "./auth-social-router.js";
4
4
  export { createAdminFleetUpdateRouterFromContainer, createFleetUpdateConfigRouterFromContainer, createNotificationTemplateRouterFromContainer, createOrgRemovePaymentMethodRouterFromContainer, createProductConfigRouterFromContainer, initTrpcFromContainer, } from "./container-factories.js";
5
5
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
6
- export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
6
+ export { adminProcedure, createCallerFactory, createTRPCContext, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
7
7
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
8
8
  export { createOrgRemovePaymentMethodRouter, type OrgRemovePaymentMethodDeps, } from "./org-remove-payment-method-router.js";
9
9
  export { createProductConfigRouter } from "./product-config-router.js";
10
+ export { type PageContextRouterDeps, pageContextRouter, setPageContextRouterDeps } from "./routers/page-context.js";
11
+ export { type ProfileRouterDeps, profileRouter, setProfileRouterDeps } from "./routers/profile.js";
12
+ export { type SettingsRouterDeps, setSettingsRouterDeps, settingsRouter } from "./routers/settings.js";
@@ -3,7 +3,10 @@ export { createAssertOrgAdminOrOwner } from "./auth-helpers.js";
3
3
  export { authSocialRouter } from "./auth-social-router.js";
4
4
  export { createAdminFleetUpdateRouterFromContainer, createFleetUpdateConfigRouterFromContainer, createNotificationTemplateRouterFromContainer, createOrgRemovePaymentMethodRouterFromContainer, createProductConfigRouterFromContainer, initTrpcFromContainer, } from "./container-factories.js";
5
5
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
6
- export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
6
+ export { adminProcedure, createCallerFactory, createTRPCContext, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
7
7
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
8
8
  export { createOrgRemovePaymentMethodRouter, } from "./org-remove-payment-method-router.js";
9
9
  export { createProductConfigRouter } from "./product-config-router.js";
10
+ export { pageContextRouter, setPageContextRouterDeps } from "./routers/page-context.js";
11
+ export { profileRouter, setProfileRouterDeps } from "./routers/profile.js";
12
+ export { setSettingsRouterDeps, settingsRouter } from "./routers/settings.js";
@@ -12,6 +12,11 @@ export interface TRPCContext {
12
12
  /** Tenant ID associated with the bearer token, if any. */
13
13
  tenantId: string | undefined;
14
14
  }
15
+ /**
16
+ * Create a TRPCContext from an incoming request.
17
+ * Resolves the user from BetterAuth session cookies.
18
+ */
19
+ export declare function createTRPCContext(req: Request): Promise<TRPCContext>;
15
20
  /** Wire the org member repository for tRPC tenant validation. Called from services.ts on startup. */
16
21
  export declare function setTrpcOrgMemberRepo(repo: IOrgMemberRepository): void;
17
22
  export declare const router: import("@trpc/server").TRPCRouterBuilder<{
package/dist/trpc/init.js CHANGED
@@ -8,6 +8,34 @@ import { initTRPC, TRPCError } from "@trpc/server";
8
8
  import { z } from "zod";
9
9
  import { validateTenantAccess } from "../auth/index.js";
10
10
  // ---------------------------------------------------------------------------
11
+ // Context factory — resolves BetterAuth session into TRPCContext
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Create a TRPCContext from an incoming request.
15
+ * Resolves the user from BetterAuth session cookies.
16
+ */
17
+ export async function createTRPCContext(req) {
18
+ let user;
19
+ let tenantId;
20
+ try {
21
+ const { getAuth } = await import("../auth/better-auth.js");
22
+ const auth = getAuth();
23
+ const session = await auth.api.getSession({ headers: req.headers });
24
+ if (session?.user) {
25
+ const sessionUser = session.user;
26
+ const roles = [];
27
+ if (sessionUser.role)
28
+ roles.push(sessionUser.role);
29
+ user = { id: sessionUser.id, roles };
30
+ tenantId = req.headers.get("x-tenant-id") || sessionUser.id;
31
+ }
32
+ }
33
+ catch {
34
+ // No session — unauthenticated request
35
+ }
36
+ return { user, tenantId: tenantId ?? "" };
37
+ }
38
+ // ---------------------------------------------------------------------------
11
39
  // tRPC init
12
40
  // ---------------------------------------------------------------------------
13
41
  const t = initTRPC.context().create();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * tRPC page-context router — stores and retrieves per-user page context.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+ import type { IPageContextRepository } from "../../fleet/page-context-repository.js";
7
+ export interface PageContextRouterDeps {
8
+ repo: IPageContextRepository;
9
+ }
10
+ export declare function setPageContextRouterDeps(deps: PageContextRouterDeps): void;
11
+ export declare const pageContextRouter: import("@trpc/server").TRPCBuiltRouter<{
12
+ ctx: import("../init.js").TRPCContext;
13
+ meta: object;
14
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
15
+ transformer: false;
16
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
17
+ /** Update the page context for the current user. Called on route change. */
18
+ update: import("@trpc/server").TRPCMutationProcedure<{
19
+ input: {
20
+ currentPage: string;
21
+ pagePrompt: string | null;
22
+ };
23
+ output: {
24
+ ok: true;
25
+ };
26
+ meta: object;
27
+ }>;
28
+ /** Get the current page context for the authenticated user. */
29
+ current: import("@trpc/server").TRPCQueryProcedure<{
30
+ input: void;
31
+ output: {
32
+ currentPage: string;
33
+ pagePrompt: string | null;
34
+ } | null;
35
+ meta: object;
36
+ }>;
37
+ }>>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * tRPC page-context router — stores and retrieves per-user page context.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+ import { TRPCError } from "@trpc/server";
7
+ import { z } from "zod";
8
+ import { protectedProcedure, router } from "../init.js";
9
+ let _deps = null;
10
+ export function setPageContextRouterDeps(deps) {
11
+ _deps = deps;
12
+ }
13
+ function deps() {
14
+ if (!_deps)
15
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Page context not initialized" });
16
+ return _deps;
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Schema
20
+ // ---------------------------------------------------------------------------
21
+ const updatePageContextSchema = z.object({
22
+ currentPage: z.string().min(1).max(500),
23
+ pagePrompt: z.string().max(2000).nullable(),
24
+ });
25
+ // ---------------------------------------------------------------------------
26
+ // Router
27
+ // ---------------------------------------------------------------------------
28
+ export const pageContextRouter = router({
29
+ /** Update the page context for the current user. Called on route change. */
30
+ update: protectedProcedure.input(updatePageContextSchema).mutation(async ({ ctx, input }) => {
31
+ await deps().repo.set(ctx.user.id, input.currentPage, input.pagePrompt);
32
+ return { ok: true };
33
+ }),
34
+ /** Get the current page context for the authenticated user. */
35
+ current: protectedProcedure.query(async ({ ctx }) => {
36
+ const pc = await deps().repo.get(ctx.user.id);
37
+ if (!pc)
38
+ return null;
39
+ return { currentPage: pc.currentPage, pagePrompt: pc.pagePrompt };
40
+ }),
41
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * tRPC profile router — get/update user profile and change password.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+ export interface ProfileRouterDeps {
7
+ getUser: (userId: string) => Promise<{
8
+ id: string;
9
+ name: string;
10
+ email: string;
11
+ image: string | null;
12
+ twoFactorEnabled: boolean;
13
+ } | null>;
14
+ updateUser: (userId: string, data: {
15
+ name?: string;
16
+ image?: string | null;
17
+ }) => Promise<{
18
+ id: string;
19
+ name: string;
20
+ email: string;
21
+ image: string | null;
22
+ twoFactorEnabled: boolean;
23
+ }>;
24
+ changePassword: (userId: string, currentPassword: string, newPassword: string) => Promise<boolean>;
25
+ }
26
+ export declare function setProfileRouterDeps(deps: ProfileRouterDeps): void;
27
+ export declare const profileRouter: import("@trpc/server").TRPCBuiltRouter<{
28
+ ctx: import("../init.js").TRPCContext;
29
+ meta: object;
30
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
31
+ transformer: false;
32
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
33
+ /** Get the authenticated user's profile. */
34
+ getProfile: import("@trpc/server").TRPCQueryProcedure<{
35
+ input: void;
36
+ output: {
37
+ id: string;
38
+ name: string;
39
+ email: string;
40
+ image: string | null;
41
+ twoFactorEnabled: boolean;
42
+ };
43
+ meta: object;
44
+ }>;
45
+ /** Update the authenticated user's display name and/or avatar. */
46
+ updateProfile: import("@trpc/server").TRPCMutationProcedure<{
47
+ input: {
48
+ name?: string | undefined;
49
+ image?: string | null | undefined;
50
+ };
51
+ output: {
52
+ id: string;
53
+ name: string;
54
+ email: string;
55
+ image: string | null;
56
+ twoFactorEnabled: boolean;
57
+ };
58
+ meta: object;
59
+ }>;
60
+ /** Change the authenticated user's password. */
61
+ changePassword: import("@trpc/server").TRPCMutationProcedure<{
62
+ input: {
63
+ currentPassword: string;
64
+ newPassword: string;
65
+ };
66
+ output: {
67
+ ok: true;
68
+ };
69
+ meta: object;
70
+ }>;
71
+ }>>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * tRPC profile router — get/update user profile and change password.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+ import { TRPCError } from "@trpc/server";
7
+ import { z } from "zod";
8
+ import { protectedProcedure, router } from "../init.js";
9
+ let _deps = null;
10
+ export function setProfileRouterDeps(deps) {
11
+ _deps = deps;
12
+ }
13
+ function deps() {
14
+ if (!_deps)
15
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Profile router not initialized" });
16
+ return _deps;
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Router
20
+ // ---------------------------------------------------------------------------
21
+ export const profileRouter = router({
22
+ /** Get the authenticated user's profile. */
23
+ getProfile: protectedProcedure.query(async ({ ctx }) => {
24
+ const user = await deps().getUser(ctx.user.id);
25
+ if (!user) {
26
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
27
+ }
28
+ return {
29
+ id: user.id,
30
+ name: user.name,
31
+ email: user.email,
32
+ image: user.image,
33
+ twoFactorEnabled: user.twoFactorEnabled,
34
+ };
35
+ }),
36
+ /** Update the authenticated user's display name and/or avatar. */
37
+ updateProfile: protectedProcedure
38
+ .input(z.object({
39
+ name: z.string().min(1).max(128).optional(),
40
+ image: z.string().url().max(2048).nullable().optional(),
41
+ }))
42
+ .mutation(async ({ input, ctx }) => {
43
+ const updated = await deps().updateUser(ctx.user.id, {
44
+ ...(input.name !== undefined && { name: input.name }),
45
+ ...(input.image !== undefined && { image: input.image }),
46
+ });
47
+ return {
48
+ id: updated.id,
49
+ name: updated.name,
50
+ email: updated.email,
51
+ image: updated.image,
52
+ twoFactorEnabled: updated.twoFactorEnabled,
53
+ };
54
+ }),
55
+ /** Change the authenticated user's password. */
56
+ changePassword: protectedProcedure
57
+ .input(z.object({
58
+ currentPassword: z.string().min(1),
59
+ newPassword: z.string().min(8, "Password must be at least 8 characters"),
60
+ }))
61
+ .mutation(async ({ input, ctx }) => {
62
+ const ok = await deps().changePassword(ctx.user.id, input.currentPassword, input.newPassword);
63
+ if (!ok) {
64
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Current password is incorrect" });
65
+ }
66
+ return { ok: true };
67
+ }),
68
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * tRPC settings router — tenant config, preferences, health.
3
+ *
4
+ * Pure platform-core — service name parameterized via deps.
5
+ */
6
+ import type { INotificationPreferencesRepository } from "../../email/index.js";
7
+ export interface SettingsRouterDeps {
8
+ /** Service name returned by health endpoint (e.g. "paperclip-platform"). */
9
+ serviceName: string;
10
+ getNotificationPrefsStore: () => INotificationPreferencesRepository;
11
+ testProvider?: (provider: string, tenantId: string) => Promise<{
12
+ ok: boolean;
13
+ latencyMs?: number;
14
+ error?: string;
15
+ }>;
16
+ }
17
+ export declare function setSettingsRouterDeps(deps: SettingsRouterDeps): void;
18
+ export declare const settingsRouter: import("@trpc/server").TRPCBuiltRouter<{
19
+ ctx: import("../init.js").TRPCContext;
20
+ meta: object;
21
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
22
+ transformer: false;
23
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
24
+ /** Health check — publicly accessible. */
25
+ health: import("@trpc/server").TRPCQueryProcedure<{
26
+ input: void;
27
+ output: {
28
+ status: "ok";
29
+ service: string;
30
+ };
31
+ meta: object;
32
+ }>;
33
+ /** Get tenant configuration summary. */
34
+ tenantConfig: import("@trpc/server").TRPCQueryProcedure<{
35
+ input: void;
36
+ output: {
37
+ tenantId: string;
38
+ configured: boolean;
39
+ };
40
+ meta: object;
41
+ }>;
42
+ /** Ping — verify auth and tenant context. */
43
+ ping: import("@trpc/server").TRPCQueryProcedure<{
44
+ input: void;
45
+ output: {
46
+ ok: true;
47
+ tenantId: string;
48
+ userId: string;
49
+ timestamp: number;
50
+ };
51
+ meta: object;
52
+ }>;
53
+ /** Get notification preferences for the authenticated tenant. */
54
+ notificationPreferences: import("@trpc/server").TRPCQueryProcedure<{
55
+ input: void;
56
+ output: import("../../email/notification-repository-types.js").NotificationPrefs;
57
+ meta: object;
58
+ }>;
59
+ /** Test connectivity to a provider. */
60
+ testProvider: import("@trpc/server").TRPCMutationProcedure<{
61
+ input: {
62
+ provider: string;
63
+ };
64
+ output: {
65
+ ok: boolean;
66
+ latencyMs?: number;
67
+ error?: string;
68
+ };
69
+ meta: object;
70
+ }>;
71
+ /** Update notification preferences for the authenticated tenant. */
72
+ updateNotificationPreferences: import("@trpc/server").TRPCMutationProcedure<{
73
+ input: {
74
+ billing_low_balance?: boolean | undefined;
75
+ billing_receipts?: boolean | undefined;
76
+ billing_auto_topup?: boolean | undefined;
77
+ agent_channel_disconnect?: boolean | undefined;
78
+ agent_status_changes?: boolean | undefined;
79
+ account_role_changes?: boolean | undefined;
80
+ account_team_invites?: boolean | undefined;
81
+ };
82
+ output: import("../../email/notification-repository-types.js").NotificationPrefs;
83
+ meta: object;
84
+ }>;
85
+ }>>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * tRPC settings router — tenant config, preferences, health.
3
+ *
4
+ * Pure platform-core — service name parameterized via deps.
5
+ */
6
+ import { TRPCError } from "@trpc/server";
7
+ import { z } from "zod";
8
+ import { publicProcedure, router, tenantProcedure } from "../init.js";
9
+ let _deps = null;
10
+ export function setSettingsRouterDeps(deps) {
11
+ _deps = deps;
12
+ }
13
+ function deps() {
14
+ if (!_deps)
15
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Settings not initialized" });
16
+ return _deps;
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Router
20
+ // ---------------------------------------------------------------------------
21
+ export const settingsRouter = router({
22
+ /** Health check — publicly accessible. */
23
+ health: publicProcedure.query(() => {
24
+ return { status: "ok", service: deps().serviceName };
25
+ }),
26
+ /** Get tenant configuration summary. */
27
+ tenantConfig: tenantProcedure.query(({ ctx }) => {
28
+ return {
29
+ tenantId: ctx.tenantId,
30
+ configured: true,
31
+ };
32
+ }),
33
+ /** Ping — verify auth and tenant context. */
34
+ ping: tenantProcedure.query(({ ctx }) => {
35
+ return {
36
+ ok: true,
37
+ tenantId: ctx.tenantId,
38
+ userId: ctx.user.id,
39
+ timestamp: Date.now(),
40
+ };
41
+ }),
42
+ /** Get notification preferences for the authenticated tenant. */
43
+ notificationPreferences: tenantProcedure.query(({ ctx }) => {
44
+ const store = deps().getNotificationPrefsStore();
45
+ return store.get(ctx.tenantId);
46
+ }),
47
+ /** Test connectivity to a provider. */
48
+ testProvider: tenantProcedure
49
+ .input(z.object({ provider: z.string().min(1).max(64) }))
50
+ .mutation(async ({ input, ctx }) => {
51
+ const testFn = deps().testProvider;
52
+ if (!testFn) {
53
+ return { ok: false, error: "Provider testing not configured" };
54
+ }
55
+ return testFn(input.provider, ctx.tenantId);
56
+ }),
57
+ /** Update notification preferences for the authenticated tenant. */
58
+ updateNotificationPreferences: tenantProcedure
59
+ .input(z.object({
60
+ billing_low_balance: z.boolean().optional(),
61
+ billing_receipts: z.boolean().optional(),
62
+ billing_auto_topup: z.boolean().optional(),
63
+ agent_channel_disconnect: z.boolean().optional(),
64
+ agent_status_changes: z.boolean().optional(),
65
+ account_role_changes: z.boolean().optional(),
66
+ account_team_invites: z.boolean().optional(),
67
+ }))
68
+ .mutation(async ({ input, ctx }) => {
69
+ const store = deps().getNotificationPrefsStore();
70
+ await store.update(ctx.tenantId, input);
71
+ return store.get(ctx.tenantId);
72
+ }),
73
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.70.0",
3
+ "version": "1.71.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,6 +19,7 @@ import type { FleetManager } from "../fleet/fleet-manager.js";
19
19
  import type { IProfileStore } from "../fleet/profile-store.js";
20
20
  import type { IServiceKeyRepository } from "../gateway/service-key-repository.js";
21
21
  import type { ProductConfig } from "../product-config/repository-types.js";
22
+ import type { ProductConfigService } from "../product-config/service.js";
22
23
  import type { ProxyManagerInterface } from "../proxy/types.js";
23
24
  import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
24
25
  import type { OrgService } from "../tenancy/org-service.js";
@@ -75,6 +76,7 @@ export interface PlatformContainer {
75
76
  db: DrizzleDb;
76
77
  pool: Pool;
77
78
  productConfig: ProductConfig;
79
+ productConfigService: ProductConfigService;
78
80
  creditLedger: ILedger;
79
81
  orgMemberRepo: IOrgMemberRepository;
80
82
  orgService: OrgService;
@@ -128,7 +130,7 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
128
130
 
129
131
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
130
132
  const { platformBoot } = await import("../product-config/boot.js");
131
- const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
133
+ const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
132
134
 
133
135
  // 5. Credit ledger
134
136
  const { DrizzleLedger } = await import("../credits/ledger.js");
@@ -232,6 +234,7 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
232
234
  db,
233
235
  pool,
234
236
  productConfig,
237
+ productConfigService,
235
238
  creditLedger,
236
239
  orgMemberRepo,
237
240
  orgService,
@@ -12,6 +12,7 @@ import type { IUserRoleRepository } from "../auth/user-role-repository.js";
12
12
  import type { ILedger } from "../credits/ledger.js";
13
13
  import type { DrizzleDb } from "../db/index.js";
14
14
  import type { ProductConfig } from "../product-config/repository-types.js";
15
+ import { ProductConfigService } from "../product-config/service.js";
15
16
  import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
16
17
  import type { OrgService } from "../tenancy/org-service.js";
17
18
  import type { PlatformContainer } from "./container.js";
@@ -90,6 +91,19 @@ function stubProductConfig(): ProductConfig {
90
91
  };
91
92
  }
92
93
 
94
+ function stubProductConfigService(): ProductConfigService {
95
+ const stubRepo = {
96
+ getBySlug: async () => stubProductConfig(),
97
+ listAll: async () => [],
98
+ upsertProduct: async () => stubProductConfig().product,
99
+ replaceNavItems: async () => {},
100
+ upsertFeatures: async () => {},
101
+ upsertFleetConfig: async () => {},
102
+ upsertBillingConfig: async () => {},
103
+ };
104
+ return new ProductConfigService(stubRepo as never);
105
+ }
106
+
93
107
  // ---------------------------------------------------------------------------
94
108
  // Public API
95
109
  // ---------------------------------------------------------------------------
@@ -103,6 +117,7 @@ export function createTestContainer(overrides?: Partial<PlatformContainer>): Pla
103
117
  db: {} as DrizzleDb,
104
118
  pool: { end: async () => {} } as never,
105
119
  productConfig: stubProductConfig(),
120
+ productConfigService: stubProductConfigService(),
106
121
  creditLedger: stubLedger(),
107
122
  orgMemberRepo: stubOrgMemberRepo(),
108
123
  orgService: {} as OrgService,
package/src/trpc/index.ts CHANGED
@@ -13,6 +13,7 @@ export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
13
13
  export {
14
14
  adminProcedure,
15
15
  createCallerFactory,
16
+ createTRPCContext,
16
17
  orgAdminProcedure,
17
18
  orgMemberProcedure,
18
19
  protectedProcedure,
@@ -28,3 +29,6 @@ export {
28
29
  type OrgRemovePaymentMethodDeps,
29
30
  } from "./org-remove-payment-method-router.js";
30
31
  export { createProductConfigRouter } from "./product-config-router.js";
32
+ export { type PageContextRouterDeps, pageContextRouter, setPageContextRouterDeps } from "./routers/page-context.js";
33
+ export { type ProfileRouterDeps, profileRouter, setProfileRouterDeps } from "./routers/profile.js";
34
+ export { type SettingsRouterDeps, setSettingsRouterDeps, settingsRouter } from "./routers/settings.js";
package/src/trpc/init.ts CHANGED
@@ -22,6 +22,34 @@ export interface TRPCContext {
22
22
  tenantId: string | undefined;
23
23
  }
24
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // Context factory — resolves BetterAuth session into TRPCContext
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Create a TRPCContext from an incoming request.
31
+ * Resolves the user from BetterAuth session cookies.
32
+ */
33
+ export async function createTRPCContext(req: Request): Promise<TRPCContext> {
34
+ let user: AuthUser | undefined;
35
+ let tenantId: string | undefined;
36
+ try {
37
+ const { getAuth } = await import("../auth/better-auth.js");
38
+ const auth = getAuth();
39
+ const session = await auth.api.getSession({ headers: req.headers });
40
+ if (session?.user) {
41
+ const sessionUser = session.user as { id: string; role?: string };
42
+ const roles: string[] = [];
43
+ if (sessionUser.role) roles.push(sessionUser.role);
44
+ user = { id: sessionUser.id, roles };
45
+ tenantId = req.headers.get("x-tenant-id") || sessionUser.id;
46
+ }
47
+ } catch {
48
+ // No session — unauthenticated request
49
+ }
50
+ return { user, tenantId: tenantId ?? "" };
51
+ }
52
+
25
53
  // ---------------------------------------------------------------------------
26
54
  // tRPC init
27
55
  // ---------------------------------------------------------------------------
@@ -0,0 +1,57 @@
1
+ /**
2
+ * tRPC page-context router — stores and retrieves per-user page context.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+
7
+ import { TRPCError } from "@trpc/server";
8
+ import { z } from "zod";
9
+ import type { IPageContextRepository } from "../../fleet/page-context-repository.js";
10
+ import { protectedProcedure, router } from "../init.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Deps
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface PageContextRouterDeps {
17
+ repo: IPageContextRepository;
18
+ }
19
+
20
+ let _deps: PageContextRouterDeps | null = null;
21
+
22
+ export function setPageContextRouterDeps(deps: PageContextRouterDeps): void {
23
+ _deps = deps;
24
+ }
25
+
26
+ function deps(): PageContextRouterDeps {
27
+ if (!_deps) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Page context not initialized" });
28
+ return _deps;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Schema
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const updatePageContextSchema = z.object({
36
+ currentPage: z.string().min(1).max(500),
37
+ pagePrompt: z.string().max(2000).nullable(),
38
+ });
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Router
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export const pageContextRouter = router({
45
+ /** Update the page context for the current user. Called on route change. */
46
+ update: protectedProcedure.input(updatePageContextSchema).mutation(async ({ ctx, input }) => {
47
+ await deps().repo.set(ctx.user.id, input.currentPage, input.pagePrompt);
48
+ return { ok: true as const };
49
+ }),
50
+
51
+ /** Get the current page context for the authenticated user. */
52
+ current: protectedProcedure.query(async ({ ctx }) => {
53
+ const pc = await deps().repo.get(ctx.user.id);
54
+ if (!pc) return null;
55
+ return { currentPage: pc.currentPage, pagePrompt: pc.pagePrompt };
56
+ }),
57
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * tRPC profile router — get/update user profile and change password.
3
+ *
4
+ * Pure platform-core — no product-specific imports.
5
+ */
6
+
7
+ import { TRPCError } from "@trpc/server";
8
+ import { z } from "zod";
9
+ import { protectedProcedure, router } from "../init.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Deps
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface ProfileRouterDeps {
16
+ getUser: (
17
+ userId: string,
18
+ ) => Promise<{ id: string; name: string; email: string; image: string | null; twoFactorEnabled: boolean } | null>;
19
+ updateUser: (
20
+ userId: string,
21
+ data: { name?: string; image?: string | null },
22
+ ) => Promise<{ id: string; name: string; email: string; image: string | null; twoFactorEnabled: boolean }>;
23
+ changePassword: (userId: string, currentPassword: string, newPassword: string) => Promise<boolean>;
24
+ }
25
+
26
+ let _deps: ProfileRouterDeps | null = null;
27
+
28
+ export function setProfileRouterDeps(deps: ProfileRouterDeps): void {
29
+ _deps = deps;
30
+ }
31
+
32
+ function deps(): ProfileRouterDeps {
33
+ if (!_deps) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Profile router not initialized" });
34
+ return _deps;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Router
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export const profileRouter = router({
42
+ /** Get the authenticated user's profile. */
43
+ getProfile: protectedProcedure.query(async ({ ctx }) => {
44
+ const user = await deps().getUser(ctx.user.id);
45
+ if (!user) {
46
+ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
47
+ }
48
+ return {
49
+ id: user.id,
50
+ name: user.name,
51
+ email: user.email,
52
+ image: user.image,
53
+ twoFactorEnabled: user.twoFactorEnabled,
54
+ };
55
+ }),
56
+
57
+ /** Update the authenticated user's display name and/or avatar. */
58
+ updateProfile: protectedProcedure
59
+ .input(
60
+ z.object({
61
+ name: z.string().min(1).max(128).optional(),
62
+ image: z.string().url().max(2048).nullable().optional(),
63
+ }),
64
+ )
65
+ .mutation(async ({ input, ctx }) => {
66
+ const updated = await deps().updateUser(ctx.user.id, {
67
+ ...(input.name !== undefined && { name: input.name }),
68
+ ...(input.image !== undefined && { image: input.image }),
69
+ });
70
+ return {
71
+ id: updated.id,
72
+ name: updated.name,
73
+ email: updated.email,
74
+ image: updated.image,
75
+ twoFactorEnabled: updated.twoFactorEnabled,
76
+ };
77
+ }),
78
+
79
+ /** Change the authenticated user's password. */
80
+ changePassword: protectedProcedure
81
+ .input(
82
+ z.object({
83
+ currentPassword: z.string().min(1),
84
+ newPassword: z.string().min(8, "Password must be at least 8 characters"),
85
+ }),
86
+ )
87
+ .mutation(async ({ input, ctx }) => {
88
+ const ok = await deps().changePassword(ctx.user.id, input.currentPassword, input.newPassword);
89
+ if (!ok) {
90
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Current password is incorrect" });
91
+ }
92
+ return { ok: true as const };
93
+ }),
94
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * tRPC settings router — tenant config, preferences, health.
3
+ *
4
+ * Pure platform-core — service name parameterized via deps.
5
+ */
6
+
7
+ import { TRPCError } from "@trpc/server";
8
+ import { z } from "zod";
9
+ import type { INotificationPreferencesRepository } from "../../email/index.js";
10
+ import { publicProcedure, router, tenantProcedure } from "../init.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Deps
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface SettingsRouterDeps {
17
+ /** Service name returned by health endpoint (e.g. "paperclip-platform"). */
18
+ serviceName: string;
19
+ getNotificationPrefsStore: () => INotificationPreferencesRepository;
20
+ testProvider?: (provider: string, tenantId: string) => Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
21
+ }
22
+
23
+ let _deps: SettingsRouterDeps | null = null;
24
+
25
+ export function setSettingsRouterDeps(deps: SettingsRouterDeps): void {
26
+ _deps = deps;
27
+ }
28
+
29
+ function deps(): SettingsRouterDeps {
30
+ if (!_deps) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Settings not initialized" });
31
+ return _deps;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Router
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const settingsRouter = router({
39
+ /** Health check — publicly accessible. */
40
+ health: publicProcedure.query(() => {
41
+ return { status: "ok" as const, service: deps().serviceName };
42
+ }),
43
+
44
+ /** Get tenant configuration summary. */
45
+ tenantConfig: tenantProcedure.query(({ ctx }) => {
46
+ return {
47
+ tenantId: ctx.tenantId,
48
+ configured: true,
49
+ };
50
+ }),
51
+
52
+ /** Ping — verify auth and tenant context. */
53
+ ping: tenantProcedure.query(({ ctx }) => {
54
+ return {
55
+ ok: true as const,
56
+ tenantId: ctx.tenantId,
57
+ userId: ctx.user.id,
58
+ timestamp: Date.now(),
59
+ };
60
+ }),
61
+
62
+ /** Get notification preferences for the authenticated tenant. */
63
+ notificationPreferences: tenantProcedure.query(({ ctx }) => {
64
+ const store = deps().getNotificationPrefsStore();
65
+ return store.get(ctx.tenantId);
66
+ }),
67
+
68
+ /** Test connectivity to a provider. */
69
+ testProvider: tenantProcedure
70
+ .input(z.object({ provider: z.string().min(1).max(64) }))
71
+ .mutation(async ({ input, ctx }) => {
72
+ const testFn = deps().testProvider;
73
+ if (!testFn) {
74
+ return { ok: false as const, error: "Provider testing not configured" };
75
+ }
76
+ return testFn(input.provider, ctx.tenantId);
77
+ }),
78
+
79
+ /** Update notification preferences for the authenticated tenant. */
80
+ updateNotificationPreferences: tenantProcedure
81
+ .input(
82
+ z.object({
83
+ billing_low_balance: z.boolean().optional(),
84
+ billing_receipts: z.boolean().optional(),
85
+ billing_auto_topup: z.boolean().optional(),
86
+ agent_channel_disconnect: z.boolean().optional(),
87
+ agent_status_changes: z.boolean().optional(),
88
+ account_role_changes: z.boolean().optional(),
89
+ account_team_invites: z.boolean().optional(),
90
+ }),
91
+ )
92
+ .mutation(async ({ input, ctx }) => {
93
+ const store = deps().getNotificationPrefsStore();
94
+ await store.update(ctx.tenantId, input);
95
+ return store.get(ctx.tenantId);
96
+ }),
97
+ });