feat(api): complete phase 9 mobile contracts
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import type { PublicBranding } from "../../branding/service";
|
||||
import type { InstanceIdentity } from "../../instance/service";
|
||||
|
||||
export const NETA_PROTOCOL = "neta" as const;
|
||||
export const NETA_DISCOVERY_VERSION = 1 as const;
|
||||
export const NETA_API_VERSION = "1" as const;
|
||||
export const NETA_API_BASE_PATH = "/api/v1" as const;
|
||||
|
||||
export type CapabilityStatus = "available" | "planned";
|
||||
export type CapabilityAccess = "public" | "session" | "freelancer" | "client";
|
||||
|
||||
export type NetaCapability = {
|
||||
id: string;
|
||||
version: number;
|
||||
status: CapabilityStatus;
|
||||
access: CapabilityAccess;
|
||||
};
|
||||
|
||||
export const NETA_CAPABILITIES = [
|
||||
{ id: "instance.discovery", version: 1, status: "available", access: "public" },
|
||||
{ id: "instance.branding", version: 1, status: "available", access: "public" },
|
||||
{ id: "auth.better-auth-cookie", version: 1, status: "available", access: "session" },
|
||||
{ id: "files.local", version: 1, status: "available", access: "session" },
|
||||
{ id: "freelancer.core", version: 1, status: "available", access: "freelancer" },
|
||||
{ id: "portal.client", version: 1, status: "available", access: "client" },
|
||||
{ id: "ai.assistant", version: 1, status: "available", access: "freelancer" },
|
||||
{ id: "auth.device-pairing", version: 1, status: "planned", access: "freelancer" },
|
||||
] as const satisfies readonly NetaCapability[];
|
||||
|
||||
export type NetaDiscoveryDocument = {
|
||||
protocol: typeof NETA_PROTOCOL;
|
||||
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
|
||||
instanceId: string;
|
||||
applicationName: string;
|
||||
api: {
|
||||
version: typeof NETA_API_VERSION;
|
||||
baseUrl: string;
|
||||
metaUrl: string;
|
||||
healthUrl: string;
|
||||
};
|
||||
security: {
|
||||
httpsRequired: true;
|
||||
insecureLoopbackAllowed: true;
|
||||
};
|
||||
};
|
||||
|
||||
export type NetaInstanceMetadata = {
|
||||
protocol: {
|
||||
name: typeof NETA_PROTOCOL;
|
||||
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
|
||||
apiVersion: typeof NETA_API_VERSION;
|
||||
};
|
||||
server: {
|
||||
version: string;
|
||||
};
|
||||
instance: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
applicationName: string;
|
||||
shortName: string;
|
||||
organizationName: string | null;
|
||||
};
|
||||
branding: {
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
defaultColorMode: PublicBranding["defaultColorMode"];
|
||||
radiusScale: PublicBranding["radiusScale"];
|
||||
lightLogoUrl: string | null;
|
||||
darkLogoUrl: string | null;
|
||||
iconUrl: string | null;
|
||||
};
|
||||
client: {
|
||||
minimumSupportedVersion: string | null;
|
||||
platforms: readonly ["ios", "android"];
|
||||
};
|
||||
authentication: {
|
||||
sessionMethod: "better-auth-cookie";
|
||||
devicePairing: "planned";
|
||||
};
|
||||
capabilities: readonly NetaCapability[];
|
||||
links: {
|
||||
discovery: string;
|
||||
apiBase: string;
|
||||
health: string;
|
||||
me: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ContractInput = {
|
||||
appUrl: string;
|
||||
serverVersion: string;
|
||||
minimumMobileClientVersion: string | null;
|
||||
identity: InstanceIdentity;
|
||||
branding: PublicBranding;
|
||||
};
|
||||
|
||||
export function buildDiscoveryDocument(
|
||||
input: ContractInput,
|
||||
): NetaDiscoveryDocument {
|
||||
const apiBaseUrl = absoluteUrl(input.appUrl, NETA_API_BASE_PATH);
|
||||
return {
|
||||
protocol: NETA_PROTOCOL,
|
||||
discoveryVersion: NETA_DISCOVERY_VERSION,
|
||||
instanceId: input.identity.instanceId,
|
||||
applicationName: input.branding.applicationName,
|
||||
api: {
|
||||
version: NETA_API_VERSION,
|
||||
baseUrl: apiBaseUrl,
|
||||
metaUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/meta`),
|
||||
healthUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
|
||||
},
|
||||
security: {
|
||||
httpsRequired: true,
|
||||
insecureLoopbackAllowed: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInstanceMetadata(
|
||||
input: ContractInput,
|
||||
): NetaInstanceMetadata {
|
||||
return {
|
||||
protocol: {
|
||||
name: NETA_PROTOCOL,
|
||||
discoveryVersion: NETA_DISCOVERY_VERSION,
|
||||
apiVersion: NETA_API_VERSION,
|
||||
},
|
||||
server: {
|
||||
version: input.serverVersion,
|
||||
},
|
||||
instance: {
|
||||
id: input.identity.instanceId,
|
||||
createdAt: input.identity.createdAt,
|
||||
applicationName: input.branding.applicationName,
|
||||
shortName: input.branding.shortName,
|
||||
organizationName: input.branding.organizationName,
|
||||
},
|
||||
branding: {
|
||||
primaryColor: input.branding.primaryColor,
|
||||
accentColor: input.branding.accentColor,
|
||||
defaultColorMode: input.branding.defaultColorMode,
|
||||
radiusScale: input.branding.radiusScale,
|
||||
lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl),
|
||||
darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl),
|
||||
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
||||
},
|
||||
client: {
|
||||
minimumSupportedVersion: input.minimumMobileClientVersion,
|
||||
platforms: ["ios", "android"],
|
||||
},
|
||||
authentication: {
|
||||
sessionMethod: "better-auth-cookie",
|
||||
devicePairing: "planned",
|
||||
},
|
||||
capabilities: NETA_CAPABILITIES,
|
||||
links: {
|
||||
discovery: absoluteUrl(input.appUrl, "/.well-known/neta"),
|
||||
apiBase: absoluteUrl(input.appUrl, NETA_API_BASE_PATH),
|
||||
health: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
|
||||
me: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteOptionalUrl(baseUrl: string, value: string | null): string | null {
|
||||
return value ? absoluteUrl(baseUrl, value) : null;
|
||||
}
|
||||
|
||||
function absoluteUrl(baseUrl: string, pathname: string): string {
|
||||
return new URL(pathname, `${baseUrl}/`).toString();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import "server-only";
|
||||
|
||||
import type { NextResponse } from "next/server";
|
||||
import { apiError, apiSuccess } from "../responses";
|
||||
import { NETA_API_VERSION } from "./contracts";
|
||||
|
||||
export function apiV1Success<T>(
|
||||
data: T,
|
||||
init?: ResponseInit,
|
||||
): NextResponse {
|
||||
return withV1Headers(apiSuccess(data, init));
|
||||
}
|
||||
|
||||
export function apiV1Error(error: unknown): NextResponse {
|
||||
return withV1Headers(apiError(error));
|
||||
}
|
||||
|
||||
function withV1Headers(response: NextResponse): NextResponse {
|
||||
response.headers.set("X-Neta-API-Version", NETA_API_VERSION);
|
||||
if (!response.headers.has("Cache-Control")) {
|
||||
response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import "server-only";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { getPublicBranding } from "../../branding/runtime";
|
||||
import { getServerConfig } from "../../config";
|
||||
import { getInstanceService } from "../../instance/runtime";
|
||||
import {
|
||||
buildDiscoveryDocument,
|
||||
buildInstanceMetadata,
|
||||
} from "./contracts";
|
||||
|
||||
export function getNetaDiscoveryDocument() {
|
||||
return buildDiscoveryDocument(getContractInput());
|
||||
}
|
||||
|
||||
export function getNetaInstanceMetadata() {
|
||||
return buildInstanceMetadata(getContractInput());
|
||||
}
|
||||
|
||||
function getContractInput() {
|
||||
const config = getServerConfig();
|
||||
return {
|
||||
appUrl: config.appUrl,
|
||||
serverVersion: packageJson.version,
|
||||
minimumMobileClientVersion: config.minimumMobileClientVersion,
|
||||
identity: getInstanceService().getIdentity(),
|
||||
branding: getPublicBranding(),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,11 @@ const envSchema = z.object({
|
||||
DATABASE_PATH: z.string().trim().optional(),
|
||||
OLLAMA_BASE_URL: z.string().url().optional(),
|
||||
AI_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).optional(),
|
||||
NETA_MINIMUM_MOBILE_VERSION: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ServerConfig = {
|
||||
@@ -31,6 +36,7 @@ export type ServerConfig = {
|
||||
betterAuthSecret?: string;
|
||||
ollamaBaseUrl: string;
|
||||
aiRequestTimeoutMs: number;
|
||||
minimumMobileClientVersion: string | null;
|
||||
};
|
||||
|
||||
let cachedConfig: ServerConfig | undefined;
|
||||
@@ -82,6 +88,7 @@ export function getServerConfig(): ServerConfig {
|
||||
betterAuthSecret,
|
||||
ollamaBaseUrl: parsed.OLLAMA_BASE_URL ?? "http://127.0.0.1:11434/v1",
|
||||
aiRequestTimeoutMs: parsed.AI_REQUEST_TIMEOUT_MS ?? 30_000,
|
||||
minimumMobileClientVersion: parsed.NETA_MINIMUM_MOBILE_VERSION ?? null,
|
||||
};
|
||||
|
||||
return cachedConfig;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `instance_settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`instance_id` text NOT NULL,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `instance_settings_instance_id_unique` ON `instance_settings` (`instance_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1784266217938,
|
||||
"tag": "0006_moaning_kitty_pryde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1784268984532,
|
||||
"tag": "0007_flaky_kinsey_walden",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,13 @@ import { user } from "./auth";
|
||||
|
||||
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
|
||||
|
||||
export const instanceSettings = sqliteTable("instance_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
instanceId: text("instance_id").notNull().unique(),
|
||||
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const userAiSettings = sqliteTable(
|
||||
"user_ai_settings",
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@ export type DomainErrorCode =
|
||||
| "CONFLICT"
|
||||
| "INVARIANT_VIOLATION"
|
||||
| "UPSTREAM_ERROR"
|
||||
| "UPSTREAM_TIMEOUT";
|
||||
| "UPSTREAM_TIMEOUT"
|
||||
| "SERVICE_UNAVAILABLE";
|
||||
|
||||
const statusByCode: Record<DomainErrorCode, number> = {
|
||||
VALIDATION_ERROR: 400,
|
||||
@@ -17,6 +18,7 @@ const statusByCode: Record<DomainErrorCode, number> = {
|
||||
INVARIANT_VIOLATION: 422,
|
||||
UPSTREAM_ERROR: 502,
|
||||
UPSTREAM_TIMEOUT: 504,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
};
|
||||
|
||||
export class DomainError extends Error {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { getSqliteConnection } from "../db/client";
|
||||
import { InstanceService } from "./service";
|
||||
|
||||
export function getInstanceService(): InstanceService {
|
||||
return new InstanceService(getSqliteConnection().db);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
import { DomainError } from "../domain/errors";
|
||||
import { createInstanceRepository } from "../repositories/instance";
|
||||
|
||||
export type InstanceIdentity = {
|
||||
instanceId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export class InstanceService {
|
||||
private readonly repository;
|
||||
|
||||
constructor(private readonly db: DomainDatabase) {
|
||||
this.repository = createInstanceRepository(db);
|
||||
}
|
||||
|
||||
getIdentity(): InstanceIdentity {
|
||||
const existing = this.repository.get();
|
||||
if (existing) return toIdentity(existing);
|
||||
|
||||
this.repository.createIfMissing(randomUUID());
|
||||
const created = this.repository.get();
|
||||
if (!created) {
|
||||
throw new DomainError(
|
||||
"INVARIANT_VIOLATION",
|
||||
"Instance kimliği oluşturulamadı.",
|
||||
);
|
||||
}
|
||||
return toIdentity(created);
|
||||
}
|
||||
}
|
||||
|
||||
function toIdentity(value: {
|
||||
instanceId: string;
|
||||
createdAt: string;
|
||||
}): InstanceIdentity {
|
||||
return {
|
||||
instanceId: value.instanceId,
|
||||
createdAt: sqliteTimestampToIso(value.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function sqliteTimestampToIso(value: string): string {
|
||||
const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
||||
const timestamp = new Date(normalized);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
throw new DomainError(
|
||||
"INVARIANT_VIOLATION",
|
||||
"Instance oluşturulma zamanı geçersiz.",
|
||||
);
|
||||
}
|
||||
return timestamp.toISOString();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { instanceSettings } from "../db/schema";
|
||||
import type { DomainDatabase } from "../domain/database";
|
||||
|
||||
const INSTANCE_SETTINGS_KEY = "default";
|
||||
|
||||
export function createInstanceRepository(db: DomainDatabase) {
|
||||
return {
|
||||
get: () =>
|
||||
db
|
||||
.select()
|
||||
.from(instanceSettings)
|
||||
.where(eq(instanceSettings.key, INSTANCE_SETTINGS_KEY))
|
||||
.get(),
|
||||
createIfMissing: (instanceId: string) =>
|
||||
db
|
||||
.insert(instanceSettings)
|
||||
.values({ key: INSTANCE_SETTINGS_KEY, instanceId })
|
||||
.onConflictDoNothing({ target: instanceSettings.key })
|
||||
.run(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user