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 ## 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 ## Backup ve restore
+4
View File
@@ -1,7 +1,9 @@
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getSessionContextFromHeaders } from "@/server/auth/session"; import { getSessionContextFromHeaders } from "@/server/auth/session";
import { getServerConfig } from "@/server/config"; import { getServerConfig } from "@/server/config";
import { DomainError } from "@/server/domain/errors"; import { DomainError } from "@/server/domain/errors";
import { getUserPreferences } from "@/server/settings/preferences";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,6 +14,7 @@ export async function GET(request: Request) {
if (!context) { if (!context) {
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli."); throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
} }
const preferences = getUserPreferences(domainActorFromSession(context));
return apiV1Success({ return apiV1Success({
user: { user: {
@@ -25,6 +28,7 @@ export async function GET(request: Request) {
session: { session: {
expiresAt: context.session.expiresAt.toISOString(), expiresAt: context.session.expiresAt.toISOString(),
}, },
preferences,
}); });
} catch (error) { } catch (error) {
return apiV1Error(error); return apiV1Error(error);
@@ -113,8 +113,8 @@ Mevcut hata kodları:
- Protokol/discovery/API major sürümü - Protokol/discovery/API major sürümü
- Neta server package sürümü - Neta server package sürümü
- Kalıcı instance kimliği ve oluşturulma zamanı - Kalıcı instance kimliği ve oluşturulma zamanı
- Application/organization adı - Workspace adı, meta title ve kısa uygulama adı
- Mobil için absolute logo/icon URL'leri ve semantic marka bilgisi - Mobil için ayrı light/dark logo, favicon absolute URL'leri ve semantic marka bilgisi
- Minimum desteklenen mobil client sürümü - Minimum desteklenen mobil client sürümü
- `ios` ve `android` platform listesi - `ios` ve `android` platform listesi
- Authentication durumu - 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. İ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: İlk capability seti:
- `instance.discovery` - `instance.discovery`
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
output: "standalone", output: "standalone",
experimental: { experimental: {
serverActions: { 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 dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db"); 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 port = await getAvailablePort();
const baseUrl = `http://127.0.0.1:${port}`; const baseUrl = `http://127.0.0.1:${port}`;
const env = { const env = {
@@ -22,6 +27,7 @@ const env = {
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters", BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
TRUSTED_ORIGINS: baseUrl, TRUSTED_ORIGINS: baseUrl,
NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1", NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1",
NEXT_DIST_DIR: distDir,
NEXT_TELEMETRY_DISABLED: "1", NEXT_TELEMETRY_DISABLED: "1",
}; };
@@ -178,6 +184,7 @@ try {
clientId: null, clientId: null,
}, },
); );
assert.equal(ownerMe.payload.data.preferences.colorMode, "system");
assert.doesNotMatch( assert.doesNotMatch(
JSON.stringify(ownerMe.payload), JSON.stringify(ownerMe.payload),
/token|password/i, /token|password/i,
@@ -333,38 +340,83 @@ try {
"File API errors must use the standard envelope", "File API errors must use the standard envelope",
); );
const logoUpload = await uploadFile("branding_logo", { const lightLogoUpload = await uploadFile("branding_logo", {
cookie: ownerCookie, cookie: ownerCookie,
fileName: "logo.png", fileName: "light-logo.png",
}); });
assert.equal(logoUpload.response.status, 201, JSON.stringify(logoUpload.payload)); const darkLogoUpload = await uploadFile("branding_logo", {
assert.equal(logoUpload.payload.ok, true, "File API success must use the standard envelope"); cookie: ownerCookie,
const logoFileId = logoUpload.payload.data.id; 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", { const brandingUpdate = await jsonRequest("/api/branding", {
method: "PATCH", method: "PATCH",
cookie: ownerCookie, cookie: ownerCookie,
body: { body: {
applicationName: "Neta Smoke Studio", applicationName: "Neta Smoke Meta",
organizationName: "Neta Smoke Studio",
shortName: "Neta Smoke",
primaryColor: "#336699", primaryColor: "#336699",
accentColor: "#F0CC22", accentColor: "#F0CC22",
lightLogoFileId: logoFileId, lightLogoFileId,
darkLogoFileId,
iconFileId: faviconFileId,
}, },
}); });
assert.equal(brandingUpdate.response.ok, true, JSON.stringify(brandingUpdate.payload)); 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(); 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"); assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response");
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json(); 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"); 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( assert.equal(
brandedMeta.payload.data.branding.lightLogoUrl, brandedMeta.payload.data.branding.lightLogoUrl,
`${baseUrl}/api/branding/assets/${logoFileId}`, `${baseUrl}/api/branding/assets/${lightLogoFileId}`,
"Mobile metadata must expose absolute branding asset URLs", "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.status, 200, "Referenced branding asset must be publicly readable");
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff"); assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES); assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES);
@@ -690,6 +742,10 @@ try {
new Promise((resolve) => server.once("exit", resolve)), new Promise((resolve) => server.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 5000)), 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) { async function acceptInvite(token) {
+8
View File
@@ -21,6 +21,9 @@ for (const value of [
'"auth.device-pairing"', '"auth.device-pairing"',
'status: "planned"', 'status: "planned"',
"minimumSupportedVersion", "minimumSupportedVersion",
"workspaceName",
"metaTitle",
"faviconUrl",
]) { ]) {
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`); assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
} }
@@ -38,6 +41,11 @@ assert.doesNotMatch(
/getSession|requireSession|authorization/i, /getSession|requireSession|authorization/i,
"Discovery must remain public and session-independent", "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)) { for (const route of requiredRoutes.slice(1)) {
const content = read(route); const content = read(route);
+8
View File
@@ -32,6 +32,7 @@ export type NetaDiscoveryDocument = {
discoveryVersion: typeof NETA_DISCOVERY_VERSION; discoveryVersion: typeof NETA_DISCOVERY_VERSION;
instanceId: string; instanceId: string;
applicationName: string; applicationName: string;
workspaceName: string;
api: { api: {
version: typeof NETA_API_VERSION; version: typeof NETA_API_VERSION;
baseUrl: string; baseUrl: string;
@@ -57,6 +58,8 @@ export type NetaInstanceMetadata = {
id: string; id: string;
createdAt: string; createdAt: string;
applicationName: string; applicationName: string;
workspaceName: string;
metaTitle: string;
shortName: string; shortName: string;
organizationName: string | null; organizationName: string | null;
}; };
@@ -68,6 +71,7 @@ export type NetaInstanceMetadata = {
lightLogoUrl: string | null; lightLogoUrl: string | null;
darkLogoUrl: string | null; darkLogoUrl: string | null;
iconUrl: string | null; iconUrl: string | null;
faviconUrl: string | null;
}; };
client: { client: {
minimumSupportedVersion: string | null; minimumSupportedVersion: string | null;
@@ -103,6 +107,7 @@ export function buildDiscoveryDocument(
discoveryVersion: NETA_DISCOVERY_VERSION, discoveryVersion: NETA_DISCOVERY_VERSION,
instanceId: input.identity.instanceId, instanceId: input.identity.instanceId,
applicationName: input.branding.applicationName, applicationName: input.branding.applicationName,
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
api: { api: {
version: NETA_API_VERSION, version: NETA_API_VERSION,
baseUrl: apiBaseUrl, baseUrl: apiBaseUrl,
@@ -132,6 +137,8 @@ export function buildInstanceMetadata(
id: input.identity.instanceId, id: input.identity.instanceId,
createdAt: input.identity.createdAt, createdAt: input.identity.createdAt,
applicationName: input.branding.applicationName, applicationName: input.branding.applicationName,
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
metaTitle: input.branding.applicationName,
shortName: input.branding.shortName, shortName: input.branding.shortName,
organizationName: input.branding.organizationName, organizationName: input.branding.organizationName,
}, },
@@ -143,6 +150,7 @@ export function buildInstanceMetadata(
lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl), lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl),
darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl), darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl),
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl), iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
}, },
client: { client: {
minimumSupportedVersion: input.minimumMobileClientVersion, minimumSupportedVersion: input.minimumMobileClientVersion,