export type THostedAppHostType = 'onebox' | 'cloudly' | string; export const hostedAppOidcRoleClaim = 'roles' as const; export const hostedAppOidcAppInstanceIdClaim = 'servezone_app_instance_id' as const; export const hostedAppMachineServiceIdClaim = 'servezone_service_id' as const; export const hostedAppMachineAudience = 'serve.zone/hosted-app-lifecycle' as const; export interface IHostedAppMachineClaims { sub: string; aud: typeof hostedAppMachineAudience; iat: number; exp: number; [hostedAppOidcAppInstanceIdClaim]: string; [hostedAppMachineServiceIdClaim]: string; } export const createHostedAppMachineSubject = (appInstanceIdArg: string): string => ( `urn:serve.zone:hosted-app:${appInstanceIdArg}` ); export const validateHostedAppMachineClaims = ( claimsArg: unknown, expectedArg: { appInstanceId: string; serviceId: string; nowEpochSeconds?: number; }, ): string[] => { if (!claimsArg || typeof claimsArg !== 'object' || Array.isArray(claimsArg)) { return ['hosted app machine claims must be an object']; } const claims = claimsArg as Record; const errors: string[] = []; if (claims.sub !== createHostedAppMachineSubject(expectedArg.appInstanceId)) { errors.push('hosted app machine subject does not match'); } if (claims.aud !== hostedAppMachineAudience) { errors.push('hosted app machine audience does not match'); } if (claims[hostedAppOidcAppInstanceIdClaim] !== expectedArg.appInstanceId) { errors.push('hosted app machine app instance does not match'); } if (claims[hostedAppMachineServiceIdClaim] !== expectedArg.serviceId) { errors.push('hosted app machine service does not match'); } const now = expectedArg.nowEpochSeconds ?? Math.floor(Date.now() / 1000); if (!Number.isSafeInteger(claims.iat) || (claims.iat as number) < 0 || (claims.iat as number) > now) { errors.push('hosted app machine issued-at time is malformed'); } if (!Number.isSafeInteger(claims.exp) || (claims.exp as number) <= now || (Number.isSafeInteger(claims.iat) && (claims.exp as number) <= (claims.iat as number))) { errors.push('hosted app machine expiry is malformed or expired'); } return errors; }; export interface IHostedAppRoleDefinition { /** Stable role identifier emitted in the hosted app's audience-scoped token. */ id: string; label: string; description?: string; } export interface IHostedAppRoleAssignment { /** Immutable hosted app instance identifier. Never use a mutable service name as this boundary. */ appInstanceId: string; userId: string; roleIds: string[]; assignedAt: number; assignedByUserId: string; } export interface IHostedAppPlatformOidcClaims { iss: string; sub: string; /** Mutable display name only. Never use this claim as identity authority. */ preferred_username: string; aud: string; iat: number; exp: number; auth_time: number; nonce?: string; [hostedAppOidcAppInstanceIdClaim]: string; [hostedAppOidcRoleClaim]: string[]; } interface IHostedAppPlatformOidcRegistrationBase { /** Immutable OIDC client_id and ID-token audience for this hosted app. */ appInstanceId: string; roleDefinitions: IHostedAppRoleDefinition[]; updatedAt: number; } type THostedAppRedirectUris = [string, ...string[]]; export type IHostedAppPlatformOidcRegistration = | (IHostedAppPlatformOidcRegistrationBase & { status: 'disabled'; redirectUris: []; issuer?: never; clientId?: never; error?: never; }) | (IHostedAppPlatformOidcRegistrationBase & { status: 'enabling'; issuer: string; clientId: string; redirectUris: THostedAppRedirectUris; error?: never; }) | (IHostedAppPlatformOidcRegistrationBase & { status: 'enabled' | 'disabling'; issuer: string; clientId: string; redirectUris: THostedAppRedirectUris; error?: never; }) | (IHostedAppPlatformOidcRegistrationBase & { status: 'error'; redirectUris: string[]; issuer?: string; clientId?: string; error: string; }); const hostedAppRoleIdRegex = /^[a-z][a-z0-9:_-]{0,63}$/; const hostedAppOpaqueIdRegex = /^[^\s\u0000-\u001f\u007f]{1,200}$/; export const validateHostedAppRoleDefinitions = ( rolesArg: unknown, ): string[] => { if (!Array.isArray(rolesArg) || rolesArg.length < 1 || rolesArg.length > 32) { return ['roles must contain between 1 and 32 role definitions']; } const errors: string[] = []; const roleIds = new Set(); rolesArg.forEach((roleArg, indexArg) => { if (!roleArg || typeof roleArg !== 'object' || Array.isArray(roleArg)) { errors.push(`role ${indexArg + 1} must be an object`); return; } const role = roleArg as Record; if (typeof role.id !== 'string' || !hostedAppRoleIdRegex.test(role.id)) { errors.push(`role ${indexArg + 1} id must be a stable lowercase identifier`); } else if (roleIds.has(role.id)) { errors.push(`role id '${role.id}' is duplicated`); } else { roleIds.add(role.id); } if (typeof role.label !== 'string' || role.label.trim().length < 1 || role.label.length > 120) { errors.push(`role ${indexArg + 1} label must contain between 1 and 120 characters`); } if (role.description !== undefined && (typeof role.description !== 'string' || role.description.length > 500)) { errors.push(`role ${indexArg + 1} description must contain at most 500 characters`); } }); return errors; }; export const validateHostedAppPlatformOidcClaims = ( claimsArg: unknown, expectedArg: { issuer: string; appInstanceId: string; nonce?: string; nowEpochSeconds?: number; }, ): string[] => { if (!claimsArg || typeof claimsArg !== 'object' || Array.isArray(claimsArg)) { return ['OIDC claims must be an object']; } const claims = claimsArg as Record; const errors: string[] = []; if (claims.iss !== expectedArg.issuer) errors.push('OIDC issuer does not match'); if (claims.aud !== expectedArg.appInstanceId) errors.push('OIDC audience does not match'); if (claims[hostedAppOidcAppInstanceIdClaim] !== expectedArg.appInstanceId) { errors.push('OIDC app instance does not match the audience'); } if (typeof claims.sub !== 'string' || claims.sub.length === 0) { errors.push('OIDC subject is missing or malformed'); } if (typeof claims.preferred_username !== 'string' || claims.preferred_username.trim().length === 0) { errors.push('OIDC preferred username is missing or malformed'); } const now = expectedArg.nowEpochSeconds ?? Math.floor(Date.now() / 1000); if (!Number.isSafeInteger(claims.iat) || (claims.iat as number) < 0) { errors.push('OIDC issued-at time is missing or malformed'); } if (!Number.isSafeInteger(claims.exp) || (claims.exp as number) <= now) { errors.push('OIDC token is expired or has malformed expiry'); } if (!Number.isSafeInteger(claims.auth_time) || (claims.auth_time as number) < 0 || (claims.auth_time as number) > now) { errors.push('OIDC authentication time is missing or malformed'); } if (expectedArg.nonce !== undefined && claims.nonce !== expectedArg.nonce) { errors.push('OIDC nonce does not match'); } const roles = claims[hostedAppOidcRoleClaim]; if (!Array.isArray(roles) || roles.length < 1 || roles.length > 32) { errors.push('OIDC roles must be an array with between 1 and 32 entries'); } else if (roles.some((roleArg) => typeof roleArg !== 'string' || !hostedAppRoleIdRegex.test(roleArg)) || new Set(roles).size !== roles.length) { errors.push('OIDC roles contain malformed or duplicate identifiers'); } return errors; }; export const validateHostedAppPlatformOidcRegistration = ( registrationArg: unknown, expectedArg: { appOrigin: string }, ): string[] => { if (!registrationArg || typeof registrationArg !== 'object' || Array.isArray(registrationArg)) { return ['OIDC registration must be an object']; } const registration = registrationArg as Record; const errors: string[] = []; let expectedOrigin: string | undefined; try { const parsedAppOrigin = new URL(expectedArg.appOrigin); if (parsedAppOrigin.protocol !== 'https:' || parsedAppOrigin.username || parsedAppOrigin.password || parsedAppOrigin.origin !== expectedArg.appOrigin) { errors.push('Expected OIDC app origin must be a canonical credential-free HTTPS origin'); } else { expectedOrigin = parsedAppOrigin.origin; } } catch { errors.push('Expected OIDC app origin must be an absolute URL origin'); } if (typeof registration.appInstanceId !== 'string' || !hostedAppOpaqueIdRegex.test(registration.appInstanceId)) { errors.push('OIDC registration appInstanceId is missing or malformed'); } const statuses = ['disabled', 'enabling', 'enabled', 'disabling', 'error']; if (typeof registration.status !== 'string' || !statuses.includes(registration.status)) { errors.push('OIDC registration status is unsupported'); } errors.push(...validateHostedAppRoleDefinitions(registration.roleDefinitions) .map((errorArg) => `OIDC registration ${errorArg}`)); if (!Number.isSafeInteger(registration.updatedAt) || (registration.updatedAt as number) < 0) { errors.push('OIDC registration updatedAt is missing or malformed'); } const redirectUris = registration.redirectUris; if (!Array.isArray(redirectUris) || redirectUris.length > 8 || redirectUris.some((uriArg) => typeof uriArg !== 'string')) { errors.push('OIDC registration redirectUris are malformed'); } else { if (registration.status === 'disabled' && redirectUris.length !== 0) { errors.push('Disabled OIDC registration must not retain redirect URIs'); } if (registration.status !== 'disabled' && registration.status !== 'error' && redirectUris.length < 1) { errors.push('Active OIDC registration requires a redirect URI'); } if (new Set(redirectUris).size !== redirectUris.length) { errors.push('OIDC registration redirect URIs must be unique'); } for (const redirectUri of redirectUris) { try { const parsed = new URL(redirectUri); if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.toString() !== redirectUri) { errors.push('OIDC registration redirect URIs must be canonical credential-free HTTPS URLs without query or fragment'); } if (expectedOrigin && parsed.origin !== expectedOrigin) { errors.push('OIDC registration redirect URIs must use the app origin'); } } catch { errors.push('OIDC registration redirect URIs must be absolute URLs'); } } } const requiresIssuer = registration.status === 'enabling' || registration.status === 'enabled' || registration.status === 'disabling'; if (requiresIssuer || registration.issuer !== undefined) { try { const issuer = new URL(registration.issuer as string); if (issuer.protocol !== 'https:' || issuer.username || issuer.password || issuer.search || issuer.hash) { errors.push('OIDC registration issuer must be a credential-free HTTPS URL without query or fragment'); } } catch { errors.push('OIDC registration issuer must be an absolute URL'); } } const requiresClientId = registration.status === 'enabling' || registration.status === 'enabled' || registration.status === 'disabling'; if (registration.status === 'disabled' && (registration.issuer !== undefined || registration.clientId !== undefined)) { errors.push('Disabled OIDC registration must not retain issuer or client metadata'); } if (requiresClientId && registration.clientId !== registration.appInstanceId) { errors.push('OIDC registration clientId must equal appInstanceId'); } else if (registration.clientId !== undefined && registration.clientId !== registration.appInstanceId) { errors.push('OIDC registration clientId must equal appInstanceId when present'); } if (registration.status === 'error' && (typeof registration.error !== 'string' || registration.error.length < 1)) { errors.push('Errored OIDC registration requires an error message'); } return errors; }; export type THostedAppRuntimeStatus = | 'starting' | 'running' | 'setupRequired' | 'degraded' | 'stopped' | 'unknown'; export type THostedAppBootstrapActionType = 'setupRoute' | 'message'; export type THostedAppBootstrapActionStatus = | 'requested' | 'ready' | 'completed' | 'dismissed' | 'expired'; export type THostedAppUpgradeStatus = | 'unknown' | 'upToDate' | 'available' | 'running' | 'success' | 'failed'; interface IHostedAppBootstrapActionBase { id: string; /** Server-managed CAS revision incremented on every action transition. */ revision: number; status: THostedAppBootstrapActionStatus; label: string; expiresAt?: number; createdAt: number; updatedAt: number; completedAt?: number; dismissedAt?: number; } export type IHostedAppBootstrapAction = | (IHostedAppBootstrapActionBase & { type: 'setupRoute'; route: string; message?: never; }) | (IHostedAppBootstrapActionBase & { type: 'message'; route?: never; message: string; }); export interface IHostedAppUpgradeState { status: THostedAppUpgradeStatus; appTemplateId?: string; currentVersion?: string; latestVersion?: string; targetVersion?: string; operationId?: string; warnings?: string[]; blockers?: string[]; error?: string; startedAt?: number; updatedAt?: number; completedAt?: number; } export interface IHostedAppLifecycleReport { appName?: string; appVersion?: string; publicUrl?: string; runtimeStatus?: THostedAppRuntimeStatus; statusMessage?: string; capabilities?: string[]; } export interface IHostedAppLifecycleState extends IHostedAppLifecycleReport { appInstanceId: string; hostType: THostedAppHostType; reportedAt?: number; bootstrapAction?: IHostedAppBootstrapAction; upgradeState?: IHostedAppUpgradeState; } type THostedAppBootstrapActionServerFields = | 'id' | 'revision' | 'status' | 'createdAt' | 'updatedAt' | 'completedAt' | 'dismissedAt'; export type IHostedAppBootstrapActionRequest = | (Omit< Extract, THostedAppBootstrapActionServerFields > & { id?: string }) | (Omit< Extract, THostedAppBootstrapActionServerFields > & { id?: string }); export const normalizeHostedAppSetupRoute = (routeArg: unknown): string | undefined => { if (typeof routeArg !== 'string' || !routeArg.startsWith('/') || routeArg.startsWith('//') || /[\\?#\s]/.test(routeArg) || /%5c/i.test(routeArg)) { return undefined; } const parsed = new URL(routeArg, 'https://app.invalid'); return parsed.pathname === routeArg ? routeArg : undefined; }; export const validateHostedAppBootstrapActionRequest = ( actionArg: unknown, ): string[] => { if (!actionArg || typeof actionArg !== 'object' || Array.isArray(actionArg)) { return ['hosted app bootstrap action must be an object']; } const action = actionArg as Record; const errors: string[] = []; const allowedKeys = action.type === 'setupRoute' ? new Set(['id', 'type', 'label', 'route', 'expiresAt']) : action.type === 'message' ? new Set(['id', 'type', 'label', 'message', 'expiresAt']) : new Set(['id', 'type', 'label', 'expiresAt']); if (Object.keys(action).some((keyArg) => !allowedKeys.has(keyArg))) { errors.push('hosted app bootstrap action contains fields outside its schema'); } if (action.id !== undefined && (typeof action.id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(action.id))) { errors.push('hosted app bootstrap action id must be canonical'); } if (action.expiresAt !== undefined && (!Number.isSafeInteger(action.expiresAt) || (action.expiresAt as number) < 1)) { errors.push('hosted app bootstrap action expiresAt must be a positive integer'); } if (action.type !== 'setupRoute' && action.type !== 'message') { errors.push('hosted app bootstrap action type is unsupported'); } if (typeof action.label !== 'string' || !action.label.trim() || action.label.length > 200) { errors.push('hosted app bootstrap action label must be bounded'); } if (action.type === 'setupRoute') { if (normalizeHostedAppSetupRoute(action.route) !== action.route) { errors.push('hosted app setup route must be a canonical same-origin absolute path'); } if ('message' in action) errors.push('hosted app setup route forbids message'); } if (action.type === 'message') { if (typeof action.message !== 'string' || !action.message.trim() || action.message.length > 4000) { errors.push('hosted app bootstrap message must be bounded'); } if ('route' in action) errors.push('hosted app message forbids route'); } return errors; };