From cb2ed90046e22de00d7bbe36418df1a2771d723c Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Fri, 17 Jul 2026 21:56:37 +0300 Subject: [PATCH] feat(api): expose workspace branding to mobile clients --- README.md | 2 +- app/api/v1/me/route.ts | 4 + .../phase-9-mobile-api.md | 12 ++- next.config.ts | 1 + scripts/phase1-auth-smoke.mjs | 84 +++++++++++++++---- scripts/phase9-api-boundary.mjs | 8 ++ server/api/v1/contracts.ts | 8 ++ 7 files changed, 102 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 736f680..ca50582 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/v1/me/route.ts b/app/api/v1/me/route.ts index ac5619d..8646924 100644 --- a/app/api/v1/me/route.ts +++ b/app/api/v1/me/route.ts @@ -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); diff --git a/docs/self-hosted-redesign/phase-9-mobile-api.md b/docs/self-hosted-redesign/phase-9-mobile-api.md index 7fdc801..3e10116 100644 --- a/docs/self-hosted-redesign/phase-9-mobile-api.md +++ b/docs/self-hosted-redesign/phase-9-mobile-api.md @@ -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` diff --git a/next.config.ts b/next.config.ts index 69449b4..9e27707 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", output: "standalone", experimental: { serverActions: { diff --git a/scripts/phase1-auth-smoke.mjs b/scripts/phase1-auth-smoke.mjs index 6a43462..d73d757 100644 --- a/scripts/phase1-auth-smoke.mjs +++ b/scripts/phase1-auth-smoke.mjs @@ -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) { diff --git a/scripts/phase9-api-boundary.mjs b/scripts/phase9-api-boundary.mjs index 8d1ab96..85f85d7 100644 --- a/scripts/phase9-api-boundary.mjs +++ b/scripts/phase9-api-boundary.mjs @@ -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); diff --git a/server/api/v1/contracts.ts b/server/api/v1/contracts.ts index f5751a7..084c52f 100644 --- a/server/api/v1/contracts.ts +++ b/server/api/v1/contracts.ts @@ -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,