From 102a2354075345c4c3b62ec4b64eb2e018be5e27 Mon Sep 17 00:00:00 2001
From: Owen
Date: Wed, 18 Mar 2026 20:54:38 -0700
Subject: [PATCH 01/25] Adjust schema for many to one site resources
---
server/db/pg/schema/schema.ts | 12 +++++++++---
server/db/sqlite/schema/schema.ts | 12 +++++++++---
2 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index b93c21fd6..685cca0f2 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -216,9 +216,6 @@ export const exitNodes = pgTable("exitNodes", {
export const siteResources = pgTable("siteResources", {
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
- siteId: integer("siteId")
- .notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
@@ -241,6 +238,15 @@ export const siteResources = pgTable("siteResources", {
.default("site")
});
+export const siteSiteResources = pgTable("siteSiteResources", {
+ siteId: integer("siteId")
+ .notNull()
+ .references(() => sites.siteId, { onDelete: "cascade" }),
+ siteResourceId: integer("siteResourceId")
+ .notNull()
+ .references(() => siteResources.siteResourceId, { onDelete: "cascade" })
+});
+
export const clientSiteResources = pgTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 188caac2b..20fca1c94 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -239,9 +239,6 @@ export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true
}),
- siteId: integer("siteId")
- .notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
@@ -266,6 +263,15 @@ export const siteResources = sqliteTable("siteResources", {
.default("site")
});
+export const siteSiteResources = sqliteTable("siteSiteResources", {
+ siteId: integer("siteId")
+ .notNull()
+ .references(() => sites.siteId, { onDelete: "cascade" }),
+ siteResourceId: integer("siteResourceId")
+ .notNull()
+ .references(() => siteResources.siteResourceId, { onDelete: "cascade" })
+});
+
export const clientSiteResources = sqliteTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
From d8b511b198759692f449b017bf1a770ed7b5540d Mon Sep 17 00:00:00 2001
From: Owen
Date: Wed, 18 Mar 2026 20:54:49 -0700
Subject: [PATCH 02/25] Adjust create and update to be many to one
---
.../siteResource/createSiteResource.ts | 46 +--
.../siteResource/updateSiteResource.ts | 296 ++++++++++--------
2 files changed, 193 insertions(+), 149 deletions(-)
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index b9494776e..273c7c022 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -8,6 +8,7 @@ import {
SiteResource,
siteResources,
sites,
+ siteSiteResources,
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
@@ -23,7 +24,7 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -37,7 +38,7 @@ const createSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]),
- siteId: z.int(),
+ siteIds: z.array(z.int()),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
@@ -159,7 +160,7 @@ export async function createSiteResource(
const { orgId } = parsedParams.data;
const {
name,
- siteId,
+ siteIds,
mode,
// protocol,
// proxyPort,
@@ -178,14 +179,14 @@ export async function createSiteResource(
} = parsedBody.data;
// Verify the site exists and belongs to the org
- const [site] = await db
+ const sitesToAssign = await db
.select()
.from(sites)
- .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
+ .where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)))
.limit(1);
- if (!site) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
+ if (sitesToAssign.length !== siteIds.length) {
+ return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found"));
}
const [org] = await db
@@ -289,7 +290,6 @@ export async function createSiteResource(
await db.transaction(async (trx) => {
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
- siteId,
niceId,
orgId,
name,
@@ -317,6 +317,13 @@ export async function createSiteResource(
//////////////////// update the associations ////////////////////
+ for (const siteId of siteIds) {
+ await trx.insert(siteSiteResources).values({
+ siteId: siteId,
+ siteResourceId: siteResourceId
+ });
+ }
+
const [adminRole] = await trx
.select()
.from(roles)
@@ -359,17 +366,18 @@ export async function createSiteResource(
);
}
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, site.siteId))
- .limit(1);
+ // Not sure what this is doing??
+ // const [newt] = await trx
+ // .select()
+ // .from(newts)
+ // .where(eq(newts.siteId, site.siteId))
+ // .limit(1);
- if (!newt) {
- return next(
- createHttpError(HttpCode.NOT_FOUND, "Newt not found")
- );
- }
+ // if (!newt) {
+ // return next(
+ // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
+ // );
+ // }
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
@@ -387,7 +395,7 @@ export async function createSiteResource(
}
logger.info(
- `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
+ `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
return response(res, {
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index 596ed9a3f..f22c5a047 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -9,6 +9,7 @@ import {
roles,
roleSiteResources,
sites,
+ siteSiteResources,
Transaction,
userSiteResources
} from "@server/db";
@@ -16,7 +17,7 @@ import { siteResources, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
-import { eq, and, ne } from "drizzle-orm";
+import { eq, and, ne, inArray } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -42,7 +43,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
- siteId: z.int(),
+ siteIds: z.array(z.int()),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr"]).optional(),
@@ -166,7 +167,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data;
const {
name,
- siteId, // because it can change
+ siteIds, // because it can change
mode,
destination,
alias,
@@ -181,16 +182,6 @@ export async function updateSiteResource(
authDaemonMode
} = parsedBody.data;
- const [site] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteId))
- .limit(1);
-
- if (!site) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
- }
-
// Check if site resource exists
const [existingSiteResource] = await db
.select()
@@ -230,6 +221,24 @@ export async function updateSiteResource(
);
}
+ // Verify the site exists and belongs to the org
+ const sitesToAssign = await db
+ .select()
+ .from(sites)
+ .where(
+ and(
+ inArray(sites.siteId, siteIds),
+ eq(sites.orgId, existingSiteResource.orgId)
+ )
+ )
+ .limit(1);
+
+ if (sitesToAssign.length !== siteIds.length) {
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
+ }
+
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -247,25 +256,20 @@ export async function updateSiteResource(
);
}
- let existingSite = site;
- let siteChanged = false;
- if (existingSiteResource.siteId !== siteId) {
- siteChanged = true;
- // get the existing site
- [existingSite] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, existingSiteResource.siteId))
- .limit(1);
+ let sitesChanged = false;
+ const existingSiteIds = await db
+ .select()
+ .from(siteSiteResources)
+ .where(eq(siteSiteResources.siteResourceId, siteResourceId));
- if (!existingSite) {
- return next(
- createHttpError(
- HttpCode.NOT_FOUND,
- "Existing site not found"
- )
- );
- }
+ const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
+ const newSiteIdSet = new Set(siteIds);
+
+ if (
+ existingSiteIdSet.size !== newSiteIdSet.size ||
+ ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
+ ) {
+ sitesChanged = true;
}
// make sure the alias is unique within the org if provided
@@ -295,7 +299,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
- if (siteChanged) {
+ if (sitesChanged) {
// delete the existing site resource
await trx
.delete(siteResources)
@@ -321,7 +325,8 @@ export async function updateSiteResource(
const sshPamSet =
isLicensedSshPam &&
- (authDaemonPort !== undefined || authDaemonMode !== undefined)
+ (authDaemonPort !== undefined ||
+ authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
@@ -335,7 +340,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
- siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
@@ -423,7 +427,8 @@ export async function updateSiteResource(
// Update the site resource
const sshPamSet =
isLicensedSshPam &&
- (authDaemonPort !== undefined || authDaemonMode !== undefined)
+ (authDaemonPort !== undefined ||
+ authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
@@ -437,7 +442,6 @@ export async function updateSiteResource(
.update(siteResources)
.set({
name: name,
- siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
@@ -454,6 +458,20 @@ export async function updateSiteResource(
//////////////////// update the associations ////////////////////
+ // delete the site - site resources associations
+ await trx
+ .delete(siteSiteResources)
+ .where(
+ eq(siteSiteResources.siteResourceId, siteResourceId)
+ );
+
+ for (const siteId of siteIds) {
+ await trx.insert(siteSiteResources).values({
+ siteId: siteId,
+ siteResourceId: siteResourceId
+ });
+ }
+
await trx
.delete(clientSiteResources)
.where(
@@ -524,13 +542,16 @@ export async function updateSiteResource(
}
logger.info(
- `Updated site resource ${siteResourceId} for site ${siteId}`
+ `Updated site resource ${siteResourceId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
- { siteId: site.siteId, orgId: site.orgId },
+ siteIds.map((siteId) => ({
+ siteId,
+ orgId: existingSiteResource.orgId
+ })),
trx
);
}
@@ -557,7 +578,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
- site: { siteId: number; orgId: string },
+ sites: { siteId: number; orgId: string }[],
trx: Transaction
) {
logger.debug(
@@ -594,101 +615,116 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) {
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, site.siteId))
- .limit(1);
-
- if (!newt) {
- throw new Error(
- "Newt not found for site during site resource update"
- );
- }
-
- // Only update targets on newt if destination changed
- if (destinationChanged || portRangesChanged) {
- const oldTargets = generateSubnetProxyTargets(
- existingSiteResource,
- mergedAllClients
- );
- const newTargets = generateSubnetProxyTargets(
- updatedSiteResource,
- mergedAllClients
- );
-
- await updateTargets(newt.newtId, {
- oldTargets: oldTargets,
- newTargets: newTargets
- }, newt.version);
- }
-
- const olmJobs: Promise[] = [];
- for (const client of mergedAllClients) {
- // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
- // todo: optimize this query if needed
- const oldDestinationStillInUseSites = await trx
+ for (const site of sites) {
+ const [newt] = await trx
.select()
- .from(siteResources)
- .innerJoin(
- clientSiteResourcesAssociationsCache,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- ),
- eq(siteResources.siteId, site.siteId),
- eq(
- siteResources.destination,
- existingSiteResource.destination
- ),
- ne(
- siteResources.siteResourceId,
- existingSiteResource.siteResourceId
- )
- )
+ .from(newts)
+ .where(eq(newts.siteId, site.siteId))
+ .limit(1);
+
+ if (!newt) {
+ throw new Error(
+ "Newt not found for site during site resource update"
+ );
+ }
+
+ // Only update targets on newt if destination changed
+ if (destinationChanged || portRangesChanged) {
+ const oldTargets = generateSubnetProxyTargets(
+ existingSiteResource,
+ mergedAllClients
+ );
+ const newTargets = generateSubnetProxyTargets(
+ updatedSiteResource,
+ mergedAllClients
);
- const oldDestinationStillInUseByASite =
- oldDestinationStillInUseSites.length > 0;
+ await updateTargets(
+ newt.newtId,
+ {
+ oldTargets: oldTargets,
+ newTargets: newTargets
+ },
+ newt.version
+ );
+ }
- // we also need to update the remote subnets on the olms for each client that has access to this site
- olmJobs.push(
- updatePeerData(
- client.clientId,
- updatedSiteResource.siteId,
- destinationChanged
- ? {
- oldRemoteSubnets: !oldDestinationStillInUseByASite
- ? generateRemoteSubnets([
- existingSiteResource
- ])
- : [],
- newRemoteSubnets: generateRemoteSubnets([
- updatedSiteResource
- ])
- }
- : undefined,
- aliasChanged
- ? {
- oldAliases: generateAliasConfig([
- existingSiteResource
- ]),
- newAliases: generateAliasConfig([
- updatedSiteResource
- ])
- }
- : undefined
- )
- );
+ const olmJobs: Promise[] = [];
+ for (const client of mergedAllClients) {
+ // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
+ // todo: optimize this query if needed
+ const oldDestinationStillInUseSites = await trx
+ .select()
+ .from(siteResources)
+ .innerJoin(
+ clientSiteResourcesAssociationsCache,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteSiteResources,
+ eq(
+ siteSiteResources.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .where(
+ and(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
+ ),
+ eq(siteSiteResources.siteId, site.siteId),
+ eq(
+ siteResources.destination,
+ existingSiteResource.destination
+ ),
+ ne(
+ siteResources.siteResourceId,
+ existingSiteResource.siteResourceId
+ )
+ )
+ );
+
+
+ const oldDestinationStillInUseByASite =
+ oldDestinationStillInUseSites.length > 0;
+
+ // we also need to update the remote subnets on the olms for each client that has access to this site
+ olmJobs.push(
+ updatePeerData(
+ client.clientId,
+ site.siteId,
+ destinationChanged
+ ? {
+ oldRemoteSubnets:
+ !oldDestinationStillInUseByASite
+ ? generateRemoteSubnets([
+ existingSiteResource
+ ])
+ : [],
+ newRemoteSubnets: generateRemoteSubnets([
+ updatedSiteResource
+ ])
+ }
+ : undefined,
+ aliasChanged
+ ? {
+ oldAliases: generateAliasConfig([
+ existingSiteResource
+ ]),
+ newAliases: generateAliasConfig([
+ updatedSiteResource
+ ])
+ }
+ : undefined
+ )
+ );
+ }
+
+ await Promise.all(olmJobs);
}
-
- await Promise.all(olmJobs);
}
}
From 7cbe3d42a14bf88176233a07d7988bbf71937b22 Mon Sep 17 00:00:00 2001
From: Owen
Date: Thu, 19 Mar 2026 12:10:04 -0700
Subject: [PATCH 03/25] Working on refactoring
---
server/lib/blueprints/applyBlueprint.ts | 56 ++++----
server/lib/blueprints/clientResources.ts | 125 +++++++++++++-----
server/lib/blueprints/types.ts | 3 +-
.../siteResource/createSiteResource.ts | 27 ++--
.../siteResource/listAllSiteResourcesByOrg.ts | 20 +--
5 files changed, 156 insertions(+), 75 deletions(-)
diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts
index a304bb392..fd189e6ca 100644
--- a/server/lib/blueprints/applyBlueprint.ts
+++ b/server/lib/blueprints/applyBlueprint.ts
@@ -121,8 +121,8 @@ export async function applyBlueprint({
for (const result of clientResourcesResults) {
if (
result.oldSiteResource &&
- result.oldSiteResource.siteId !=
- result.newSiteResource.siteId
+ JSON.stringify(result.newSites?.sort()) !==
+ JSON.stringify(result.oldSites?.sort())
) {
// query existing associations
const existingRoleIds = await trx
@@ -222,38 +222,46 @@ export async function applyBlueprint({
trx
);
} else {
- const [newSite] = await trx
- .select()
- .from(sites)
- .innerJoin(newts, eq(sites.siteId, newts.siteId))
- .where(
- and(
- eq(sites.siteId, result.newSiteResource.siteId),
- eq(sites.orgId, orgId),
- eq(sites.type, "newt"),
- isNotNull(sites.pubKey)
+ let good = true;
+ for (const newSite of result.newSites) {
+ const [site] = await trx
+ .select()
+ .from(sites)
+ .innerJoin(newts, eq(sites.siteId, newts.siteId))
+ .where(
+ and(
+ eq(sites.siteId, newSite.siteId),
+ eq(sites.orgId, orgId),
+ eq(sites.type, "newt"),
+ isNotNull(sites.pubKey)
+ )
)
- )
- .limit(1);
+ .limit(1);
+
+ if (!site) {
+ logger.debug(
+ `No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
+ );
+ good = false;
+ break;
+ }
- if (!newSite) {
logger.debug(
- `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
+ `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
- continue;
}
- logger.debug(
- `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
- );
+ if (!good) {
+ continue;
+ }
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
- {
- siteId: newSite.sites.siteId,
- orgId: newSite.sites.orgId
- },
+ result.newSites.map((site) => ({
+ siteId: site.siteId,
+ orgId: result.newSiteResource.orgId
+ })),
trx
);
}
diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts
index 80c691c63..2ad36cd9f 100644
--- a/server/lib/blueprints/clientResources.ts
+++ b/server/lib/blueprints/clientResources.ts
@@ -3,8 +3,10 @@ import {
clientSiteResources,
roles,
roleSiteResources,
+ Site,
SiteResource,
siteResources,
+ siteSiteResources,
Transaction,
userOrgs,
users,
@@ -19,6 +21,8 @@ import { getNextAvailableAliasAddress } from "../ip";
export type ClientResourcesResults = {
newSiteResource: SiteResource;
oldSiteResource?: SiteResource;
+ newSites: { siteId: number }[];
+ oldSites: { siteId: number }[];
}[];
export async function updateClientResources(
@@ -43,36 +47,75 @@ export async function updateClientResources(
)
.limit(1);
- const resourceSiteId = resourceData.site;
- let site;
-
- if (resourceSiteId) {
- // Look up site by niceId
- [site] = await trx
- .select({ siteId: sites.siteId })
- .from(sites)
- .where(
- and(
- eq(sites.niceId, resourceSiteId),
- eq(sites.orgId, orgId)
+ const existingSiteIds = await trx
+ .select({ siteId: sites.siteId })
+ .from(siteSiteResources)
+ .where(
+ and(
+ eq(
+ siteSiteResources.siteResourceId,
+ existingResource.siteResourceId
)
)
- .limit(1);
- } else if (siteId) {
- // Use the provided siteId directly, but verify it belongs to the org
- [site] = await trx
- .select({ siteId: sites.siteId })
- .from(sites)
- .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
- .limit(1);
- } else {
- throw new Error(`Target site is required`);
+ );
+
+ let allSites: { siteId: number }[] = [];
+ if (resourceData.site) {
+ let siteSingle;
+ const resourceSiteId = resourceData.site;
+
+ if (resourceSiteId) {
+ // Look up site by niceId
+ [siteSingle] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(
+ eq(sites.niceId, resourceSiteId),
+ eq(sites.orgId, orgId)
+ )
+ )
+ .limit(1);
+ } else if (siteId) {
+ // Use the provided siteId directly, but verify it belongs to the org
+ [siteSingle] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
+ )
+ .limit(1);
+ } else {
+ throw new Error(`Target site is required`);
+ }
+
+ if (!siteSingle) {
+ throw new Error(
+ `Site not found: ${resourceSiteId} in org ${orgId}`
+ );
+ }
+ allSites.push(siteSingle);
}
- if (!site) {
- throw new Error(
- `Site not found: ${resourceSiteId} in org ${orgId}`
- );
+ if (resourceData.sites) {
+ for (const siteNiceId of resourceData.sites) {
+ const [site] = await trx
+ .select({ siteId: sites.siteId })
+ .from(sites)
+ .where(
+ and(
+ eq(sites.niceId, siteNiceId),
+ eq(sites.orgId, orgId)
+ )
+ )
+ .limit(1);
+ if (!site) {
+ throw new Error(
+ `Site not found: ${siteId} in org ${orgId}`
+ );
+ }
+ allSites.push(site);
+ }
}
if (existingResource) {
@@ -81,7 +124,6 @@ export async function updateClientResources(
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
- siteId: site.siteId,
mode: resourceData.mode,
destination: resourceData.destination,
enabled: true, // hardcoded for now
@@ -102,6 +144,17 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
+ await trx
+ .delete(siteSiteResources)
+ .where(eq(siteSiteResources.siteResourceId, siteResourceId));
+
+ for (const site of allSites) {
+ await trx.insert(siteSiteResources).values({
+ siteId: site.siteId,
+ siteResourceId: siteResourceId
+ });
+ }
+
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -204,7 +257,9 @@ export async function updateClientResources(
results.push({
newSiteResource: updatedResource,
- oldSiteResource: existingResource
+ oldSiteResource: existingResource,
+ newSites: allSites,
+ oldSites: existingSiteIds
});
} else {
let aliasAddress: string | null = null;
@@ -218,7 +273,6 @@ export async function updateClientResources(
.insert(siteResources)
.values({
orgId: orgId,
- siteId: site.siteId,
niceId: resourceNiceId,
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
@@ -235,6 +289,13 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId;
+ for (const site of allSites) {
+ await trx.insert(siteSiteResources).values({
+ siteId: site.siteId,
+ siteResourceId: siteResourceId
+ });
+ }
+
const [adminRole] = await trx
.select()
.from(roles)
@@ -324,7 +385,11 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
- results.push({ newSiteResource: newResource });
+ results.push({
+ newSiteResource: newResource,
+ newSites: allSites,
+ oldSites: existingSiteIds
+ });
}
}
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index 2239e4f9a..efbdb3891 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -312,7 +312,8 @@ export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr"]),
- site: z.string(),
+ site: z.string(), // DEPRECATED IN FAVOR OF sites
+ sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index 273c7c022..4fa8c9960 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -366,18 +366,23 @@ export async function createSiteResource(
);
}
- // Not sure what this is doing??
- // const [newt] = await trx
- // .select()
- // .from(newts)
- // .where(eq(newts.siteId, site.siteId))
- // .limit(1);
+ for (const siteToAssign of sitesToAssign) {
+ const [newt] = await trx
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteToAssign.siteId))
+ .limit(1);
+
+ if (!newt) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Newt not found for site ${siteToAssign.siteId}`
+ )
+ );
+ }
+ }
- // if (!newt) {
- // return next(
- // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
- // );
- // }
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index 3320aa3b7..40736f7c0 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -1,4 +1,4 @@
-import { db, SiteResource, siteResources, sites } from "@server/db";
+import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -73,9 +73,9 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & {
- siteName: string;
- siteNiceId: string;
- siteAddress: string | null;
+ siteNames: string[];
+ siteNiceIds: string[];
+ siteAddresses: (string | null)[];
})[];
}>;
@@ -83,7 +83,6 @@ function querySiteResourcesBase() {
return db
.select({
siteResourceId: siteResources.siteResourceId,
- siteId: siteResources.siteId,
orgId: siteResources.orgId,
niceId: siteResources.niceId,
name: siteResources.name,
@@ -100,14 +99,17 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
- siteName: sites.name,
- siteNiceId: sites.niceId,
- siteAddress: sites.address
+ siteNames: sql`array_agg(${sites.name})`,
+ siteNiceIds: sql`array_agg(${sites.niceId})`,
+ siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
})
.from(siteResources)
- .innerJoin(sites, eq(siteResources.siteId, sites.siteId));
+ .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId))
+ .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId))
+ .groupBy(siteResources.siteResourceId);
}
+
registry.registerPath({
method: "get",
path: "/org/{orgId}/site-resources",
From b7421e47ccf8da49cd6e334f6324b70c9dd307e6 Mon Sep 17 00:00:00 2001
From: Owen
Date: Thu, 19 Mar 2026 21:22:04 -0700
Subject: [PATCH 04/25] Switch to using networks
---
server/db/pg/schema/schema.ts | 31 ++++++++++++++++++++++++++-----
server/db/sqlite/schema/schema.ts | 25 ++++++++++++++++++++-----
2 files changed, 46 insertions(+), 10 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 685cca0f2..d76f4241d 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -81,6 +81,10 @@ export const sites = pgTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
+ networkId: integer("networkId").references(
+ () => networks.networkId,
+ { onDelete: "set null" }
+ ),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
@@ -219,6 +223,16 @@ export const siteResources = pgTable("siteResources", {
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
+ networkId: integer("networkId").references(
+ () => networks.networkId,
+ { onDelete: "set null" }
+ ),
+ defaultNetworkId: integer("defaultNetworkId").references(
+ () => networks.networkId,
+ {
+ onDelete: "restrict"
+ }
+ ),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -238,13 +252,19 @@ export const siteResources = pgTable("siteResources", {
.default("site")
});
-export const siteSiteResources = pgTable("siteSiteResources", {
- siteId: integer("siteId")
+export const networks = pgTable("networks", {
+ networkId: serial("networkId").primaryKey(),
+ niceId: text("niceId").notNull(),
+ name: text("name").notNull(),
+ scope: varchar("scope")
+ .$type<"global" | "resource">()
.notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
- siteResourceId: integer("siteResourceId")
+ .default("global"),
+ orgId: varchar("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
.notNull()
- .references(() => siteResources.siteResourceId, { onDelete: "cascade" })
});
export const clientSiteResources = pgTable("clientSiteResources", {
@@ -1080,3 +1100,4 @@ export type RequestAuditLog = InferSelectModel;
export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
+export type Network = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 20fca1c94..c1555d7ee 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -82,6 +82,9 @@ export const sites = sqliteTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet"),
@@ -242,6 +245,13 @@ export const siteResources = sqliteTable("siteResources", {
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
+ defaultNetworkId: integer("defaultNetworkId").references(
+ () => networks.networkId,
+ { onDelete: "restrict" }
+ ),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -263,13 +273,17 @@ export const siteResources = sqliteTable("siteResources", {
.default("site")
});
-export const siteSiteResources = sqliteTable("siteSiteResources", {
- siteId: integer("siteId")
+export const networks = sqliteTable("networks", {
+ networkId: integer("networkId").primaryKey({ autoIncrement: true }),
+ niceId: text("niceId").notNull(),
+ name: text("name").notNull(),
+ scope: text("scope")
+ .$type<"global" | "resource">()
.notNull()
- .references(() => sites.siteId, { onDelete: "cascade" }),
- siteResourceId: integer("siteResourceId")
+ .default("global"),
+ orgId: text("orgId")
.notNull()
- .references(() => siteResources.siteResourceId, { onDelete: "cascade" })
+ .references(() => orgs.orgId, { onDelete: "cascade" })
});
export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -1164,6 +1178,7 @@ export type ApiKey = InferSelectModel;
export type ApiKeyAction = InferSelectModel;
export type ApiKeyOrg = InferSelectModel;
export type SiteResource = InferSelectModel;
+export type Network = InferSelectModel;
export type OrgDomains = InferSelectModel;
export type SetupToken = InferSelectModel;
export type HostMeta = InferSelectModel;
From 6f2e37948c089b40877155cbad3d4b278d649cfe Mon Sep 17 00:00:00 2001
From: Owen
Date: Thu, 19 Mar 2026 21:30:00 -0700
Subject: [PATCH 05/25] Its many to one now
---
server/db/pg/schema/schema.ts | 22 ++++++++++++++--------
server/db/sqlite/schema/schema.ts | 11 +++++++++++
2 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index d76f4241d..d4817283c 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -81,10 +81,6 @@ export const sites = pgTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
- networkId: integer("networkId").references(
- () => networks.networkId,
- { onDelete: "set null" }
- ),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
@@ -223,10 +219,9 @@ export const siteResources = pgTable("siteResources", {
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
- networkId: integer("networkId").references(
- () => networks.networkId,
- { onDelete: "set null" }
- ),
+ networkId: integer("networkId").references(() => networks.networkId, {
+ onDelete: "set null"
+ }),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
@@ -267,6 +262,17 @@ export const networks = pgTable("networks", {
.notNull()
});
+export const siteNetworks = pgTable("siteNetworks", {
+ siteId: integer("siteId")
+ .notNull()
+ .references(() => sites.siteId, {
+ onDelete: "cascade"
+ }),
+ networkId: integer("networkId")
+ .notNull()
+ .references(() => networks.networkId, { onDelete: "cascade" })
+});
+
export const clientSiteResources = pgTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index c1555d7ee..2578e236d 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -286,6 +286,17 @@ export const networks = sqliteTable("networks", {
.references(() => orgs.orgId, { onDelete: "cascade" })
});
+export const siteNetworks = sqliteTable("siteNetworks", {
+ siteId: integer("siteId")
+ .notNull()
+ .references(() => sites.siteId, {
+ onDelete: "cascade"
+ }),
+ networkId: integer("networkId")
+ .notNull()
+ .references(() => networks.networkId, { onDelete: "cascade" })
+});
+
export const clientSiteResources = sqliteTable("clientSiteResources", {
clientId: integer("clientId")
.notNull()
From 2093bb5357ca4beb70a98235119b28796b7dda2b Mon Sep 17 00:00:00 2001
From: Owen
Date: Thu, 19 Mar 2026 21:44:59 -0700
Subject: [PATCH 06/25] Remove siteSiteResources
---
server/db/pg/schema/schema.ts | 4 +-
server/db/sqlite/schema/schema.ts | 4 +-
server/lib/blueprints/clientResources.ts | 55 +++++++++++--------
.../siteResource/createSiteResource.ts | 30 ++++++++--
.../siteResource/listAllSiteResourcesByOrg.ts | 8 ++-
.../siteResource/updateSiteResource.ts | 34 +++++++-----
6 files changed, 87 insertions(+), 48 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index d4817283c..bb4a096df 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", {
export const networks = pgTable("networks", {
networkId: serial("networkId").primaryKey(),
- niceId: text("niceId").notNull(),
- name: text("name").notNull(),
+ niceId: text("niceId"),
+ name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 2578e236d..c28816883 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", {
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
- niceId: text("niceId").notNull(),
- name: text("name").notNull(),
+ niceId: text("niceId"),
+ name: text("name"),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts
index 2ad36cd9f..42c3b76da 100644
--- a/server/lib/blueprints/clientResources.ts
+++ b/server/lib/blueprints/clientResources.ts
@@ -5,12 +5,13 @@ import {
roleSiteResources,
Site,
SiteResource,
+ siteNetworks,
siteResources,
- siteSiteResources,
Transaction,
userOrgs,
users,
- userSiteResources
+ userSiteResources,
+ networks
} from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm";
@@ -47,17 +48,12 @@ export async function updateClientResources(
)
.limit(1);
- const existingSiteIds = await trx
- .select({ siteId: sites.siteId })
- .from(siteSiteResources)
- .where(
- and(
- eq(
- siteSiteResources.siteResourceId,
- existingResource.siteResourceId
- )
- )
- );
+ const existingSiteIds = existingResource?.networkId
+ ? await trx
+ .select({ siteId: sites.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, existingResource.networkId))
+ : [];
let allSites: { siteId: number }[] = [];
if (resourceData.site) {
@@ -144,15 +140,19 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
- await trx
- .delete(siteSiteResources)
- .where(eq(siteSiteResources.siteResourceId, siteResourceId));
+ if (updatedResource.networkId) {
+ await trx
+ .delete(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, updatedResource.networkId)
+ );
- for (const site of allSites) {
- await trx.insert(siteSiteResources).values({
- siteId: site.siteId,
- siteResourceId: siteResourceId
- });
+ for (const site of allSites) {
+ await trx.insert(siteNetworks).values({
+ siteId: site.siteId,
+ networkId: updatedResource.networkId
+ });
+ }
}
await trx
@@ -268,12 +268,21 @@ export async function updateClientResources(
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
// Create new resource
const [newResource] = await trx
.insert(siteResources)
.values({
orgId: orgId,
niceId: resourceNiceId,
+ networkId: network.networkId,
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
destination: resourceData.destination,
@@ -290,9 +299,9 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: site.siteId,
- siteResourceId: siteResourceId
+ networkId: network.networkId
});
}
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index 4fa8c9960..720b55f6c 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -5,10 +5,11 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
+ networks,
SiteResource,
siteResources,
sites,
- siteSiteResources,
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
@@ -186,7 +187,9 @@ export async function createSiteResource(
.limit(1);
if (sitesToAssign.length !== siteIds.length) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found"));
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
}
const [org] = await db
@@ -288,11 +291,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
+ if (!network) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ `Failed to create network`
+ )
+ );
+ }
+
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
niceId,
orgId,
name,
+ networkId: network.networkId,
mode: mode as "host" | "cidr",
destination,
enabled,
@@ -318,9 +339,9 @@ export async function createSiteResource(
//////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: siteId,
- siteResourceId: siteResourceId
+ networkId: network.networkId
});
}
@@ -383,7 +404,6 @@ export async function createSiteResource(
}
}
-
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
trx
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index 40736f7c0..2d90d69e0 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -1,4 +1,4 @@
-import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db";
+import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -99,13 +99,15 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
+ networkId: siteResources.networkId,
+ defaultNetworkId: siteResources.defaultNetworkId,
siteNames: sql`array_agg(${sites.name})`,
siteNiceIds: sql`array_agg(${sites.niceId})`,
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
})
.from(siteResources)
- .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId))
- .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId))
+ .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
+ .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
}
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index f22c5a047..338957249 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -8,8 +8,9 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
sites,
- siteSiteResources,
+ networks,
Transaction,
userSiteResources
} from "@server/db";
@@ -257,10 +258,14 @@ export async function updateSiteResource(
}
let sitesChanged = false;
- const existingSiteIds = await db
- .select()
- .from(siteSiteResources)
- .where(eq(siteSiteResources.siteResourceId, siteResourceId));
+ const existingSiteIds = existingSiteResource.networkId
+ ? await db
+ .select()
+ .from(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, existingSiteResource.networkId)
+ )
+ : [];
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const newSiteIdSet = new Set(siteIds);
@@ -460,15 +465,17 @@ export async function updateSiteResource(
// delete the site - site resources associations
await trx
- .delete(siteSiteResources)
+ .delete(siteNetworks)
.where(
- eq(siteSiteResources.siteResourceId, siteResourceId)
+ eq(siteNetworks.networkId, updatedSiteResource.networkId!)
+ // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
);
for (const siteId of siteIds) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: siteId,
- siteResourceId: siteResourceId
+ networkId: updatedSiteResource.networkId!
+ // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
});
}
@@ -664,10 +671,11 @@ export async function handleMessagingForUpdatedSiteResource(
)
)
.innerJoin(
- siteSiteResources,
+ siteNetworks,
eq(
- siteSiteResources.siteResourceId,
- siteResources.siteResourceId
+ siteNetworks.networkId,
+ siteResources.networkId
+ // TODO: HERE WE FORCE THE NETWORK TO BE DEFINED BUT THE NETWORK CAN GET DELETED and we need to handle that
)
)
.where(
@@ -676,7 +684,7 @@ export async function handleMessagingForUpdatedSiteResource(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
- eq(siteSiteResources.siteId, site.siteId),
+ eq(siteNetworks.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
From 87524fe8aefad5285cc261bd492afb9e3d575422 Mon Sep 17 00:00:00 2001
From: Owen
Date: Thu, 19 Mar 2026 21:44:59 -0700
Subject: [PATCH 07/25] Remove siteSiteResources
---
server/db/pg/schema/schema.ts | 4 +-
server/db/sqlite/schema/schema.ts | 4 +-
server/lib/blueprints/clientResources.ts | 56 +++++++++++--------
.../siteResource/createSiteResource.ts | 30 ++++++++--
.../siteResource/listAllSiteResourcesByOrg.ts | 8 ++-
.../siteResource/updateSiteResource.ts | 31 +++++-----
6 files changed, 85 insertions(+), 48 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index d4817283c..bb4a096df 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -249,8 +249,8 @@ export const siteResources = pgTable("siteResources", {
export const networks = pgTable("networks", {
networkId: serial("networkId").primaryKey(),
- niceId: text("niceId").notNull(),
- name: text("name").notNull(),
+ niceId: text("niceId"),
+ name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 2578e236d..c28816883 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -275,8 +275,8 @@ export const siteResources = sqliteTable("siteResources", {
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
- niceId: text("niceId").notNull(),
- name: text("name").notNull(),
+ niceId: text("niceId"),
+ name: text("name"),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts
index 2ad36cd9f..dd609936d 100644
--- a/server/lib/blueprints/clientResources.ts
+++ b/server/lib/blueprints/clientResources.ts
@@ -5,12 +5,13 @@ import {
roleSiteResources,
Site,
SiteResource,
+ siteNetworks,
siteResources,
- siteSiteResources,
Transaction,
userOrgs,
users,
- userSiteResources
+ userSiteResources,
+ networks
} from "@server/db";
import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm";
@@ -47,17 +48,12 @@ export async function updateClientResources(
)
.limit(1);
- const existingSiteIds = await trx
- .select({ siteId: sites.siteId })
- .from(siteSiteResources)
- .where(
- and(
- eq(
- siteSiteResources.siteResourceId,
- existingResource.siteResourceId
- )
- )
- );
+ const existingSiteIds = existingResource?.networkId
+ ? await trx
+ .select({ siteId: sites.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, existingResource.networkId))
+ : [];
let allSites: { siteId: number }[] = [];
if (resourceData.site) {
@@ -144,15 +140,19 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
- await trx
- .delete(siteSiteResources)
- .where(eq(siteSiteResources.siteResourceId, siteResourceId));
+ if (updatedResource.networkId) {
+ await trx
+ .delete(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, updatedResource.networkId)
+ );
- for (const site of allSites) {
- await trx.insert(siteSiteResources).values({
- siteId: site.siteId,
- siteResourceId: siteResourceId
- });
+ for (const site of allSites) {
+ await trx.insert(siteNetworks).values({
+ siteId: site.siteId,
+ networkId: updatedResource.networkId
+ });
+ }
}
await trx
@@ -268,12 +268,22 @@ export async function updateClientResources(
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
// Create new resource
const [newResource] = await trx
.insert(siteResources)
.values({
orgId: orgId,
niceId: resourceNiceId,
+ networkId: network.networkId,
+ defaultNetworkId: network.networkId,
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
destination: resourceData.destination,
@@ -290,9 +300,9 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: site.siteId,
- siteResourceId: siteResourceId
+ networkId: network.networkId
});
}
diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts
index 4fa8c9960..720b55f6c 100644
--- a/server/routers/siteResource/createSiteResource.ts
+++ b/server/routers/siteResource/createSiteResource.ts
@@ -5,10 +5,11 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
+ networks,
SiteResource,
siteResources,
sites,
- siteSiteResources,
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
@@ -186,7 +187,9 @@ export async function createSiteResource(
.limit(1);
if (sitesToAssign.length !== siteIds.length) {
- return next(createHttpError(HttpCode.NOT_FOUND, "Some site not found"));
+ return next(
+ createHttpError(HttpCode.NOT_FOUND, "Some site not found")
+ );
}
const [org] = await db
@@ -288,11 +291,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
+ const [network] = await trx
+ .insert(networks)
+ .values({
+ scope: "resource",
+ orgId: orgId
+ })
+ .returning();
+
+ if (!network) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ `Failed to create network`
+ )
+ );
+ }
+
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
niceId,
orgId,
name,
+ networkId: network.networkId,
mode: mode as "host" | "cidr",
destination,
enabled,
@@ -318,9 +339,9 @@ export async function createSiteResource(
//////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: siteId,
- siteResourceId: siteResourceId
+ networkId: network.networkId
});
}
@@ -383,7 +404,6 @@ export async function createSiteResource(
}
}
-
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
trx
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index 40736f7c0..2d90d69e0 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -1,4 +1,4 @@
-import { db, SiteResource, siteResources, sites, siteSiteResources } from "@server/db";
+import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -99,13 +99,15 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
+ networkId: siteResources.networkId,
+ defaultNetworkId: siteResources.defaultNetworkId,
siteNames: sql`array_agg(${sites.name})`,
siteNiceIds: sql`array_agg(${sites.niceId})`,
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
})
.from(siteResources)
- .innerJoin(siteSiteResources, eq(siteResources.siteResourceId, siteSiteResources.siteResourceId))
- .innerJoin(sites, eq(siteSiteResources.siteId, sites.siteId))
+ .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
+ .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
}
diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts
index f22c5a047..f7e1262bb 100644
--- a/server/routers/siteResource/updateSiteResource.ts
+++ b/server/routers/siteResource/updateSiteResource.ts
@@ -8,8 +8,9 @@ import {
orgs,
roles,
roleSiteResources,
+ siteNetworks,
sites,
- siteSiteResources,
+ networks,
Transaction,
userSiteResources
} from "@server/db";
@@ -257,10 +258,14 @@ export async function updateSiteResource(
}
let sitesChanged = false;
- const existingSiteIds = await db
- .select()
- .from(siteSiteResources)
- .where(eq(siteSiteResources.siteResourceId, siteResourceId));
+ const existingSiteIds = existingSiteResource.networkId
+ ? await db
+ .select()
+ .from(siteNetworks)
+ .where(
+ eq(siteNetworks.networkId, existingSiteResource.networkId)
+ )
+ : [];
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const newSiteIdSet = new Set(siteIds);
@@ -460,15 +465,15 @@ export async function updateSiteResource(
// delete the site - site resources associations
await trx
- .delete(siteSiteResources)
+ .delete(siteNetworks)
.where(
- eq(siteSiteResources.siteResourceId, siteResourceId)
+ eq(siteNetworks.networkId, updatedSiteResource.networkId!)
);
for (const siteId of siteIds) {
- await trx.insert(siteSiteResources).values({
+ await trx.insert(siteNetworks).values({
siteId: siteId,
- siteResourceId: siteResourceId
+ networkId: updatedSiteResource.networkId!
});
}
@@ -664,10 +669,10 @@ export async function handleMessagingForUpdatedSiteResource(
)
)
.innerJoin(
- siteSiteResources,
+ siteNetworks,
eq(
- siteSiteResources.siteResourceId,
- siteResources.siteResourceId
+ siteNetworks.networkId,
+ siteResources.networkId
)
)
.where(
@@ -676,7 +681,7 @@ export async function handleMessagingForUpdatedSiteResource(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
- eq(siteSiteResources.siteId, site.siteId),
+ eq(siteNetworks.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
From a1ce7f54a0e511b0ffec46e09241588cd0cd4687 Mon Sep 17 00:00:00 2001
From: Owen
Date: Fri, 20 Mar 2026 09:17:10 -0700
Subject: [PATCH 08/25] Continue to rebase
---
server/routers/site/deleteSite.ts | 27 +++++++++++--------
.../siteResource/deleteSiteResource.ts | 21 ++++++++-------
2 files changed, 27 insertions(+), 21 deletions(-)
diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts
index 587572535..344f6b4e3 100644
--- a/server/routers/site/deleteSite.ts
+++ b/server/routers/site/deleteSite.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db, Site, siteResources } from "@server/db";
+import { db, Site, siteNetworks, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -71,18 +71,23 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") {
- // delete all of the site resources on this site
- const siteResourcesOnSite = trx
- .delete(siteResources)
- .where(eq(siteResources.siteId, siteId))
- .returning();
+ const networks = await trx
+ .select({ networkId: siteNetworks.networkId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.siteId, siteId));
// loop through them
- for (const removedSiteResource of await siteResourcesOnSite) {
- await rebuildClientAssociationsFromSiteResource(
- removedSiteResource,
- trx
- );
+ for (const network of await networks) {
+ const [siteResource] = await trx
+ .select()
+ .from(siteResources)
+ .where(eq(siteResources.networkId, network.networkId));
+ if (siteResource) {
+ await rebuildClientAssociationsFromSiteResource(
+ siteResource,
+ trx
+ );
+ }
}
// get the newt on the site by querying the newt table for siteId
diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts
index 5b50b0ea3..8d08d545d 100644
--- a/server/routers/siteResource/deleteSiteResource.ts
+++ b/server/routers/siteResource/deleteSiteResource.ts
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning();
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, removedSiteResource.siteId))
- .limit(1);
+ // not sure why this is here...
+ // const [newt] = await trx
+ // .select()
+ // .from(newts)
+ // .where(eq(newts.siteId, removedSiteResource.siteId))
+ // .limit(1);
- if (!newt) {
- return next(
- createHttpError(HttpCode.NOT_FOUND, "Newt not found")
- );
- }
+ // if (!newt) {
+ // return next(
+ // createHttpError(HttpCode.NOT_FOUND, "Newt not found")
+ // );
+ // }
await rebuildClientAssociationsFromSiteResource(
removedSiteResource,
From d85496453f63e9a420c4dce307eb8538563eef7a Mon Sep 17 00:00:00 2001
From: Owen
Date: Sat, 21 Mar 2026 10:40:12 -0700
Subject: [PATCH 09/25] Change SSH WIP
---
server/private/routers/ssh/signSshKey.ts | 129 ++++++++++++-----------
1 file changed, 70 insertions(+), 59 deletions(-)
diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts
index 5cffb4a34..b9b6fed1a 100644
--- a/server/private/routers/ssh/signSshKey.ts
+++ b/server/private/routers/ssh/signSshKey.ts
@@ -21,7 +21,7 @@ import {
roles,
roundTripMessageTracker,
siteResources,
- sites,
+ siteNetworks,
userOrgs
} from "@server/db";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -62,11 +62,11 @@ const bodySchema = z
export type SignSshKeyResponse = {
certificate: string;
- messageId: number;
+ messageIds: number[];
sshUsername: string;
sshHost: string;
resourceId: number;
- siteId: number;
+ siteIds: number[];
keyId: string;
validPrincipals: string[];
validAfter: string;
@@ -250,10 +250,7 @@ export async function signSshKey(
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
- and(
- eq(userOrgs.orgId, orgId),
- eq(userOrgs.userId, userId)
- )
+ and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
);
} else {
usernameToUse = userOrg.pamUsername;
@@ -374,21 +371,12 @@ export async function signSshKey(
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
- // get the site
- const [newt] = await db
- .select()
- .from(newts)
- .where(eq(newts.siteId, resource.siteId))
- .limit(1);
+ const sites = await db
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, resource.networkId!));
- if (!newt) {
- return next(
- createHttpError(
- HttpCode.INTERNAL_SERVER_ERROR,
- "Site associated with resource not found"
- )
- );
- }
+ const siteIds = sites.map((site) => site.siteId);
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
@@ -402,43 +390,64 @@ export async function signSshKey(
validBefore: now + validFor
});
- const [message] = await db
- .insert(roundTripMessageTracker)
- .values({
- wsClientId: newt.newtId,
- messageType: `newt/pam/connection`,
- sentAt: Math.floor(Date.now() / 1000)
- })
- .returning();
+ const messageIds: number[] = [];
+ for (const siteId of siteIds) {
+ // get the site
+ const [newt] = await db
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
- if (!message) {
- return next(
- createHttpError(
- HttpCode.INTERNAL_SERVER_ERROR,
- "Failed to create message tracker entry"
- )
- );
- }
-
- await sendToClient(newt.newtId, {
- type: `newt/pam/connection`,
- data: {
- messageId: message.messageId,
- orgId: orgId,
- agentPort: resource.authDaemonPort ?? 22123,
- externalAuthDaemon: resource.authDaemonMode === "remote",
- agentHost: resource.destination,
- caCert: caKeys.publicKeyOpenSSH,
- username: usernameToUse,
- niceId: resource.niceId,
- metadata: {
- sudoMode: sudoMode,
- sudoCommands: parsedSudoCommands,
- homedir: homedir,
- groups: parsedGroups
- }
+ if (!newt) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Site associated with resource not found"
+ )
+ );
}
- });
+
+ const [message] = await db
+ .insert(roundTripMessageTracker)
+ .values({
+ wsClientId: newt.newtId,
+ messageType: `newt/pam/connection`,
+ sentAt: Math.floor(Date.now() / 1000)
+ })
+ .returning();
+
+ if (!message) {
+ return next(
+ createHttpError(
+ HttpCode.INTERNAL_SERVER_ERROR,
+ "Failed to create message tracker entry"
+ )
+ );
+ }
+
+ messageIds.push(message.messageId);
+
+ await sendToClient(newt.newtId, {
+ type: `newt/pam/connection`,
+ data: {
+ messageId: message.messageId,
+ orgId: orgId,
+ agentPort: resource.authDaemonPort ?? 22123,
+ externalAuthDaemon: resource.authDaemonMode === "remote",
+ agentHost: resource.destination,
+ caCert: caKeys.publicKeyOpenSSH,
+ username: usernameToUse,
+ niceId: resource.niceId,
+ metadata: {
+ sudoMode: sudoMode,
+ sudoCommands: parsedSudoCommands,
+ homedir: homedir,
+ groups: parsedGroups
+ }
+ }
+ });
+ }
const expiresIn = Number(validFor); // seconds
@@ -459,18 +468,20 @@ export async function signSshKey(
metadata: JSON.stringify({
resourceId: resource.siteResourceId,
resource: resource.name,
- siteId: resource.siteId,
+ siteIds: siteIds
})
});
+ // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS
+
return response(res, {
data: {
certificate: cert.certificate,
- messageId: message.messageId,
+ messageIds: messageIds,
sshUsername: usernameToUse,
sshHost: sshHost,
resourceId: resource.siteResourceId,
- siteId: resource.siteId,
+ siteIds: siteIds,
keyId: cert.keyId,
validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(),
From c48bc714430a6e52b5f39ec24f04c28413f7e7ef Mon Sep 17 00:00:00 2001
From: Owen
Date: Sun, 22 Mar 2026 14:18:34 -0700
Subject: [PATCH 10/25] Update crud endpoints and ui
---
server/private/routers/ssh/signSshKey.ts | 6 +-
.../routers/siteResource/getSiteResource.ts | 13 +--
.../siteResource/listAllSiteResourcesByOrg.ts | 2 +
.../routers/siteResource/listSiteResources.ts | 17 +++-
.../settings/resources/client/page.tsx | 8 +-
src/components/ClientResourcesTable.tsx | 85 +++++++++++++++----
6 files changed, 94 insertions(+), 37 deletions(-)
diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts
index b9b6fed1a..46976bb1d 100644
--- a/server/private/routers/ssh/signSshKey.ts
+++ b/server/private/routers/ssh/signSshKey.ts
@@ -63,10 +63,12 @@ const bodySchema = z
export type SignSshKeyResponse = {
certificate: string;
messageIds: number[];
+ messageId: number;
sshUsername: string;
sshHost: string;
resourceId: number;
siteIds: number[];
+ siteId: number;
keyId: string;
validPrincipals: string[];
validAfter: string;
@@ -472,16 +474,16 @@ export async function signSshKey(
})
});
- // TODO: WE NEED TO MAKE SURE THE MESSAGEIDS ARE BACKWARD COMPATABILE AND THE SITEIDS TOO AND UPDATE THE CLI TO HANDLE THE MESSAGE IDS
-
return response(res, {
data: {
certificate: cert.certificate,
messageIds: messageIds,
+ messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse,
sshHost: sshHost,
resourceId: resource.siteResourceId,
siteIds: siteIds,
+ siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId,
validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(),
diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts
index be28d36e4..2e3dfe87b 100644
--- a/server/routers/siteResource/getSiteResource.ts
+++ b/server/routers/siteResource/getSiteResource.ts
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional())
.optional(),
- siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(),
orgId: z.string()
});
async function query(
siteResourceId?: number,
- siteId?: number,
niceId?: string,
orgId?: string
) {
- if (siteResourceId && siteId && orgId) {
+ if (siteResourceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
- eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1);
return siteResource;
- } else if (niceId && siteId && orgId) {
+ } else if (niceId && orgId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.niceId, niceId),
- eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
@@ -84,7 +80,6 @@ registry.registerPath({
request: {
params: z.object({
niceId: z.string(),
- siteId: z.number(),
orgId: z.string()
})
},
@@ -107,10 +102,10 @@ export async function getSiteResource(
);
}
- const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
+ const { siteResourceId, niceId, orgId } = parsedParams.data;
// Get the site resource
- const siteResource = await query(siteResourceId, siteId, niceId, orgId);
+ const siteResource = await query(siteResourceId, niceId, orgId);
if (!siteResource) {
return next(
diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
index 2d90d69e0..759b06d4d 100644
--- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts
+++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts
@@ -73,6 +73,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & {
+ siteIds: number[];
siteNames: string[];
siteNiceIds: string[];
siteAddresses: (string | null)[];
@@ -103,6 +104,7 @@ function querySiteResourcesBase() {
defaultNetworkId: siteResources.defaultNetworkId,
siteNames: sql`array_agg(${sites.name})`,
siteNiceIds: sql`array_agg(${sites.niceId})`,
+ siteIds: sql`array_agg(${sites.siteId})`,
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
})
.from(siteResources)
diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts
index 358aa0497..8a1469f76 100644
--- a/server/routers/siteResource/listSiteResources.ts
+++ b/server/routers/siteResource/listSiteResources.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db } from "@server/db";
+import { db, networks, siteNetworks } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -108,13 +108,21 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
- // Get site resources
+ // Get site resources by joining networks to siteResources via siteNetworks
const siteResourcesList = await db
.select()
- .from(siteResources)
+ .from(siteNetworks)
+ .innerJoin(
+ networks,
+ eq(siteNetworks.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteResources,
+ eq(siteResources.networkId, networks.networkId)
+ )
.where(
and(
- eq(siteResources.siteId, siteId),
+ eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
@@ -128,6 +136,7 @@ export async function listSiteResources(
.limit(limit)
.offset(offset);
+
return response(res, {
data: { siteResources: siteResourcesList },
success: true,
diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx
index f0f582f0f..8ba3e29e6 100644
--- a/src/app/[orgId]/settings/resources/client/page.tsx
+++ b/src/app/[orgId]/settings/resources/client/page.tsx
@@ -60,17 +60,17 @@ export default async function ClientResourcesPage(
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
- siteName: siteResource.siteName,
- siteAddress: siteResource.siteAddress || null,
+ siteNames: siteResource.siteNames,
+ siteAddresses: siteResource.siteAddresses || null,
mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
- siteId: siteResource.siteId,
+ siteIds: siteResource.siteIds,
destination: siteResource.destination,
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null,
- siteNiceId: siteResource.siteNiceId,
+ siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx
index 5066f273d..a45dc944e 100644
--- a/src/components/ClientResourcesTable.tsx
+++ b/src/components/ClientResourcesTable.tsx
@@ -21,6 +21,7 @@ import {
ArrowUp10Icon,
ArrowUpDown,
ArrowUpRight,
+ ChevronDown,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
@@ -43,14 +44,14 @@ export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
- siteName: string;
- siteAddress: string | null;
+ siteNames: string[];
+ siteAddresses: (string | null)[];
+ siteIds: number[];
+ siteNiceIds: string[];
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
- siteId: number;
- siteNiceId: string;
destination: string;
// destinationPort: number | null;
alias: string | null;
@@ -136,6 +137,60 @@ export default function ClientResourcesTable({
}
};
+ function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
+ const { siteNames, siteNiceIds, orgId } = resourceRow;
+
+ if (!siteNames || siteNames.length === 0) {
+ return -;
+ }
+
+ if (siteNames.length === 1) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {siteNames.map((siteName, idx) => (
+
+
+ {siteName}
+
+
+
+ ))}
+
+
+ );
+ }
+
const internalColumns: ExtendedColumnDef[] = [
{
accessorKey: "name",
@@ -185,21 +240,11 @@ export default function ClientResourcesTable({
}
},
{
- accessorKey: "siteName",
+ accessorKey: "siteNames",
friendlyName: t("site"),
header: () => {t("site")},
cell: ({ row }) => {
- const resourceRow = row.original;
- return (
-
-
-
- );
+ return ;
}
},
{
@@ -399,7 +444,7 @@ export default function ClientResourcesTable({
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
- selectedInternalResource!.siteId
+ selectedInternalResource!.siteIds[0]
)
}
string={selectedInternalResource.name}
@@ -433,7 +478,11 @@ export default function ClientResourcesTable({
{
From c4f48f5748af5eba3045ccba01a56d3f0cba78bf Mon Sep 17 00:00:00 2001
From: Owen
Date: Sun, 22 Mar 2026 14:29:47 -0700
Subject: [PATCH 11/25] WIP - more conversion
---
.../handleOlmServerInitAddPeerHandshake.ts | 240 +++++++++---------
.../olm/handleOlmServerPeerAddMessage.ts | 42 ++-
2 files changed, 135 insertions(+), 147 deletions(-)
diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
index 54badb2dc..0eda41e04 100644
--- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
+++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
@@ -4,10 +4,12 @@ import {
db,
exitNodes,
Site,
- siteResources
+ siteNetworks,
+ siteResources,
+ sites
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
-import { clients, Olm, sites } from "@server/db";
+import { clients, Olm } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers";
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data;
- let site: Site | null = null;
+ const sendCancel = async () => {
+ await sendToClient(
+ olm.olmId,
+ {
+ type: "olm/wg/peer/chain/cancel",
+ data: { chainId }
+ },
+ { incrementConfigVersion: false }
+ ).catch((error) => {
+ logger.warn(`Error sending message:`, error);
+ });
+ };
+
+ let sitesToProcess: Site[] = [];
+
if (siteId) {
- // get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (siteRes) {
- site = siteRes;
+ sitesToProcess = [siteRes];
}
- }
-
- if (resourceId && !site) {
+ } else if (resourceId) {
const resources = await db
.select()
.from(siteResources)
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
- logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Resource not found`
+ );
+ await sendCancel();
return;
}
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error(
- `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
+ `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
}
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) {
logger.error(
- `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
+ `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
+ await sendCancel();
return;
}
- const siteIdFromResource = resource.siteId;
-
- // get the site
- const [siteRes] = await db
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteIdFromResource));
- if (!siteRes) {
+ if (!resource.networkId) {
logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site} not found`
+ `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
+ await sendCancel();
return;
}
- site = siteRes;
+ // Get all sites associated with this resource's network via siteNetworks
+ const siteRows = await db
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(eq(siteNetworks.networkId, resource.networkId));
+
+ if (!siteRows || siteRows.length === 0) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
+ );
+ await sendCancel();
+ return;
+ }
+
+ // Fetch full site objects for all network members
+ const foundSites = await Promise.all(
+ siteRows.map(async ({ siteId: sid }) => {
+ const [s] = await db
+ .select()
+ .from(sites)
+ .where(eq(sites.siteId, sid))
+ .limit(1);
+ return s ?? null;
+ })
+ );
+
+ sitesToProcess = foundSites.filter((s): s is Site => s !== null);
}
- if (!site) {
- logger.error(`handleOlmServerPeerAddMessage: Site not found`);
+ if (sitesToProcess.length === 0) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No sites to process`
+ );
+ await sendCancel();
return;
}
- // check if the client can access this site using the cache
- const currentSiteAssociationCaches = await db
- .select()
- .from(clientSitesAssociationsCache)
- .where(
- and(
- eq(clientSitesAssociationsCache.clientId, client.clientId),
- eq(clientSitesAssociationsCache.siteId, site.siteId)
- )
- );
+ let handshakeInitiated = false;
- if (currentSiteAssociationCaches.length === 0) {
- logger.error(
- `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
- );
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
- olm.olmId,
+ for (const site of sitesToProcess) {
+ // Check if the client can access this site using the cache
+ const currentSiteAssociationCaches = await db
+ .select()
+ .from(clientSitesAssociationsCache)
+ .where(
+ and(
+ eq(clientSitesAssociationsCache.clientId, client.clientId),
+ eq(clientSitesAssociationsCache.siteId, site.siteId)
+ )
+ );
+
+ if (currentSiteAssociationCaches.length === 0) {
+ logger.warn(
+ `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
+ );
+ continue;
+ }
+
+ if (!site.exitNodeId) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
+ );
+ continue;
+ }
+
+ const [exitNode] = await db
+ .select()
+ .from(exitNodes)
+ .where(eq(exitNodes.exitNodeId, site.exitNodeId));
+
+ if (!exitNode) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
+ );
+ continue;
+ }
+
+ // Trigger the peer add handshake — if the peer was already added this will be a no-op
+ await initPeerAddHandshake(
+ client.clientId,
{
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
+ siteId: site.siteId,
+ exitNode: {
+ publicKey: exitNode.publicKey,
+ endpoint: exitNode.endpoint
}
},
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
- return;
- }
-
- if (!site.exitNodeId) {
- logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
- );
- // cancel the request from the olm side to not keep doing this
- await sendToClient(
olm.olmId,
- {
- type: "olm/wg/peer/chain/cancel",
- data: {
- chainId
- }
- },
- { incrementConfigVersion: false }
- ).catch((error) => {
- logger.warn(`Error sending message:`, error);
- });
- return;
- }
-
- // get the exit node from the side
- const [exitNode] = await db
- .select()
- .from(exitNodes)
- .where(eq(exitNodes.exitNodeId, site.exitNodeId));
-
- if (!exitNode) {
- logger.error(
- `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
+ chainId
);
- return;
+
+ handshakeInitiated = true;
}
- // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
- // if it has already been added this will be a no-op
- await initPeerAddHandshake(
- // this will kick off the add peer process for the client
- client.clientId,
- {
- siteId: site.siteId,
- exitNode: {
- publicKey: exitNode.publicKey,
- endpoint: exitNode.endpoint
- }
- },
- olm.olmId,
- chainId
- );
+ if (!handshakeInitiated) {
+ logger.error(
+ `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
+ );
+ await sendCancel();
+ }
return;
-};
+};
\ No newline at end of file
diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts
index 64284f493..5f46ea84c 100644
--- a/server/routers/olm/handleOlmServerPeerAddMessage.ts
+++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts
@@ -1,43 +1,25 @@
import {
- Client,
clientSiteResourcesAssociationsCache,
db,
- ExitNode,
- Org,
- orgs,
- roleClients,
- roles,
+ networks,
+ siteNetworks,
siteResources,
- Transaction,
- userClients,
- userOrgs,
- users
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
clientSitesAssociationsCache,
- exitNodes,
Olm,
- olms,
sites
} from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
-import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger";
-import { listExitNodes } from "#dynamic/lib/exitNodes";
import {
generateAliasConfig,
- getNextAvailableClientSubnet
} from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
-import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
-import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
-import { validateSessionToken } from "@server/auth/sessions/app";
-import config from "@server/lib/config";
import {
addPeer as newtAddPeer,
- deletePeer as newtDeletePeer
} from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId
)
)
- .where(
+ .innerJoin(
+ networks,
+ eq(siteResources.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteNetworks,
and(
- eq(siteResources.siteId, site.siteId),
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- )
+ eq(networks.networkId, siteNetworks.networkId),
+ eq(siteNetworks.siteId, site.siteId)
+ )
+ )
+ .where(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
)
);
From 1366901e24c38e12bd8d96e46f45dd51946e75a2 Mon Sep 17 00:00:00 2001
From: Owen
Date: Sun, 22 Mar 2026 14:40:57 -0700
Subject: [PATCH 12/25] Adjust build functions
---
server/routers/newt/buildConfiguration.ts | 9 +++++++--
server/routers/olm/buildConfiguration.ts | 13 ++++++++++++-
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts
index c3a261f03..875a42c7e 100644
--- a/server/routers/newt/buildConfiguration.ts
+++ b/server/routers/newt/buildConfiguration.ts
@@ -4,8 +4,10 @@ import {
clientSitesAssociationsCache,
db,
ExitNode,
+ networks,
resources,
Site,
+ siteNetworks,
siteResources,
targetHealthCheck,
targets
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null);
- // Get all enabled site resources for this site
+ // Get all enabled site resources for this site by joining through siteNetworks and networks
const allSiteResources = await db
.select()
.from(siteResources)
- .where(eq(siteResources.siteId, siteId));
+ .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
+ .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
+ .where(eq(siteNetworks.siteId, siteId))
+ .then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTarget[] = [];
diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts
index bc2611b1c..4182725d3 100644
--- a/server/routers/olm/buildConfiguration.ts
+++ b/server/routers/olm/buildConfiguration.ts
@@ -4,6 +4,8 @@ import {
clientSitesAssociationsCache,
db,
exitNodes,
+ networks,
+ siteNetworks,
siteResources,
sites
} from "@server/db";
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId
)
)
+ .innerJoin(
+ networks,
+ eq(siteResources.networkId, networks.networkId)
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(networks.networkId, siteNetworks.networkId)
+ )
.where(
and(
- eq(siteResources.siteId, site.siteId),
+ eq(siteNetworks.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
)
);
+
if (jitMode) {
// Add site configuration to the array
siteConfigurations.push({
From 02033f611f102bd1dfd1bab99b82b1cacea2a1c3 Mon Sep 17 00:00:00 2001
From: Owen
Date: Mon, 23 Mar 2026 11:44:02 -0700
Subject: [PATCH 13/25] First pass at HA
---
server/lib/rebuildClientAssociations.ts | 581 +++++++++++++++---------
1 file changed, 355 insertions(+), 226 deletions(-)
diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts
index 121e2c7f0..ece603916 100644
--- a/server/lib/rebuildClientAssociations.ts
+++ b/server/lib/rebuildClientAssociations.ts
@@ -11,6 +11,7 @@ import {
roleSiteResources,
Site,
SiteResource,
+ siteNetworks,
siteResources,
sites,
Transaction,
@@ -47,15 +48,23 @@ export async function getClientSiteResourceAccess(
siteResource: SiteResource,
trx: Transaction | typeof db = db
) {
- // get the site
- const [site] = await trx
- .select()
- .from(sites)
- .where(eq(sites.siteId, siteResource.siteId))
- .limit(1);
+ // get all sites associated with this siteResource via its network
+ const sitesList = siteResource.networkId
+ ? await trx
+ .select()
+ .from(sites)
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.siteId, sites.siteId)
+ )
+ .where(eq(siteNetworks.networkId, siteResource.networkId))
+ .then((rows) => rows.map((row) => row.sites))
+ : [];
- if (!site) {
- throw new Error(`Site with ID ${siteResource.siteId} not found`);
+ if (sitesList.length === 0) {
+ logger.warn(
+ `No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
+ );
}
const roleIds = await trx
@@ -136,7 +145,7 @@ export async function getClientSiteResourceAccess(
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
return {
- site,
+ sitesList,
mergedAllClients,
mergedAllClientIds
};
@@ -152,40 +161,51 @@ export async function rebuildClientAssociationsFromSiteResource(
subnet: string | null;
}[];
}> {
- const siteId = siteResource.siteId;
-
- const { site, mergedAllClients, mergedAllClientIds } =
+ const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(siteResource, trx);
/////////// process the client-siteResource associations ///////////
- // get all of the clients associated with other resources on this site
- const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
- .select({
- clientId: clientSiteResourcesAssociationsCache.clientId
- })
- .from(clientSiteResourcesAssociationsCache)
- .innerJoin(
- siteResources,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(siteResources.siteId, siteId),
- ne(siteResources.siteResourceId, siteResource.siteResourceId)
- )
- );
+ // get all of the clients associated with other resources in the same network,
+ // joined through siteNetworks so we know which siteId each client belongs to
+ const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
+ ? await trx
+ .select({
+ clientId: clientSiteResourcesAssociationsCache.clientId,
+ siteId: siteNetworks.siteId
+ })
+ .from(clientSiteResourcesAssociationsCache)
+ .innerJoin(
+ siteResources,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
+ .where(
+ and(
+ eq(siteResources.networkId, siteResource.networkId),
+ ne(
+ siteResources.siteResourceId,
+ siteResource.siteResourceId
+ )
+ )
+ )
+ : [];
- const allClientIdsFromOtherResourcesOnThisSite = Array.from(
- new Set(
- allUpdatedClientsFromOtherResourcesOnThisSite.map(
- (row) => row.clientId
- )
- )
- );
+ // Build a per-site map so the loop below can check by siteId rather than
+ // across the entire network.
+ const clientsFromOtherResourcesBySite = new Map>();
+ for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
+ if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
+ clientsFromOtherResourcesBySite.set(row.siteId, new Set());
+ }
+ clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
+ }
const existingClientSiteResources = await trx
.select({
@@ -259,82 +279,90 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-site associations ///////////
- const existingClientSites = await trx
- .select({
- clientId: clientSitesAssociationsCache.clientId
- })
- .from(clientSitesAssociationsCache)
- .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
+ for (const site of sitesList) {
+ const siteId = site.siteId;
- const existingClientSiteIds = existingClientSites.map(
- (row) => row.clientId
- );
+ const existingClientSites = await trx
+ .select({
+ clientId: clientSitesAssociationsCache.clientId
+ })
+ .from(clientSitesAssociationsCache)
+ .where(eq(clientSitesAssociationsCache.siteId, siteId));
- // Get full client details for existing clients (needed for sending delete messages)
- const existingClients = await trx
- .select({
- clientId: clients.clientId,
- pubKey: clients.pubKey,
- subnet: clients.subnet
- })
- .from(clients)
- .where(inArray(clients.clientId, existingClientSiteIds));
+ const existingClientSiteIds = existingClientSites.map(
+ (row) => row.clientId
+ );
- const clientSitesToAdd = mergedAllClientIds.filter(
- (clientId) =>
- !existingClientSiteIds.includes(clientId) &&
- !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
- );
+ // Get full client details for existing clients (needed for sending delete messages)
+ const existingClients =
+ existingClientSiteIds.length > 0
+ ? await trx
+ .select({
+ clientId: clients.clientId,
+ pubKey: clients.pubKey,
+ subnet: clients.subnet
+ })
+ .from(clients)
+ .where(inArray(clients.clientId, existingClientSiteIds))
+ : [];
- const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
- clientId,
- siteId
- }));
+ const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set();
- if (clientSitesToInsert.length > 0) {
- await trx
- .insert(clientSitesAssociationsCache)
- .values(clientSitesToInsert)
- .returning();
- }
+ const clientSitesToAdd = mergedAllClientIds.filter(
+ (clientId) =>
+ !existingClientSiteIds.includes(clientId) &&
+ !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
+ );
- // Now remove any client-site associations that should no longer exist
- const clientSitesToRemove = existingClientSiteIds.filter(
- (clientId) =>
- !mergedAllClientIds.includes(clientId) &&
- !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
- );
+ const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
+ clientId,
+ siteId
+ }));
- if (clientSitesToRemove.length > 0) {
- await trx
- .delete(clientSitesAssociationsCache)
- .where(
- and(
- eq(clientSitesAssociationsCache.siteId, siteId),
- inArray(
- clientSitesAssociationsCache.clientId,
- clientSitesToRemove
+ if (clientSitesToInsert.length > 0) {
+ await trx
+ .insert(clientSitesAssociationsCache)
+ .values(clientSitesToInsert)
+ .returning();
+ }
+
+ // Now remove any client-site associations that should no longer exist
+ const clientSitesToRemove = existingClientSiteIds.filter(
+ (clientId) =>
+ !mergedAllClientIds.includes(clientId) &&
+ !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
+ );
+
+ if (clientSitesToRemove.length > 0) {
+ await trx
+ .delete(clientSitesAssociationsCache)
+ .where(
+ and(
+ eq(clientSitesAssociationsCache.siteId, siteId),
+ inArray(
+ clientSitesAssociationsCache.clientId,
+ clientSitesToRemove
+ )
)
- )
- );
+ );
+ }
+
+ // Now handle the messages to add/remove peers on both the newt and olm sides
+ await handleMessagesForSiteClients(
+ site,
+ siteId,
+ mergedAllClients,
+ existingClients,
+ clientSitesToAdd,
+ clientSitesToRemove,
+ trx
+ );
}
- /////////// send the messages ///////////
-
- // Now handle the messages to add/remove peers on both the newt and olm sides
- await handleMessagesForSiteClients(
- site,
- siteId,
- mergedAllClients,
- existingClients,
- clientSitesToAdd,
- clientSitesToRemove,
- trx
- );
-
// Handle subnet proxy target updates for the resource associations
await handleSubnetProxyTargetUpdates(
siteResource,
+ sitesList,
mergedAllClients,
existingResourceClients,
clientSiteResourcesToAdd,
@@ -623,6 +651,7 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource,
+ sitesList: Site[],
allClients: {
clientId: number;
pubKey: string | null;
@@ -637,131 +666,144 @@ async function handleSubnetProxyTargetUpdates(
clientSiteResourcesToRemove: number[],
trx: Transaction | typeof db = db
): Promise {
- // Get the newt for this site
- const [newt] = await trx
- .select()
- .from(newts)
- .where(eq(newts.siteId, siteResource.siteId))
- .limit(1);
+ const proxyJobs: Promise[] = [];
+ const olmJobs: Promise[] = [];
- if (!newt) {
- logger.warn(
- `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
- );
- return;
- }
+ for (const siteData of sitesList) {
+ const siteId = siteData.siteId;
- const proxyJobs = [];
- const olmJobs = [];
- // Generate targets for added associations
- if (clientSiteResourcesToAdd.length > 0) {
- const addedClients = allClients.filter((client) =>
- clientSiteResourcesToAdd.includes(client.clientId)
- );
+ // Get the newt for this site
+ const [newt] = await trx
+ .select()
+ .from(newts)
+ .where(eq(newts.siteId, siteId))
+ .limit(1);
- if (addedClients.length > 0) {
- const targetsToAdd = generateSubnetProxyTargets(
- siteResource,
- addedClients
+ if (!newt) {
+ logger.warn(
+ `Newt not found for site ${siteId}, skipping subnet proxy target updates`
);
-
- if (targetsToAdd.length > 0) {
- logger.info(
- `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
- );
- proxyJobs.push(
- addSubnetProxyTargets(
- newt.newtId,
- targetsToAdd,
- newt.version
- )
- );
- }
-
- for (const client of addedClients) {
- olmJobs.push(
- addPeerData(
- client.clientId,
- siteResource.siteId,
- generateRemoteSubnets([siteResource]),
- generateAliasConfig([siteResource])
- )
- );
- }
+ continue;
}
- }
- // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
-
- // Generate targets for removed associations
- if (clientSiteResourcesToRemove.length > 0) {
- const removedClients = existingClients.filter((client) =>
- clientSiteResourcesToRemove.includes(client.clientId)
- );
-
- if (removedClients.length > 0) {
- const targetsToRemove = generateSubnetProxyTargets(
- siteResource,
- removedClients
+ // Generate targets for added associations
+ if (clientSiteResourcesToAdd.length > 0) {
+ const addedClients = allClients.filter((client) =>
+ clientSiteResourcesToAdd.includes(client.clientId)
);
- if (targetsToRemove.length > 0) {
- logger.info(
- `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
+ if (addedClients.length > 0) {
+ const targetsToAdd = generateSubnetProxyTargets(
+ siteResource,
+ addedClients
);
- proxyJobs.push(
- removeSubnetProxyTargets(
- newt.newtId,
- targetsToRemove,
- newt.version
- )
- );
- }
- for (const client of removedClients) {
- // Check if this client still has access to another resource on this site with the same destination
- const destinationStillInUse = await trx
- .select()
- .from(siteResources)
- .innerJoin(
- clientSiteResourcesAssociationsCache,
- eq(
- clientSiteResourcesAssociationsCache.siteResourceId,
- siteResources.siteResourceId
- )
- )
- .where(
- and(
- eq(
- clientSiteResourcesAssociationsCache.clientId,
- client.clientId
- ),
- eq(siteResources.siteId, siteResource.siteId),
- eq(
- siteResources.destination,
- siteResource.destination
- ),
- ne(
- siteResources.siteResourceId,
- siteResource.siteResourceId
- )
+ if (targetsToAdd.length > 0) {
+ logger.info(
+ `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}`
+ );
+ proxyJobs.push(
+ addSubnetProxyTargets(
+ newt.newtId,
+ targetsToAdd,
+ newt.version
)
);
+ }
- // Only remove remote subnet if no other resource uses the same destination
- const remoteSubnetsToRemove =
- destinationStillInUse.length > 0
- ? []
- : generateRemoteSubnets([siteResource]);
+ for (const client of addedClients) {
+ olmJobs.push(
+ addPeerData(
+ client.clientId,
+ siteId,
+ generateRemoteSubnets([siteResource]),
+ generateAliasConfig([siteResource])
+ )
+ );
+ }
+ }
+ }
- olmJobs.push(
- removePeerData(
- client.clientId,
- siteResource.siteId,
- remoteSubnetsToRemove,
- generateAliasConfig([siteResource])
- )
+ // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
+
+ // Generate targets for removed associations
+ if (clientSiteResourcesToRemove.length > 0) {
+ const removedClients = existingClients.filter((client) =>
+ clientSiteResourcesToRemove.includes(client.clientId)
+ );
+
+ if (removedClients.length > 0) {
+ const targetsToRemove = generateSubnetProxyTargets(
+ siteResource,
+ removedClients
);
+
+ if (targetsToRemove.length > 0) {
+ logger.info(
+ `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId} on site ${siteId}`
+ );
+ proxyJobs.push(
+ removeSubnetProxyTargets(
+ newt.newtId,
+ targetsToRemove,
+ newt.version
+ )
+ );
+ }
+
+ for (const client of removedClients) {
+ // Check if this client still has access to another resource
+ // on this specific site with the same destination. We scope
+ // by siteId (via siteNetworks) rather than networkId because
+ // removePeerData operates per-site — a resource on a different
+ // site sharing the same network should not block removal here.
+ const destinationStillInUse = await trx
+ .select()
+ .from(siteResources)
+ .innerJoin(
+ clientSiteResourcesAssociationsCache,
+ eq(
+ clientSiteResourcesAssociationsCache.siteResourceId,
+ siteResources.siteResourceId
+ )
+ )
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
+ .where(
+ and(
+ eq(
+ clientSiteResourcesAssociationsCache.clientId,
+ client.clientId
+ ),
+ eq(siteNetworks.siteId, siteId),
+ eq(
+ siteResources.destination,
+ siteResource.destination
+ ),
+ ne(
+ siteResources.siteResourceId,
+ siteResource.siteResourceId
+ )
+ )
+ );
+
+ // Only remove remote subnet if no other resource uses the same destination
+ const remoteSubnetsToRemove =
+ destinationStillInUse.length > 0
+ ? []
+ : generateRemoteSubnets([siteResource]);
+
+ olmJobs.push(
+ removePeerData(
+ client.clientId,
+ siteId,
+ remoteSubnetsToRemove,
+ generateAliasConfig([siteResource])
+ )
+ );
+ }
}
}
}
@@ -868,10 +910,25 @@ export async function rebuildClientAssociationsFromClient(
)
: [];
- // Group by siteId for site-level associations
- const newSiteIds = Array.from(
- new Set(newSiteResources.map((sr) => sr.siteId))
+ // Group by siteId for site-level associations — look up via siteNetworks since
+ // siteResources no longer carries a direct siteId column.
+ const networkIds = Array.from(
+ new Set(
+ newSiteResources
+ .map((sr) => sr.networkId)
+ .filter((id): id is number => id !== null)
+ )
);
+ const newSiteIds =
+ networkIds.length > 0
+ ? await trx
+ .select({ siteId: siteNetworks.siteId })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, networkIds))
+ .then((rows) =>
+ Array.from(new Set(rows.map((r) => r.siteId)))
+ )
+ : [];
/////////// Process client-siteResource associations ///////////
@@ -1144,13 +1201,45 @@ async function handleMessagesForClientResources(
resourcesToAdd.includes(r.siteResourceId)
);
+ // Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
+ const addedNetworkIds = Array.from(
+ new Set(
+ addedResources
+ .map((r) => r.networkId)
+ .filter((id): id is number => id !== null)
+ )
+ );
+ const addedSiteNetworkRows =
+ addedNetworkIds.length > 0
+ ? await trx
+ .select({
+ networkId: siteNetworks.networkId,
+ siteId: siteNetworks.siteId
+ })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, addedNetworkIds))
+ : [];
+ const addedNetworkToSites = new Map();
+ for (const row of addedSiteNetworkRows) {
+ if (!addedNetworkToSites.has(row.networkId)) {
+ addedNetworkToSites.set(row.networkId, []);
+ }
+ addedNetworkToSites.get(row.networkId)!.push(row.siteId);
+ }
+
// Group by site for proxy updates
const addedBySite = new Map();
for (const resource of addedResources) {
- if (!addedBySite.has(resource.siteId)) {
- addedBySite.set(resource.siteId, []);
+ const siteIds =
+ resource.networkId != null
+ ? (addedNetworkToSites.get(resource.networkId) ?? [])
+ : [];
+ for (const siteId of siteIds) {
+ if (!addedBySite.has(siteId)) {
+ addedBySite.set(siteId, []);
+ }
+ addedBySite.get(siteId)!.push(resource);
}
- addedBySite.get(resource.siteId)!.push(resource);
}
// Add subnet proxy targets for each site
@@ -1192,7 +1281,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
addPeerData(
client.clientId,
- resource.siteId,
+ siteId,
generateRemoteSubnets([resource]),
generateAliasConfig([resource])
)
@@ -1204,7 +1293,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found")
) {
logger.debug(
- `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
+ `Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
);
} else {
throw error;
@@ -1221,13 +1310,45 @@ async function handleMessagesForClientResources(
.from(siteResources)
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
+ // Build (resource, siteId) pairs via siteNetworks
+ const removedNetworkIds = Array.from(
+ new Set(
+ removedResources
+ .map((r) => r.networkId)
+ .filter((id): id is number => id !== null)
+ )
+ );
+ const removedSiteNetworkRows =
+ removedNetworkIds.length > 0
+ ? await trx
+ .select({
+ networkId: siteNetworks.networkId,
+ siteId: siteNetworks.siteId
+ })
+ .from(siteNetworks)
+ .where(inArray(siteNetworks.networkId, removedNetworkIds))
+ : [];
+ const removedNetworkToSites = new Map();
+ for (const row of removedSiteNetworkRows) {
+ if (!removedNetworkToSites.has(row.networkId)) {
+ removedNetworkToSites.set(row.networkId, []);
+ }
+ removedNetworkToSites.get(row.networkId)!.push(row.siteId);
+ }
+
// Group by site for proxy updates
const removedBySite = new Map();
for (const resource of removedResources) {
- if (!removedBySite.has(resource.siteId)) {
- removedBySite.set(resource.siteId, []);
+ const siteIds =
+ resource.networkId != null
+ ? (removedNetworkToSites.get(resource.networkId) ?? [])
+ : [];
+ for (const siteId of siteIds) {
+ if (!removedBySite.has(siteId)) {
+ removedBySite.set(siteId, []);
+ }
+ removedBySite.get(siteId)!.push(resource);
}
- removedBySite.get(resource.siteId)!.push(resource);
}
// Remove subnet proxy targets for each site
@@ -1265,7 +1386,11 @@ async function handleMessagesForClientResources(
}
try {
- // Check if this client still has access to another resource on this site with the same destination
+ // Check if this client still has access to another resource
+ // on this specific site with the same destination. We scope
+ // by siteId (via siteNetworks) rather than networkId because
+ // removePeerData operates per-site — a resource on a different
+ // site sharing the same network should not block removal here.
const destinationStillInUse = await trx
.select()
.from(siteResources)
@@ -1276,13 +1401,17 @@ async function handleMessagesForClientResources(
siteResources.siteResourceId
)
)
+ .innerJoin(
+ siteNetworks,
+ eq(siteNetworks.networkId, siteResources.networkId)
+ )
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
- eq(siteResources.siteId, resource.siteId),
+ eq(siteNetworks.siteId, siteId),
eq(
siteResources.destination,
resource.destination
@@ -1304,7 +1433,7 @@ async function handleMessagesForClientResources(
olmJobs.push(
removePeerData(
client.clientId,
- resource.siteId,
+ siteId,
remoteSubnetsToRemove,
generateAliasConfig([resource])
)
@@ -1316,7 +1445,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found")
) {
logger.debug(
- `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
+ `Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
);
} else {
throw error;
From 81eba50c9a3a6b5353e4baa58ba2216ccbd7401b Mon Sep 17 00:00:00 2001
From: Laurence
Date: Mon, 6 Apr 2026 14:03:33 +0100
Subject: [PATCH 14/25] fix: use targetId as row identifier
fix: 2797
---
src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
index 3d6e6186b..a9128b9d3 100644
--- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx
@@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
From 7d3d5b2b22aafa4e0b6bc5584ec65ba405c80878 Mon Sep 17 00:00:00 2001
From: Laurence
Date: Mon, 6 Apr 2026 14:17:04 +0100
Subject: [PATCH 15/25] use targetid also on proxy create as that also has same
issue
---
src/app/[orgId]/settings/resources/proxy/create/page.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
index f057c07c4..f5c20d8cc 100644
--- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx
+++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx
@@ -999,6 +999,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
From 028df8bf27a8c5d8879ce931974f7787acb3083e Mon Sep 17 00:00:00 2001
From: Joshua Belke
Date: Tue, 7 Apr 2026 14:58:27 -0400
Subject: [PATCH 16/25] fix: remove encodeURIComponent from invite link email
parameter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The @ symbol in email addresses was being encoded as %40 when
constructing invite URLs, causing broken or garbled links when
copied/shared by users.
- Remove encodeURIComponent(email) from server-side invite link
construction in inviteUser.ts (both new invite and regenerate paths)
- Remove encodeURIComponent(email) from client-side redirect URLs in
InviteStatusCard.tsx (login, signup, and useEffect redirect paths)
- Valid Zod-validated email addresses do not contain characters that
require URL encoding for safe query parameter use (@ is permitted
in query strings per RFC 3986 §3.4)
---
server/routers/user/inviteUser.ts | 22 ++++++++++++++--------
src/components/InviteStatusCard.tsx | 18 ++++++++++++------
2 files changed, 26 insertions(+), 14 deletions(-)
diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts
index 7ac1849b9..b11586e69 100644
--- a/server/routers/user/inviteUser.ts
+++ b/server/routers/user/inviteUser.ts
@@ -1,7 +1,14 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
-import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
+import {
+ orgs,
+ roles,
+ userInviteRoles,
+ userInvites,
+ userOrgs,
+ users
+} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
regenerate: z.boolean().optional()
})
.refine(
- (d) =>
- (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
+ (d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
@@ -265,7 +271,7 @@ export async function inviteUser(
)
);
- const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
+ const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) {
await sendEmail(
@@ -314,12 +320,12 @@ export async function inviteUser(
expiresAt,
tokenHash
});
- await trx.insert(userInviteRoles).values(
- uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
- );
+ await trx
+ .insert(userInviteRoles)
+ .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
});
- const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
+ const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
if (doEmail) {
await sendEmail(
diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx
index 417fa9892..5de8f25fd 100644
--- a/src/components/InviteStatusCard.tsx
+++ b/src/components/InviteStatusCard.tsx
@@ -39,7 +39,11 @@ export default function InviteStatusCard({
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [type, setType] = useState<
- "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
+ | "rejected"
+ | "wrong_user"
+ | "user_does_not_exist"
+ | "not_logged_in"
+ | "user_limit_exceeded"
>("rejected");
useEffect(() => {
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
if (!user && type === "user_does_not_exist") {
const redirectUrl = email
- ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else if (!user && type === "not_logged_in") {
const redirectUrl = email
- ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
} else {
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() {
await api.post("/auth/logout", {});
const redirectUrl = email
- ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
async function goToSignup() {
await api.post("/auth/logout", {});
const redirectUrl = email
- ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
+ ? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl);
}
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
Cannot Accept Invite
- This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
+ This organization has reached its user limit. Please
+ contact the organization administrator to upgrade their
+ plan before accepting this invite.