Compare commits

...

14 commits

Author SHA1 Message Date
Milo Schwartz
1f584bf3e8
Merge pull request #717 from fosrl/dev
1.4.0
2025-05-13 11:12:03 -04:00
miloschwartz
5b0200154a
add feature parity 2025-05-13 11:09:38 -04:00
Milo Schwartz
a512148348
Merge pull request #701 from fosrl/dev
1.3.2
2025-05-10 11:30:07 -04:00
miloschwartz
d9eccd6c13
bump version 2025-05-10 11:28:22 -04:00
miloschwartz
492669f68a
set default congig values 2025-05-09 18:32:14 -04:00
miloschwartz
caded23b51
allow root path 2025-05-09 17:37:55 -04:00
miloschwartz
e9cc48a3ae
fix bug causing duplicate targets 2025-05-09 17:18:42 -04:00
miloschwartz
4ed98c227b
fix setting tlsServerName and hostHeader conflict 2025-05-09 17:12:01 -04:00
miloschwartz
f66fb7d4a3
fix justification for profile icon 2025-05-09 17:09:22 -04:00
miloschwartz
f25990a9a7
add id token and claims to debug logs 2025-05-09 16:46:51 -04:00
miloschwartz
21d5b67ef1
Merge branch 'main' into dev 2025-05-09 16:44:09 -04:00
Milo Schwartz
198810121c
Update README.md 2025-05-09 16:44:01 -04:00
Owen
83c0379c6b
Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-05-03 22:04:29 -04:00
Owen
9a167b5acb
Dont overwrite the secret and crowdsec vars 2025-05-02 14:16:10 -04:00
99 changed files with 559 additions and 825 deletions

32
LICENSE
View file

@ -1,35 +1,3 @@
Copyright (c) 2025 Fossorial, LLC.
Portions of this software are licensed as follows:
* All files that include a header specifying they are licensed under the
"Fossorial Commercial License" are governed by the Fossorial Commercial
License terms. The specific terms applicable to each customer depend on the
commercial license tier agreed upon in writing with Fossorial LLC.
Unauthorized use, copying, modification, or distribution is strictly
prohibited.
* All files that include a header specifying they are licensed under the GNU
Affero General Public License, Version 3 ("AGPL-3"), are governed by the
AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However,
these files are also available under the Fossorial Commercial License if a
separate commercial license agreement has been executed between the customer
and Fossorial LLC.
* All files without a license header are, by default, licensed under the GNU
Affero General Public License, Version 3 (AGPL-3). These files may also be
made available under the Fossorial Commercial License upon agreement with
Fossorial LLC.
* All third-party components included in this repository are licensed under
their respective original licenses, as provided by their authors.
Please consult the header of each individual file to determine the applicable
license. For AGPL-3 licensed files, dual-licensing under the Fossorial
Commercial License is available subject to written agreement with Fossorial
LLC.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007

View file

@ -64,15 +64,15 @@ func main() {
}
var config Config
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput(reader)
loadVersions(&config)
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)

BIN
newt Executable file

Binary file not shown.

View file

@ -6,7 +6,7 @@ import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas";
import { createIntegrationApiServer } from "./integrationApiServer";
import license from "./license/license.js";
import config from "@server/lib/config";
async function startServers() {
await runSetupFunctions();
@ -17,7 +17,7 @@ async function startServers() {
const nextServer = await createNextServer();
let integrationServer;
if (await license.isUnlocked()) {
if (config.getRawConfig().flags?.enable_integration_api) {
integrationServer = createIntegrationApiServer();
}

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
@ -11,7 +6,6 @@ import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware,
verifyValidLicense
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/integration";
import { logIncomingMiddleware } from "./middlewares/logIncoming";
@ -26,8 +20,6 @@ const externalPort = config.getRawConfig().server.integration_port;
export function createIntegrationApiServer() {
const apiServer = express();
apiServer.use(verifyValidLicense);
if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1);
}

View file

@ -29,9 +29,12 @@ const configSchema = z.object({
.optional()
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean(),
log_failed_attempts: z.boolean().optional()
log_level: z
.enum(["debug", "info", "warn", "error"])
.optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}),
domains: z
.record(
@ -41,8 +44,8 @@ const configSchema = z.object({
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
})
)
.refine(
@ -62,19 +65,42 @@ const configSchema = z.object({
server: z.object({
integration_port: portSchema
.optional()
.default(3003)
.transform(stoi)
.pipe(portSchema.optional()),
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
session_cookie_name: z.string(),
resource_access_token_param: z.string(),
resource_access_token_headers: z.object({
id: z.string(),
token: z.string()
}),
resource_session_request_param: z.string(),
external_port: portSchema
.optional()
.default(3000)
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.default(3001)
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z.string().optional().default("p_session_token"),
resource_access_token_param: z.string().optional().default("p_token"),
resource_access_token_headers: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z
.number()
.positive()
@ -102,35 +128,61 @@ const configSchema = z.object({
.transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8))
}),
traefik: z.object({
http_entrypoint: z.string(),
https_entrypoint: z.string().optional(),
additional_middlewares: z.array(z.string()).optional()
}),
gerbil: z.object({
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
base_endpoint: z
.string()
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(),
subnet_group: z.string(),
block_size: z.number().positive().gt(0),
site_block_size: z.number().positive().gt(0)
}),
rate_limits: z.object({
global: z.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
}),
auth: z
.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
})
.optional()
}),
traefik: z
.object({
http_entrypoint: z.string().optional().default("web"),
https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional()
})
.optional()
.default({}),
gerbil: z
.object({
start_port: portSchema
.optional()
.default(51820)
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean().optional().default(false),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
site_block_size: z.number().positive().gt(0).optional().default(30)
})
.optional()
.default({}),
rate_limits: z
.object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
})
.optional()
})
.optional()
.default({}),
email: z
.object({
smtp_host: z.string().optional(),
@ -164,7 +216,8 @@ const configSchema = z.object({
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional(),
allow_local_sites: z.boolean().optional()
allow_local_sites: z.boolean().optional(),
enable_integration_api: z.boolean().optional()
})
.optional()
});

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.3.0";
export const APP_VERSION = "1.4.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
}
export function isValidUrlGlobPattern(pattern: string): boolean {
if (pattern === "/") {
return true;
}
// Remove leading slash if present
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import db from "@server/db";
import { hostMeta, licenseKey, sites } from "@server/db/schemas";
import logger from "@server/logger";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import * as crypto from "crypto";
/**

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
export * from "./verifyApiKey";
export * from "./verifyApiKeyOrgAccess";
export * from "./verifyApiKeyHasAction";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourceAccessToken, resources, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { verifyPassword } from "@server/auth/password";
import db from "@server/db";
import { apiKeys } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { apiKeys, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { Request, Response, NextFunction } from "express";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resources, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { roles, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { userOrgs } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resources, targets, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { userOrgs } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { userOrgs, apiKeys, apiKeyOrg } from "@server/db/schemas";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { NextFunction, Request, Response } from "express";
import db from "@server/db";
import HttpCode from "@server/types/HttpCode";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { NextFunction, Request, Response } from "express";
import db from "@server/db";
import HttpCode from "@server/types/HttpCode";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
export * from "./createRootApiKey";
export * from "./deleteApiKey";
export * from "./getApiKey";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { db } from "@server/db";
import { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db/schemas";
import logger from "@server/logger";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { db } from "@server/db";
import { apiKeyOrg, apiKeys } from "@server/db/schemas";
import logger from "@server/logger";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { db } from "@server/db";
import { apiKeys } from "@server/db/schemas";
import logger from "@server/logger";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,61 +1,136 @@
import { isPathAllowed } from './verifySession';
import { assertEquals } from '@test/assert';
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() {
console.log('Running path matching tests...');
// Test exact matching
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
// Test with leading and trailing slashes
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
// Test simple wildcard matching
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
// Test multiple wildcards
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
// Test wildcard consumption behavior
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
// Test complex nested paths
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
// Test for the requested padbootstrap* pattern
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
// Test wildcard edge cases
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
// Test patterns with partial segment matches
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('/', '/'), true, 'Root path should match root path');
assertEquals(isPathAllowed('/', '/test'), false, 'Root path should not match non-root path');
console.log('All tests passed!');
}
@ -64,4 +139,4 @@ try {
runTests();
} catch (error) {
console.error('Test failed:', error);
}
}

View file

@ -30,7 +30,6 @@ import {
verifyUserIsServerAdmin,
verifyIsLoggedInUser,
verifyApiKeyAccess,
verifyValidLicense
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -531,28 +530,24 @@ authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyValidLicense,
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyValidLicense,
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyValidLicense,
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.get(
"/idp/:idpId/org",
verifyValidLicense,
verifyUserIsServerAdmin,
idp.listIdpOrgPolicies
);
@ -586,49 +581,42 @@ authenticated.post(
authenticated.get(
`/api-key/:apiKeyId`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.getApiKey
);
authenticated.put(
`/api-key`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.createRootApiKey
);
authenticated.delete(
`/api-key/:apiKeyId`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.deleteApiKey
);
authenticated.get(
`/api-keys`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.listRootApiKeys
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.listApiKeyActions
);
authenticated.post(
`/api-key/:apiKeyId/actions`,
verifyValidLicense,
verifyUserIsServerAdmin,
apiKeys.setApiKeyActions
);
authenticated.get(
`/org/:orgId/api-keys`,
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listApiKeys),
apiKeys.listOrgApiKeys
@ -636,7 +624,6 @@ authenticated.get(
authenticated.post(
`/org/:orgId/api-key/:apiKeyId/actions`,
verifyValidLicense,
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.setApiKeyActions),
@ -645,7 +632,6 @@ authenticated.post(
authenticated.get(
`/org/:orgId/api-key/:apiKeyId/actions`,
verifyValidLicense,
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.listApiKeyActions),
@ -654,7 +640,6 @@ authenticated.get(
authenticated.put(
`/org/:orgId/api-key`,
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createApiKey),
apiKeys.createOrgApiKey
@ -662,7 +647,6 @@ authenticated.put(
authenticated.delete(
`/org/:orgId/api-key/:apiKeyId`,
verifyValidLicense,
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.deleteApiKey),
@ -671,7 +655,6 @@ authenticated.delete(
authenticated.get(
`/org/:orgId/api-key/:apiKeyId`,
verifyValidLicense,
verifyOrgAccess,
verifyApiKeyAccess,
verifyUserHasAction(ActionsEnum.getApiKey),

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -81,10 +81,6 @@ export async function createOidcIdp(
autoProvision
} = parsedBody.data;
if (!(await license.isUnlocked())) {
autoProvision = false;
}
const key = config.getRawConfig().server.secret;
const encryptedSecret = encrypt(clientSecret, key);

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -1,233 +0,0 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import {
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import db from "@server/db";
import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas";
import logger from "@server/logger";
import { UserType } from "@server/types/UserTypes";
import { eq, and, inArray } from "drizzle-orm";
import jmespath from "jmespath";
import { Request, Response } from "express";
export async function oidcAutoProvision({
idp,
claims,
existingUser,
userIdentifier,
email,
name,
req,
res
}: {
idp: Idp;
claims: any;
existingUser?: User;
userIdentifier: string;
email?: string;
name?: string;
req: Request;
res: Response;
}) {
const allOrgs = await db.select().from(orgs);
const defaultRoleMapping = idp.defaultRoleMapping;
const defaultOrgMapping = idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
.from(idpOrg)
.where(
and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId))
);
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId);
if (hydratedOrgMapping) {
logger.debug("Hydrated Org Mapping", {
hydratedOrgMapping
});
const orgId = jmespath.search(claims, hydratedOrgMapping);
logger.debug("Extraced Org ID", { orgId });
if (orgId !== true && orgId !== org.orgId) {
// user not allowed to access this org
continue;
}
}
const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleName = jmespath.search(claims, roleMapping);
if (!roleName) {
logger.error("Role name not found in the ID token", {
roleName
});
continue;
}
const [roleRes] = await db
.select()
.from(roles)
.where(
and(eq(roles.orgId, org.orgId), eq(roles.name, roleName))
);
if (!roleRes) {
logger.error("Role not found", {
orgId: org.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: org.orgId,
roleId
});
}
}
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
// set the name and email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId!));
}
existingUserId = userId;
// get all current user orgs
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId!));
// Delete orgs that are no longer valid
const orgsToDelete = currentUserOrgs.filter(
(currentOrg) =>
!userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId)
);
if (orgsToDelete.length > 0) {
await trx.delete(userOrgs).where(
and(
eq(userOrgs.userId, userId!),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
}
// Update roles for existing orgs where the role has changed
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
});
if (orgsToUpdate.length > 0) {
for (const org of orgsToUpdate) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId!),
eq(userOrgs.orgId, org.orgId)
)
);
}
}
}
// Add new orgs that don't exist yet
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
(currentOrg) => currentOrg.orgId === newOrg.orgId
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
dateCreated: new Date().toISOString()
}))
);
}
});
const token = generateSessionToken();
const sess = await createSession(token, existingUserId!);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | undefined {
if (!orgMapping) {
return undefined;
}
return orgMapping.split("{{orgId}}").join(orgId);
}

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";

View file

@ -100,10 +100,6 @@ export async function updateOidcIdp(
defaultOrgMapping
} = parsedBody.data;
if (!(await license.isUnlocked())) {
autoProvision = false;
}
// Check if IDP exists and is of type OIDC
const [existingIdp] = await db
.select()

View file

@ -6,7 +6,15 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { idp, idpOidcConfig, users } from "@server/db/schemas";
import {
idp,
idpOidcConfig,
idpOrg,
orgs,
roles,
userOrgs,
users
} from "@server/db/schemas";
import { and, eq, inArray } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
@ -15,12 +23,12 @@ import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import {
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
import { oidcAutoProvision } from "./oidcAutoProvision";
import license from "@server/license/license";
import { UserType } from "@server/types/UserTypes";
const ensureTrailingSlash = (url: string): string => {
return url;
@ -160,7 +168,9 @@ export async function validateOidcCallback(
);
const idToken = tokens.idToken();
logger.debug("ID token", { idToken });
const claims = arctic.decodeIdToken(idToken);
logger.debug("ID token claims", { claims });
const userIdentifier = jmespath.search(
claims,
@ -210,25 +220,203 @@ export async function validateOidcCallback(
);
if (existingIdp.idp.autoProvision) {
if (!(await license.isUnlocked())) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Auto-provisioning is not available"
)
const allOrgs = await db.select().from(orgs);
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
.from(idpOrg)
.where(
and(
eq(idpOrg.idpId, existingIdp.idp.idpId),
eq(idpOrg.orgId, org.orgId)
)
);
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(
orgMapping,
org.orgId
);
if (hydratedOrgMapping) {
logger.debug("Hydrated Org Mapping", {
hydratedOrgMapping
});
const orgId = jmespath.search(claims, hydratedOrgMapping);
logger.debug("Extraced Org ID", { orgId });
if (orgId !== true && orgId !== org.orgId) {
// user not allowed to access this org
continue;
}
}
const roleMapping =
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleName = jmespath.search(claims, roleMapping);
if (!roleName) {
logger.error("Role name not found in the ID token", {
roleName
});
continue;
}
const [roleRes] = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, org.orgId),
eq(roles.name, roleName)
)
);
if (!roleRes) {
logger.error("Role not found", {
orgId: org.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: org.orgId,
roleId
});
}
}
await oidcAutoProvision({
idp: existingIdp.idp,
userIdentifier,
email,
name,
claims,
existingUser,
req,
res
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: existingIdp.idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
// set the name and email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId!));
}
existingUserId = userId;
// get all current user orgs
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId!));
// Delete orgs that are no longer valid
const orgsToDelete = currentUserOrgs.filter(
(currentOrg) =>
!userOrgInfo.some(
(newOrg) => newOrg.orgId === currentOrg.orgId
)
);
if (orgsToDelete.length > 0) {
await trx.delete(userOrgs).where(
and(
eq(userOrgs.userId, userId!),
inArray(
userOrgs.orgId,
orgsToDelete.map((org) => org.orgId)
)
)
);
}
// Update roles for existing orgs where the role has changed
const orgsToUpdate = currentUserOrgs.filter((currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
});
if (orgsToUpdate.length > 0) {
for (const org of orgsToUpdate) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId!),
eq(userOrgs.orgId, org.orgId)
)
);
}
}
}
// Add new orgs that don't exist yet
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
(currentOrg) => currentOrg.orgId === newOrg.orgId
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
dateCreated: new Date().toISOString()
}))
);
}
});
const token = generateSessionToken();
const sess = await createSession(token, existingUserId!);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
@ -276,3 +464,13 @@ export async function validateOidcCallback(
);
}
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | undefined {
if (!orgMapping) {
return undefined;
}
return orgMapping.split("{{orgId}}").join(orgId);
}

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import * as site from "./site";
import * as org from "./org";
import * as resource from "./resource";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
export * from "./getLicenseStatus";
export * from "./activateLicense";
export * from "./listLicenseKeys";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";

View file

@ -318,8 +318,8 @@ async function updateHttpResource(
domainId: updatePayload.domainId,
enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null,
setHostHeader: updatePayload.setHostHeader || null,
tlsServerName: updatePayload.tlsServerName,
setHostHeader: updatePayload.setHostHeader,
fullDomain: updatePayload.fullDomain
})
.where(eq(resources.resourceId, resource.resourceId))

View file

@ -42,7 +42,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
username: z.string(),
roleId: z.string().min(1, { message: "Please select a role" })
});
@ -59,7 +59,7 @@ export default function AccessControlsPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email!,
username: user.username!,
roleId: user.roleId?.toString()
}
});

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { DataTable } from "@app/components/ui/data-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { redirect } from "next/navigation";
export default async function ApiKeysPage(props: {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";

View file

@ -1,8 +1,6 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
@ -25,6 +23,10 @@ export function ResourcesDataTable<TData, TValue>({
searchColumn="name"
onAdd={createResource}
addButtonText="Add Resource"
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View file

@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) {
await api.post(`/target/${target.targetId}`, data);
target.updated = false;
}
}
@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
type="submit"
variant="outlinePrimary"
className="mt-6"
disabled={
!(
addTargetForm.getValues("ip") &&
addTargetForm.getValues("port")
)
}
>
Add Target
</Button>

View file

@ -64,7 +64,6 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Separator } from "@app/components/ui/separator";
import { InfoPopup } from "@app/components/ui/info-popup";
import {
isValidCIDR,

View file

@ -1,8 +1,6 @@
"use client";
import {
ColumnDef,
} from "@tanstack/react-table";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
@ -25,6 +23,10 @@ export function SitesDataTable<TData, TValue>({
searchColumn="name"
onAdd={createSite}
addButtonText="Add Site"
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { redirect } from "next/navigation";
export default async function ApiKeysPage(props: {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";

View file

@ -232,7 +232,6 @@ export default function GeneralPage() {
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={!isUnlocked()}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
@ -240,14 +239,6 @@ export default function GeneralPage() {
);
}}
/>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be

View file

@ -43,8 +43,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
},
{
title: "Organization Policies",
href: `/admin/idp/${params.idpId}/policies`,
showProfessional: true
href: `/admin/idp/${params.idpId}/policies`
}
];

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { useEffect, useState } from "react";
@ -377,9 +372,7 @@ export default function PoliciesPage() {
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract role
information from the ID
token. The result of this
The result of this
expression must return the
role name as defined in the
organization as a string.
@ -401,13 +394,10 @@ export default function PoliciesPage() {
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract
organization information
from the ID token. This
expression must return thr
org ID or true for the user
to be allowed to access the
organization.
This expression must return
thr org ID or true for the
user to be allowed to access
the organization.
</FormDescription>
<FormMessage />
</FormItem>
@ -578,8 +568,6 @@ export default function PoliciesPage() {
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract role
information from the ID token.
The result of this expression
must return the role name as
defined in the organization as a
@ -603,8 +591,6 @@ export default function PoliciesPage() {
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract organization
information from the ID token.
This expression must return the
org ID or true for the user to
be allowed to access the

View file

@ -192,7 +192,6 @@ export default function Page() {
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={!isUnlocked()}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
@ -200,14 +199,6 @@ export default function Page() {
);
}}
/>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
@ -421,7 +412,7 @@ export default function Page() {
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
The path to the user
identifier in the ID
token
</FormDescription>
@ -442,7 +433,7 @@ export default function Page() {
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
The path to the
user's email in the ID
token
</FormDescription>
@ -463,7 +454,7 @@ export default function Page() {
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
The path to the
user's name in the ID
token
</FormDescription>

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { MinusCircle, PlusCircle } from "lucide-react";
@ -107,35 +102,6 @@ export function SitePriceCalculator({
</div>
<div className="border-t pt-4">
{mode === "license" && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
License fee:
</span>
<span className="font-medium">
${licenseFlatRate.toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Price per site:
</span>
<span className="font-medium">
${pricePerSite.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Number of sites:
</span>
<span className="font-medium">{siteCount}</span>
</div>
<div className="flex justify-between items-center mt-4 text-lg font-bold">
<span>Total:</span>
<span>${totalCost.toFixed(2)} / mo</span>
</div>
<p className="text-muted-foreground text-sm mt-2 text-center">
For the most up-to-date pricing and discounts,
please visit the{" "}
@ -157,7 +123,7 @@ export function SitePriceCalculator({
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button onClick={continueToPayment}>
Continue to Payment
See Purchase Portal
</Button>
</CredenzaFooter>
</CredenzaContent>

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { useState, useEffect } from "react";
@ -49,7 +44,7 @@ import {
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Badge } from "@app/components/ui/badge";
import { Check, ShieldCheck, ShieldOff } from "lucide-react";
import { Check, Heart, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { Progress } from "@app/components/ui/progress";
import { MinusCircle, PlusCircle } from "lucide-react";
@ -57,6 +52,8 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "./components/SitePriceCalculator";
import Link from "next/link";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
const formSchema = z.object({
licenseKey: z
@ -95,6 +92,7 @@ export default function LicensePage() {
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
const { supporterStatus } = useSupporterStatusContext();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -370,6 +368,18 @@ export default function LicensePage() {
description="View and manage license keys in the system"
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Licensing
</AlertTitle>
<AlertDescription>
This is for business and enterprise users who are using
Pangolin in a commercial environment. If you are using
Pangolin for personal use, you can ignore this section.
</AlertDescription>
</Alert>
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
@ -387,18 +397,25 @@ export default function LicensePage() {
<Check />
{licenseStatus?.tier ===
"PROFESSIONAL"
? "Professional License"
? "Commercial License"
: licenseStatus?.tier ===
"ENTERPRISE"
? "Enterprise License"
? "Commercial License"
: "Licensed"}
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-2xl">
Not Licensed
</div>
{supporterStatus?.visible ? (
<div className="text-2xl">
Community Edition
</div>
) : (
<div className="text-2xl flex items-center gap-2 text-pink-500">
<Heart />
Community Edition
</div>
)}
</div>
)}
</div>

View file

@ -1,6 +1,11 @@
import ProfileIcon from "@app/components/ProfileIcon";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { AxiosResponse } from "axios";
import { ExternalLink } from "lucide-react";
import { Metadata } from "next";
import { cache } from "react";
@ -17,21 +22,68 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (
<div className="h-full flex flex-col">
{user && (
<UserProvider user={user}>
<div className="p-3">
<div className="p-3 ml-auto">
<ProfileIcon />
</div>
</UserProvider>
)}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">
{children}
</div>
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Community Edition</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
)}
</div>
);
}

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { Button } from "@app/components/ui/button";

View file

@ -12,6 +12,7 @@ import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import LicenseViolation from "./components/LicenseViolation";
import { cache } from "react";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@ -40,10 +41,12 @@ export default async function RootLayout({
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
const licenseStatusRes =
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
);
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (

View file

@ -68,8 +68,7 @@ export const orgNavItems: SidebarNavItem[] = [
{
title: "API Keys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showProfessional: true
icon: <KeyRound className="h-4 w-4" />
},
{
title: "Settings",
@ -87,8 +86,7 @@ export const adminNavItems: SidebarNavItem[] = [
{
title: "API Keys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showProfessional: true
icon: <KeyRound className="h-4 w-4" />
},
{
title: "Identity Providers",

View file

@ -0,0 +1,3 @@
"use client";
export function AuthFooter() {}

View file

@ -22,6 +22,7 @@ import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
interface LayoutProps {
children: React.ReactNode;
@ -58,6 +59,7 @@ export function Layout({
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
return (
<div className="flex flex-col h-screen overflow-hidden">
@ -207,7 +209,9 @@ export function Layout({
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Open Source
{!isUnlocked()
? "Community Edition"
: "Commercial Edition"}
<ExternalLink size={12} />
</Link>
</div>

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
@ -26,7 +21,6 @@ function getActionsCategories(root: boolean) {
"Update Organization": "updateOrg",
"Get Organization User": "getOrgUser",
"List Organization Domains": "listOrgDomains",
"Check Org ID": "checkOrgId",
},
Site: {

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { cn } from "@app/lib/cn";

View file

@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
}
export function SettingsSectionGrid({

View file

@ -193,7 +193,7 @@ export default function SupporterStatus() {
contribution allows us to commit more time to
maintain and add new features to the application for
everyone. We will never use this to paywall
features. This is separate from the Professional
features. This is separate from any Commercial
Edition.
</p>

View file

@ -31,7 +31,7 @@ import {
CardTitle
} from "@app/components/ui/card";
interface DataTableProps<TData, TValue> {
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
title?: string;
@ -39,7 +39,11 @@ interface DataTableProps<TData, TValue> {
onAdd?: () => void;
searchPlaceholder?: string;
searchColumn?: string;
}
defaultSort?: {
id: string;
desc: boolean;
};
};
export function DataTable<TData, TValue>({
columns,
@ -48,9 +52,12 @@ export function DataTable<TData, TValue>({
addButtonText,
onAdd,
searchPlaceholder = "Search...",
searchColumn = "name"
searchColumn = "name",
defaultSort
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>(
defaultSort ? [defaultSort] : []
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState<any>([]);

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import { createContext } from "react";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { LicenseStatus } from "@server/license/license";
import { createContext } from "react";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import ApiKeyContext from "@app/contexts/apiKeyContext";
import { useContext } from "react";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { useContext } from "react";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import ApiKeyContext from "@app/contexts/apiKeyContext";

View file

@ -1,8 +1,3 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import LicenseStatusContext from "@app/contexts/licenseStatusContext";