Files
SnapOtter/scripts/generate-license.mjs
T
SnapOtterandGitHub 8f4235d2c6 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.
2026-06-24 17:27:59 +08:00

112 lines
3.5 KiB
JavaScript

#!/usr/bin/env node
import { createPrivateKey, generateKeyPairSync, sign } from "node:crypto";
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",
"audit_export",
"siem_forwarding",
"sso_enforcement",
"upgrade_management",
"admin_alerts",
],
enterprise: [
"saml_sso",
"s3_storage",
"scim",
"multi_tenancy",
"webhooks",
"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",
],
};
const command = process.argv[2];
if (command === "keygen") {
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
writeFileSync(PRIVATE_KEY_PATH, privateKey, "utf-8");
console.log("Private key saved to", PRIVATE_KEY_PATH);
console.log("\nPublic key (paste into packages/enterprise/src/license.ts):\n");
console.log(publicKey);
} else if (command === "sign") {
const args = process.argv.slice(3);
const get = (name) => {
const i = args.indexOf(`--${name}`);
return i >= 0 ? args[i + 1] : undefined;
};
const org = get("org");
const plan = get("plan") || "enterprise";
const seats = parseInt(get("seats") || "0", 10);
const expires = get("expires");
if (!org || !expires) {
console.error(
"Usage: generate-license.mjs sign --org <name> --expires <YYYY-MM-DD> [--plan team|enterprise] [--seats N]",
);
process.exit(1);
}
if (!existsSync(PRIVATE_KEY_PATH)) {
console.error(
`Private key not found at ${PRIVATE_KEY_PATH}. Run 'generate-license.mjs keygen' first.`,
);
process.exit(1);
}
const features = PLAN_FEATURES[plan] || PLAN_FEATURES.enterprise;
const payload = {
org,
plan,
features,
seats,
expiresAt: new Date(expires).toISOString(),
issuedAt: new Date().toISOString(),
};
const payloadBytes = Buffer.from(JSON.stringify(payload), "utf-8");
const privateKeyPem = readFileSync(PRIVATE_KEY_PATH, "utf-8");
const privateKey = createPrivateKey(privateKeyPem);
const signature = sign(null, payloadBytes, privateKey);
const licenseKey = `${payloadBytes.toString("base64url")}.${signature.toString("base64url")}`;
console.log("License payload:", JSON.stringify(payload, null, 2));
console.log("\nLicense key:\n");
console.log(licenseKey);
} else {
console.log("SnapOtter License Key Generator\n");
console.log("Commands:");
console.log(" keygen Generate a new Ed25519 signing keypair");
console.log(" sign Sign a license key\n");
console.log("Sign options:");
console.log(" --org <name> Organization name (required)");
console.log(" --expires <YYYY-MM-DD> Expiration date (required)");
console.log(" --plan <team|enterprise> License plan (default: enterprise)");
console.log(" --seats <N> Seat count (default: 0 = unlimited)");
}