@wopr-network/platform-core 1.69.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.
Files changed (44) hide show
  1. package/dist/db/schema/pool-config.d.ts +41 -0
  2. package/dist/db/schema/pool-config.js +5 -0
  3. package/dist/db/schema/pool-instances.d.ts +126 -0
  4. package/dist/db/schema/pool-instances.js +10 -0
  5. package/dist/server/__tests__/container.test.js +4 -1
  6. package/dist/server/container.d.ts +20 -2
  7. package/dist/server/container.js +20 -5
  8. package/dist/server/lifecycle.js +10 -0
  9. package/dist/server/routes/admin.d.ts +18 -0
  10. package/dist/server/routes/admin.js +21 -0
  11. package/dist/server/services/hot-pool-claim.d.ts +30 -0
  12. package/dist/server/services/hot-pool-claim.js +92 -0
  13. package/dist/server/services/hot-pool.d.ts +25 -0
  14. package/dist/server/services/hot-pool.js +129 -0
  15. package/dist/server/services/pool-repository.d.ts +44 -0
  16. package/dist/server/services/pool-repository.js +72 -0
  17. package/dist/server/test-container.js +14 -0
  18. package/dist/trpc/index.d.ts +4 -1
  19. package/dist/trpc/index.js +4 -1
  20. package/dist/trpc/init.d.ts +5 -0
  21. package/dist/trpc/init.js +28 -0
  22. package/dist/trpc/routers/page-context.d.ts +37 -0
  23. package/dist/trpc/routers/page-context.js +41 -0
  24. package/dist/trpc/routers/profile.d.ts +71 -0
  25. package/dist/trpc/routers/profile.js +68 -0
  26. package/dist/trpc/routers/settings.d.ts +85 -0
  27. package/dist/trpc/routers/settings.js +73 -0
  28. package/drizzle/migrations/0025_hot_pool_tables.sql +29 -0
  29. package/package.json +1 -1
  30. package/src/db/schema/pool-config.ts +6 -0
  31. package/src/db/schema/pool-instances.ts +11 -0
  32. package/src/server/__tests__/container.test.ts +4 -1
  33. package/src/server/container.ts +38 -8
  34. package/src/server/lifecycle.ts +10 -0
  35. package/src/server/routes/admin.ts +26 -0
  36. package/src/server/services/hot-pool-claim.ts +130 -0
  37. package/src/server/services/hot-pool.ts +174 -0
  38. package/src/server/services/pool-repository.ts +107 -0
  39. package/src/server/test-container.ts +15 -0
  40. package/src/trpc/index.ts +4 -0
  41. package/src/trpc/init.ts +28 -0
  42. package/src/trpc/routers/page-context.ts +57 -0
  43. package/src/trpc/routers/profile.ts +94 -0
  44. package/src/trpc/routers/settings.ts +97 -0
@@ -172,7 +172,10 @@ describe("createTestContainer", () => {
172
172
  };
173
173
 
174
174
  const hotPool: HotPoolServices = {
175
- poolManager: {},
175
+ start: async () => ({ stop: () => {} }),
176
+ claim: async () => null,
177
+ getPoolSize: async () => 2,
178
+ setPoolSize: async () => {},
176
179
  };
177
180
 
178
181
  const c = createTestContainer({ fleet, crypto, stripe, gateway, hotPool });
@@ -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";
@@ -53,8 +54,18 @@ export interface GatewayServices {
53
54
  }
54
55
 
55
56
  export interface HotPoolServices {
56
- /** Will be typed properly when extracted from nemoclaw. */
57
- poolManager: unknown;
57
+ /** Start the pool manager (replenish loop + cleanup). */
58
+ start: () => Promise<{ stop: () => void }>;
59
+ /** Claim a warm instance from the pool. Returns null if empty. */
60
+ claim: (
61
+ name: string,
62
+ tenantId: string,
63
+ adminUser: { id: string; email: string; name: string },
64
+ ) => Promise<{ id: string; name: string; subdomain: string } | null>;
65
+ /** Get current pool size from DB. */
66
+ getPoolSize: () => Promise<number>;
67
+ /** Set pool size in DB. */
68
+ setPoolSize: (size: number) => Promise<void>;
58
69
  }
59
70
 
60
71
  // ---------------------------------------------------------------------------
@@ -65,6 +76,7 @@ export interface PlatformContainer {
65
76
  db: DrizzleDb;
66
77
  pool: Pool;
67
78
  productConfig: ProductConfig;
79
+ productConfigService: ProductConfigService;
68
80
  creditLedger: ILedger;
69
81
  orgMemberRepo: IOrgMemberRepository;
70
82
  orgService: OrgService;
@@ -118,7 +130,7 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
118
130
 
119
131
  // 4. Bootstrap product config from DB (auto-seeds from presets if needed)
120
132
  const { platformBoot } = await import("../product-config/boot.js");
121
- const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
133
+ const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
122
134
 
123
135
  // 5. Credit ledger
124
136
  const { DrizzleLedger } = await import("../credits/ledger.js");
@@ -217,13 +229,12 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
217
229
  gateway = { serviceKeyRepo };
218
230
  }
219
231
 
220
- // hotPool: not yet implemented needs nemoclaw extraction
221
- const hotPool: HotPoolServices | null = null;
222
-
223
- return {
232
+ // 12. Build the container (hotPool bound after construction)
233
+ const result: PlatformContainer = {
224
234
  db,
225
235
  pool,
226
236
  productConfig,
237
+ productConfigService,
227
238
  creditLedger,
228
239
  orgMemberRepo,
229
240
  orgService,
@@ -232,6 +243,25 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
232
243
  crypto,
233
244
  stripe,
234
245
  gateway,
235
- hotPool,
246
+ hotPool: null,
236
247
  };
248
+
249
+ // Bind hot pool after container construction (closures need the full container)
250
+ if (bootConfig.features.hotPool && fleet) {
251
+ const { startHotPool, setPoolSize: setSize, getPoolSize: getSize } = await import("./services/hot-pool.js");
252
+ const { claimPoolInstance } = await import("./services/hot-pool-claim.js");
253
+ const { DrizzlePoolRepository } = await import("./services/pool-repository.js");
254
+ const poolRepo = new DrizzlePoolRepository(pool);
255
+
256
+ const hotPoolConfig = { provisionSecret: bootConfig.provisionSecret };
257
+ result.hotPool = {
258
+ start: () => startHotPool(result, poolRepo, hotPoolConfig),
259
+ claim: (name, tenantId, adminUser) =>
260
+ claimPoolInstance(result, poolRepo, name, tenantId, adminUser, hotPoolConfig),
261
+ getPoolSize: () => getSize(poolRepo),
262
+ setPoolSize: (size) => setSize(poolRepo, size),
263
+ };
264
+ }
265
+
266
+ return result;
237
267
  }
@@ -40,6 +40,16 @@ export async function startBackgroundServices(container: PlatformContainer): Pro
40
40
  }
41
41
  }
42
42
 
43
+ // Hot pool manager (if enabled)
44
+ if (container.hotPool) {
45
+ try {
46
+ const poolHandles = await container.hotPool.start();
47
+ handles.unsubscribes.push(poolHandles.stop);
48
+ } catch {
49
+ // Non-fatal — pool will be empty but claiming falls back to cold create
50
+ }
51
+ }
52
+
43
53
  return handles;
44
54
  }
45
55
 
@@ -330,5 +330,31 @@ export function createAdminRouter(container: PlatformContainer, config?: AdminRo
330
330
  orgCount,
331
331
  };
332
332
  }),
333
+
334
+ // -----------------------------------------------------------------------
335
+ // Hot pool config (DB-driven, admin-only)
336
+ // -----------------------------------------------------------------------
337
+
338
+ getPoolConfig: adminProcedure.query(async () => {
339
+ if (!container.hotPool) {
340
+ return { enabled: false, poolSize: 0, warmCount: 0 };
341
+ }
342
+ const poolSize = await container.hotPool.getPoolSize();
343
+ const warmRes = await container.pool.query<{ count: string }>(
344
+ "SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'",
345
+ );
346
+ const warmCount = Number(warmRes.rows[0]?.count ?? 0);
347
+ return { enabled: true, poolSize, warmCount };
348
+ }),
349
+
350
+ setPoolSize: adminProcedure
351
+ .input(z.object({ size: z.number().int().min(0).max(50) }))
352
+ .mutation(async ({ input }) => {
353
+ if (!container.hotPool) {
354
+ throw new Error("Hot pool not enabled");
355
+ }
356
+ await container.hotPool.setPoolSize(input.size);
357
+ return { poolSize: input.size };
358
+ }),
333
359
  });
334
360
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Atomic hot pool claiming.
3
+ *
4
+ * Uses IPoolRepository for all DB operations. No raw pool.query().
5
+ */
6
+
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ import { logger } from "../../config/logger.js";
10
+ import type { PlatformContainer } from "../container.js";
11
+ import { replenishPool } from "./hot-pool.js";
12
+ import type { IPoolRepository } from "./pool-repository.js";
13
+
14
+ export interface ClaimConfig {
15
+ /** Shared secret for provision auth. */
16
+ provisionSecret: string;
17
+ /** Container name prefix. Default: "wopr". */
18
+ containerPrefix?: string;
19
+ }
20
+
21
+ export interface ClaimAdminUser {
22
+ id: string;
23
+ email: string;
24
+ name: string;
25
+ }
26
+
27
+ export interface ClaimResult {
28
+ id: string;
29
+ name: string;
30
+ subdomain: string;
31
+ }
32
+
33
+ /**
34
+ * Claim a warm pool instance, rename it, create a fleet profile,
35
+ * and register the proxy route.
36
+ *
37
+ * Returns the claim result on success, or null if the pool is empty.
38
+ */
39
+ export async function claimPoolInstance(
40
+ container: PlatformContainer,
41
+ repo: IPoolRepository,
42
+ name: string,
43
+ tenantId: string,
44
+ _adminUser: ClaimAdminUser,
45
+ config?: ClaimConfig,
46
+ ): Promise<ClaimResult | null> {
47
+ if (!container.fleet) throw new Error("Fleet services required for pool claim");
48
+
49
+ const pc = container.productConfig;
50
+ const containerPort = pc.fleet?.containerPort ?? 3100;
51
+ const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
52
+ const platformDomain = pc.product?.domain ?? "localhost";
53
+ const prefix = config?.containerPrefix ?? "wopr";
54
+
55
+ // ---- Step 1: Atomically claim a warm instance ----
56
+ const claimed = await repo.claimWarm(tenantId, name);
57
+ if (!claimed) return null;
58
+
59
+ const { id: instanceId, containerId } = claimed;
60
+
61
+ // ---- Step 2: Rename Docker container ----
62
+ const docker = container.fleet.docker;
63
+ const containerName = `${prefix}-${name}`;
64
+
65
+ try {
66
+ const c = docker.getContainer(containerId);
67
+ await c.rename({ name: containerName });
68
+ logger.info(`Pool claim: renamed container to ${containerName}`);
69
+ } catch (err) {
70
+ logger.error("Pool claim: rename failed", { error: (err as Error).message });
71
+ await repo.markDead(instanceId);
72
+ return null;
73
+ }
74
+
75
+ // ---- Step 3: Create fleet profile ----
76
+ const serviceKeyRepo = container.fleet.serviceKeyRepo;
77
+ const gatewayKey = serviceKeyRepo ? await serviceKeyRepo.generate(tenantId, instanceId) : crypto.randomUUID();
78
+
79
+ const store = container.fleet.profileStore;
80
+ const profile = {
81
+ id: instanceId,
82
+ name,
83
+ tenantId,
84
+ image: containerImage,
85
+ description: `Managed instance: ${name}`,
86
+ env: {
87
+ PORT: String(containerPort),
88
+ HOST: "0.0.0.0",
89
+ NODE_ENV: "production",
90
+ PROVISION_SECRET: config?.provisionSecret ?? "",
91
+ BETTER_AUTH_SECRET: randomBytes(32).toString("hex"),
92
+ DATA_HOME: "/data",
93
+ HOSTED_MODE: "true",
94
+ DEPLOYMENT_MODE: "hosted_proxy",
95
+ DEPLOYMENT_EXPOSURE: "private",
96
+ MIGRATION_AUTO_APPLY: "true",
97
+ GATEWAY_KEY: gatewayKey,
98
+ },
99
+ restartPolicy: "unless-stopped" as const,
100
+ releaseChannel: "stable" as const,
101
+ updatePolicy: "manual" as const,
102
+ };
103
+
104
+ await store.save(profile);
105
+ logger.info(`Pool claim: saved fleet profile for ${name} (${instanceId})`);
106
+
107
+ // ---- Step 4: Register proxy route ----
108
+ try {
109
+ if (container.fleet.proxy.addRoute) {
110
+ await container.fleet.proxy.addRoute({
111
+ instanceId,
112
+ subdomain: name,
113
+ upstreamHost: containerName,
114
+ upstreamPort: containerPort,
115
+ healthy: true,
116
+ });
117
+ logger.info(`Pool claim: registered proxy route ${name}.${platformDomain}`);
118
+ }
119
+ } catch (err) {
120
+ logger.error("Pool claim: proxy route registration failed", { error: (err as Error).message });
121
+ }
122
+
123
+ // ---- Step 5: Replenish pool in background ----
124
+ replenishPool(container, repo, { provisionSecret: config?.provisionSecret ?? "" }).catch((err) => {
125
+ logger.error("Pool replenish after claim failed", { error: (err as Error).message });
126
+ });
127
+
128
+ const subdomain = `${name}.${platformDomain}`;
129
+ return { id: instanceId, name, subdomain };
130
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Hot pool manager — pre-provisions warm containers for instant claiming.
3
+ *
4
+ * Reads desired pool size from DB (`pool_config` table) via IPoolRepository.
5
+ * Periodically replenishes the pool and cleans up dead containers.
6
+ *
7
+ * All config is DB-driven — no env vars for pool size, container image,
8
+ * or port. Admin API updates pool_config, this reads it.
9
+ */
10
+
11
+ import { logger } from "../../config/logger.js";
12
+ import type { PlatformContainer } from "../container.js";
13
+ import type { IPoolRepository } from "./pool-repository.js";
14
+
15
+ export interface HotPoolConfig {
16
+ /** Shared secret for provision auth between platform and managed instances. */
17
+ provisionSecret: string;
18
+ /** Replenish interval in ms. Default: 60_000. */
19
+ replenishIntervalMs?: number;
20
+ }
21
+
22
+ export interface HotPoolHandles {
23
+ replenishTimer: ReturnType<typeof setInterval>;
24
+ stop: () => void;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Pool size — delegates to repository
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export async function getPoolSize(repo: IPoolRepository): Promise<number> {
32
+ return repo.getPoolSize();
33
+ }
34
+
35
+ export async function setPoolSize(repo: IPoolRepository, size: number): Promise<void> {
36
+ return repo.setPoolSize(size);
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Warm container management
41
+ // ---------------------------------------------------------------------------
42
+
43
+ async function createWarmContainer(
44
+ container: PlatformContainer,
45
+ repo: IPoolRepository,
46
+ config: HotPoolConfig,
47
+ ): Promise<void> {
48
+ if (!container.fleet) throw new Error("Fleet services required for hot pool");
49
+
50
+ const pc = container.productConfig;
51
+ const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
52
+ const containerPort = pc.fleet?.containerPort ?? 3100;
53
+ const provisionSecret = config.provisionSecret;
54
+ const dockerNetwork = pc.fleet?.dockerNetwork ?? "";
55
+ const docker = container.fleet.docker;
56
+ const id = crypto.randomUUID();
57
+ const containerName = `pool-${id.slice(0, 8)}`;
58
+ const volumeName = `pool-${id.slice(0, 8)}`;
59
+
60
+ try {
61
+ // Init volume permissions
62
+ const init = await docker.createContainer({
63
+ Image: containerImage,
64
+ Entrypoint: ["/bin/sh", "-c"],
65
+ Cmd: ["chown -R 999:999 /data"],
66
+ User: "root",
67
+ HostConfig: { Binds: [`${volumeName}:/data`] },
68
+ });
69
+ await init.start();
70
+ await init.wait();
71
+ await init.remove();
72
+
73
+ const warmContainer = await docker.createContainer({
74
+ Image: containerImage,
75
+ name: containerName,
76
+ Env: [`PORT=${containerPort}`, `PROVISION_SECRET=${provisionSecret}`, "HOME=/data"],
77
+ HostConfig: {
78
+ Binds: [`${volumeName}:/data`],
79
+ RestartPolicy: { Name: "unless-stopped" },
80
+ },
81
+ });
82
+
83
+ await warmContainer.start();
84
+
85
+ if (dockerNetwork) {
86
+ const network = docker.getNetwork(dockerNetwork);
87
+ await network.connect({ Container: warmContainer.id });
88
+ }
89
+
90
+ await repo.insertWarm(id, warmContainer.id);
91
+
92
+ logger.info(`Hot pool: created warm container ${containerName} (${id})`);
93
+ } catch (err) {
94
+ logger.error("Hot pool: failed to create warm container", {
95
+ error: (err as Error).message,
96
+ });
97
+ }
98
+ }
99
+
100
+ export async function replenishPool(
101
+ container: PlatformContainer,
102
+ repo: IPoolRepository,
103
+ config: HotPoolConfig,
104
+ ): Promise<void> {
105
+ const desired = await repo.getPoolSize();
106
+ const current = await repo.warmCount();
107
+ const deficit = desired - current;
108
+
109
+ if (deficit <= 0) return;
110
+
111
+ logger.info(`Hot pool: replenishing ${deficit} container(s) (have ${current}, want ${desired})`);
112
+
113
+ for (let i = 0; i < deficit; i++) {
114
+ await createWarmContainer(container, repo, config);
115
+ }
116
+ }
117
+
118
+ async function cleanupDead(container: PlatformContainer, repo: IPoolRepository): Promise<void> {
119
+ if (!container.fleet) return;
120
+
121
+ const docker = container.fleet.docker;
122
+ const warmInstances = await repo.listWarm();
123
+
124
+ for (const instance of warmInstances) {
125
+ try {
126
+ const c = docker.getContainer(instance.containerId);
127
+ const info = await c.inspect();
128
+ if (!info.State.Running) {
129
+ await repo.markDead(instance.id);
130
+ try {
131
+ await c.remove({ force: true });
132
+ } catch {
133
+ /* already gone */
134
+ }
135
+ logger.warn(`Hot pool: marked dead container ${instance.id}`);
136
+ }
137
+ } catch {
138
+ await repo.markDead(instance.id);
139
+ logger.warn(`Hot pool: marked missing container ${instance.id} as dead`);
140
+ }
141
+ }
142
+
143
+ await repo.deleteDead();
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Lifecycle
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export async function startHotPool(
151
+ container: PlatformContainer,
152
+ repo: IPoolRepository,
153
+ config: HotPoolConfig,
154
+ ): Promise<HotPoolHandles> {
155
+ await cleanupDead(container, repo);
156
+ await replenishPool(container, repo, config);
157
+
158
+ const intervalMs = config.replenishIntervalMs ?? 60_000;
159
+ const replenishTimer = setInterval(async () => {
160
+ try {
161
+ await cleanupDead(container, repo);
162
+ await replenishPool(container, repo, config);
163
+ } catch (err) {
164
+ logger.error("Hot pool tick failed", { error: (err as Error).message });
165
+ }
166
+ }, intervalMs);
167
+
168
+ logger.info("Hot pool manager started");
169
+
170
+ return {
171
+ replenishTimer,
172
+ stop: () => clearInterval(replenishTimer),
173
+ };
174
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Repository for hot pool database operations.
3
+ *
4
+ * Encapsulates all pool_config and pool_instances queries behind
5
+ * a testable interface. No raw pool.query() outside this file.
6
+ */
7
+
8
+ import type { Pool } from "pg";
9
+
10
+ export interface PoolInstance {
11
+ id: string;
12
+ containerId: string;
13
+ status: string;
14
+ tenantId: string | null;
15
+ name: string | null;
16
+ }
17
+
18
+ export interface IPoolRepository {
19
+ getPoolSize(): Promise<number>;
20
+ setPoolSize(size: number): Promise<void>;
21
+ warmCount(): Promise<number>;
22
+ insertWarm(id: string, containerId: string): Promise<void>;
23
+ listWarm(): Promise<PoolInstance[]>;
24
+ markDead(id: string): Promise<void>;
25
+ deleteDead(): Promise<void>;
26
+ claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null>;
27
+ updateInstanceStatus(id: string, status: string): Promise<void>;
28
+ }
29
+
30
+ export class DrizzlePoolRepository implements IPoolRepository {
31
+ constructor(private pool: Pool) {}
32
+
33
+ async getPoolSize(): Promise<number> {
34
+ try {
35
+ const res = await this.pool.query("SELECT pool_size FROM pool_config WHERE id = 1");
36
+ return res.rows[0]?.pool_size ?? 2;
37
+ } catch {
38
+ return 2;
39
+ }
40
+ }
41
+
42
+ async setPoolSize(size: number): Promise<void> {
43
+ await this.pool.query(
44
+ "INSERT INTO pool_config (id, pool_size) VALUES (1, $1) ON CONFLICT (id) DO UPDATE SET pool_size = $1",
45
+ [size],
46
+ );
47
+ }
48
+
49
+ async warmCount(): Promise<number> {
50
+ const res = await this.pool.query("SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'");
51
+ return res.rows[0].count;
52
+ }
53
+
54
+ async insertWarm(id: string, containerId: string): Promise<void> {
55
+ await this.pool.query("INSERT INTO pool_instances (id, container_id, status) VALUES ($1, $2, 'warm')", [
56
+ id,
57
+ containerId,
58
+ ]);
59
+ }
60
+
61
+ async listWarm(): Promise<PoolInstance[]> {
62
+ const res = await this.pool.query(
63
+ "SELECT id, container_id, status, tenant_id, name FROM pool_instances WHERE status = 'warm'",
64
+ );
65
+ return res.rows.map((r: Record<string, unknown>) => ({
66
+ id: r.id as string,
67
+ containerId: r.container_id as string,
68
+ status: r.status as string,
69
+ tenantId: (r.tenant_id as string) ?? null,
70
+ name: (r.name as string) ?? null,
71
+ }));
72
+ }
73
+
74
+ async markDead(id: string): Promise<void> {
75
+ await this.pool.query("UPDATE pool_instances SET status = 'dead' WHERE id = $1", [id]);
76
+ }
77
+
78
+ async deleteDead(): Promise<void> {
79
+ await this.pool.query("DELETE FROM pool_instances WHERE status = 'dead'");
80
+ }
81
+
82
+ async claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null> {
83
+ const res = await this.pool.query(
84
+ `UPDATE pool_instances
85
+ SET status = 'claimed',
86
+ claimed_at = NOW(),
87
+ tenant_id = $1,
88
+ name = $2
89
+ WHERE id = (
90
+ SELECT id FROM pool_instances
91
+ WHERE status = 'warm'
92
+ ORDER BY created_at ASC
93
+ LIMIT 1
94
+ FOR UPDATE SKIP LOCKED
95
+ )
96
+ RETURNING id, container_id`,
97
+ [tenantId, name],
98
+ );
99
+ if (res.rowCount === 0) return null;
100
+ const row = res.rows[0] as { id: string; container_id: string };
101
+ return { id: row.id, containerId: row.container_id };
102
+ }
103
+
104
+ async updateInstanceStatus(id: string, status: string): Promise<void> {
105
+ await this.pool.query("UPDATE pool_instances SET status = $1 WHERE id = $2", [status, id]);
106
+ }
107
+ }
@@ -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
  // ---------------------------------------------------------------------------