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