Skip to content
Open
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
39 changes: 39 additions & 0 deletions docs/admin-teams.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ Account and Team grants are additive. Removing a direct grant does not remove ac

Provider URLs, upstream model IDs, and credentials belong in **Provider connections**, separate from the shared model list.

## Mail delivery (optional)

Owners configure SMTP under **Settings → System → Mail delivery**. Mail is off until the owner
enables it with a valid host, sender address, and public HTTPS origin; the SMTP
password is stored Host-side and never displayed again. Members cannot see these
settings. Use **Send test mail** to verify delivery before announcing the feature.

When mail is enabled, any signed-in Account may move its own contact email to a new
address from the profile menu: the current password is rechecked, and the stored
email is replaced only after a single-use confirmation link (30 minutes) is opened
from the new inbox. Confirmation mail follows the reader's language (English,
German, Spanish). Login stays username-based; sessions are not revoked by an
email change. Mail is not a password-recovery mechanism: forgotten member passwords
still use the existing owner-operated reset, regardless of mail settings.

SMTP needs a host, sender address, and public HTTPS origin. Port 465 uses implicit
TLS; any other host outside a loopback relay must negotiate STARTTLS, which the
Host enforces when saving. The SMTP password is kept in Host secret storage and
is never displayed; leave the field untouched to keep it, or use **Remove saved
password** to clear it. Disable mail before changing SMTP providers, then save and
test the complete new configuration before enabling it. Metadata and secret storage
are separate authorities; a failed or interrupted settings save may require resaving
both before using mail.

Known limitation: an authenticated member who guesses another member's address
receives an "unavailable" response when requesting an email change to it. This matches
the pre-existing invite behavior for known addresses and is accepted under the
trusted-team Host model; it is not exposed to unauthenticated callers. Any Host
role (including viewer) may change its own contact email; mail configuration stays
owner-only.

Failed password verifications during email change count toward rate limits (10 per
account per hour, plus a Host-wide verification budget): repeated guessing locks
the request path temporarily without disturbing valid challenges. Public confirmation
attempts share a separate 120/hour Host-wide budget; anonymous traffic can exhaust it
and temporarily delay legitimate email changes. Username/password login is unaffected.
Interrupted deliveries can leave pending reservations until their 30-minute expiry;
a delivered but unusable link may require a fresh request.

## Personal credentials

Restrictions take priority. System, Everyone, or any named Team the Account belongs to can disable personal API keys or subscriptions. A Team cannot override a restriction from another scope. These settings govern supported credential types; they do not add new provider authentication integrations.
Expand Down
6 changes: 5 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"@opengui/harness": "workspace:*",
"@opengui/protocol": "workspace:*",
"better-auth": "1.7.6",
"hono": "^4.13.12"
"hono": "^4.13.12",
"nodemailer": "^10.0.15"
},
"devDependencies": {
"@types/nodemailer": "^8.0.2"
},
"engines": {
"node": ">=22.5"
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/create-backend-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ export type CreateBackendHostOptions = {
identityDatabasePath?: string;
identitySecret?: string;
identityBaseURL?: string;
/**
* SMTP credential callbacks for identity mail. Tests inject an in-memory
* pair; production defaults to a bridge over Host secret storage.
*/
identityMailSecrets?: {
readSmtpPassword: () => Promise<string | null>;
writeSmtpPassword: (password: string | null) => Promise<void>;
};
model?: ModelTransport;
/** Canary switch; OPENGUI_MODEL_TRANSPORT=native is the process-level equivalent. */
usePiAiTransport?: boolean;
Expand Down Expand Up @@ -57,6 +65,27 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe
authToken: env.authToken,
allowedCorsOrigin: env.allowedCorsOrigin,
});
// Bridge from identity mail to Host secret storage. The Host starts
// asynchronously after the identity service exists, so the callbacks await
// startup (bounded); tests inject an in-memory pair instead.
let resolveMailHost: ((host: OpenGuiHost) => void) | null = null;
const mailHostPromise = new Promise<OpenGuiHost>((resolve) => {
resolveMailHost = resolve;
});
async function mailHost(): Promise<OpenGuiHost> {
let timeout!: NodeJS.Timeout;
const deadline = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new IdentityError("MAIL_SECRETS_UNAVAILABLE", 503, "Host is starting")),
15_000,
);
});
try {
return await Promise.race([mailHostPromise, deadline]);
} finally {
clearTimeout(timeout);
}
}
const identity =
env.identityMode === "remote"
? new IdentityService({
Expand All @@ -67,6 +96,12 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe
trustedOrigins: [env.allowedCorsOrigin],
pathGrantsMode: env.pathGrantsMode,
allowedRoots: env.allowedRoots,
mailSecrets: options.identityMailSecrets ?? {
readSmtpPassword: async () => (await mailHost()).getMailSmtpPassword(),
writeSmtpPassword: async (password: string | null) => {
await (await mailHost()).setMailSmtpPassword(password);
},
},
})
: undefined;
const authorizer = createAuthorizer({
Expand Down Expand Up @@ -183,6 +218,10 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe
}
: undefined,
}).then((context) => context.host);
hostReady.then(
(host) => resolveMailHost?.(host),
() => {},
);
const ready = Promise.all([hostReady, identity?.ready]).then(() => undefined);

const app = new Hono<BackendRequestEnv>();
Expand All @@ -205,6 +244,7 @@ export function createBackendHost(options: CreateBackendHostOptions = {}): Backe
c.req.path === "/api/identity/register" ||
c.req.path === "/api/identity/policy" ||
(c.req.path === "/api/identity/invites/accept" && c.req.method === "POST") ||
(c.req.path === "/api/identity/email-change/confirm" && c.req.method === "POST") ||
(c.req.path === "/api/identity/session-view-links/resolve" && c.req.method === "GET") ||
c.req.path === "/api/auth/login"
) {
Expand Down
36 changes: 36 additions & 0 deletions packages/backend/src/host/opengui-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,19 @@ interface HostSettingsFile {

type PersonalCredential<T> = { connectionId: string; tokens: T };

type HostMailSecrets = {
smtpPassword: string;
};

function validMailSecrets(value: unknown): HostMailSecrets | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const smtpPassword = (value as Record<string, unknown>).smtpPassword;
return typeof smtpPassword === "string" && smtpPassword.length > 0 ? { smtpPassword } : null;
}

type HostSecretsFile = {
apiKeys: Record<string, string>;
mail: HostMailSecrets | null;
codexTokens: CodexTokens | null;
subscriptionTokens: Partial<Record<"xai", OAuthTokens>>;
personalCodexTokens: Record<string, PersonalCredential<CodexTokens>>;
Expand Down Expand Up @@ -358,6 +369,7 @@ export class OpenGuiHost {
#personalDeviceAuth: Record<string, DeviceAuthorization> = {};
#personalSubscriptionTokens: HostSecretsFile["personalSubscriptionTokens"] = {};
#mcpSecrets: HostSecretsFile["mcp"] = {};
#mailSecrets: HostSecretsFile["mail"] = null;
#subscriptionPending: Partial<Record<"xai", DeviceOAuthPending>> = {};
#personalSubscriptionPending: Record<string, Partial<Record<"xai", DeviceOAuthPending>>> = {};
readonly #codexRefreshes = new Map<string, Promise<CodexTokens>>();
Expand Down Expand Up @@ -1035,6 +1047,7 @@ export class OpenGuiHost {
personalCodexTokens: {},
personalSubscriptionTokens: {},
mcp: {},
mail: null,
};
} catch {
return {
Expand All @@ -1044,6 +1057,7 @@ export class OpenGuiHost {
personalCodexTokens: {},
personalSubscriptionTokens: {},
mcp: {},
mail: null,
};
}
}
Expand Down Expand Up @@ -1130,6 +1144,7 @@ export class OpenGuiHost {
state.secrets.mcp && typeof state.secrets.mcp === "object"
? structuredClone(state.secrets.mcp)
: {},
mail: validMailSecrets(state.secrets.mail),
},
};
}
Expand All @@ -1142,6 +1157,7 @@ export class OpenGuiHost {
this.#personalCodexTokens = structuredClone(state.secrets.personalCodexTokens);
this.#personalSubscriptionTokens = structuredClone(state.secrets.personalSubscriptionTokens);
this.#mcpSecrets = structuredClone(state.secrets.mcp);
this.#mailSecrets = validMailSecrets(state.secrets.mail);
this.#refreshTransport();
}

Expand Down Expand Up @@ -1170,6 +1186,7 @@ export class OpenGuiHost {
personalCodexTokens: structuredClone(this.#personalCodexTokens),
personalSubscriptionTokens: structuredClone(this.#personalSubscriptionTokens),
mcp: structuredClone(this.#mcpSecrets),
mail: this.#mailSecrets ? { ...this.#mailSecrets } : null,
};
await this.#updateState((state) => ({ ...state, secrets }));
}
Expand Down Expand Up @@ -1257,6 +1274,25 @@ export class OpenGuiHost {
};
}

/**
* Owner-configured SMTP password for Host mail delivery. The secret lives in
* Host secret storage alongside the other Host secrets; it is never exposed
* through settings reads, events, or diagnostics. `null` means no password.
*/
getMailSmtpPassword(): string | null {
return this.#mailSecrets?.smtpPassword ?? null;
}

async setMailSmtpPassword(password: string | null): Promise<void> {
if (password !== null && (password.length < 1 || password.length > 1024)) {
throw new Error("SMTP password must be 1-1024 characters or cleared");
}
await this.#updateState((state) => ({
...state,
secrets: { ...state.secrets, mail: password === null ? null : { smtpPassword: password } },
}));
}

getCustomInstructions() {
return this.#settings.customInstructions;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/identity/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const identityAuditEventTypes = {
inviteAccepted: "invite.accepted",
memberRemoved: "member.removed",
memberPasswordReset: "member.password_reset",
emailChangeRequested: "email.change_requested",
emailChangeConfirmed: "email.change_confirmed",
apiKeyMinted: "api_key.minted",
apiKeyRevoked: "api_key.revoked",
} as const;
Expand Down
Loading
Loading