Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion apps/web/src/features/hosting-signup/hosting-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ async function post<T>(path: string, body: unknown): Promise<T> {
return r.json() as Promise<T>;
}

async function patch<T>(path: string, token: string, body: unknown): Promise<T> {
const r = await fetch(`${HOSTING_API}${path}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
});
if (!r.ok) throw new Error(await parseError(r));
return r.json() as Promise<T>;
}

export const hostingApi = {
/** The signup page only renders when the service URL is configured. */
isConfigured: () => HOSTING_API.length > 0,
Expand Down Expand Up @@ -135,9 +149,54 @@ export const hostingApi = {
/** Prorated cost to add a custom domain (upgrade an existing active standard tenant to Pro) for
* the months remaining on its current term. `eligible: false` when not active / already Pro. */
upgradeQuote: (username: string) =>
get<UpgradeQuote>(`/v1/payments/upgrade-quote/${encodeURIComponent(username)}`)
get<UpgradeQuote>(`/v1/payments/upgrade-quote/${encodeURIComponent(username)}`),

/** The tenant's stored config document (public for ACTIVE tenants; 402 otherwise). The manage
* panel's settings editor prefills from it. */
tenantConfig: (username: string) =>
get<StoredTenantConfig>(`/v1/tenants/${encodeURIComponent(username)}/config`),

/** Exchange an ecency.com session's Hivesigner-compatible access token for a hosting token.
* Every login method on ecency.com holds one, so this is the universal rail. */
authHivesigner: (accessToken: string) =>
post<HostingAuthResult>("/v1/auth/hivesigner", { accessToken }),

/** Keychain rail: fetch a challenge to sign with the posting key... */
authChallenge: (username: string) =>
post<{ username: string; challenge: string; expiresAt: string }>("/v1/auth/challenge", {
username
}),

/** ...and trade the signature for the hosting token. */
authVerify: (username: string, signature: string, challenge: string) =>
post<HostingAuthResult>("/v1/auth/verify", { username, signature, challenge }),

/** Update a tenant's config remotely with flat keys (title, description, theme, accent...).
* Requires a hosting token for the tenant's OWNER; persists for inactive tenants too and
* publishes on activation. `published` is the server's authoritative word on whether the
* change is live or only stored. */
updateTenant: (username: string, token: string, config: HostingConfigInput) =>
patch<{ message?: string; published?: boolean }>(
`/v1/tenants/${encodeURIComponent(username)}`,
token,
{ config }
)
};

export interface HostingAuthResult {
token: string;
username: string;
expiresAt?: string;
}

/** The slice of the stored config document the settings editor reads. */
export interface StoredTenantConfig {
configuration?: {
general?: { theme?: string; styles?: { accent?: string } };
instanceConfiguration?: { meta?: { title?: string; description?: string } };
};
}

export type UpgradeQuote =
| { eligible: false; reason: string }
| {
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-manage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { CustomDomainManager } from "./custom-domain-manager";
import { CustomDomainUpgrade } from "./custom-domain-upgrade";
import { hostingApi, type OwnedTenant } from "./hosting-api";
import { TenantSettings } from "./tenant-settings";

/**
* Compact "your hosted sites" panel for the /hosting page. Signup used to be the only surface,
Expand All @@ -19,6 +20,7 @@ export function HostingManage() {
const username = activeUser?.username ?? "";
const [domainOpenFor, setDomainOpenFor] = useState<string | null>(null);
const [upgradeOpenFor, setUpgradeOpenFor] = useState<string | null>(null);
const [settingsOpenFor, setSettingsOpenFor] = useState<string | null>(null);

// Keyed by owner so switching accounts can never render the previous account's tenants.
const { data, refetch } = useQuery({
Expand All @@ -41,6 +43,7 @@ export function HostingManage() {
useEffect(() => {
setDomainOpenFor(null);
setUpgradeOpenFor(null);
setSettingsOpenFor(null);
}, [username]);

if (!activeUser || tenants.length === 0) {
Expand Down Expand Up @@ -93,6 +96,23 @@ export function HostingManage() {
<span className="text-sm opacity-75">{statusLabel(t)}</span>
</div>

{/* Remote settings: title, look and theme PATCH straight to the
hosting API with a token obtained in place, no instance visit.
Offered for every status, since the PATCH persists for a tenant
that is still activating and publishes on activation. */}
<div className="text-sm">
{settingsOpenFor === t.username ? (
<TenantSettings tenant={t} owner={username} />
) : (
<button
className="text-blue-dark-sky hover:underline"
onClick={() => setSettingsOpenFor(t.username)}
>
{i18next.t("hosting.manage-settings")}
</button>
)}
</div>

{t.subscriptionPlan === "pro" && t.subscriptionStatus === "active" && (
<div className="text-sm">
{t.customDomain && t.customDomainVerified ? (
Expand Down
66 changes: 66 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getLoginType, ensureValidToken } from "@/utils/user-token";
import { signBuffer } from "@/utils/keychain";
import { hostingApi, type HostingAuthResult } from "./hosting-api";

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
/**
* A hosting-API token for the signed-in account, obtained in place so the
* manage panel can call the tenant PATCH endpoint without a visit to the
* instance.
*
* Two rails, tried in order:
* - Every ecency.com login method holds a Hivesigner-compatible access token,
* so exchanging it (/v1/auth/hivesigner) is the universal path.
* - A Keychain login can also sign the challenge (/v1/auth/challenge +
* /v1/auth/verify) when the exchange is unavailable.
*
* Tokens are cached per account for their lifetime (the API issues 24h), so
* a session edits many settings on one authorization.
*/

const cache = new Map<string, { token: string; expiresAt: number }>();

/** Test seam: module memory otherwise leaks between cases. */
export function resetHostingTokenCache(): void {
cache.clear();
}

function remember(result: HostingAuthResult): string {
const expiresAt = result.expiresAt
? Date.parse(result.expiresAt)
: Date.now() + 23 * 60 * 60 * 1000;
cache.set(result.username, { token: result.token, expiresAt });
return result.token;
}

export async function obtainHostingToken(username: string): Promise<string> {
const hit = cache.get(username);
// A minute of slack: a token that expires mid-request helps nobody.
if (hit && hit.expiresAt - 60_000 > Date.now()) {
return hit.token;
}
cache.delete(username);

// The universal rail first. ensureValidToken refreshes a stale stored
// token before it is exchanged, so a long-lived login works too.
let exchangeError: Error | null = null;
try {
const accessToken = await ensureValidToken(username);
if (accessToken) {
return remember(await hostingApi.authHivesigner(accessToken));
}
} catch (e) {
exchangeError = e as Error;
}

// Keychain can prove the account by signing the challenge directly.
if (getLoginType(username) === "keychain") {
const { challenge } = await hostingApi.authChallenge(username);
const signed = await signBuffer(username, challenge, "Posting");
if (!signed.success || !signed.result) {
throw new Error(signed.message || "Signature refused");
}
return remember(await hostingApi.authVerify(username, signed.result, challenge));
}

throw exchangeError ?? new Error("No session token available");
Comment on lines +59 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Localize fallback authentication errors.

TenantSettings renders these error messages to the owner. "Signature refused" and "No session token available" bypass en-US.json and i18next.

Return stable error codes from this helper. Map those codes to localized messages in the UI.

As per coding guidelines, “All new user-facing strings must be added to en-US.json and accessed through i18next.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/features/hosting-signup/hosting-token.ts` around lines 59 - 65,
Update the authentication helper around the signature and session-token failure
paths to throw stable, non-user-facing error codes instead of the literal
messages “Signature refused” and “No session token available”. In
TenantSettings, detect those codes and resolve the displayed messages through
i18next using entries added to en-US.json, while preserving existing
exchange-error handling.

Source: Coding guidelines

}
Loading
Loading