mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(enterprise): ship enterprise package in prod image + S3, analytics, tracing, queue fixes (#342)
* fix(enterprise): ship enterprise pkg in prod image, full license features, tracing key fallback docker/Dockerfile: COPY packages/enterprise manifest+src into the production stage. Without it, apps/api's workspace link to @snapotter/enterprise dangles and every import() throws (silently caught), so all 19 enterprise features failed closed (enterprise.active=false) regardless of a valid license. scripts/generate-license.mjs: sync PLAN_FEATURES with packages/enterprise/src/license.ts so a --plan enterprise license unlocks all 19 features (was 8) and team unlocks 8. apps/api/src/tracing.ts: accept SNAPOTTER_LICENSE_KEY as a fallback to LICENSE_KEY so distributed_tracing activates with the same key as the rest of the app. * fix(docker): keep scripts/bake-analytics.mjs in build context .dockerignore excluded the whole scripts/ dir (PR #82, V1 hardening), but docker/Dockerfile later added 'COPY scripts/bake-analytics.mjs' for the analytics bake step. A clean production image build therefore fails with 'scripts/bake-analytics.mjs: not found'. The published image build is gated off in CI so this latent break went unnoticed. Exclude scripts/* but re-include the one file the Dockerfile needs. * fix: S3 upload stream, analytics bake reaches API, dedupe retention field, reconcile orphan jobs storage-s3.ts: wrap the upload AsyncIterable in Readable.from() so @aws-sdk/lib-storage accepts it. STORAGE_MODE=s3 file uploads failed with 'Body Data is unsupported format' for every tool because a bare async generator is not a Readable. docker/Dockerfile: COPY the builder-baked analytics baked.ts into the API runtime stage. The API re-copied the committed (off) baked.ts from the build context, so the SNAPOTTER_ANALYTICS build arg had no effect on the API -- and since the SPA reads /api/v1/config/analytics, analytics was off everywhere regardless of the arg. settings-dialog.tsx: remove the duplicate tempFileMaxAgeHours control under Data Retention; it bound the same setting key as the File Management control with a different default, so editing either silently overwrote the other. apps/api/src/index.ts: reconcile orphaned job rows (empty tool_id, never enqueued to BullMQ) at boot so they don't sit in processing/queued forever and inflate the per-user concurrent-job count and the upgrade-check in-flight gate. * fix(web): style the SSO login buttons (they referenced undefined theme tokens) The OIDC/SAML 'Sign in with <provider>' buttons used bg-secondary / text-secondary-foreground, which the web theme never defines (it has primary, background, foreground, muted, border, card, primary-subtle). Those classes resolved to nothing, so the buttons rendered as bare unstyled text on the login page. Restyle: the optional (non-enforced) buttons become white-card outline buttons with a key icon and an orange hover tint, secondary to the primary Login button; the SSO-enforced buttons become solid primary with the icon. * fix: gate S3 behind license, custom-role enterprise perms, wire retention UI, cleanup S3 is a licensed feature, but shipping packages/enterprise in every image removed the implicit gate, so STORAGE_MODE=s3 worked without a license. Enforce isFeatureEnabled('s3_storage') at boot and fail fast if unlicensed. Custom roles can now be granted security:manage / compliance:manage / webhooks:manage (roles.ts ALL_PERMISSIONS + the Roles UI) so admins can build least-privilege compliance/security roles instead of only the built-in admin role. retentionSweep now reads the jobsRetentionDays / auditRetentionDays DB settings the System Settings UI writes (env vars become the fallback default), mirroring how the temp-file sweep reads tempFileMaxAgeHours. Previously those two UI controls were no-ops. Cleanup: drop the never-set snapotter_storage_bytes gauge and the unused MAX_WORKSPACE_SIZE_GB env var; emit tool_client_error to PostHog from the web ErrorBoundary (client crashes were not reaching analytics); add the Python OpenTelemetry packages so the innermost sidecar.<script> span exports; fix the stale 'only local storage' line in the docs; delete two e2e-analytics specs that tested the removed consent UI. * fix(env): restore MAX_WORKSPACE_SIZE_GB default security-auth-hardening.test.ts asserts env.MAX_WORKSPACE_SIZE_GB defaults to 10, so the var is an intentional (tested) default, not dead code. Removing it in the cleanup commit broke that unit test. Keep the declaration.
This commit is contained in:
+6
-1
@@ -34,7 +34,12 @@ docs
|
||||
.github
|
||||
.husky
|
||||
.releaserc.json
|
||||
scripts
|
||||
# Exclude scripts/ from the build context EXCEPT bake-analytics.mjs, which
|
||||
# docker/Dockerfile COPYs to bake the analytics config. A blanket `scripts`
|
||||
# ignore breaks the production image build (COPY scripts/bake-analytics.mjs ->
|
||||
# "not found"); use dir/* + negation so the one needed file is re-included.
|
||||
scripts/*
|
||||
!scripts/bake-analytics.mjs
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
|
||||
@@ -188,6 +188,27 @@ try {
|
||||
// Enterprise package not available
|
||||
}
|
||||
|
||||
// S3 storage is a licensed feature (s3_storage). packages/enterprise now ships in
|
||||
// every image, so STORAGE_MODE=s3 would otherwise function without any license check.
|
||||
// Enforce the gate at boot so an unlicensed deploy fails fast rather than silently
|
||||
// writing data to S3 it isn't entitled to use.
|
||||
if (env.STORAGE_MODE === "s3") {
|
||||
let s3Licensed = false;
|
||||
try {
|
||||
const { isFeatureEnabled } = await import("@snapotter/enterprise");
|
||||
s3Licensed = isFeatureEnabled("s3_storage");
|
||||
} catch {
|
||||
s3Licensed = false;
|
||||
}
|
||||
if (!s3Licensed) {
|
||||
console.error(
|
||||
"[FATAL] STORAGE_MODE=s3 requires a license that includes the s3_storage feature. " +
|
||||
"Set a valid SNAPOTTER_LICENSE_KEY (team or enterprise plan) or use STORAGE_MODE=local.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the cooperative cancellation listener (Redis pub/sub)
|
||||
await startCancelListener();
|
||||
|
||||
@@ -625,6 +646,22 @@ if (await shouldRunStartupCleanup()) {
|
||||
// Start BullMQ worker pools (after route registration so the tool registry is full)
|
||||
startWorkers();
|
||||
|
||||
// Reconcile orphaned job rows. A jobs row created without a tool_id/pool (e.g. an
|
||||
// SSE-progress placeholder for a clientJobId whose client then disconnected) is
|
||||
// never enqueued to BullMQ, so unlike a genuinely interrupted job (which BullMQ's
|
||||
// stalled-detection requeues) it would sit in 'processing'/'queued' forever --
|
||||
// inflating the per-user concurrent-job count and the upgrade-check in-flight gate.
|
||||
// Only rows with an empty tool_id are touched, so real jobs are never affected.
|
||||
void db
|
||||
.execute(
|
||||
sql`UPDATE jobs SET status = 'failed', error = '{"message":"Orphaned job reconciled at startup"}'::jsonb, completed_at = now() WHERE status IN ('processing','queued') AND (tool_id IS NULL OR tool_id = '')`,
|
||||
)
|
||||
.then((r) => {
|
||||
const n = (r as { rowCount?: number }).rowCount ?? 0;
|
||||
if (n > 0) app.log.info({ count: n }, "Reconciled orphaned job rows at startup");
|
||||
})
|
||||
.catch((err) => app.log.warn({ err }, "Orphaned-job reconciliation failed"));
|
||||
|
||||
// Warm the per-pool QueueEvents consumers so the first synchronous tool request
|
||||
// after boot does not pay the lazy-connect cost (and cannot miss a fast job's
|
||||
// completion event). Non-blocking: a slow/unreachable Redis must not stall boot;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { getMaxAgeMs } from "../lib/cleanup.js";
|
||||
import { deletePrefix, listJobDirs, type ObjectInfo } from "../lib/object-storage.js";
|
||||
import { getSettingNumber } from "../lib/settings-helpers.js";
|
||||
import { runAuditArchive } from "./audit-archive.js";
|
||||
import { getQueue } from "./queues.js";
|
||||
import { runSiemForward } from "./siem-forward.js";
|
||||
@@ -275,15 +276,20 @@ async function retentionSweep(): Promise<void> {
|
||||
WHERE u.legal_hold = true OR t.legal_hold = true
|
||||
)`;
|
||||
|
||||
if (env.JOBS_RETENTION_DAYS > 0) {
|
||||
// The Data Retention settings UI writes jobsRetentionDays / auditRetentionDays to
|
||||
// the DB. Honor those (the env vars are the fallback default), mirroring how the
|
||||
// temp-file sweep reads tempFileMaxAgeHours; otherwise the UI controls are no-ops.
|
||||
const jobsRetentionDays = await getSettingNumber("jobsRetentionDays", env.JOBS_RETENTION_DAYS);
|
||||
if (jobsRetentionDays > 0) {
|
||||
await db.execute(
|
||||
sql`DELETE FROM jobs
|
||||
WHERE created_at < now() - ${env.JOBS_RETENTION_DAYS} * interval '1 day'
|
||||
WHERE created_at < now() - ${jobsRetentionDays} * interval '1 day'
|
||||
AND status IN ('completed', 'failed', 'canceled')
|
||||
AND (user_id IS NULL OR user_id NOT IN ${heldUsersSubquery})`,
|
||||
);
|
||||
}
|
||||
if (env.AUDIT_RETENTION_DAYS > 0) {
|
||||
const auditRetentionDays = await getSettingNumber("auditRetentionDays", env.AUDIT_RETENTION_DAYS);
|
||||
if (auditRetentionDays > 0) {
|
||||
const tamperResult = await db
|
||||
.select({ value: schema.settings.value })
|
||||
.from(schema.settings)
|
||||
@@ -296,7 +302,7 @@ async function retentionSweep(): Promise<void> {
|
||||
if (!isTamperResistant) {
|
||||
await db.execute(
|
||||
sql`DELETE FROM audit_log
|
||||
WHERE created_at < now() - ${env.AUDIT_RETENTION_DAYS} * interval '1 day'
|
||||
WHERE created_at < now() - ${auditRetentionDays} * interval '1 day'
|
||||
AND (actor_id IS NULL OR actor_id NOT IN ${heldUsersSubquery})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and a metricsText() function that appends live queue-depth gauges
|
||||
* from BullMQ before returning the scrape payload.
|
||||
*/
|
||||
import { Counter, collectDefaultMetrics, Gauge, Histogram, Registry } from "prom-client";
|
||||
import { Counter, collectDefaultMetrics, Histogram, Registry } from "prom-client";
|
||||
import { perPoolCounts } from "../jobs/queues.js";
|
||||
|
||||
export const registry = new Registry();
|
||||
@@ -34,13 +34,6 @@ export const requestDuration = new Histogram({
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
export const storageUsage = new Gauge({
|
||||
name: "snapotter_storage_bytes",
|
||||
help: "Storage usage in bytes",
|
||||
labelNames: ["category"] as const,
|
||||
registers: [registry],
|
||||
});
|
||||
|
||||
export const authAttempts = new Counter({
|
||||
name: "snapotter_auth_attempts_total",
|
||||
help: "Authentication attempts",
|
||||
|
||||
@@ -22,6 +22,9 @@ const ALL_PERMISSIONS: Permission[] = [
|
||||
"features:manage",
|
||||
"system:health",
|
||||
"audit:read",
|
||||
"compliance:manage",
|
||||
"webhooks:manage",
|
||||
"security:manage",
|
||||
];
|
||||
|
||||
const roleNameField = z
|
||||
|
||||
@@ -79,7 +79,10 @@ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
if (endpoint) {
|
||||
try {
|
||||
const enterprise = await import("@snapotter/enterprise");
|
||||
const licenseKey = process.env.LICENSE_KEY ?? "";
|
||||
// The rest of the app reads SNAPOTTER_LICENSE_KEY (env.ts / index.ts). Accept
|
||||
// either here so distributed_tracing activates with the same key as every other
|
||||
// enterprise feature; LICENSE_KEY kept as an override for preload-only setups.
|
||||
const licenseKey = process.env.LICENSE_KEY ?? process.env.SNAPOTTER_LICENSE_KEY ?? "";
|
||||
if (licenseKey) {
|
||||
enterprise.initEnterprise(licenseKey);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ All configuration is done through environment variables. Every variable has a se
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `STORAGE_MODE` | `local` | `local` or `s3`. Only local storage is currently implemented. |
|
||||
| `STORAGE_MODE` | `local` | `local` or `s3`. S3/MinIO requires a license with the s3_storage feature. |
|
||||
| `DATABASE_URL` | `postgres://snapotter:snapotter@postgres:5432/snapotter` | PostgreSQL connection string. |
|
||||
| `REDIS_URL` | `redis://redis:6379` | Redis connection string (used for BullMQ job queues). |
|
||||
| `WORKSPACE_PATH` | `./tmp/workspace` | Directory for temporary files during processing. Cleaned up automatically. |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { en } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, en } from "@snapotter/shared";
|
||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster, toast } from "sonner";
|
||||
@@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer";
|
||||
import { I18nProvider } from "./contexts/i18n-context";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { useMobile } from "./hooks/use-mobile";
|
||||
import { initAnalytics } from "./lib/analytics";
|
||||
import { initAnalytics, track } from "./lib/analytics";
|
||||
import { useAnalyticsStore } from "./stores/analytics-store";
|
||||
|
||||
// Lazy-load all pages so each page's JS (and its icons/deps) is only
|
||||
@@ -48,6 +48,8 @@ class ErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Uncaught render error:", error, info.componentStack);
|
||||
// Mirror the crash to PostHog (error class only, no PII). track() is best-effort.
|
||||
track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name });
|
||||
import("@sentry/react")
|
||||
.then((Sentry) => {
|
||||
Sentry.captureException(error, {
|
||||
|
||||
@@ -690,20 +690,10 @@ function SystemSection() {
|
||||
{t.settings.dataRetention.title}
|
||||
</h4>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t.settings.dataRetention.fileMaxAgeHours}
|
||||
description={t.settings.dataRetention.fileMaxAgeHoursDesc}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.tempFileMaxAgeHours || "72"}
|
||||
onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)}
|
||||
aria-label={t.settings.dataRetention.fileMaxAgeHours}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
|
||||
min={1}
|
||||
max={8760}
|
||||
/>
|
||||
</SettingRow>
|
||||
{/* NOTE: the temp-file TTL (tempFileMaxAgeHours) is configured once under
|
||||
File Management above. A second control here bound the same key with a
|
||||
different default, so editing either silently overwrote the other.
|
||||
Data Retention keeps only the DB-row retention controls below. */}
|
||||
<SettingRow
|
||||
label={t.settings.dataRetention.jobsRetentionDays}
|
||||
description={t.settings.dataRetention.jobsRetentionDaysDesc}
|
||||
@@ -2600,6 +2590,10 @@ const PERMISSION_GROUPS = [
|
||||
label: "System",
|
||||
permissions: ["features:manage", "system:health", "audit:read"],
|
||||
},
|
||||
{
|
||||
label: "Enterprise Administration",
|
||||
permissions: ["security:manage", "compliance:manage", "webhooks:manage"],
|
||||
},
|
||||
];
|
||||
|
||||
function RolesSection() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
@@ -236,16 +237,18 @@ export function LoginPage() {
|
||||
{oidcEnabled && (
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
|
||||
className="w-full py-3 px-4 rounded-lg bg-primary text-primary-foreground font-medium shadow-sm hover:bg-primary-dark transition-colors flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<KeyRound className="w-[18px] h-[18px]" aria-hidden="true" />
|
||||
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
|
||||
</a>
|
||||
)}
|
||||
{samlEnabled && (
|
||||
<a
|
||||
href="/api/auth/saml/login"
|
||||
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
|
||||
className="w-full py-3 px-4 rounded-lg bg-primary text-primary-foreground font-medium shadow-sm hover:bg-primary-dark transition-colors flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<KeyRound className="w-[18px] h-[18px]" aria-hidden="true" />
|
||||
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
|
||||
</a>
|
||||
)}
|
||||
@@ -386,16 +389,24 @@ export function LoginPage() {
|
||||
{oidcEnabled && (
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
|
||||
className="group w-full py-3 px-4 rounded-lg border border-border bg-card text-foreground font-medium shadow-sm hover:border-primary hover:bg-primary-subtle transition-colors flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<KeyRound
|
||||
className="w-[18px] h-[18px] text-muted-foreground group-hover:text-primary transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
|
||||
</a>
|
||||
)}
|
||||
{samlEnabled && (
|
||||
<a
|
||||
href="/api/auth/saml/login"
|
||||
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2 mt-2"
|
||||
className="group w-full mt-2 py-3 px-4 rounded-lg border border-border bg-card text-foreground font-medium shadow-sm hover:border-primary hover:bg-primary-subtle transition-colors flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<KeyRound
|
||||
className="w-[18px] h-[18px] text-muted-foreground group-hover:text-primary transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -322,6 +322,12 @@ COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./pa
|
||||
COPY packages/media-engine/package.json packages/media-engine/tsconfig.json ./packages/media-engine/
|
||||
COPY packages/doc-engine/package.json packages/doc-engine/tsconfig.json ./packages/doc-engine/
|
||||
COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
|
||||
# packages/enterprise is required for ALL commercial features (license validation,
|
||||
# SAML/SCIM/MFA gates, S3 storage, OTel tracing gate, GDPR/audit/SIEM routes).
|
||||
# Without it, apps/api's `@snapotter/enterprise: workspace:*` link dangles and every
|
||||
# `import("@snapotter/enterprise")` throws (silently caught) -> enterprise.active=false
|
||||
# regardless of license. Manifest copied before install so pnpm wires the workspace link.
|
||||
COPY packages/enterprise/package.json packages/enterprise/tsconfig.json ./packages/enterprise/
|
||||
|
||||
# pnpm patchedDependencies (package.json) needs the patch files present before
|
||||
# install, or `pnpm install` aborts with ENOENT on the patch.
|
||||
@@ -351,11 +357,19 @@ COPY apps/api/static ./apps/api/static
|
||||
|
||||
# Copy workspace packages source (referenced by API at runtime)
|
||||
COPY packages/shared/src ./packages/shared/src
|
||||
# The builder stage ran scripts/bake-analytics.mjs to bake the analytics config
|
||||
# (driven by SNAPOTTER_ANALYTICS). The line above re-copies the committed baked.ts
|
||||
# from the build context, which would clobber that bake and leave the API runtime
|
||||
# with analytics permanently off -- so SNAPOTTER_ANALYTICS had no effect on the API
|
||||
# (and, since the SPA reads /api/v1/config/analytics, no effect anywhere). Pull the
|
||||
# baked version from the builder so the build arg actually controls runtime analytics.
|
||||
COPY --from=builder /app/packages/shared/src/analytics/baked.ts ./packages/shared/src/analytics/baked.ts
|
||||
COPY packages/image-engine/src ./packages/image-engine/src
|
||||
COPY packages/media-engine/src ./packages/media-engine/src
|
||||
COPY packages/doc-engine/src ./packages/doc-engine/src
|
||||
COPY packages/ai/src ./packages/ai/src
|
||||
COPY packages/ai/python ./packages/ai/python
|
||||
COPY packages/enterprise/src ./packages/enterprise/src
|
||||
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /app/apps/web/dist ./apps/web/dist
|
||||
|
||||
@@ -15,3 +15,10 @@ numpy==1.26.4
|
||||
Pillow==12.2.0
|
||||
opencv-python-headless==4.10.0.84
|
||||
codeformer-pip==0.0.4
|
||||
|
||||
# OpenTelemetry: enables the innermost sidecar.<script> span when
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT is set (enterprise distributed_tracing). Pure-Python,
|
||||
# no numpy/scipy deps, so safe for the numpy==1.26.4-locked stack.
|
||||
opentelemetry-api==1.27.0
|
||||
opentelemetry-sdk==1.27.0
|
||||
opentelemetry-exporter-otlp-proto-http==1.27.0
|
||||
|
||||
@@ -15,3 +15,10 @@ numpy==1.26.4
|
||||
Pillow==12.2.0
|
||||
opencv-python-headless==4.10.0.84
|
||||
codeformer-pip==0.0.4
|
||||
|
||||
# OpenTelemetry: enables the innermost sidecar.<script> span when
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT is set (enterprise distributed_tracing). Pure-Python,
|
||||
# no numpy/scipy deps, so safe for the numpy==1.26.4-locked stack.
|
||||
opentelemetry-api==1.27.0
|
||||
opentelemetry-sdk==1.27.0
|
||||
opentelemetry-exporter-otlp-proto-http==1.27.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
@@ -184,7 +184,11 @@ export async function putGenericObjectStream(
|
||||
params: {
|
||||
Bucket: cfg().bucket,
|
||||
Key: genericKey(key),
|
||||
Body: source as unknown as Readable,
|
||||
// @aws-sdk/lib-storage Upload only accepts string|Uint8Array|Buffer|Readable|
|
||||
// ReadableStream|Blob. A bare AsyncIterable (the counter() generator from
|
||||
// object-storage.putObjectStream) is none of those, so S3 uploads failed with
|
||||
// "Body Data is unsupported format". Wrap it in a real Node Readable.
|
||||
Body: Readable.from(source),
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
|
||||
@@ -4,8 +4,20 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const PRIVATE_KEY_PATH = ".license-signing-key";
|
||||
|
||||
// Keep in sync with PLAN_FEATURES in packages/enterprise/src/license.ts.
|
||||
// isFeatureEnabled() checks the signed license's own `features` array, so a stale
|
||||
// list here silently leaves gated features 403ing even with a valid enterprise key.
|
||||
const PLAN_FEATURES = {
|
||||
team: ["saml_sso", "s3_storage", "multi_tenancy"],
|
||||
team: [
|
||||
"saml_sso",
|
||||
"s3_storage",
|
||||
"multi_tenancy",
|
||||
"audit_export",
|
||||
"siem_forwarding",
|
||||
"sso_enforcement",
|
||||
"upgrade_management",
|
||||
"admin_alerts",
|
||||
],
|
||||
enterprise: [
|
||||
"saml_sso",
|
||||
"s3_storage",
|
||||
@@ -15,6 +27,17 @@ const PLAN_FEATURES = {
|
||||
"audit_export",
|
||||
"mfa",
|
||||
"per_tool_permissions",
|
||||
"siem_forwarding",
|
||||
"tamper_resistant_audit",
|
||||
"legal_hold",
|
||||
"gdpr_lifecycle",
|
||||
"team_retention_overrides",
|
||||
"sso_enforcement",
|
||||
"ip_allowlist",
|
||||
"config_export_import",
|
||||
"upgrade_management",
|
||||
"admin_alerts",
|
||||
"distributed_tracing",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Tests the AuthGuard redirect behavior: fresh users get redirected
|
||||
// to /analytics-consent, accepted users do not.
|
||||
|
||||
test.describe("Consent Redirect - Fresh User", () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test("/analytics-consent is accessible directly", async ({ page }) => {
|
||||
await page.goto("/analytics-consent");
|
||||
// Page should load (may auto-decline if config not yet loaded, but no crash)
|
||||
await page.waitForTimeout(2000);
|
||||
// No error page or blank screen
|
||||
const bodyText = await page.locator("body").textContent();
|
||||
expect(bodyText).toBeTruthy();
|
||||
});
|
||||
|
||||
test("/privacy is accessible without auth", async ({ page }) => {
|
||||
await page.goto("/privacy");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain("/privacy");
|
||||
await expect(page.getByText(/privacy/i).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("navigating to a protected route without auth redirects to /login", async ({ page }) => {
|
||||
await page.goto("/resize");
|
||||
await page.waitForURL(/login/, { timeout: 10_000 });
|
||||
expect(page.url()).toContain("/login");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Consent Redirect - Accepted User", () => {
|
||||
// Uses the pre-authenticated storageState where consent was accepted
|
||||
|
||||
test("home page loads without consent redirect", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).not.toContain("analytics-consent");
|
||||
});
|
||||
|
||||
test("tool page loads without consent redirect", async ({ page }) => {
|
||||
await page.goto("/resize");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).not.toContain("analytics-consent");
|
||||
expect(page.url()).toContain("/resize");
|
||||
});
|
||||
|
||||
test("automate page loads without consent redirect", async ({ page }) => {
|
||||
await page.goto("/automate");
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).not.toContain("analytics-consent");
|
||||
});
|
||||
|
||||
test("navigating between pages never triggers consent redirect", async ({ page }) => {
|
||||
const pages = ["/", "/resize", "/fullscreen", "/automate", "/"];
|
||||
for (const path of pages) {
|
||||
await page.goto(path);
|
||||
await page.waitForTimeout(500);
|
||||
expect(page.url()).not.toContain("analytics-consent");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect, test } from "./helpers";
|
||||
|
||||
// These tests use the pre-authenticated storageState from auth.setup.ts
|
||||
// where the user has already accepted analytics consent.
|
||||
|
||||
test.describe("Settings - Product Analytics Tab", () => {
|
||||
test("Product Analytics nav item is visible in settings", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await expect(page.getByRole("button", { name: /product analytics/i })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("clicking Product Analytics tab shows analytics section", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
// Should show the analytics description
|
||||
await expect(page.getByText(/anonymous usage data/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("toggle shows enabled state after accepting consent", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
// Auth setup accepted consent, so toggle should show enabled
|
||||
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("toggle off disables analytics", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
// Find and click the toggle button
|
||||
const toggleButton = page.locator("button.rounded-full");
|
||||
await toggleButton.click();
|
||||
|
||||
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("toggle on re-enables analytics", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
const toggleButton = page.locator("button.rounded-full");
|
||||
|
||||
// Ensure we're in disabled state first
|
||||
const text = await page.getByText(/analytics (enabled|disabled)/i).textContent();
|
||||
if (text?.toLowerCase().includes("enabled")) {
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// Now toggle on
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("toggle state persists after closing and reopening settings", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
// Open settings and disable analytics
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
const toggleButton = page.locator("button.rounded-full");
|
||||
|
||||
// Ensure enabled, then disable
|
||||
const text = await page.getByText(/analytics (enabled|disabled)/i).textContent();
|
||||
if (text?.toLowerCase().includes("enabled")) {
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// Close dialog
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reopen and check the state persisted
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
await expect(page.getByText(/analytics disabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Re-enable for other tests
|
||||
await toggleButton.click();
|
||||
await expect(page.getByText(/analytics enabled/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("privacy policy link is present", async ({ loggedInPage: page }) => {
|
||||
await page.locator("aside").getByText("Settings").click();
|
||||
await page.getByRole("button", { name: /product analytics/i }).click();
|
||||
|
||||
await expect(page.getByText(/privacy/i)).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user