feat(api): expose workspace branding to mobile clients

This commit is contained in:
poyrazavsever
2026-07-17 21:56:37 +03:00
parent ae370b4313
commit cb2ed90046
7 changed files with 102 additions and 17 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ Davet token'ının yalnızca hash'i saklanır. Eski Supabase Auth şifre/session
## Marka özelleştirmesi
Instance adı, kısa ad, açık/koyu logo, ikon, primary/accent renk, varsayılan görünüm ve radius yoğunluğu SQLite'ta tutulur ve root layout'a server-side uygulanır. Görseller yerel upload alanında saklanır. Branding mutation'ı yalnızca owner rolüne açıktır; portal aynı güvenli public marka çıktısını kullanır.
`Ayarlar > Genel` alanındaki workspace adı, meta title, kısa uygulama adı, açık/koyu tema logoları, favicon, ana renk ve görünüm tercihi SQLite'ta tutulur ve root layout'a server-side uygulanır. Görseller yerel upload alanında saklanır. Branding mutation'ı yalnızca owner rolüne açıktır; portal aynı güvenli public marka çıktısını kullanır. `/api/v1/meta` bu markayı absolute asset URL'leriyle, `/api/v1/me` ise oturum sahibinin renk modu tercihiyle mobil istemcilere sunar.
## Backup ve restore
+4
View File
@@ -1,7 +1,9 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getServerConfig } from "@/server/config";
import { DomainError } from "@/server/domain/errors";
import { getUserPreferences } from "@/server/settings/preferences";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -12,6 +14,7 @@ export async function GET(request: Request) {
if (!context) {
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
}
const preferences = getUserPreferences(domainActorFromSession(context));
return apiV1Success({
user: {
@@ -25,6 +28,7 @@ export async function GET(request: Request) {
session: {
expiresAt: context.session.expiresAt.toISOString(),
},
preferences,
});
} catch (error) {
return apiV1Error(error);
@@ -113,8 +113,8 @@ Mevcut hata kodları:
- Protokol/discovery/API major sürümü
- Neta server package sürümü
- Kalıcı instance kimliği ve oluşturulma zamanı
- Application/organization adı
- Mobil için absolute logo/icon URL'leri ve semantic marka bilgisi
- Workspace adı, meta title ve kısa uygulama adı
- Mobil için ayrı light/dark logo, favicon absolute URL'leri ve semantic marka bilgisi
- Minimum desteklenen mobil client sürümü
- `ios` ve `android` platform listesi
- Authentication durumu
@@ -134,6 +134,14 @@ Capability kaydı:
İstemci bilinmeyen capability ID ve alanlarını yok saymalıdır. `status=planned`, endpoint'in kullanılabilir olduğu anlamına gelmez.
`instance.workspaceName` kullanıcıya gösterilecek firma/freelance çalışma alanı adıdır.
`instance.metaTitle` web metadata başlığıdır. `branding.lightLogoUrl`,
`branding.darkLogoUrl` ve `branding.faviconUrl` absolute URL döner; mobil istemci
aktif renk moduna göre doğru logoyu seçmeli ve eksik yeni alanlarda eski
`applicationName` / `iconUrl` alanlarına geriye uyumlu fallback uygulamalıdır.
Authenticated `/api/v1/me` yanıtındaki `preferences.colorMode`, kullanıcının
`light`, `dark` veya `system` tercihini taşır.
İlk capability seti:
- `instance.discovery`
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
output: "standalone",
experimental: {
serverActions: {
+70 -14
View File
@@ -10,6 +10,11 @@ const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const distDir = `.next-phase1-auth-smoke-${Date.now()}`;
const nextGeneratedConfigFiles = ["next-env.d.ts", "tsconfig.json"].map((file) => ({
file,
content: fs.readFileSync(path.join(process.cwd(), file), "utf8"),
}));
const port = await getAvailablePort();
const baseUrl = `http://127.0.0.1:${port}`;
const env = {
@@ -22,6 +27,7 @@ const env = {
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
TRUSTED_ORIGINS: baseUrl,
NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1",
NEXT_DIST_DIR: distDir,
NEXT_TELEMETRY_DISABLED: "1",
};
@@ -178,6 +184,7 @@ try {
clientId: null,
},
);
assert.equal(ownerMe.payload.data.preferences.colorMode, "system");
assert.doesNotMatch(
JSON.stringify(ownerMe.payload),
/token|password/i,
@@ -333,38 +340,83 @@ try {
"File API errors must use the standard envelope",
);
const logoUpload = await uploadFile("branding_logo", {
const lightLogoUpload = await uploadFile("branding_logo", {
cookie: ownerCookie,
fileName: "logo.png",
fileName: "light-logo.png",
});
assert.equal(logoUpload.response.status, 201, JSON.stringify(logoUpload.payload));
assert.equal(logoUpload.payload.ok, true, "File API success must use the standard envelope");
const logoFileId = logoUpload.payload.data.id;
const darkLogoUpload = await uploadFile("branding_logo", {
cookie: ownerCookie,
fileName: "dark-logo.png",
});
const faviconUpload = await uploadFile("branding_icon", {
cookie: ownerCookie,
fileName: "favicon.png",
});
for (const upload of [lightLogoUpload, darkLogoUpload, faviconUpload]) {
assert.equal(upload.response.status, 201, JSON.stringify(upload.payload));
assert.equal(upload.payload.ok, true, "File API success must use the standard envelope");
}
const lightLogoFileId = lightLogoUpload.payload.data.id;
const darkLogoFileId = darkLogoUpload.payload.data.id;
const faviconFileId = faviconUpload.payload.data.id;
const brandingUpdate = await jsonRequest("/api/branding", {
method: "PATCH",
cookie: ownerCookie,
body: {
applicationName: "Neta Smoke Studio",
applicationName: "Neta Smoke Meta",
organizationName: "Neta Smoke Studio",
shortName: "Neta Smoke",
primaryColor: "#336699",
accentColor: "#F0CC22",
lightLogoFileId: logoFileId,
lightLogoFileId,
darkLogoFileId,
iconFileId: faviconFileId,
},
});
assert.equal(brandingUpdate.response.ok, true, JSON.stringify(brandingUpdate.payload));
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Meta");
assert.equal(brandingUpdate.payload.data.organizationName, "Neta Smoke Studio");
assert.notEqual(
brandingUpdate.payload.data.lightLogoUrl,
brandingUpdate.payload.data.darkLogoUrl,
"Light and dark logo assets must remain distinct",
);
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Workspace branding must be server-rendered");
assert.match(brandedLoginHtml, /Neta Smoke Meta/, "Meta title must be server-rendered");
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${lightLogoFileId}`));
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${darkLogoFileId}`));
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${faviconFileId}`));
assert.doesNotMatch(
brandedLoginHtml,
/href="\/favicon\.ico"/,
"Static favicon metadata must not override the active branding favicon",
);
assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response");
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use workspace branding");
assert.equal(dynamicManifest.short_name, "Neta Smoke");
assert.equal(dynamicManifest.icons[0].src, `/api/branding/assets/${faviconFileId}`);
const brandedMeta = await jsonRequest("/api/v1/meta");
assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Studio");
assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Meta");
assert.equal(brandedMeta.payload.data.instance.metaTitle, "Neta Smoke Meta");
assert.equal(brandedMeta.payload.data.instance.workspaceName, "Neta Smoke Studio");
assert.equal(
brandedMeta.payload.data.branding.lightLogoUrl,
`${baseUrl}/api/branding/assets/${logoFileId}`,
"Mobile metadata must expose absolute branding asset URLs",
`${baseUrl}/api/branding/assets/${lightLogoFileId}`,
"Mobile metadata must expose the absolute light logo URL",
);
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
assert.equal(
brandedMeta.payload.data.branding.darkLogoUrl,
`${baseUrl}/api/branding/assets/${darkLogoFileId}`,
"Mobile metadata must expose the absolute dark logo URL",
);
assert.equal(
brandedMeta.payload.data.branding.faviconUrl,
`${baseUrl}/api/branding/assets/${faviconFileId}`,
"Mobile metadata must expose the absolute favicon URL",
);
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${lightLogoFileId}`);
assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable");
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES);
@@ -690,6 +742,10 @@ try {
new Promise((resolve) => server.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
fs.rmSync(path.join(process.cwd(), distDir), { recursive: true, force: true });
for (const snapshot of nextGeneratedConfigFiles) {
fs.writeFileSync(path.join(process.cwd(), snapshot.file), snapshot.content);
}
}
async function acceptInvite(token) {
+8
View File
@@ -21,6 +21,9 @@ for (const value of [
'"auth.device-pairing"',
'status: "planned"',
"minimumSupportedVersion",
"workspaceName",
"metaTitle",
"faviconUrl",
]) {
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
}
@@ -38,6 +41,11 @@ assert.doesNotMatch(
/getSession|requireSession|authorization/i,
"Discovery must remain public and session-independent",
);
assert.match(
read("app/api/v1/me/route.ts"),
/getUserPreferences/,
"Authenticated mobile metadata must expose the persisted user color mode",
);
for (const route of requiredRoutes.slice(1)) {
const content = read(route);
+8
View File
@@ -32,6 +32,7 @@ export type NetaDiscoveryDocument = {
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
instanceId: string;
applicationName: string;
workspaceName: string;
api: {
version: typeof NETA_API_VERSION;
baseUrl: string;
@@ -57,6 +58,8 @@ export type NetaInstanceMetadata = {
id: string;
createdAt: string;
applicationName: string;
workspaceName: string;
metaTitle: string;
shortName: string;
organizationName: string | null;
};
@@ -68,6 +71,7 @@ export type NetaInstanceMetadata = {
lightLogoUrl: string | null;
darkLogoUrl: string | null;
iconUrl: string | null;
faviconUrl: string | null;
};
client: {
minimumSupportedVersion: string | null;
@@ -103,6 +107,7 @@ export function buildDiscoveryDocument(
discoveryVersion: NETA_DISCOVERY_VERSION,
instanceId: input.identity.instanceId,
applicationName: input.branding.applicationName,
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
api: {
version: NETA_API_VERSION,
baseUrl: apiBaseUrl,
@@ -132,6 +137,8 @@ export function buildInstanceMetadata(
id: input.identity.instanceId,
createdAt: input.identity.createdAt,
applicationName: input.branding.applicationName,
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
metaTitle: input.branding.applicationName,
shortName: input.branding.shortName,
organizationName: input.branding.organizationName,
},
@@ -143,6 +150,7 @@ export function buildInstanceMetadata(
lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl),
darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl),
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
},
client: {
minimumSupportedVersion: input.minimumMobileClientVersion,