@wopr-network/platform-core 1.70.0 → 1.72.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 (32) hide show
  1. package/dist/server/boot-config.d.ts +11 -1
  2. package/dist/server/container.d.ts +2 -0
  3. package/dist/server/container.js +21 -11
  4. package/dist/server/services/__tests__/hot-pool.test.d.ts +6 -0
  5. package/dist/server/services/__tests__/hot-pool.test.js +26 -0
  6. package/dist/server/services/__tests__/in-memory-pool-repository.d.ts +21 -0
  7. package/dist/server/services/__tests__/in-memory-pool-repository.js +57 -0
  8. package/dist/server/services/__tests__/pool-repository.test.d.ts +7 -0
  9. package/dist/server/services/__tests__/pool-repository.test.js +108 -0
  10. package/dist/server/test-container.js +14 -0
  11. package/dist/trpc/index.d.ts +4 -1
  12. package/dist/trpc/index.js +4 -1
  13. package/dist/trpc/init.d.ts +5 -0
  14. package/dist/trpc/init.js +28 -0
  15. package/dist/trpc/routers/page-context.d.ts +37 -0
  16. package/dist/trpc/routers/page-context.js +41 -0
  17. package/dist/trpc/routers/profile.d.ts +71 -0
  18. package/dist/trpc/routers/profile.js +68 -0
  19. package/dist/trpc/routers/settings.d.ts +85 -0
  20. package/dist/trpc/routers/settings.js +73 -0
  21. package/package.json +1 -1
  22. package/src/server/boot-config.ts +12 -1
  23. package/src/server/container.ts +22 -12
  24. package/src/server/services/__tests__/hot-pool.test.ts +30 -0
  25. package/src/server/services/__tests__/in-memory-pool-repository.ts +66 -0
  26. package/src/server/services/__tests__/pool-repository.test.ts +135 -0
  27. package/src/server/test-container.ts +15 -0
  28. package/src/trpc/index.ts +4 -0
  29. package/src/trpc/init.ts +28 -0
  30. package/src/trpc/routers/page-context.ts +57 -0
  31. package/src/trpc/routers/profile.ts +94 -0
  32. package/src/trpc/routers/settings.ts +97 -0
@@ -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.72.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,9 +37,20 @@ export interface BootConfig {
37
37
  /** Short product identifier (e.g. "paperclip", "wopr", "holyship"). */
38
38
  slug: string;
39
39
 
40
- /** PostgreSQL connection string. */
40
+ /** PostgreSQL connection string. Required unless `pool` is provided. */
41
41
  databaseUrl: string;
42
42
 
43
+ /**
44
+ * Pre-created PostgreSQL connection pool. When provided, buildContainer
45
+ * reuses this pool and skips pool creation + Drizzle migrations. The
46
+ * caller is responsible for running their own migrations before calling
47
+ * buildContainer.
48
+ *
49
+ * Use this when the product has its own migration set (e.g. wopr-platform
50
+ * generates migrations locally from the shared schema).
51
+ */
52
+ pool?: import("pg").Pool;
53
+
43
54
  /** Bind host (default "0.0.0.0"). */
44
55
  host?: string;
45
56
 
@@ -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;
@@ -108,27 +110,34 @@ export interface PlatformContainer {
108
110
  * `bootConfig.features`. Disabled features yield `null`.
109
111
  */
110
112
  export async function buildContainer(bootConfig: BootConfig): Promise<PlatformContainer> {
111
- if (!bootConfig.databaseUrl) {
112
- throw new Error("buildContainer: databaseUrl is required");
113
+ // 1. Database pool — reuse existing or create new
114
+ let pool: Pool;
115
+ if (bootConfig.pool) {
116
+ pool = bootConfig.pool;
117
+ } else {
118
+ if (!bootConfig.databaseUrl) {
119
+ throw new Error("buildContainer: databaseUrl is required when pool is not provided");
120
+ }
121
+ const { Pool: PgPool } = await import("pg");
122
+ pool = new PgPool({ connectionString: bootConfig.databaseUrl });
113
123
  }
114
124
 
115
- // 1. Database pool
116
- const { Pool: PgPool } = await import("pg");
117
- const pool: Pool = new PgPool({ connectionString: bootConfig.databaseUrl });
118
-
119
125
  // 2. Drizzle ORM instance
120
126
  const { createDb } = await import("../db/index.js");
121
127
  const db = createDb(pool);
122
128
 
123
- // 3. Run Drizzle migrations
124
- const { migrate } = await import("drizzle-orm/node-postgres/migrator");
125
- const path = await import("node:path");
126
- const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
127
- await migrate(db as never, { migrationsFolder });
129
+ // 3. Run Drizzle migrations (skip when caller provided their own pool —
130
+ // they are responsible for running product-specific migrations first)
131
+ if (!bootConfig.pool) {
132
+ const { migrate } = await import("drizzle-orm/node-postgres/migrator");
133
+ const path = await import("node:path");
134
+ const migrationsFolder = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../drizzle");
135
+ await migrate(db as never, { migrationsFolder });
136
+ }
128
137
 
129
138
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
130
139
  const { platformBoot } = await import("../product-config/boot.js");
131
- const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
140
+ const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
132
141
 
133
142
  // 5. Credit ledger
134
143
  const { DrizzleLedger } = await import("../credits/ledger.js");
@@ -232,6 +241,7 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
232
241
  db,
233
242
  pool,
234
243
  productConfig,
244
+ productConfigService,
235
245
  creditLedger,
236
246
  orgMemberRepo,
237
247
  orgService,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Tests for hot pool service functions (getPoolSize, setPoolSize).
3
+ *
4
+ * Uses InMemoryPoolRepository to test service logic without Docker or DB.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import { getPoolSize, setPoolSize } from "../hot-pool.js";
9
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
10
+
11
+ describe("hot pool service", () => {
12
+ describe("getPoolSize / setPoolSize", () => {
13
+ it("returns default pool size", async () => {
14
+ const repo = new InMemoryPoolRepository();
15
+ expect(await getPoolSize(repo)).toBe(2);
16
+ });
17
+
18
+ it("sets and reads pool size", async () => {
19
+ const repo = new InMemoryPoolRepository();
20
+ await setPoolSize(repo, 10);
21
+ expect(await getPoolSize(repo)).toBe(10);
22
+ });
23
+
24
+ it("pool size of 0 is valid", async () => {
25
+ const repo = new InMemoryPoolRepository();
26
+ await setPoolSize(repo, 0);
27
+ expect(await getPoolSize(repo)).toBe(0);
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * In-memory IPoolRepository for testing.
3
+ * FIFO claiming, dead instance handling — no DB required.
4
+ */
5
+
6
+ import type { IPoolRepository, PoolInstance } from "../pool-repository.js";
7
+
8
+ export class InMemoryPoolRepository implements IPoolRepository {
9
+ private poolSize = 2;
10
+ private instances: Array<PoolInstance & { createdAt: Date; claimedAt: Date | null }> = [];
11
+
12
+ async getPoolSize(): Promise<number> {
13
+ return this.poolSize;
14
+ }
15
+
16
+ async setPoolSize(size: number): Promise<void> {
17
+ this.poolSize = size;
18
+ }
19
+
20
+ async warmCount(): Promise<number> {
21
+ return this.instances.filter((i) => i.status === "warm").length;
22
+ }
23
+
24
+ async insertWarm(id: string, containerId: string): Promise<void> {
25
+ this.instances.push({
26
+ id,
27
+ containerId,
28
+ status: "warm",
29
+ tenantId: null,
30
+ name: null,
31
+ createdAt: new Date(),
32
+ claimedAt: null,
33
+ });
34
+ }
35
+
36
+ async listWarm(): Promise<PoolInstance[]> {
37
+ return this.instances.filter((i) => i.status === "warm").map(({ createdAt, claimedAt, ...rest }) => rest);
38
+ }
39
+
40
+ async markDead(id: string): Promise<void> {
41
+ const inst = this.instances.find((i) => i.id === id);
42
+ if (inst) inst.status = "dead";
43
+ }
44
+
45
+ async deleteDead(): Promise<void> {
46
+ this.instances = this.instances.filter((i) => i.status !== "dead");
47
+ }
48
+
49
+ async claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null> {
50
+ const warm = this.instances
51
+ .filter((i) => i.status === "warm")
52
+ .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
53
+ if (warm.length === 0) return null;
54
+ const target = warm[0];
55
+ target.status = "claimed";
56
+ target.tenantId = tenantId;
57
+ target.name = name;
58
+ target.claimedAt = new Date();
59
+ return { id: target.id, containerId: target.containerId };
60
+ }
61
+
62
+ async updateInstanceStatus(id: string, status: string): Promise<void> {
63
+ const inst = this.instances.find((i) => i.id === id);
64
+ if (inst) inst.status = status;
65
+ }
66
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for IPoolRepository interface contract.
3
+ *
4
+ * Uses an in-memory implementation to verify the interface
5
+ * behavior without requiring a real database.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import { InMemoryPoolRepository } from "./in-memory-pool-repository.js";
10
+
11
+ describe("IPoolRepository (InMemory)", () => {
12
+ it("returns default pool size of 2", async () => {
13
+ const repo = new InMemoryPoolRepository();
14
+ expect(await repo.getPoolSize()).toBe(2);
15
+ });
16
+
17
+ it("sets and gets pool size", async () => {
18
+ const repo = new InMemoryPoolRepository();
19
+ await repo.setPoolSize(5);
20
+ expect(await repo.getPoolSize()).toBe(5);
21
+ });
22
+
23
+ it("inserts and counts warm instances", async () => {
24
+ const repo = new InMemoryPoolRepository();
25
+ expect(await repo.warmCount()).toBe(0);
26
+
27
+ await repo.insertWarm("a", "container-a");
28
+ await repo.insertWarm("b", "container-b");
29
+
30
+ expect(await repo.warmCount()).toBe(2);
31
+ });
32
+
33
+ it("lists warm instances", async () => {
34
+ const repo = new InMemoryPoolRepository();
35
+ await repo.insertWarm("a", "container-a");
36
+ await repo.insertWarm("b", "container-b");
37
+
38
+ const warm = await repo.listWarm();
39
+ expect(warm).toHaveLength(2);
40
+ expect(warm[0].id).toBe("a");
41
+ expect(warm[0].containerId).toBe("container-a");
42
+ expect(warm[0].status).toBe("warm");
43
+ });
44
+
45
+ it("claims warm instance FIFO", async () => {
46
+ const repo = new InMemoryPoolRepository();
47
+ await repo.insertWarm("first", "c-first");
48
+ await repo.insertWarm("second", "c-second");
49
+
50
+ const claimed = await repo.claimWarm("tenant-1", "my-bot");
51
+ expect(claimed).not.toBeNull();
52
+ expect(claimed?.id).toBe("first");
53
+ expect(claimed?.containerId).toBe("c-first");
54
+
55
+ // Warm count drops by 1
56
+ expect(await repo.warmCount()).toBe(1);
57
+ });
58
+
59
+ it("returns null when claiming from empty pool", async () => {
60
+ const repo = new InMemoryPoolRepository();
61
+ const result = await repo.claimWarm("tenant-1", "bot");
62
+ expect(result).toBeNull();
63
+ });
64
+
65
+ it("does not re-claim already claimed instances", async () => {
66
+ const repo = new InMemoryPoolRepository();
67
+ await repo.insertWarm("only", "c-only");
68
+
69
+ const first = await repo.claimWarm("t1", "bot1");
70
+ expect(first).not.toBeNull();
71
+
72
+ const second = await repo.claimWarm("t2", "bot2");
73
+ expect(second).toBeNull();
74
+ });
75
+
76
+ it("marks instances dead", async () => {
77
+ const repo = new InMemoryPoolRepository();
78
+ await repo.insertWarm("a", "c-a");
79
+ await repo.markDead("a");
80
+
81
+ expect(await repo.warmCount()).toBe(0);
82
+ const warm = await repo.listWarm();
83
+ expect(warm).toHaveLength(0);
84
+ });
85
+
86
+ it("deletes dead instances", async () => {
87
+ const repo = new InMemoryPoolRepository();
88
+ await repo.insertWarm("a", "c-a");
89
+ await repo.insertWarm("b", "c-b");
90
+ await repo.markDead("a");
91
+
92
+ await repo.deleteDead();
93
+
94
+ // Only 'b' remains (warm)
95
+ expect(await repo.warmCount()).toBe(1);
96
+ const warm = await repo.listWarm();
97
+ expect(warm[0].id).toBe("b");
98
+ });
99
+
100
+ it("updates instance status", async () => {
101
+ const repo = new InMemoryPoolRepository();
102
+ await repo.insertWarm("a", "c-a");
103
+ await repo.updateInstanceStatus("a", "provisioning");
104
+
105
+ // No longer warm
106
+ expect(await repo.warmCount()).toBe(0);
107
+ });
108
+
109
+ it("handles multiple claims in order", async () => {
110
+ const repo = new InMemoryPoolRepository();
111
+ await repo.insertWarm("1", "c-1");
112
+ await repo.insertWarm("2", "c-2");
113
+ await repo.insertWarm("3", "c-3");
114
+
115
+ const c1 = await repo.claimWarm("t-a", "bot-a");
116
+ const c2 = await repo.claimWarm("t-b", "bot-b");
117
+ const c3 = await repo.claimWarm("t-c", "bot-c");
118
+ const c4 = await repo.claimWarm("t-d", "bot-d");
119
+
120
+ expect(c1?.id).toBe("1");
121
+ expect(c2?.id).toBe("2");
122
+ expect(c3?.id).toBe("3");
123
+ expect(c4).toBeNull();
124
+ expect(await repo.warmCount()).toBe(0);
125
+ });
126
+
127
+ it("dead instances are not claimable", async () => {
128
+ const repo = new InMemoryPoolRepository();
129
+ await repo.insertWarm("a", "c-a");
130
+ await repo.markDead("a");
131
+
132
+ const result = await repo.claimWarm("t1", "bot");
133
+ expect(result).toBeNull();
134
+ });
135
+ });
@@ -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
+ });