Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/emulate/core/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,31 @@ export const ID_PREFIXES = {
directory: 'directory',
directory_user: 'directory_user',
directory_group: 'directory_grp',
event: 'event',
event: 'evt',
invitation: 'inv',
session: 'session',
email_verification: 'email_verification',
password_reset: 'password_reset',
magic_auth: 'magic_auth',
authentication_factor: 'auth_factor',
authentication_challenge: 'auth_challenge',
authorization_code: 'auth_code',
identity: 'identity',
sso_authorization: 'sso_auth',
refresh_token: 'ref',
device_authorization: 'dev_auth',
api_key: 'api_key',
profile: 'prof',
pipe_connection: 'pipe_conn',
redirect_uri: 'redir',
cors_origin: 'cors',
authorized_application: 'auth_app',
connected_account: 'conn_acct',
role: 'role',
permission: 'perm',
role_permission: 'rp',
authorization_resource: 'auth_res',
role_assignment: 'ra',
audit_log_action: 'audit_action',
audit_log_event: 'audit_event',
audit_log_export: 'audit_export',
Expand All @@ -61,4 +75,5 @@ export const ID_PREFIXES = {
client_secret: 'client_secret',
data_integration_auth: 'di_auth',
radar_attempt: 'radar_attempt',
webhook_endpoint: 'we',
} as const;
2 changes: 1 addition & 1 deletion src/emulate/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export {
type CollectionHooks,
} from './store.js';
export { generateId, resetIdState, ID_PREFIXES } from './id.js';
export { cursorPaginate, type CursorPaginationOptions, type CursorPaginatedResult } from './pagination.js';
export { parseListParams, cursorPaginate, type CursorPaginationOptions, type CursorPaginatedResult } from './pagination.js';
export { JWTManager, type JWTPayload } from './jwt.js';
export { createServer, type ServerOptions } from './server.js';
export { type ServicePlugin, type RouteContext } from './plugin.js';
Expand Down
32 changes: 4 additions & 28 deletions src/emulate/core/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Context, Next } from 'hono';
import { unauthorized } from './error-handler.js';

export interface WorkOSAuthContext {
environment: string;
Expand All @@ -17,38 +18,13 @@ export type ApiKeyMap = Record<string, { environment: string }>;
export function authMiddleware(apiKeys: ApiKeyMap) {
return async (c: Context, next: Next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
return c.json(
{
message: 'Unauthorized',
code: 'unauthorized',
},
401,
);
}
if (!authHeader) throw unauthorized();

const token = authHeader.replace(/^Bearer\s+/i, '').trim();

if (!token.startsWith('sk_')) {
return c.json(
{
message: 'Unauthorized',
code: 'unauthorized',
},
401,
);
}
if (!token.startsWith('sk_')) throw unauthorized();

const keyInfo = apiKeys[token];
if (!keyInfo) {
return c.json(
{
message: 'Unauthorized',
code: 'unauthorized',
},
401,
);
}
if (!keyInfo) throw unauthorized();

c.set('auth', { environment: keyInfo.environment, apiKey: token } satisfies WorkOSAuthContext);
await next();
Expand Down
11 changes: 10 additions & 1 deletion src/emulate/core/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ export interface CursorPaginatedResult<T> {
};
}

export function parseListParams(url: URL) {
const limit = parseInt(url.searchParams.get('limit') ?? '10') || 10;
const order = (url.searchParams.get('order') as 'asc' | 'desc') ?? 'desc';
const before = url.searchParams.get('before') ?? undefined;
const after = url.searchParams.get('after') ?? undefined;
return { limit, order, before, after };
}

export function cursorPaginate<T extends Entity>(
items: T[],
options: CursorPaginationOptions<T> = {},
): CursorPaginatedResult<T> {
let filtered = options.filter ? items.filter(options.filter) : [...items];
// Callers must pass a fresh array (e.g. Collection.all()) — sort mutates in-place
let filtered = options.filter ? items.filter(options.filter) : items;

const order = options.order ?? 'desc';
const defaultSort = (a: T, b: T) =>
Expand Down
76 changes: 28 additions & 48 deletions src/emulate/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,57 +33,37 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
return c.json(jwt.getJWKS());
});

// Auth middleware for API routes
app.use('/api/*', authMiddleware(apiKeys));
app.use('/user_management/*', async (c, next) => {
// Auth middleware — single catch-all instance
const auth = authMiddleware(apiKeys);

const PUBLIC_PATHS = new Set([
'/health',
'/user_management/authorize',
'/user_management/authenticate',
'/user_management/sessions/logout',
]);

const PUBLIC_PATH_PREFIXES = [
'/sso/',
'/user_management/sessions/jwks/',
'/data-integrations/',
];

app.use('*', async (c, next) => {
const path = new URL(c.req.url).pathname;
// Public endpoints (no auth required)
if (
path === '/user_management/authorize' ||
path === '/user_management/authenticate' ||
path === '/user_management/sessions/logout' ||
path.startsWith('/user_management/sessions/jwks/')
) {
return next();

// Skip auth for public paths
if (PUBLIC_PATHS.has(path)) return next();
for (const prefix of PUBLIC_PATH_PREFIXES) {
if (path.startsWith(prefix)) {
// data-integrations: only /authorize subpath is public
if (prefix === '/data-integrations/' && !path.endsWith('/authorize')) break;
return next();
}
}
return authMiddleware(apiKeys)(c, next);
});
app.use('/x/authkit/*', authMiddleware(apiKeys));
app.use('/organizations', authMiddleware(apiKeys));
app.use('/organizations/*', authMiddleware(apiKeys));
app.use('/organization_memberships', authMiddleware(apiKeys));
app.use('/organization_memberships/*', authMiddleware(apiKeys));
app.use('/organization_domains', authMiddleware(apiKeys));
app.use('/organization_domains/*', authMiddleware(apiKeys));
app.use('/connections', authMiddleware(apiKeys));
app.use('/connections/*', authMiddleware(apiKeys));
app.use('/directories', authMiddleware(apiKeys));
app.use('/directories/*', authMiddleware(apiKeys));
app.use('/directory_groups', authMiddleware(apiKeys));
app.use('/directory_groups/*', authMiddleware(apiKeys));
app.use('/directory_users', authMiddleware(apiKeys));
app.use('/directory_users/*', authMiddleware(apiKeys));
app.use('/events', authMiddleware(apiKeys));
app.use('/events/*', authMiddleware(apiKeys));
app.use('/pipes/*', authMiddleware(apiKeys));
app.use('/audit_logs/*', authMiddleware(apiKeys));
app.use('/feature-flags', authMiddleware(apiKeys));
app.use('/feature-flags/*', authMiddleware(apiKeys));
app.use('/connect/*', authMiddleware(apiKeys));
app.use('/data-integrations/*', async (c, next) => {
const path = new URL(c.req.url).pathname;
if (path.endsWith('/authorize')) return next();
return authMiddleware(apiKeys)(c, next);

return auth(c, next);
});
app.use('/radar/*', authMiddleware(apiKeys));
app.use('/api_keys', authMiddleware(apiKeys));
app.use('/api_keys/*', authMiddleware(apiKeys));
app.use('/portal/*', authMiddleware(apiKeys));
app.use('/webhook_endpoints', authMiddleware(apiKeys));
app.use('/webhook_endpoints/*', authMiddleware(apiKeys));
app.use('/auth/factors', authMiddleware(apiKeys));
app.use('/auth/factors/*', authMiddleware(apiKeys));
app.use('/auth/challenges/*', authMiddleware(apiKeys));

// Rate limiting
const rateLimitCounters = new Map<string, { remaining: number; resetAt: number }>();
Expand Down
23 changes: 22 additions & 1 deletion src/emulate/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export class Collection<T extends Entity> {
return this.items.delete(id);
}

deleteBy(field: keyof T, value: string | number): number {
const items = this.findBy(field, value);
for (const item of items) this.delete(item.id);
return items.length;
}

setHooks(hooks: CollectionHooks<T>): void {
this.hooks = hooks;
}
Expand All @@ -127,7 +133,11 @@ export class Collection<T extends Entity> {

count(filter?: FilterFn<T>): number {
if (!filter) return this.items.size;
return this.all().filter(filter).length;
let n = 0;
for (const item of this.items.values()) {
if (filter(item)) n++;
}
return n;
}

clear(): void {
Expand Down Expand Up @@ -168,6 +178,17 @@ export class Store {
this._data.set(key, value);
}

deleteDataByPrefix(prefix: string): number {
let count = 0;
for (const key of this._data.keys()) {
if (key.startsWith(prefix)) {
this._data.delete(key);
count++;
}
}
return count;
}

reset(): void {
for (const collection of this.collections.values()) {
collection.clear();
Expand Down
59 changes: 59 additions & 0 deletions src/emulate/workos/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Typed keys for Store.getData/setData */
export const STORE_KEYS = {
workosStore: '_workos_store',
eventBus: 'eventBus',
apiKeyMap: 'apiKeyMap',
jwtTemplate: 'jwt_template',
} as const;

/** Prefix for dynamic store keys */
export const STORE_KEY_PREFIXES = {
pendingAuth: 'pending_auth:',
ssoToken: 'sso_token:',
ssoLogout: 'sso_logout:',
auditSchema: 'audit_schema_',
radarIpList: 'radar_ip_list',
} as const;

/** All WorkOS webhook event names */
export const EVENTS = {
userCreated: 'user.created',
userUpdated: 'user.updated',
userDeleted: 'user.deleted',
organizationCreated: 'organization.created',
organizationUpdated: 'organization.updated',
organizationDeleted: 'organization.deleted',
organizationDomainCreated: 'organization_domain.created',
organizationDomainVerified: 'organization_domain.verified',
organizationDomainUpdated: 'organization_domain.updated',
organizationDomainDeleted: 'organization_domain.deleted',
organizationMembershipCreated: 'organization_membership.created',
organizationMembershipUpdated: 'organization_membership.updated',
organizationMembershipDeleted: 'organization_membership.deleted',
connectionCreated: 'connection.created',
connectionUpdated: 'connection.updated',
connectionDeleted: 'connection.deleted',
sessionCreated: 'session.created',
sessionRevoked: 'session.revoked',
invitationCreated: 'invitation.created',
invitationAccepted: 'invitation.accepted',
invitationRevoked: 'invitation.revoked',
invitationResent: 'invitation.resent',
roleCreated: 'role.created',
roleUpdated: 'role.updated',
roleDeleted: 'role.deleted',
permissionCreated: 'permission.created',
permissionUpdated: 'permission.updated',
permissionDeleted: 'permission.deleted',
directoryCreated: 'directory.created',
directoryUpdated: 'directory.updated',
directoryDeleted: 'directory.deleted',
directoryUserCreated: 'directory_user.created',
directoryUserUpdated: 'directory_user.updated',
directoryUserDeleted: 'directory_user.deleted',
directoryGroupCreated: 'directory_group.created',
directoryGroupUpdated: 'directory_group.updated',
directoryGroupDeleted: 'directory_group.deleted',
} as const;

export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS];
2 changes: 0 additions & 2 deletions src/emulate/workos/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,6 @@ export interface WorkOSRoleAssignment extends Entity {
role_id: string;
}

// --- Phase 4: CRUD Domains ---

export interface WorkOSDirectory extends Entity {
object: 'directory';
name: string;
Expand Down
4 changes: 4 additions & 0 deletions src/emulate/workos/event-bus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe('EventBus', () => {
description: null,
});

bus.rebuildIndex();
// This should not attempt delivery (no fetch error even though URL is unreachable)
bus.emit({ event: 'user.created', data: {} });
expect(ws.events.all()).toHaveLength(1);
Expand All @@ -70,6 +71,7 @@ describe('EventBus', () => {
description: null,
});

bus.rebuildIndex();
// user.created should not match the endpoint's filter
bus.emit({ event: 'user.created', data: {} });
expect(ws.events.all()).toHaveLength(1);
Expand All @@ -93,6 +95,7 @@ describe('EventBus', () => {
description: null,
});

bus.rebuildIndex();
bus.emit({ event: 'user.created', data: { id: 'user_1' } });

// Wait for async delivery
Expand Down Expand Up @@ -132,6 +135,7 @@ describe('EventBus', () => {
description: null,
});

bus.rebuildIndex();
// emit() should return immediately (fire-and-forget)
const start = Date.now();
bus.emit({ event: 'user.created', data: {} });
Expand Down
Loading
Loading