import * as env from "$app/env/private";
import { knotOf, type KnotOrigin } from "#lib/api/syntax.ts";
// accepts comma-separated entries, ignoring empties
const list = (value: string | undefined): string[] => {
const raw = value?.trim();
if (!raw) return [];
return raw
.split(",")
.map((v) => v.trim())
.filter(Boolean);
};
// accepts "0" to disable a limit; non-numeric values fall back
const limit = (value: string | undefined, fallback: number) => {
const raw = value?.trim();
if (!raw) return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const cleanUrl = (value: string | undefined, fallback: string) => {
const raw = value?.trim() || fallback;
return raw.replace(/\/+$/, "");
};
const fosterKnotOrigin = (value: string | undefined): KnotOrigin | "" => {
const raw = value?.trim();
if (!raw) return "";
const parsed = URL.parse(raw);
if (parsed === null || parsed.pathname !== "/")
throw new Error(
`FOSTER_KNOT_URL must be a bare origin like https://knot.oyster.cafe, got "${raw}"`
);
return knotOf(parsed.host);
};
const liarPdsUrl = (value: string | undefined): string => {
const raw = cleanUrl(value, "");
if (!raw) return "";
const parsed = URL.parse(raw);
if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:"))
throw new Error(
`LIAR_PDS_URL must be an http(s) URL like https://liar.tangled.sh, got "${raw}"`
);
return raw;
};
export type WebConfig = {
bobbinUrl: string;
apiUrl: string;
deliberiUrl: string;
delegationUrl: string;
delegationDid: string;
delegationHandleDomain: string;
/** the domain user sites are served under, e.g. "tngl.io" */
sitesDomain: string;
/** the sites Worker (claims + serving), e.g. "https://sites.tangled.org" */
sitesUrl: string;
/** written into markdown as <url>/blob/<did>/<cid> for uploaded images */
blobUrl: string;
camoUrl: string;
avatarUrl: string;
ogreUrl: string;
/** the secrets camo and avatar sign with, so neither leaves the server */
camoSecret: string;
avatarSecret: string;
/** the email-did worker and the token it wants, both server-only */
emailDidUrl: string;
emailDidToken: string;
/** where /sitemap.xml comes from when there is no SITEMAP worker binding */
sitemapUrl: string;
moderationServiceDid: string;
primaryKnotHostname: string;
primarySpindleHostname: string;
turnstileSiteKey: string;
/** see SSR_DIFF_MAX_BYTES */
ssrDiffMaxBytes: number;
/** see UPSTREAM_TIMEOUT_MS */
upstreamTimeoutMs: number;
lookupCacheTtlMs: number;
hydrantSubscribeUrl: string;
migratorUrl: string;
googleSiteVerification: string[];
/** empty turns the migration off */
liarPdsUrl: string;
/** empty turns foster read routing off */
fosterKnotUrl: KnotOrigin | "";
/** fakes the liar pds and hydrant's unmark */
liarMock: boolean;
/** empty turns analytics off; server-only */
posthogApiKey: string;
posthogHost: string;
posthogEnv: string;
};
export type PublicWebConfig = Pick<
WebConfig,
| "bobbinUrl"
| "apiUrl"
| "deliberiUrl"
| "delegationUrl"
| "delegationDid"
| "delegationHandleDomain"
| "sitesDomain"
| "sitesUrl"
| "blobUrl"
| "moderationServiceDid"
| "primaryKnotHostname"
| "primarySpindleHostname"
| "turnstileSiteKey"
| "ssrDiffMaxBytes"
| "ogreUrl"
| "migratorUrl"
| "googleSiteVerification"
| "liarPdsUrl"
| "fosterKnotUrl"
| "liarMock"
> & {
/** camo has a secret, so markup can route images through it */
camoEnabled: boolean;
};
type WebConfigEnv = {
BOBBIN_URL?: string;
TANGLED_API_URL?: string;
API_URL?: string;
DELIBERI_URL?: string;
DELEGATION_URL?: string;
DELEGATION_DID?: string;
DELEGATION_HANDLE_DOMAIN?: string;
KNOT_RESOLVER_URL?: string;
SITES_DOMAIN?: string;
TANGLED_SITES_URL?: string;
BLOB_URL?: string;
CAMO_URL?: string;
CAMO_SHARED_SECRET?: string;
AVATAR_URL?: string;
AVATAR_SHARED_SECRET?: string;
EMAIL_DID_URL?: string;
EMAIL_DID_TOKEN?: string;
SITEMAP_URL?: string;
OGRE_URL?: string;
MODERATION_SERVICE_DID?: string;
PRIMARY_KNOT?: string;
PRIMARY_SPINDLE?: string;
TURNSTILE_SITE_KEY?: string;
MIGRATOR_URL?: string;
GOOGLE_SITE_VERIFICATION?: string;
LIAR_PDS_URL?: string;
FOSTER_KNOT_URL?: string;
LIAR_MOCK?: string;
POSTHOG_API_KEY?: string;
POSTHOG_HOST?: string;
POSTHOG_ENV?: string;
/** diff payloads above this many bytes are not buffered through the ssr
* worker; the page refetches them from the browser instead. "0" disables */
SSR_DIFF_MAX_BYTES?: string;
/** how long a server side GET waits for an upstream's headers. "0" turns it off */
UPSTREAM_TIMEOUT_MS?: string;
/** how long a server keeps handle and repo name lookups for every request. "0" turns it off */
LOOKUP_CACHE_TTL_MS?: string;
/** the hydrant jetstream whose identity and repo events drop those lookups early */
HYDRANT_SUBSCRIBE_URL?: string;
};
export const resolveConfig = (values: WebConfigEnv): WebConfig => ({
bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"),
apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"),
deliberiUrl: cleanUrl(values.DELIBERI_URL, "https://notifs-dev.tangled.network"),
delegationUrl: cleanUrl(values.DELEGATION_URL, ""),
delegationDid: values.DELEGATION_DID?.trim() ?? "",
delegationHandleDomain: values.DELEGATION_HANDLE_DOMAIN?.trim().toLowerCase() ?? "",
sitesDomain: values.SITES_DOMAIN?.trim() || "tngl.io",
sitesUrl: cleanUrl(values.TANGLED_SITES_URL, "https://sites.tangled.org"),
blobUrl: cleanUrl(values.BLOB_URL, "https://tangled.org"),
camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"),
avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"),
ogreUrl: cleanUrl(values.OGRE_URL, "https://ogre.tangled.network"),
migratorUrl: cleanUrl(values.MIGRATOR_URL, "https://migrator.tangled.sh"),
camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "",
avatarSecret: values.AVATAR_SHARED_SECRET?.trim() ?? "",
emailDidUrl: cleanUrl(values.EMAIL_DID_URL, ""),
emailDidToken: values.EMAIL_DID_TOKEN?.trim() ?? "",
sitemapUrl: cleanUrl(values.SITEMAP_URL, ""),
moderationServiceDid: values.MODERATION_SERVICE_DID?.trim() ?? "",
primaryKnotHostname: values.PRIMARY_KNOT?.trim() ?? "knot1.tangled.sh",
primarySpindleHostname: values.PRIMARY_SPINDLE?.trim() ?? "spindle.tangled.sh",
turnstileSiteKey: values.TURNSTILE_SITE_KEY?.trim() ?? "",
ssrDiffMaxBytes: limit(values.SSR_DIFF_MAX_BYTES, 5 * 1024 * 1024),
upstreamTimeoutMs: limit(values.UPSTREAM_TIMEOUT_MS, 15_000),
lookupCacheTtlMs: limit(values.LOOKUP_CACHE_TTL_MS, 60_000),
hydrantSubscribeUrl: cleanUrl(
values.HYDRANT_SUBSCRIBE_URL,
"wss://index.tangled.network/subscribe"
),
googleSiteVerification: list(values.GOOGLE_SITE_VERIFICATION),
liarPdsUrl: liarPdsUrl(values.LIAR_PDS_URL),
fosterKnotUrl: fosterKnotOrigin(values.FOSTER_KNOT_URL),
liarMock: ["1", "true"].includes(values.LIAR_MOCK?.trim().toLowerCase() ?? ""),
posthogApiKey: values.POSTHOG_API_KEY?.trim() ?? "",
posthogHost: cleanUrl(values.POSTHOG_HOST, "https://eu.i.posthog.com"),
posthogEnv: values.POSTHOG_ENV?.trim() || "dev"
});
// every declared variable is present on $app/env/private, unset ones as undefined:
// keep only the set ones, so they don't mask a platform binding of the same name
const processEnv = (): WebConfigEnv =>
Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined));
// process env takes precedence over platform bindings in dev
export const getConfig = (platform?: App.Platform): WebConfig =>
resolveConfig({
...((platform?.env as WebConfigEnv | undefined) ?? {}),
...processEnv()
});
// listed one by one, so anything new in WebConfig stays private until it is
// named here
export const getPublicConfig = (platform?: App.Platform): PublicWebConfig => {
const config = getConfig(platform);
return {
bobbinUrl: config.bobbinUrl,
apiUrl: config.apiUrl,
deliberiUrl: config.deliberiUrl,
delegationUrl: config.delegationUrl,
delegationDid: config.delegationDid,
delegationHandleDomain: config.delegationHandleDomain,
sitesDomain: config.sitesDomain,
sitesUrl: config.sitesUrl,
blobUrl: config.blobUrl,
moderationServiceDid: config.moderationServiceDid,
primaryKnotHostname: config.primaryKnotHostname,
primarySpindleHostname: config.primarySpindleHostname,
turnstileSiteKey: config.turnstileSiteKey,
ssrDiffMaxBytes: config.ssrDiffMaxBytes,
ogreUrl: config.ogreUrl,
migratorUrl: config.migratorUrl,
googleSiteVerification: config.googleSiteVerification,
liarPdsUrl: config.liarPdsUrl,
fosterKnotUrl: config.fosterKnotUrl,
liarMock: config.liarMock,
camoEnabled: config.camoSecret !== ""
};
};