Compare commits

..

15 Commits

Author SHA1 Message Date
Owen Schwartz
741850880e Merge pull request #2959 from fosrl/dev
1.18.1-s.4
2026-05-01 15:05:59 -07:00
Owen
53e096f7cb Allow deleting account with trial 2026-05-01 15:01:48 -07:00
Owen
3dfd7e8a43 Update limits 2026-05-01 11:47:14 -07:00
Owen
db6e60d0a3 Adjust language 2026-05-01 10:48:09 -07:00
Owen
54d2d689c1 Run messaging for delete in the background as well 2026-04-30 14:38:03 -07:00
Owen Schwartz
bb5853827b Merge pull request #2948 from fosrl/dev
1.18.1-s.3
2026-04-30 14:11:16 -07:00
Owen
68f5512732 Handle messaging in the background; dont time out 2026-04-30 14:00:32 -07:00
Owen
416e124c02 Rotate the secret on the new things using it 2026-04-30 11:53:55 -07:00
Owen
d3e4d8cda8 Fix pr blueprints not picking up site 2026-04-30 11:39:37 -07:00
Owen
81972dbb73 Add name to migration
Fixes #2943
2026-04-30 10:56:12 -07:00
Owen Schwartz
b715786a1e Merge pull request #2939 from fosrl/dev
1.18.1-s.2
2026-04-29 21:33:03 -07:00
Owen
ae24eb2d2c Disable the alerts and hc when downgrading 2026-04-29 21:31:02 -07:00
Owen
20fc59dcda Delete trial when upgrading 2026-04-29 21:25:58 -07:00
Owen
93b09de425 Adjust cloud api endpoints 2026-04-29 21:04:11 -07:00
Owen
bacc130453 Clean up sign and verify 2026-04-29 17:14:22 -07:00
18 changed files with 389 additions and 181 deletions

View File

@@ -414,28 +414,18 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign - name: Install cosign
# cosign is used to sign and verify container images (key and keyless) # cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Dual-sign and verify (GHCR & Docker Hub) - name: Sign (GHCR, keyless)
# Sign each image by digest using keyless (OIDC) and key-based signing, # Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
# then verify both the public key signature and the keyless OIDC signature. # Signatures are stored in the registry alongside the image.
env: env:
TAG: ${{ env.TAG }} TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true" COSIGN_YES: "true"
run: | run: |
set -euo pipefail set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release # Determine if this is an RC release
IS_RC="false" IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then if [[ "$TAG" == *"-rc."* ]]; then
@@ -463,95 +453,47 @@ jobs:
) )
fi fi
# Sign each image variant for both registries FAILED_TAGS=()
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do SUCCESSFUL_TAGS=()
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false
# Wrap the entire tag processing in error handling for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
( echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
set -e TAG_FAILED=false
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}" (
cosign sign --recursive "${REF}" set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}"
echo "==> cosign sign (key) --recursive ${REF}" echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" cosign sign --recursive "${REF}"
) || TAG_FAILED=true
# Retry wrapper for verification to handle registry propagation delays if [ "$TAG_FAILED" = "true" ]; then
retry_verify() { echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
local cmd="$1" FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
local attempts=6 else
local delay=5 echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
local i=1 SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
until eval "$cmd"; do fi
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
fi
done
done done
# Report summary
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Sign and Verify Summary" echo "Sign Summary"
echo "==========================================" echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:" echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag" echo " - $tag"
done done
echo "" echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else else
echo "✓ All images signed and verified successfully!" echo "✓ All images signed successfully!"
fi fi
shell: bash shell: bash

View File

@@ -1,5 +1,5 @@
import { CommandModule } from "yargs"; import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey } from "@server/db"; import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto"; import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -129,9 +129,15 @@ export const rotateServerSecret: CommandModule<
console.log("\nReading encrypted data from database..."); console.log("\nReading encrypted data from database...");
const idpConfigs = await db.select().from(idpOidcConfig); const idpConfigs = await db.select().from(idpOidcConfig);
const licenseKeys = await db.select().from(licenseKey); const licenseKeys = await db.select().from(licenseKey);
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`); console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
// Prepare all decrypted and re-encrypted values // Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values..."); console.log("\nDecrypting and re-encrypting values...");
@@ -149,8 +155,27 @@ export const rotateServerSecret: CommandModule<
encryptedInstanceId: string; encryptedInstanceId: string;
}; };
type CertUpdate = {
certId: number;
encryptedCertFile: string | null;
encryptedKeyFile: string | null;
};
type StreamingDestinationUpdate = {
destinationId: number;
encryptedConfig: string;
};
type WebhookActionUpdate = {
webhookActionId: number;
encryptedConfig: string;
};
const idpUpdates: IdpUpdate[] = []; const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
// Process idpOidcConfig entries // Process idpOidcConfig entries
for (const idpConfig of idpConfigs) { for (const idpConfig of idpConfigs) {
@@ -217,6 +242,70 @@ export const rotateServerSecret: CommandModule<
} }
} }
// Process certificate entries
for (const cert of certs) {
try {
const encryptedCertFile = cert.certFile
? encrypt(decrypt(cert.certFile, oldSecret), newSecret)
: null;
const encryptedKeyFile = cert.keyFile
? encrypt(decrypt(cert.keyFile, oldSecret), newSecret)
: null;
certUpdates.push({
certId: cert.certId,
encryptedCertFile,
encryptedKeyFile
});
} catch (error) {
console.error(
`Error processing certificate ${cert.certId} (${cert.domain}):`,
error
);
throw error;
}
}
// Process eventStreamingDestinations entries
for (const dest of streamingDestinations) {
try {
const decryptedConfig = decrypt(dest.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
streamingDestinationUpdates.push({
destinationId: dest.destinationId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing event streaming destination ${dest.destinationId}:`,
error
);
throw error;
}
}
// Process alertWebhookActions entries
for (const webhook of webhookActions) {
try {
if (webhook.config == null) continue;
const decryptedConfig = decrypt(webhook.config, oldSecret);
const encryptedConfig = encrypt(decryptedConfig, newSecret);
webhookActionUpdates.push({
webhookActionId: webhook.webhookActionId,
encryptedConfig
});
} catch (error) {
console.error(
`Error processing alert webhook action ${webhook.webhookActionId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction // Perform all database updates in a single transaction
console.log("\nUpdating database in transaction..."); console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@@ -250,10 +339,50 @@ export const rotateServerSecret: CommandModule<
instanceId: update.encryptedInstanceId instanceId: update.encryptedInstanceId
}); });
} }
// Update certificate entries
for (const update of certUpdates) {
await trx
.update(certificates)
.set({
certFile: update.encryptedCertFile,
keyFile: update.encryptedKeyFile
})
.where(eq(certificates.certId, update.certId));
}
// Update event streaming destination entries
for (const update of streamingDestinationUpdates) {
await trx
.update(eventStreamingDestinations)
.set({ config: update.encryptedConfig })
.where(
eq(
eventStreamingDestinations.destinationId,
update.destinationId
)
);
}
// Update alert webhook action entries
for (const update of webhookActionUpdates) {
await trx
.update(alertWebhookActions)
.set({ config: update.encryptedConfig })
.where(
eq(
alertWebhookActions.webhookActionId,
update.webhookActionId
)
);
}
}); });
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`);
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
// Update config file with new secret // Update config file with new secret
console.log("\nUpdating config file..."); console.log("\nUpdating config file...");
@@ -270,6 +399,9 @@ export const rotateServerSecret: CommandModule<
console.log(`\nSummary:`); console.log(`\nSummary:`);
console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`);
console.log(` - License keys: ${licenseKeyUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`);
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log( console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.` `\n IMPORTANT: Restart the server for the new secret to take effect.`
); );

View File

@@ -122,8 +122,6 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain", createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain", deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain", restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode", createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode", getRemoteExitNode = "getRemoteExitNode",

View File

@@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
}); });
export const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -21,6 +21,9 @@ import {
targetHealthCheck, targetHealthCheck,
users users
} from "./schema"; } from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
lastSentAt: integer("lastSentAt") lastSentAt: integer("lastSentAt")
}); });
export const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -64,7 +64,7 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
Some features and resources may now be Some features and resources may now be
restricted or disconnected. To restore full restricted. To restore full
access and continue using all the features access and continue using all the features
you had during your trial, please upgrade to you had during your trial, please upgrade to
a paid plan. a paid plan.
@@ -85,7 +85,7 @@ export const NotifyTrialExpiring = ({
<strong>{orgName}</strong> will end on{" "} <strong>{orgName}</strong> will end on{" "}
<strong>{trialEndsAt}</strong> <strong>{trialEndsAt}</strong>
{isLastDay {isLastDay
? " that's tomorrow!" ? " - that's tomorrow!"
: `, in ${daysRemaining} days`} : `, in ${daysRemaining} days`}
. .
</EmailText> </EmailText>
@@ -93,8 +93,7 @@ export const NotifyTrialExpiring = ({
<EmailText> <EmailText>
After your trial ends, your account will be After your trial ends, your account will be
moved to the free plan and some moved to the free plan and some
functionality may be restricted or your functionality may be restricted.
sites may disconnect.
</EmailText> </EmailText>
<EmailText> <EmailText>

View File

@@ -25,7 +25,7 @@ export const tier1LimitSet: LimitSet = {
export const tier2LimitSet: LimitSet = { export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: { [FeatureId.USERS]: {
value: 100, value: 50,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.SITES]: { [FeatureId.SITES]: {
@@ -48,7 +48,7 @@ export const tier2LimitSet: LimitSet = {
export const tier3LimitSet: LimitSet = { export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: { [FeatureId.USERS]: {
value: 500, value: 250,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.SITES]: { [FeatureId.SITES]: {

View File

@@ -131,41 +131,22 @@ export async function updateClientResources(
: []; : [];
const allSites: { siteId: number }[] = []; const allSites: { siteId: number }[] = [];
if (resourceData.site) { if (resourceData.site) {
let siteSingle; // Look up site by niceId
const resourceSiteId = resourceData.site; const [siteSingle] = await trx
.select({ siteId: sites.siteId })
if (resourceSiteId) { .from(sites)
// Look up site by niceId .where(
[siteSingle] = await trx and(
.select({ siteId: sites.siteId }) eq(sites.niceId, resourceData.site),
.from(sites) eq(sites.orgId, orgId)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
) )
.limit(1); )
} else if (siteId) { .limit(1);
// Use the provided siteId directly, but verify it belongs to the org if (siteSingle) {
[siteSingle] = await trx allSites.push(siteSingle);
.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 (resourceData.sites) { if (resourceData.sites) {
@@ -180,15 +161,31 @@ export async function updateClientResources(
) )
) )
.limit(1); .limit(1);
if (!site) { if (site) {
throw new Error( allSites.push(site);
`Site not found: ${siteId} in org ${orgId}`
);
} }
allSites.push(site);
} }
} }
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
}
if (allSites.length === 0) {
throw new Error(
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
}
if (existingResource) { if (existingResource) {
let domainInfo: let domainInfo:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }

View File

@@ -19,12 +19,13 @@ import { eq, and, ne } from "drizzle-orm";
export async function getOrgTierData( export async function getOrgTierData(
orgId: string orgId: string
): Promise<{ tier: Tier | null; active: boolean }> { ): Promise<{ tier: Tier | null; active: boolean; isTrial: boolean }> {
let tier: Tier | null = null; let tier: Tier | null = null;
let active = false; let active = false;
let isTrial = false;
if (build !== "saas") { if (build !== "saas") {
return { tier, active }; return { tier, active, isTrial };
} }
try { try {
@@ -35,7 +36,7 @@ export async function getOrgTierData(
.limit(1); .limit(1);
if (!org) { if (!org) {
return { tier, active }; return { tier, active, isTrial };
} }
let orgIdToUse = org.orgId; let orgIdToUse = org.orgId;
@@ -44,7 +45,7 @@ export async function getOrgTierData(
logger.warn( logger.warn(
`Org ${orgId} is not a billing org and does not have a billingOrgId` `Org ${orgId} is not a billing org and does not have a billingOrgId`
); );
return { tier, active }; return { tier, active, isTrial };
} }
orgIdToUse = org.billingOrgId; orgIdToUse = org.billingOrgId;
} }
@@ -57,7 +58,7 @@ export async function getOrgTierData(
.limit(1); .limit(1);
if (!customer) { if (!customer) {
return { tier, active }; return { tier, active, isTrial };
} }
// Query for active subscriptions that are not license type // Query for active subscriptions that are not license type
@@ -84,11 +85,13 @@ export async function getOrgTierData(
tier = subscription.type; tier = subscription.type;
active = true; active = true;
} }
isTrial = subscription.trial ?? false;
} }
} catch (error) { } catch (error) {
// If org not found or error occurs, return null tier and inactive // If org not found or error occurs, return null tier and inactive
// This is acceptable behavior as per the function signature // This is acceptable behavior as per the function signature
} }
return { tier, active }; return { tier, active, isTrial };
} }

View File

@@ -30,8 +30,10 @@ import {
userOrgRoles, userOrgRoles,
siteProvisioningKeyOrg, siteProvisioningKeyOrg,
siteProvisioningKeys, siteProvisioningKeys,
alertRules,
targetHealthCheck
} from "@server/db"; } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
/** /**
* Get the maximum allowed retention days for a given tier * Get the maximum allowed retention days for a given tier
@@ -318,6 +320,14 @@ async function disableFeature(
await disableSiteProvisioningKeys(orgId); await disableSiteProvisioningKeys(orgId);
break; break;
case TierFeature.AlertingRules:
await disableAlertingRules(orgId);
break;
case TierFeature.StandaloneHealthChecks:
await disableStandaloneHealthChecks(orgId);
break;
default: default:
logger.warn( logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping` `Unknown feature ${feature} for org ${orgId}, skipping`
@@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise<void> {
async function disableSiteProvisioningKeys(orgId: string): Promise<void> { async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
const rows = await db const rows = await db
.select({ .select({
siteProvisioningKeyId: siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
siteProvisioningKeyOrg.siteProvisioningKeyId
}) })
.from(siteProvisioningKeyOrg) .from(siteProvisioningKeyOrg)
.where(eq(siteProvisioningKeyOrg.orgId, orgId)); .where(eq(siteProvisioningKeyOrg.orgId, orgId));
@@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
logger.info(`Disabled password expiration policies for org ${orgId}`); logger.info(`Disabled password expiration policies for org ${orgId}`);
} }
async function disableAlertingRules(orgId: string): Promise<void> {
await db
.update(alertRules)
.set({ enabled: false })
.where(eq(alertRules.orgId, orgId));
logger.info(`Disabled all alert rules for org ${orgId}`);
}
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
await db
.update(targetHealthCheck)
.set({ hcEnabled: false })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId)
)
);
logger.info(`Disabled standalone health checks for org ${orgId}`);
}
async function disableAutoProvisioning(orgId: string): Promise<void> { async function disableAutoProvisioning(orgId: string): Promise<void> {
// Get all IDP IDs for this org through the idpOrg join table // Get all IDP IDs for this org through the idpOrg join table
const orgIdps = await db const orgIdps = await db

View File

@@ -174,6 +174,19 @@ export async function handleSubscriptionCreated(
// TODO: update user in Sendy // TODO: update user in Sendy
} }
} }
// delete the trial subscrition if we have one
await db
.delete(subscriptions)
.where(
and(
eq(
subscriptions.customerId,
subscription.customer as string
),
eq(subscriptions.trial, true)
)
);
} else if (type === "license") { } else if (type === "license") {
logger.debug( logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.` `License subscription created for org ${customer.orgId}, no lifecycle handling needed.`

View File

@@ -67,24 +67,20 @@ if (build == "saas") {
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
certificates.syncCertToNewts certificates.syncCertToNewts
); );
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
org.sendTrialNotification
);
} }
authenticated.post(
`/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
logActionAudit(ActionsEnum.sendUsageNotification),
org.sendUsageNotification
);
authenticated.post(
`/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
logActionAudit(ActionsEnum.sendTrialNotification),
org.sendTrialNotification
);
authenticated.delete( authenticated.delete(
"/idp/:idpId", "/idp/:idpId",
verifyApiKeyIsRoot, verifyApiKeyIsRoot,

View File

@@ -104,8 +104,9 @@ export async function deleteMyAccount(
(r) => r.isBillingOrg && r.isOwner (r) => r.isBillingOrg && r.isOwner
)?.orgId; )?.orgId;
if (primaryOrgId) { if (primaryOrgId) {
const { tier, active } = await getOrgTierData(primaryOrgId); const { tier, active, isTrial } =
if (active && tier) { await getOrgTierData(primaryOrgId);
if (active && tier && !isTrial) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View File

@@ -496,11 +496,6 @@ export async function createSiteResource(
); );
} }
} }
await rebuildClientAssociationsFromSiteResource(
newSiteResource,
trx
); // we need to call this because we added to the admin role
}); });
if (!newSiteResource) { if (!newSiteResource) {
@@ -526,6 +521,22 @@ export async function createSiteResource(
await createCertificate(domainId, fullDomain, db); await createCertificate(domainId, fullDomain, db);
} }
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource(
newSiteResource!,
trx
);
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
err
);
});
return response(res, { return response(res, {
data: newSiteResource, data: newSiteResource,
success: true, success: true,

View File

@@ -63,17 +63,26 @@ export async function deleteSiteResource(
); );
} }
await db.transaction(async (trx) => { // Delete the site resource
// Delete the site resource const [removedSiteResource] = await db
const [removedSiteResource] = await trx .delete(siteResources)
.delete(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId))
.where(eq(siteResources.siteResourceId, siteResourceId)) .returning();
.returning();
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
db.transaction(async (trx) => {
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, removedSiteResource,
trx trx
); );
}).catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
}); });
logger.info(`Deleted site resource ${siteResourceId}`); logger.info(`Deleted site resource ${siteResourceId}`);

View File

@@ -431,9 +431,6 @@ export async function updateSiteResource(
}) })
.returning(); .returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
const sshPamSet = const sshPamSet =
isLicensedSshPam && isLicensedSshPam &&
(authDaemonPort !== undefined || (authDaemonPort !== undefined ||
@@ -556,11 +553,6 @@ export async function updateSiteResource(
})) }))
); );
} }
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
} else { } else {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
@@ -690,7 +682,24 @@ export async function updateSiteResource(
} }
logger.info(`Updated site resource ${siteResourceId}`); logger.info(`Updated site resource ${siteResourceId}`);
}
});
// Background: wait for removal messages to propagate, then rebuild
// associations for the re-created resource. Own transaction ensures
// execution on the primary against fully committed state.
(async () => {
await db.transaction(async (trx) => {
if (!updatedSiteResource) {
throw new Error("No updated resource found after update");
}
if (sitesChanged) {
await new Promise((resolve) => setTimeout(resolve, 750));
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
);
}
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
existingSiteResource, existingSiteResource,
updatedSiteResource, updatedSiteResource,
@@ -700,7 +709,12 @@ export async function updateSiteResource(
})), })),
trx trx
); );
} });
})().catch((err) => {
logger.error(
`Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`,
err
);
}); });
return response(res, { return response(res, {

View File

@@ -16,6 +16,9 @@ export default async function migration() {
thc."targetId", thc."targetId",
t."siteId", t."siteId",
s."orgId", s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled", thc."hcEnabled",
thc."hcPath", thc."hcPath",
thc."hcScheme", thc."hcScheme",
@@ -33,13 +36,17 @@ export default async function migration() {
thc."hcTlsServerName" thc."hcTlsServerName"
FROM "targetHealthCheck" thc FROM "targetHealthCheck" thc
JOIN "targets" t ON thc."targetId" = t."targetId" JOIN "targets" t ON thc."targetId" = t."targetId"
JOIN "sites" s ON t."siteId" = s."siteId"` JOIN "sites" s ON t."siteId" = s."siteId"
JOIN "resources" r ON t."resourceId" = r."resourceId"`
); );
const existingHealthChecks = healthChecksQuery.rows as { const existingHealthChecks = healthChecksQuery.rows as {
targetHealthCheckId: number; targetHealthCheckId: number;
targetId: number; targetId: number;
siteId: number; siteId: number;
orgId: string; orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: boolean; hcEnabled: boolean;
hcPath: string | null; hcPath: string | null;
hcScheme: string | null; hcScheme: string | null;
@@ -385,6 +392,7 @@ export default async function migration() {
"targetId", "targetId",
"orgId", "orgId",
"siteId", "siteId",
"name",
"hcEnabled", "hcEnabled",
"hcPath", "hcPath",
"hcScheme", "hcScheme",
@@ -405,6 +413,7 @@ export default async function migration() {
${hc.targetId}, ${hc.targetId},
${hc.orgId}, ${hc.orgId},
${hc.siteId}, ${hc.siteId},
${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`},
${hc.hcEnabled}, ${hc.hcEnabled},
${hc.hcPath}, ${hc.hcPath},
${hc.hcScheme}, ${hc.hcScheme},

View File

@@ -22,6 +22,9 @@ export default async function migration() {
thc."targetId", thc."targetId",
t."siteId", t."siteId",
s."orgId", s."orgId",
r."name" AS "resourceName",
t."ip",
t."port",
thc."hcEnabled", thc."hcEnabled",
thc."hcPath", thc."hcPath",
thc."hcScheme", thc."hcScheme",
@@ -39,13 +42,17 @@ export default async function migration() {
thc."hcTlsServerName" thc."hcTlsServerName"
FROM 'targetHealthCheck' thc FROM 'targetHealthCheck' thc
JOIN 'targets' t ON thc."targetId" = t."targetId" JOIN 'targets' t ON thc."targetId" = t."targetId"
JOIN 'sites' s ON t."siteId" = s."siteId"` JOIN 'sites' s ON t."siteId" = s."siteId"
JOIN 'resources' r ON t."resourceId" = r."resourceId"`
) )
.all() as { .all() as {
targetHealthCheckId: number; targetHealthCheckId: number;
targetId: number; targetId: number;
siteId: number; siteId: number;
orgId: string; orgId: string;
resourceName: string;
ip: string;
port: number;
hcEnabled: number; hcEnabled: number;
hcPath: string | null; hcPath: string | null;
hcScheme: string | null; hcScheme: string | null;
@@ -392,6 +399,7 @@ export default async function migration() {
"targetId", "targetId",
"orgId", "orgId",
"siteId", "siteId",
"name",
"hcEnabled", "hcEnabled",
"hcPath", "hcPath",
"hcScheme", "hcScheme",
@@ -407,7 +415,7 @@ export default async function migration() {
"hcStatus", "hcStatus",
"hcHealth", "hcHealth",
"hcTlsServerName" "hcTlsServerName"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
); );
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
@@ -417,6 +425,7 @@ export default async function migration() {
hc.targetId, hc.targetId,
hc.orgId, hc.orgId,
hc.siteId, hc.siteId,
`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`,
hc.hcEnabled, hc.hcEnabled,
hc.hcPath, hc.hcPath,
hc.hcScheme, hc.hcScheme,