improved a lot of things
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── SSO — SAML 2.0 (via Clerk Enterprise) ────────────────────────────────────
|
||||
|
||||
export const SamlProviderSchema = z.enum(['okta', 'azure', 'google-workspace', 'onelogin', 'custom']);
|
||||
export type SamlProvider = z.infer<typeof SamlProviderSchema>;
|
||||
|
||||
export const SsoConfigSchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
provider: SamlProviderSchema,
|
||||
/** Entity ID of the Identity Provider */
|
||||
idpEntityId: z.string().url(),
|
||||
/** SSO URL (Single Sign-On service endpoint) */
|
||||
idpSsoUrl: z.string().url(),
|
||||
/** PEM-encoded X.509 certificate from the IdP */
|
||||
idpCertificate: z.string().startsWith('-----BEGIN CERTIFICATE-----'),
|
||||
/** Whether SSO is required for all workspace members */
|
||||
enforced: z.boolean().default(false),
|
||||
/** Domains that trigger SSO redirect (e.g. "acme.com") */
|
||||
emailDomains: z.array(z.string().min(3)),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SsoConfig = z.infer<typeof SsoConfigSchema>;
|
||||
export type InsertSsoConfig = Omit<SsoConfig, 'createdAt' | 'updatedAt'>;
|
||||
|
||||
// ── SCIM — User Provisioning ──────────────────────────────────────────────────
|
||||
// SCIM 2.0 endpoint handled by Clerk Enterprise.
|
||||
// These types represent the normalized user/group objects we store after sync.
|
||||
|
||||
export const ScimUserSchema = z.object({
|
||||
scimId: z.string(),
|
||||
workspaceId: z.string().uuid(),
|
||||
externalId: z.string(),
|
||||
email: z.string().email(),
|
||||
displayName: z.string(),
|
||||
active: z.boolean(),
|
||||
/** Maps to TeamRole in origin-graph */
|
||||
role: z.enum(['OWNER', 'DESIGNER', 'ENGINEER', 'PM', 'VIEWER']).default('VIEWER'),
|
||||
groups: z.array(z.string()),
|
||||
syncedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ScimUser = z.infer<typeof ScimUserSchema>;
|
||||
|
||||
export const ScimGroupSchema = z.object({
|
||||
scimId: z.string(),
|
||||
workspaceId: z.string().uuid(),
|
||||
displayName: z.string(),
|
||||
memberScimIds: z.array(z.string()),
|
||||
syncedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ScimGroup = z.infer<typeof ScimGroupSchema>;
|
||||
|
||||
// ── Audit Log ─────────────────────────────────────────────────────────────────
|
||||
// All workspace-level actions are logged for enterprise compliance.
|
||||
// Backed by Supabase audit log extension (pg_audit) or a dedicated audit table.
|
||||
|
||||
export const AuditActionSchema = z.enum([
|
||||
// Auth
|
||||
'auth.login',
|
||||
'auth.logout',
|
||||
'auth.sso_login',
|
||||
'auth.token_issued',
|
||||
// Workspace
|
||||
'workspace.created',
|
||||
'workspace.settings_updated',
|
||||
'workspace.member_invited',
|
||||
'workspace.member_removed',
|
||||
'workspace.member_role_changed',
|
||||
// Artboard
|
||||
'artboard.created',
|
||||
'artboard.deleted',
|
||||
'artboard.locked',
|
||||
'artboard.unlocked',
|
||||
// Diff
|
||||
'diff.exported',
|
||||
'diff.status_updated',
|
||||
// AI
|
||||
'ai.completion_zone_submitted',
|
||||
'ai.completion_zone_accepted',
|
||||
'ai.completion_zone_rejected',
|
||||
// Plugin
|
||||
'plugin.installed',
|
||||
'plugin.uninstalled',
|
||||
'plugin.enabled',
|
||||
'plugin.disabled',
|
||||
// DLF
|
||||
'dlf.uploaded',
|
||||
'dlf.activated',
|
||||
// Agent Bridge
|
||||
'agent_bridge.token_issued',
|
||||
'agent_bridge.connected',
|
||||
'agent_bridge.rate_limit_hit',
|
||||
]);
|
||||
|
||||
export type AuditAction = z.infer<typeof AuditActionSchema>;
|
||||
|
||||
export const AuditLogEntrySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspaceId: z.string().uuid(),
|
||||
actorId: z.string(),
|
||||
actorEmail: z.string().email().optional(),
|
||||
action: AuditActionSchema,
|
||||
resourceType: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
occurredAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
|
||||
export type InsertAuditLogEntry = Omit<AuditLogEntry, 'id'>;
|
||||
|
||||
/** Convenience function for constructing a well-typed audit entry. */
|
||||
export function buildAuditEntry(
|
||||
params: Omit<InsertAuditLogEntry, 'occurredAt'>
|
||||
): InsertAuditLogEntry {
|
||||
return { ...params, occurredAt: new Date().toISOString() };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Plugin API
|
||||
export type {
|
||||
PluginPermission,
|
||||
PluginManifest,
|
||||
PluginReadAPI,
|
||||
PluginWriteAPI,
|
||||
PluginContext,
|
||||
CustomCompletionZoneDefinition,
|
||||
CustomIngesterDefinition,
|
||||
InstalledPlugin,
|
||||
PluginRegistry,
|
||||
} from './plugin-api.js';
|
||||
export { PluginPermissionSchema, PluginManifestSchema } from './plugin-api.js';
|
||||
|
||||
// Enterprise
|
||||
export type {
|
||||
SamlProvider,
|
||||
SsoConfig,
|
||||
InsertSsoConfig,
|
||||
ScimUser,
|
||||
ScimGroup,
|
||||
AuditAction,
|
||||
AuditLogEntry,
|
||||
InsertAuditLogEntry,
|
||||
} from './enterprise.js';
|
||||
export {
|
||||
SamlProviderSchema,
|
||||
SsoConfigSchema,
|
||||
ScimUserSchema,
|
||||
ScimGroupSchema,
|
||||
AuditActionSchema,
|
||||
AuditLogEntrySchema,
|
||||
buildAuditEntry,
|
||||
} from './enterprise.js';
|
||||
|
||||
// White-label theming
|
||||
export type { BrandTokens } from './theming.js';
|
||||
export { BrandTokensSchema, brandVariantsFromHex } from './theming.js';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from 'zod';
|
||||
import type { Artboard, InsertOrigin, IntentDiff } from '@originmain/origin-graph';
|
||||
|
||||
// ── Plugin Manifest ───────────────────────────────────────────────────────────
|
||||
// Plugins declare their identity, permissions, and extension points in a
|
||||
// manifest. The host validates this on install and on every load.
|
||||
|
||||
export const PluginPermissionSchema = z.enum([
|
||||
'artboards:read', // read artboard metadata and component trees
|
||||
'artboards:write', // create artboards and origins (requires approval)
|
||||
'diffs:read', // read IntentDiff objects
|
||||
'diffs:export', // export diffs to external tools
|
||||
'completion-zones:register', // register custom Completion Zone types
|
||||
'ingesters:register', // register custom ingestion connectors
|
||||
'design-language:read', // read the workspace Design Language File
|
||||
]);
|
||||
|
||||
export type PluginPermission = z.infer<typeof PluginPermissionSchema>;
|
||||
|
||||
export const PluginManifestSchema = z.object({
|
||||
/** Unique reverse-DNS identifier: e.g. "com.acme.my-plugin" */
|
||||
id: z.string().regex(/^[a-z0-9]+(\.[a-z0-9-]+)+$/, 'Must be reverse-DNS format'),
|
||||
name: z.string().min(1).max(80),
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be semver'),
|
||||
description: z.string().max(300),
|
||||
/** URL where the plugin's sandboxed JS bundle is hosted */
|
||||
entryUrl: z.string().url(),
|
||||
permissions: z.array(PluginPermissionSchema),
|
||||
/** SHA-256 hash of the entryUrl bundle — required; verified before execution */
|
||||
bundleHash: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
});
|
||||
|
||||
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
|
||||
|
||||
// ── Plugin Context ────────────────────────────────────────────────────────────
|
||||
// The API surface exposed to a sandboxed plugin via postMessage.
|
||||
// Split into read and write sides so permission checks are explicit.
|
||||
|
||||
export interface PluginReadAPI {
|
||||
getArtboard(id: string): Promise<Artboard | null>;
|
||||
listArtboards(): Promise<readonly Artboard[]>;
|
||||
getDiff(id: string): Promise<IntentDiff | null>;
|
||||
listPendingDiffs(): Promise<readonly IntentDiff[]>;
|
||||
getDesignLanguageFile(): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
export interface PluginWriteAPI {
|
||||
/**
|
||||
* Creates a new artboard linked to the given origin.
|
||||
* The host injects workspaceId from PluginContext.workspaceId — plugins
|
||||
* do not set workspace scoping; it is enforced server-side.
|
||||
*/
|
||||
createArtboard(params: {
|
||||
name: string;
|
||||
origin: InsertOrigin;
|
||||
}): Promise<{ artboardId: string }>;
|
||||
|
||||
exportDiff(diffId: string, format: 'json' | 'markdown'): Promise<string>;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
/** Plugin's declared manifest (read-only inside sandbox) */
|
||||
readonly manifest: PluginManifest;
|
||||
/** Workspace ID the plugin is operating within */
|
||||
readonly workspaceId: string;
|
||||
/** Read APIs — available if 'artboards:read' or 'diffs:read' is granted */
|
||||
readonly read: PluginReadAPI;
|
||||
/** Write APIs — available only if 'artboards:write' is granted */
|
||||
readonly write: PluginWriteAPI | null;
|
||||
/** Emits a UI notification to the host application */
|
||||
notify(message: string, level?: 'info' | 'warning' | 'error'): void;
|
||||
}
|
||||
|
||||
// ── Custom Completion Zone ────────────────────────────────────────────────────
|
||||
// Plugins can register custom Completion Zone types with their own AI prompts
|
||||
// and rendering logic.
|
||||
|
||||
export interface CustomCompletionZoneDefinition {
|
||||
/** Unique type identifier: e.g. "com.acme.marketing-copy" */
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** System prompt appended to the standard Completion Zone system prompt */
|
||||
systemPromptAddendum: string;
|
||||
/** JSON Schema describing the expected output format */
|
||||
outputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Custom Ingester ───────────────────────────────────────────────────────────
|
||||
// Plugins can register additional ingestion connectors beyond the 4 first-party set.
|
||||
|
||||
export interface CustomIngesterDefinition {
|
||||
/** Matches the 'type' field in the plugin manifest */
|
||||
sourceType: string;
|
||||
label: string;
|
||||
/** Webhook URL path registered with the host's Edge Function router */
|
||||
webhookPath: string;
|
||||
/** The plugin's JS bundle handles validatePayload + ingest via sandboxed eval */
|
||||
handleWebhook(rawBody: string, headers: Record<string, string>): Promise<{
|
||||
artboardTitle: string;
|
||||
renderUrl?: string;
|
||||
sourceRef: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Plugin Registry ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface InstalledPlugin {
|
||||
manifest: PluginManifest;
|
||||
installedAt: string;
|
||||
installedByUserId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
list(workspaceId: string): Promise<readonly InstalledPlugin[]>;
|
||||
install(workspaceId: string, manifest: PluginManifest, byUserId: string): Promise<void>;
|
||||
uninstall(workspaceId: string, pluginId: string): Promise<void>;
|
||||
setEnabled(workspaceId: string, pluginId: string, enabled: boolean): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── White-label Theming ───────────────────────────────────────────────────────
|
||||
// Enterprise workspaces can override the Originmain UI with their own brand.
|
||||
// The theming pipeline:
|
||||
// 1. WorkspaceBrand (stored in workspace.metadata_jsonb) → BrandTokens
|
||||
// 2. BrandTokens → Fluent 2 BrandVariants (via createLightTheme / createDarkTheme)
|
||||
// 3. BrandVariants → FluentProvider theme prop
|
||||
//
|
||||
// Note: Actual Fluent 2 createLightTheme / createDarkTheme calls happen in
|
||||
// packages/app/src/lib/workspace-theme.ts to avoid a @fluentui dep here.
|
||||
|
||||
export const BrandTokensSchema = z.object({
|
||||
/** Primary brand colour in 6-digit hex */
|
||||
primaryColor: z.string().regex(/^#[0-9a-f]{6}$/i),
|
||||
/** Optional secondary accent */
|
||||
secondaryColor: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
|
||||
/** Brand logo URL (SVG or PNG, ≤512KB) */
|
||||
logoUrl: z.string().url().optional(),
|
||||
/** Tab title prefix: e.g. "Acme Design" → "Acme Design — Originmain" */
|
||||
productName: z.string().max(40).optional(),
|
||||
/** Favicon URL (ICO, PNG, or SVG) */
|
||||
faviconUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export type BrandTokens = z.infer<typeof BrandTokensSchema>;
|
||||
|
||||
/**
|
||||
* Derives the 10-shade Fluent 2 BrandVariants record from a single hex colour.
|
||||
* Shades are generated by interpolating HSL lightness across the standard scale.
|
||||
* The returned object is passed directly to createLightTheme / createDarkTheme.
|
||||
*/
|
||||
export function brandVariantsFromHex(hex: string): Record<`shade${10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | 110 | 120 | 130 | 140 | 150 | 160}`, string> {
|
||||
const [r, g, b] = hexToRgb(hex);
|
||||
const [h, s] = rgbToHsl(r, g, b);
|
||||
|
||||
// Fluent 2 uses shades 10–160 in 10-step increments (16 shades).
|
||||
// shade10 = lightest, shade160 = darkest.
|
||||
const shadeNumbers = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160] as const;
|
||||
const result = {} as Record<string, string>;
|
||||
|
||||
for (const shade of shadeNumbers) {
|
||||
// Map shade index to lightness: shade10 ≈ 95%, shade160 ≈ 10%
|
||||
const lightness = 95 - ((shade - 10) / 150) * 85;
|
||||
result[`shade${shade}`] = hslToHex(h, s, lightness);
|
||||
}
|
||||
|
||||
return result as ReturnType<typeof brandVariantsFromHex>;
|
||||
}
|
||||
|
||||
// ── HSL ↔ RGB ↔ Hex helpers ──────────────────────────────────────────────────
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const clean = hex.replace('#', '');
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||
throw new Error(`Invalid hex color: "${hex}"`);
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
|
||||
const l = (max + min) / 2;
|
||||
if (max === min) return [0, 0, l * 100];
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
let h = 0;
|
||||
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
||||
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
||||
else h = ((rn - gn) / d + 4) / 6;
|
||||
|
||||
return [h * 360, s * 100, l * 100];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const sn = s / 100, ln = l / 100;
|
||||
const a = sn * Math.min(ln, 1 - ln);
|
||||
const f = (n: number): string => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user