diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 1dab4a2b..55dc47fb 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -17,6 +17,6 @@ class AppConfig { /// The base URL for the V2 Unified API gateway. static const String v2ApiBaseUrl = String.fromEnvironment( 'V2_API_BASE_URL', - defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app', + defaultValue: 'https://krow-api-v2-e3g6witsvq-uc.a.run.app', ); } diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js index 0c6fa44a..aca81b20 100644 --- a/backend/command-api/src/app.js +++ b/backend/command-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createCommandsRouter } from './routes/commands.js'; import { createMobileCommandsRouter } from './routes/mobile.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js index f4f0d567..181850ae 100644 --- a/backend/command-api/src/contracts/commands/mobile.js +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -202,6 +202,11 @@ export const shiftApplySchema = z.object({ instantBook: z.boolean().optional(), }); +export const orderBookSchema = z.object({ + orderId: z.string().uuid(), + roleId: z.string().uuid(), +}); + export const shiftDecisionSchema = z.object({ shiftId: z.string().uuid(), reason: z.string().max(1000).optional(), diff --git a/backend/command-api/src/lib/runtime-safety.js b/backend/command-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..74bbf0ca --- /dev/null +++ b/backend/command-api/src/lib/runtime-safety.js @@ -0,0 +1,44 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (`${process.env.IDEMPOTENCY_STORE || ''}`.trim().toLowerCase() === 'memory') { + errors.push('IDEMPOTENCY_STORE must not be memory'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe command-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} + +export function assertSafeWorkerRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + const deliveryMode = `${process.env.PUSH_DELIVERY_MODE || 'live'}`.trim().toLowerCase(); + + if (deliveryMode !== 'live') { + errors.push('PUSH_DELIVERY_MODE must be live'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe notification-worker runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/command-api/src/middleware/auth.js b/backend/command-api/src/middleware/auth.js index 9c62c86d..d6fa04f8 100644 --- a/backend/command-api/src/middleware/auth.js +++ b/backend/command-api/src/middleware/auth.js @@ -9,6 +9,30 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js index 99bb8c8b..309ff014 100644 --- a/backend/command-api/src/routes/mobile.js +++ b/backend/command-api/src/routes/mobile.js @@ -7,6 +7,7 @@ import { addStaffBankAccount, approveInvoice, applyForShift, + bookOrder, assignHubManager, assignHubNfc, cancelLateWorker, @@ -76,6 +77,7 @@ import { profileExperienceSchema, pushTokenDeleteSchema, pushTokenRegisterSchema, + orderBookSchema, shiftManagerCreateSchema, shiftApplySchema, shiftDecisionSchema, @@ -95,6 +97,7 @@ const defaultHandlers = { addStaffBankAccount, approveInvoice, applyForShift, + bookOrder, assignHubManager, assignHubNfc, cancelLateWorker, @@ -438,6 +441,14 @@ export function createMobileCommandsRouter(handlers = defaultHandlers) { paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), })); + router.post(...mobileCommand('/staff/orders/:orderId/book', { + schema: orderBookSchema, + policyAction: 'staff.orders.book', + resource: 'order', + handler: handlers.bookOrder, + paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), + })); + router.post(...mobileCommand('/staff/shifts/:shiftId/accept', { schema: shiftDecisionSchema, policyAction: 'staff.shifts.accept', diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js index d8fb721e..ce516acb 100644 --- a/backend/command-api/src/services/command-service.js +++ b/backend/command-api/src/services/command-service.js @@ -4,6 +4,10 @@ import { recordGeofenceIncident } from './attendance-monitoring.js'; import { recordAttendanceSecurityProof } from './attendance-security.js'; import { evaluateClockInAttempt } from './clock-in-policy.js'; import { enqueueHubManagerAlert } from './notification-outbox.js'; +import { + requireClientContext as requireActorClientContext, + requireStaffContext as requireActorStaffContext, +} from './actor-context.js'; function toIsoOrNull(value) { return value ? new Date(value).toISOString() : null; @@ -68,6 +72,33 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s } } +function assertTenantScope(context, tenantId) { + if (context.tenant.tenantId !== tenantId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor tenant scope', 403, { + tenantId, + actorTenantId: context.tenant.tenantId, + }); + } +} + +function assertBusinessScope(context, businessId) { + if (context.business && context.business.businessId !== businessId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor business scope', 403, { + businessId, + actorBusinessId: context.business.businessId, + }); + } +} + +function assertStaffScope(context, staffId) { + if (context.staff.staffId !== staffId) { + throw new AppError('FORBIDDEN', 'Resource is outside actor staff scope', 403, { + staffId, + actorStaffId: context.staff.staffId, + }); + } +} + async function insertDomainEvent(client, { tenantId, aggregateType, @@ -451,6 +482,9 @@ function buildOrderUpdateStatement(payload) { export async function createOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId); if (payload.vendorId) { await requireVendor(client, payload.tenantId, payload.vendorId); @@ -620,8 +654,10 @@ export async function createOrder(actor, payload) { export async function acceptShift(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorStaffContext(actor.uid); const shiftRole = await requireShiftRole(client, payload.shiftRoleId); + assertTenantScope(actorContext, shiftRole.tenant_id); if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) { throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, { shiftId: payload.shiftId, @@ -629,6 +665,13 @@ export async function acceptShift(actor, payload) { }); } + if (!actorContext.staff.workforceId || actorContext.staff.workforceId !== payload.workforceId) { + throw new AppError('FORBIDDEN', 'Staff can only accept shifts for their own workforce record', 403, { + workforceId: payload.workforceId, + actorWorkforceId: actorContext.staff.workforceId || null, + }); + } + if (shiftRole.assigned_count >= shiftRole.workers_needed) { const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce( client, @@ -736,7 +779,10 @@ export async function acceptShift(actor, payload) { export async function updateOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId); + assertBusinessScope(actorContext, existingOrder.business_id); if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) { await requireVendor(client, payload.tenantId, payload.vendorId); @@ -787,7 +833,10 @@ export async function updateOrder(actor, payload) { export async function cancelOrder(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const order = await requireOrder(client, payload.tenantId, payload.orderId); + assertBusinessScope(actorContext, order.business_id); if (order.status === 'CANCELLED') { return { @@ -910,7 +959,10 @@ export async function cancelOrder(actor, payload) { export async function changeShiftStatus(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const shift = await requireShift(client, payload.tenantId, payload.shiftId); + assertBusinessScope(actorContext, shift.business_id); if (payload.status === 'COMPLETED') { const openSession = await client.query( @@ -999,7 +1051,10 @@ export async function changeShiftStatus(actor, payload) { export async function assignStaffToShift(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); const shift = await requireShift(client, payload.tenantId, payload.shiftId); + assertBusinessScope(actorContext, shift.business_id); const shiftRole = await requireShiftRole(client, payload.shiftRoleId); if (shiftRole.shift_id !== shift.id) { @@ -1120,7 +1175,10 @@ export async function assignStaffToShift(actor, payload) { async function createAttendanceEvent(actor, payload, eventType) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorStaffContext(actor.uid); const assignment = await requireAssignment(client, payload.assignmentId); + assertTenantScope(actorContext, assignment.tenant_id); + assertStaffScope(actorContext, assignment.staff_id); const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString(); let securityProof = null; @@ -1553,6 +1611,9 @@ export async function clockOut(actor, payload) { export async function addFavoriteStaff(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId); const staffResult = await client.query( @@ -1605,6 +1666,9 @@ export async function addFavoriteStaff(actor, payload) { export async function removeFavoriteStaff(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); const deleted = await client.query( ` DELETE FROM staff_favorites @@ -1640,7 +1704,11 @@ export async function removeFavoriteStaff(actor, payload) { export async function createStaffReview(actor, payload) { return withTransaction(async (client) => { await ensureActorUser(client, actor); + const actorContext = await requireActorClientContext(actor.uid); + assertTenantScope(actorContext, payload.tenantId); + assertBusinessScope(actorContext, payload.businessId); const assignment = await requireAssignment(client, payload.assignmentId); + assertBusinessScope(actorContext, assignment.business_id); if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) { throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, { assignmentId: payload.assignmentId, diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js index 377ab9af..def1d189 100644 --- a/backend/command-api/src/services/mobile-command-service.js +++ b/backend/command-api/src/services/mobile-command-service.js @@ -2883,6 +2883,299 @@ export async function applyForShift(actor, payload) { }); } +export async function bookOrder(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + + if (!staff.workforce_id) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Staff must have an active workforce profile before booking an order', 422, { + orderId: payload.orderId, + staffId: staff.id, + }); + } + + const roleLookup = await client.query( + ` + SELECT id, code, name + FROM roles_catalog + WHERE tenant_id = $1 + AND id = $2 + AND status = 'ACTIVE' + LIMIT 1 + `, + [context.tenant.tenantId, payload.roleId] + ); + if (roleLookup.rowCount === 0) { + throw new AppError('VALIDATION_ERROR', 'roleId must reference an active role in the tenant catalog', 400, { + roleId: payload.roleId, + }); + } + const selectedRole = roleLookup.rows[0]; + + const orderLookup = await client.query( + ` + SELECT id, business_id, metadata + FROM orders + WHERE tenant_id = $1 + AND id = $2 + LIMIT 1 + FOR UPDATE + `, + [context.tenant.tenantId, payload.orderId] + ); + if (orderLookup.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found', 404, { + orderId: payload.orderId, + }); + } + + const existingOrderParticipation = await client.query( + ` + SELECT + s.id AS shift_id, + sr.id AS shift_role_id, + a.id AS assignment_id, + app.id AS application_id + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN assignments a + ON a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LEFT JOIN applications app + ON app.shift_role_id = sr.id + AND app.staff_id = $3 + AND app.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + WHERE s.tenant_id = $1 + AND s.order_id = $2 + AND s.starts_at > NOW() + AND (a.id IS NOT NULL OR app.id IS NOT NULL) + LIMIT 1 + `, + [context.tenant.tenantId, payload.orderId, staff.id] + ); + if (existingOrderParticipation.rowCount > 0) { + throw new AppError('CONFLICT', 'Staff already has participation on this order', 409, { + orderId: payload.orderId, + shiftId: existingOrderParticipation.rows[0].shift_id, + shiftRoleId: existingOrderParticipation.rows[0].shift_role_id, + }); + } + + const candidateRoles = await client.query( + ` + SELECT + s.id AS shift_id, + s.order_id, + s.business_id, + s.vendor_id, + s.clock_point_id, + s.status AS shift_status, + s.starts_at, + s.ends_at, + COALESCE(s.timezone, 'UTC') AS timezone, + to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'YYYY-MM-DD') AS local_date, + to_char(s.starts_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_start_time, + to_char(s.ends_at AT TIME ZONE COALESCE(s.timezone, 'UTC'), 'HH24:MI') AS local_end_time, + sr.id AS shift_role_id, + COALESCE(sr.role_id, rc.id) AS catalog_role_id, + COALESCE(sr.role_code, rc.code) AS role_code, + COALESCE(sr.role_name, rc.name) AS role_name, + sr.workers_needed, + sr.assigned_count, + COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS instant_book + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN roles_catalog rc + ON rc.tenant_id = s.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + WHERE s.tenant_id = $1 + AND s.order_id = $2 + AND s.starts_at > NOW() + AND COALESCE(sr.role_id, rc.id) = $3 + ORDER BY s.starts_at ASC, sr.created_at ASC + FOR UPDATE OF s, sr + `, + [context.tenant.tenantId, payload.orderId, payload.roleId] + ); + + if (candidateRoles.rowCount === 0) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Order has no future shifts available for this role', 422, { + orderId: payload.orderId, + roleId: payload.roleId, + }); + } + + const blockedOrUnavailable = candidateRoles.rows.find((row) => row.shift_status !== 'OPEN' || row.assigned_count >= row.workers_needed); + if (blockedOrUnavailable) { + throw new AppError('UNPROCESSABLE_ENTITY', 'Order is no longer fully bookable', 422, { + orderId: payload.orderId, + roleId: payload.roleId, + shiftId: blockedOrUnavailable.shift_id, + shiftRoleId: blockedOrUnavailable.shift_role_id, + }); + } + + await ensureStaffNotBlockedByBusiness(client, { + tenantId: context.tenant.tenantId, + businessId: candidateRoles.rows[0].business_id, + staffId: staff.id, + }); + + const bookingId = crypto.randomUUID(); + const assignedShifts = []; + + for (const row of candidateRoles.rows) { + const dispatchMembership = await loadDispatchMembership(client, { + tenantId: context.tenant.tenantId, + businessId: row.business_id, + hubId: row.clock_point_id, + staffId: staff.id, + }); + const instantBook = Boolean(row.instant_book); + + const applicationResult = await client.query( + ` + INSERT INTO applications ( + tenant_id, + shift_id, + shift_role_id, + staff_id, + status, + origin, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb) + ON CONFLICT (shift_role_id, staff_id) DO NOTHING + RETURNING id, status + `, + [ + context.tenant.tenantId, + row.shift_id, + row.shift_role_id, + staff.id, + instantBook ? 'CONFIRMED' : 'PENDING', + JSON.stringify({ + bookingId, + bookedBy: actor.uid, + source: 'staff-order-booking', + orderId: payload.orderId, + catalogRoleId: payload.roleId, + roleCode: selectedRole.code, + dispatchTeamType: dispatchMembership.teamType, + dispatchPriority: dispatchMembership.priority, + dispatchTeamMembershipId: dispatchMembership.membershipId, + dispatchTeamScopeHubId: dispatchMembership.scopedHubId, + }), + ] + ); + if (applicationResult.rowCount === 0) { + throw new AppError('CONFLICT', 'Order booking conflicted with an existing application', 409, { + orderId: payload.orderId, + shiftId: row.shift_id, + shiftRoleId: row.shift_role_id, + }); + } + + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), CASE WHEN $10::boolean THEN NOW() ELSE NULL END, $11::jsonb) + ON CONFLICT (shift_role_id, workforce_id) DO NOTHING + RETURNING id, status + `, + [ + context.tenant.tenantId, + row.business_id, + row.vendor_id, + row.shift_id, + row.shift_role_id, + staff.workforce_id, + staff.id, + applicationResult.rows[0].id, + instantBook ? 'ACCEPTED' : 'ASSIGNED', + instantBook, + JSON.stringify({ + bookingId, + bookedBy: actor.uid, + source: 'staff-order-booking', + orderId: payload.orderId, + catalogRoleId: payload.roleId, + roleCode: selectedRole.code, + pendingApproval: !instantBook, + dispatchTeamType: dispatchMembership.teamType, + dispatchPriority: dispatchMembership.priority, + dispatchTeamMembershipId: dispatchMembership.membershipId, + dispatchTeamScopeHubId: dispatchMembership.scopedHubId, + }), + ] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('CONFLICT', 'Order booking conflicted with an existing assignment', 409, { + orderId: payload.orderId, + shiftId: row.shift_id, + shiftRoleId: row.shift_role_id, + }); + } + + await refreshShiftRoleCounts(client, row.shift_role_id); + await refreshShiftCounts(client, row.shift_id); + + assignedShifts.push({ + shiftId: row.shift_id, + date: row.local_date, + startsAt: row.starts_at, + endsAt: row.ends_at, + startTime: row.local_start_time, + endTime: row.local_end_time, + timezone: row.timezone, + assignmentId: assignmentResult.rows[0].id, + assignmentStatus: assignmentResult.rows[0].status, + }); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'order', + aggregateId: payload.orderId, + eventType: candidateRoles.rows.every((row) => row.instant_book) ? 'STAFF_ORDER_BOOKED_CONFIRMED' : 'STAFF_ORDER_BOOKED_PENDING', + actorUserId: actor.uid, + payload: { + bookingId, + roleId: payload.roleId, + roleCode: selectedRole.code, + assignedShiftCount: assignedShifts.length, + }, + }); + + return { + bookingId, + orderId: payload.orderId, + roleId: payload.roleId, + roleCode: selectedRole.code, + roleName: selectedRole.name, + assignedShiftCount: assignedShifts.length, + status: candidateRoles.rows.every((row) => row.instant_book) ? 'CONFIRMED' : 'PENDING', + assignedShifts, + }; + }); +} + export async function acceptPendingShift(actor, payload) { const context = await requireStaffContext(actor.uid); return withTransaction(async (client) => { diff --git a/backend/command-api/src/services/policy.js b/backend/command-api/src/services/policy.js index 44e7e371..ec31b98a 100644 --- a/backend/command-api/src/services/policy.js +++ b/backend/command-api/src/services/policy.js @@ -1,5 +1,125 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']); + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +function isTenantAdmin(context) { + return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase()); +} + +function hasTenantScope(context) { + return Boolean(context?.user && context?.tenant); +} + +function hasClientScope(context) { + return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context)); +} + +function hasStaffScope(context) { + return hasTenantScope(context) && Boolean(context?.staff); +} + +function requiredScopeFor(action) { + if (action === 'notifications.device.write') { + return 'tenant'; + } + + if ( + action === 'orders.create' + || action === 'orders.update' + || action === 'orders.cancel' + || action === 'shifts.change-status' + || action === 'shifts.assign-staff' + || action === 'business.favorite-staff' + || action === 'business.unfavorite-staff' + || action === 'assignments.review-staff' + || action.startsWith('client.') + || action.startsWith('billing.') + || action.startsWith('coverage.') + || action.startsWith('hubs.') + || action.startsWith('vendors.') + || action.startsWith('reports.') + ) { + return 'client'; + } + + if ( + action === 'shifts.accept' + || action === 'attendance.clock-in' + || action === 'attendance.clock-out' + || action === 'attendance.location-stream.write' + || action.startsWith('staff.') + || action.startsWith('payments.') + ) { + return 'staff'; + } + + return 'deny'; +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +function requestScopeMatches(req, context, requiredScope) { + const tenantId = requestField(req, 'tenantId'); + if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) { + return false; + } + + const businessId = requestField(req, 'businessId'); + if ( + requiredScope === 'client' + && businessId + && context?.business?.businessId + && context.business.businessId !== '*' + && context.business.businessId !== businessId + ) { + return false; + } + + return true; +} + +export async function can(action, resource, actor, req) { + void resource; + const context = await resolveActorContext(actor); + const requiredScope = requiredScopeFor(action); + + if (requiredScope === 'deny' || !context?.user) { + return false; + } + + if (requiredScope === 'tenant') { + return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'client') { + return hasClientScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'staff') { + return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope); + } + + return false; } diff --git a/backend/command-api/src/worker-app.js b/backend/command-api/src/worker-app.js index 8ccd96ed..08b42c2c 100644 --- a/backend/command-api/src/worker-app.js +++ b/backend/command-api/src/worker-app.js @@ -1,10 +1,12 @@ import express from 'express'; import pino from 'pino'; import pinoHttp from 'pino-http'; +import { assertSafeWorkerRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createWorkerApp({ dispatch = async () => ({}) } = {}) { + assertSafeWorkerRuntimeConfig(); const app = express(); app.use( diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js index ad1a91c3..e9362286 100644 --- a/backend/command-api/test/app.test.js +++ b/backend/command-api/test/app.test.js @@ -40,6 +40,7 @@ function validOrderCreatePayload() { beforeEach(() => { process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.AUTH_BYPASS_CONTEXT; delete process.env.IDEMPOTENCY_DATABASE_URL; delete process.env.DATABASE_URL; __resetIdempotencyStoreForTests(); @@ -63,6 +64,16 @@ test('GET /readyz reports database not configured when no database env is presen assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when auth bypass is enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('command route requires idempotency key', async () => { const app = createApp(); const res = await request(app) @@ -116,3 +127,36 @@ test('command route is idempotent by key and only executes handler once', async assert.equal(first.body.idempotencyKey, 'abc-123'); assert.equal(second.body.idempotencyKey, 'abc-123'); }); + +test('client command routes deny mismatched business scope before handler execution', async () => { + process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({ + user: { userId: 'test-user' }, + tenant: { tenantId, role: 'MANAGER' }, + business: { businessId: '99999999-9999-4999-8999-999999999999' }, + }); + + const app = createApp({ + commandHandlers: { + createOrder: async () => assert.fail('createOrder should not be called'), + acceptShift: async () => assert.fail('acceptShift should not be called'), + clockIn: async () => assert.fail('clockIn should not be called'), + clockOut: async () => assert.fail('clockOut should not be called'), + addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'), + removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'), + createStaffReview: async () => assert.fail('createStaffReview should not be called'), + updateOrder: async () => assert.fail('updateOrder should not be called'), + cancelOrder: async () => assert.fail('cancelOrder should not be called'), + changeShiftStatus: async () => assert.fail('changeShiftStatus should not be called'), + assignStaffToShift: async () => assert.fail('assignStaffToShift should not be called'), + }, + }); + + const res = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'scope-mismatch') + .send(validOrderCreatePayload()); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js index 276c9a11..4328dfff 100644 --- a/backend/command-api/test/mobile-routes.test.js +++ b/backend/command-api/test/mobile-routes.test.js @@ -65,6 +65,14 @@ function createMobileHandlers() { invoiceId: payload.invoiceId, status: 'APPROVED', }), + bookOrder: async (_actor, payload) => ({ + bookingId: 'booking-1', + orderId: payload.orderId, + roleId: payload.roleId, + assignedShiftCount: 3, + status: 'PENDING', + assignedShifts: [], + }), registerClientPushToken: async (_actor, payload) => ({ tokenId: 'push-token-client-1', platform: payload.platform, @@ -410,6 +418,23 @@ test('POST /commands/staff/shifts/:shiftId/submit-for-approval injects shift id assert.equal(res.body.submitted, true); }); +test('POST /commands/staff/orders/:orderId/book injects order id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/orders/88888888-8888-4888-8888-888888888888/book') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'staff-order-book-1') + .send({ + roleId: '99999999-9999-4999-8999-999999999999', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.orderId, '88888888-8888-4888-8888-888888888888'); + assert.equal(res.body.roleId, '99999999-9999-4999-8999-999999999999'); + assert.equal(res.body.assignedShiftCount, 3); + assert.equal(res.body.status, 'PENDING'); +}); + test('POST /commands/client/coverage/swap-requests/:swapRequestId/resolve injects swap request id from params', async () => { const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); const res = await request(app) diff --git a/backend/command-api/test/notification-worker.test.js b/backend/command-api/test/notification-worker.test.js index a4865b55..2816c9bf 100644 --- a/backend/command-api/test/notification-worker.test.js +++ b/backend/command-api/test/notification-worker.test.js @@ -12,6 +12,16 @@ test('GET /readyz returns healthy response', async () => { assert.equal(res.body.service, 'notification-worker-v2'); }); +test('createWorkerApp fails fast in protected env when push delivery is not live', async () => { + process.env.APP_ENV = 'staging'; + process.env.PUSH_DELIVERY_MODE = 'log-only'; + + assert.throws(() => createWorkerApp(), /PUSH_DELIVERY_MODE must be live/); + + delete process.env.APP_ENV; + delete process.env.PUSH_DELIVERY_MODE; +}); + test('POST /tasks/dispatch-notifications returns dispatch summary', async () => { const app = createWorkerApp({ dispatch: async () => ({ diff --git a/backend/command-api/test/policy.test.js b/backend/command-api/test/policy.test.js new file mode 100644 index 00000000..d5e30b65 --- /dev/null +++ b/backend/command-api/test/policy.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('client actions require business scope and matching business id', async () => { + const allowed = await can( + 'orders.create', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1', businessId: 'business-1' } } + ); + + const denied = await can( + 'orders.create', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1', businessId: 'business-2' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('staff actions require staff scope', async () => { + const allowed = await can( + 'shifts.accept', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + staff: { staffId: 'staff-1', workforceId: 'workforce-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + const denied = await can( + 'shifts.accept', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + business: { businessId: 'business-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('notifications.device.write allows tenant-scoped actor', async () => { + const allowed = await can( + 'notifications.device.write', + 'device', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + { body: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); +}); diff --git a/backend/core-api/src/app.js b/backend/core-api/src/app.js index af2f1a13..dd5e6a14 100644 --- a/backend/core-api/src/app.js +++ b/backend/core-api/src/app.js @@ -5,10 +5,12 @@ import { requestContext } from './middleware/request-context.js'; import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp() { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/core-api/src/lib/runtime-safety.js b/backend/core-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..31aa0c71 --- /dev/null +++ b/backend/core-api/src/lib/runtime-safety.js @@ -0,0 +1,45 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (process.env.UPLOAD_MOCK !== 'false') { + errors.push('UPLOAD_MOCK must be false'); + } + + if (process.env.SIGNED_URL_MOCK !== 'false') { + errors.push('SIGNED_URL_MOCK must be false'); + } + + if (process.env.LLM_MOCK !== 'false') { + errors.push('LLM_MOCK must be false'); + } + + const verificationStore = `${process.env.VERIFICATION_STORE || 'sql'}`.trim().toLowerCase(); + if (verificationStore !== 'sql') { + errors.push('VERIFICATION_STORE must be sql'); + } + + const verificationAccessMode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase(); + if (verificationAccessMode === 'authenticated') { + errors.push('VERIFICATION_ACCESS_MODE must not be authenticated'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe core-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/core-api/src/middleware/auth.js b/backend/core-api/src/middleware/auth.js index 9c62c86d..d2aa36ff 100644 --- a/backend/core-api/src/middleware/auth.js +++ b/backend/core-api/src/middleware/auth.js @@ -9,6 +9,28 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +39,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +58,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/core-api/src/services/policy.js b/backend/core-api/src/services/policy.js index 44e7e371..12fa6e2f 100644 --- a/backend/core-api/src/services/policy.js +++ b/backend/core-api/src/services/policy.js @@ -1,5 +1,46 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +export async function can(action, resource, actor, req) { + void resource; + if (!action.startsWith('core.')) { + return false; + } + + const context = await resolveActorContext(actor); + if (!context?.user || !context?.tenant) { + return false; + } + + const tenantId = requestField(req, 'tenantId'); + if (!tenantId) { + return true; + } + if (context.tenant.tenantId === '*') { + return true; + } + return context.tenant.tenantId === tenantId; } diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index ce46679b..ac70aab8 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -1,6 +1,6 @@ import { AppError } from '../lib/errors.js'; import { isDatabaseConfigured, query, withTransaction } from './db.js'; -import { requireTenantContext } from './actor-context.js'; +import { loadActorContext, requireTenantContext } from './actor-context.js'; import { invokeVertexMultimodalModel } from './llm.js'; export const VerificationStatus = Object.freeze({ @@ -95,7 +95,11 @@ async function processVerificationJobInMemory(verificationId) { } function accessMode() { - return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; + const mode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase(); + if (mode === 'owner' || mode === 'tenant' || mode === 'authenticated') { + return mode; + } + return 'tenant'; } function providerTimeoutMs() { @@ -156,12 +160,27 @@ function toPublicJob(row) { }; } -function assertAccess(row, actorUid) { - if (accessMode() === 'authenticated') { +async function assertAccess(row, actorUid) { + if (row.owner_user_id === actorUid) { return; } - if (row.owner_user_id !== actorUid) { - throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + + const mode = accessMode(); + if (mode === 'authenticated') { + return; + } + + if (mode === 'owner' || !row.tenant_id) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { + verificationId: row.id, + }); + } + + const actorContext = await loadActorContext(actorUid); + if (actorContext.tenant?.tenantId !== row.tenant_id) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, { + verificationId: row.id, + }); } } @@ -614,19 +633,19 @@ export async function createVerificationJob({ actorUid, payload }) { export async function getVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); return toPublicJob(job); } const job = await loadJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); return toPublicJob(job); } export async function reviewVerificationJob(verificationId, actorUid, review) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, @@ -668,7 +687,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { } const job = result.rows[0]; - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (HUMAN_TERMINAL_STATUSES.has(job.status)) { throw new AppError('CONFLICT', 'Verification already finalized', 409, { verificationId, @@ -735,7 +754,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) { export async function retryVerificationJob(verificationId, actorUid) { if (useMemoryStore()) { const job = loadMemoryJob(verificationId); - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, @@ -774,7 +793,7 @@ export async function retryVerificationJob(verificationId, actorUid) { } const job = result.rows[0]; - assertAccess(job, actorUid); + await assertAccess(job, actorUid); if (job.status === VerificationStatus.PROCESSING) { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { verificationId, diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index f2193843..fafed45b 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -3,7 +3,11 @@ import assert from 'node:assert/strict'; import request from 'supertest'; import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; -import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; +import { + __resetVerificationJobsForTests, + createVerificationJob, + getVerificationJob, +} from '../src/services/verification-jobs.js'; beforeEach(async () => { process.env.AUTH_BYPASS = 'true'; @@ -13,7 +17,7 @@ beforeEach(async () => { process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; - process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; + process.env.VERIFICATION_ACCESS_MODE = 'tenant'; process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; process.env.VERIFICATION_STORE = 'memory'; __resetLlmRateLimitForTests(); @@ -66,6 +70,16 @@ test('GET /readyz reports database not configured when env is absent', async () assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when unsafe core flags are enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('POST /core/create-signed-url requires auth', async () => { process.env.AUTH_BYPASS = 'false'; const app = createApp(); @@ -404,3 +418,24 @@ test('POST /core/verifications/:id/retry requeues verification', async () => { assert.equal(retried.status, 202); assert.equal(retried.body.status, 'PENDING'); }); + +test('verification access is denied to a different actor by default', async () => { + const created = await createVerificationJob({ + actorUid: 'owner-user', + payload: { + type: 'attire', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/owner-user/attire.jpg', + rules: { attireType: 'shoes' }, + }, + }); + + await assert.rejects( + () => getVerificationJob(created.verificationId, 'foreign-user'), + (error) => { + assert.equal(error.code, 'FORBIDDEN'); + return true; + } + ); +}); diff --git a/backend/core-api/test/policy.test.js b/backend/core-api/test/policy.test.js new file mode 100644 index 00000000..d4beef5d --- /dev/null +++ b/backend/core-api/test/policy.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('core actions require tenant scope', async () => { + const allowed = await can( + 'core.verification.read', + 'verification', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + {} + ); + + const denied = await can( + 'core.verification.read', + 'verification', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + }, + }, + {} + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); diff --git a/backend/query-api/src/app.js b/backend/query-api/src/app.js index 43ff81da..1e363455 100644 --- a/backend/query-api/src/app.js +++ b/backend/query-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createQueryRouter } from './routes/query.js'; import { createMobileQueryRouter } from './routes/mobile.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/query-api/src/lib/runtime-safety.js b/backend/query-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..4b45bf08 --- /dev/null +++ b/backend/query-api/src/lib/runtime-safety.js @@ -0,0 +1,17 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + if (process.env.AUTH_BYPASS === 'true') { + throw new Error(`Unsafe query-api runtime config for ${runtimeEnvName()}: AUTH_BYPASS must be disabled`); + } +} diff --git a/backend/query-api/src/middleware/auth.js b/backend/query-api/src/middleware/auth.js index 9c62c86d..d6fa04f8 100644 --- a/backend/query-api/src/middleware/auth.js +++ b/backend/query-api/src/middleware/auth.js @@ -9,6 +9,30 @@ function getBearerToken(header) { return token; } +function buildBypassActor() { + let policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + + if (process.env.AUTH_BYPASS_CONTEXT) { + try { + policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT); + } catch (_error) { + policyContext = { + user: { userId: 'test-user' }, + tenant: { tenantId: '*' }, + business: { businessId: '*' }, + staff: { staffId: '*', workforceId: '*' }, + }; + } + } + + return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext }; +} + export async function requireAuth(req, _res, next) { try { const token = getBearerToken(req.get('Authorization')); @@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) { } if (process.env.AUTH_BYPASS === 'true') { - req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + req.actor = buildBypassActor(); return next(); } @@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) { } export function requirePolicy(action, resource) { - return (req, _res, next) => { - if (!can(action, resource, req.actor)) { - return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + return async (req, _res, next) => { + try { + if (!(await can(action, resource, req.actor, req))) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + } catch (error) { + return next(error); } - return next(); }; } diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js index 947c2bc1..31bbd090 100644 --- a/backend/query-api/src/routes/mobile.js +++ b/backend/query-api/src/routes/mobile.js @@ -44,6 +44,7 @@ import { listHubs, listIndustries, listInvoiceHistory, + listAvailableOrders, listOpenShifts, listTaxForms, listTimeCardEntries, @@ -113,6 +114,7 @@ const defaultQueryService = { listHubs, listIndustries, listInvoiceHistory, + listAvailableOrders, listOpenShifts, listTaxForms, listTimeCardEntries, @@ -355,9 +357,20 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/client/shifts/scheduled', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { try { const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); + res.set('Deprecation', 'true'); + res.set('Link', '; rel="successor-version"'); return res.status(200).json({ items, requestId: req.requestId }); } catch (error) { return next(error); @@ -544,6 +557,15 @@ export function createMobileQueryRouter(queryService = defaultQueryService) { } }); + router.get('/staff/orders/available', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listAvailableOrders(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { try { const items = await queryService.listPendingAssignments(req.actor.uid); diff --git a/backend/query-api/src/routes/query.js b/backend/query-api/src/routes/query.js index 5fe33090..46109012 100644 --- a/backend/query-api/src/routes/query.js +++ b/backend/query-api/src/routes/query.js @@ -27,6 +27,11 @@ function requireUuid(value, field) { export function createQueryRouter(queryService = defaultQueryService) { const router = Router(); + function actorBusinessId(actor) { + const businessId = actor?.policyContext?.business?.businessId; + return businessId && businessId !== '*' ? businessId : null; + } + router.get( '/tenants/:tenantId/orders', requireAuth, @@ -34,9 +39,10 @@ export function createQueryRouter(queryService = defaultQueryService) { async (req, res, next) => { try { const tenantId = requireUuid(req.params.tenantId, 'tenantId'); + const scopedBusinessId = actorBusinessId(req.actor); const orders = await queryService.listOrders({ tenantId, - businessId: req.query.businessId, + businessId: scopedBusinessId || req.query.businessId, status: req.query.status, limit: req.query.limit, offset: req.query.offset, @@ -57,10 +63,16 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('orders.read', 'order'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const order = await queryService.getOrderDetail({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), orderId: requireUuid(req.params.orderId, 'orderId'), }); + if (scopedBusinessId && order.businessId !== scopedBusinessId) { + throw new AppError('FORBIDDEN', 'Order is outside actor business scope', 403, { + orderId: req.params.orderId, + }); + } return res.status(200).json({ ...order, requestId: req.requestId, @@ -77,9 +89,10 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('business.favorite-staff.read', 'staff'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const items = await queryService.listFavoriteStaff({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), - businessId: requireUuid(req.params.businessId, 'businessId'), + businessId: requireUuid(scopedBusinessId || req.params.businessId, 'businessId'), limit: req.query.limit, offset: req.query.offset, }); @@ -120,12 +133,19 @@ export function createQueryRouter(queryService = defaultQueryService) { requirePolicy('attendance.read', 'attendance'), async (req, res, next) => { try { + const scopedBusinessId = actorBusinessId(req.actor); const attendance = await queryService.getAssignmentAttendance({ tenantId: requireUuid(req.params.tenantId, 'tenantId'), assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'), }); + if (scopedBusinessId && attendance.businessId !== scopedBusinessId) { + throw new AppError('FORBIDDEN', 'Assignment attendance is outside actor business scope', 403, { + assignmentId: req.params.assignmentId, + }); + } + const { businessId: _businessId, ...publicAttendance } = attendance; return res.status(200).json({ - ...attendance, + ...publicAttendance, requestId: req.requestId, }); } catch (error) { diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js index e8942dff..9ed9ae15 100644 --- a/backend/query-api/src/services/mobile-query-service.js +++ b/backend/query-api/src/services/mobile-query-service.js @@ -60,6 +60,44 @@ function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } +function resolveTimeZone(value) { + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value || 'UTC' }).resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +function formatDateInTimeZone(value, timeZone = 'UTC') { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: resolveTimeZone(timeZone), + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(value)); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; +} + +function formatTimeInTimeZone(value, timeZone = 'UTC') { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: resolveTimeZone(timeZone), + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(new Date(value)); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.hour}:${map.minute}`; +} + +function weekdayCodeInTimeZone(value, timeZone = 'UTC') { + const label = new Intl.DateTimeFormat('en-US', { + timeZone: resolveTimeZone(timeZone), + weekday: 'short', + }).format(new Date(value)); + return label.slice(0, 3).toUpperCase(); +} + function computeReliabilityScore({ totalShifts, noShowCount, @@ -1011,6 +1049,189 @@ export async function listAssignedShifts(actorUid, { startDate, endDate }) { return result.rows; } +export async function listAvailableOrders(actorUid, { limit, search } = {}) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + o.id AS "orderId", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + COALESCE(sr.role_id, rc.id) AS "roleId", + COALESCE(sr.role_code, rc.code) AS "roleCode", + COALESCE(sr.role_name, rc.name) AS "roleName", + b.business_name AS "clientName", + COALESCE(cp.label, s.location_name) AS location, + COALESCE(s.location_address, cp.address) AS "locationAddress", + COALESCE(s.timezone, 'UTC') AS timezone, + s.id AS "shiftId", + s.status AS "shiftStatus", + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + sr.workers_needed AS "requiredWorkerCount", + sr.assigned_count AS "filledCount", + COALESCE(sr.pay_rate_cents, 0)::INTEGER AS "hourlyRateCents", + COALESCE((sr.metadata->>'instantBook')::boolean, FALSE) AS "instantBook", + COALESCE(dispatch.team_type, 'MARKETPLACE') AS "dispatchTeam", + COALESCE(dispatch.priority, 3) AS "dispatchPriority" + FROM orders o + JOIN shifts s ON s.order_id = o.id + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN roles_catalog rc + ON rc.tenant_id = o.tenant_id + AND (rc.id = sr.role_id OR (sr.role_id IS NULL AND rc.code = sr.role_code)) + JOIN businesses b ON b.id = o.business_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN LATERAL ( + SELECT + dtm.team_type, + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END AS priority + FROM dispatch_team_memberships dtm + WHERE dtm.tenant_id = $1 + AND dtm.business_id = s.business_id + AND dtm.staff_id = $3 + AND dtm.status = 'ACTIVE' + AND dtm.effective_at <= NOW() + AND (dtm.expires_at IS NULL OR dtm.expires_at > NOW()) + AND (dtm.hub_id IS NULL OR dtm.hub_id = s.clock_point_id) + ORDER BY + CASE dtm.team_type + WHEN 'CORE' THEN 1 + WHEN 'CERTIFIED_LOCATION' THEN 2 + ELSE 3 + END ASC, + CASE WHEN dtm.hub_id = s.clock_point_id THEN 0 ELSE 1 END ASC, + dtm.created_at ASC + LIMIT 1 + ) dispatch ON TRUE + WHERE o.tenant_id = $1 + AND s.starts_at > NOW() + AND COALESCE(sr.role_code, rc.code) = $4 + AND ($2::text IS NULL OR COALESCE(sr.role_name, rc.name) ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%' OR b.business_name ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM staff_blocks sb + WHERE sb.tenant_id = o.tenant_id + AND sb.business_id = o.business_id + AND sb.staff_id = $3 + ) + AND NOT EXISTS ( + SELECT 1 + FROM applications a + JOIN shifts sx ON sx.id = a.shift_id + WHERE sx.order_id = o.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + ) + AND NOT EXISTS ( + SELECT 1 + FROM assignments a + JOIN shifts sx ON sx.id = a.shift_id + WHERE sx.order_id = o.id + AND a.staff_id = $3 + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ) + ORDER BY COALESCE(dispatch.priority, 3) ASC, s.starts_at ASC + LIMIT $5 + `, + [ + context.tenant.tenantId, + search || null, + context.staff.staffId, + context.staff.primaryRole || 'BARISTA', + parseLimit(limit, 50, 250), + ] + ); + + const grouped = new Map(); + for (const row of result.rows) { + const key = `${row.orderId}:${row.roleId}`; + const existing = grouped.get(key) || { + orderId: row.orderId, + orderType: row.orderType, + roleId: row.roleId, + roleCode: row.roleCode, + roleName: row.roleName, + clientName: row.clientName, + location: row.location, + locationAddress: row.locationAddress, + hourlyRateCents: row.hourlyRateCents, + hourlyRate: Number((row.hourlyRateCents / 100).toFixed(2)), + requiredWorkerCount: row.requiredWorkerCount, + filledCount: row.filledCount, + instantBook: Boolean(row.instantBook), + dispatchTeam: row.dispatchTeam, + dispatchPriority: row.dispatchPriority, + timezone: resolveTimeZone(row.timezone), + shifts: [], + }; + existing.requiredWorkerCount = Math.max(existing.requiredWorkerCount, row.requiredWorkerCount); + existing.filledCount = Math.max(existing.filledCount, row.filledCount); + existing.instantBook = existing.instantBook && Boolean(row.instantBook); + existing.dispatchPriority = Math.min(existing.dispatchPriority, row.dispatchPriority); + existing.dispatchTeam = existing.dispatchPriority === 1 + ? 'CORE' + : existing.dispatchPriority === 2 + ? 'CERTIFIED_LOCATION' + : 'MARKETPLACE'; + existing.shifts.push({ + shiftId: row.shiftId, + shiftStatus: row.shiftStatus, + startsAt: row.startsAt, + endsAt: row.endsAt, + }); + grouped.set(key, existing); + } + + return Array.from(grouped.values()) + .filter((item) => item.shifts.length > 0) + .filter((item) => item.shifts.every((shift) => shift.shiftStatus === 'OPEN')) + .filter((item) => item.filledCount < item.requiredWorkerCount) + .sort((left, right) => { + if (left.dispatchPriority !== right.dispatchPriority) { + return left.dispatchPriority - right.dispatchPriority; + } + return new Date(left.shifts[0].startsAt).getTime() - new Date(right.shifts[0].startsAt).getTime(); + }) + .slice(0, parseLimit(limit, 20, 100)) + .map((item) => { + const firstShift = item.shifts[0]; + const lastShift = item.shifts[item.shifts.length - 1]; + const daysOfWeek = [...new Set(item.shifts.map((shift) => weekdayCodeInTimeZone(shift.startsAt, item.timezone)))]; + return { + orderId: item.orderId, + orderType: item.orderType, + roleId: item.roleId, + roleCode: item.roleCode, + roleName: item.roleName, + clientName: item.clientName, + location: item.location, + locationAddress: item.locationAddress, + hourlyRateCents: item.hourlyRateCents, + hourlyRate: item.hourlyRate, + requiredWorkerCount: item.requiredWorkerCount, + filledCount: item.filledCount, + instantBook: item.instantBook, + dispatchTeam: item.dispatchTeam, + dispatchPriority: item.dispatchPriority, + schedule: { + totalShifts: item.shifts.length, + startDate: formatDateInTimeZone(firstShift.startsAt, item.timezone), + endDate: formatDateInTimeZone(lastShift.startsAt, item.timezone), + daysOfWeek, + startTime: formatTimeInTimeZone(firstShift.startsAt, item.timezone), + endTime: formatTimeInTimeZone(firstShift.endsAt, item.timezone), + timezone: item.timezone, + firstShiftStartsAt: firstShift.startsAt, + lastShiftEndsAt: lastShift.endsAt, + }, + }; + }); +} + export async function listOpenShifts(actorUid, { limit, search } = {}) { const context = await requireStaffContext(actorUid); const result = await query( diff --git a/backend/query-api/src/services/policy.js b/backend/query-api/src/services/policy.js index 44e7e371..22e3d9c9 100644 --- a/backend/query-api/src/services/policy.js +++ b/backend/query-api/src/services/policy.js @@ -1,5 +1,118 @@ -export function can(action, resource, actor) { - void action; - void resource; - return Boolean(actor?.uid); +import { loadActorContext } from './actor-context.js'; + +const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']); + +function normalize(value) { + return `${value || ''}`.trim(); +} + +function requestField(req, field) { + return normalize( + req?.params?.[field] + ?? req?.body?.[field] + ?? req?.query?.[field] + ); +} + +function isTenantAdmin(context) { + return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase()); +} + +function hasTenantScope(context) { + return Boolean(context?.user && context?.tenant); +} + +function hasClientScope(context) { + return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context)); +} + +function hasStaffScope(context) { + return hasTenantScope(context) && Boolean(context?.staff); +} + +function requiredScopeFor(action) { + if (action === 'attendance.read') { + return 'tenant'; + } + + if ( + action === 'orders.read' + || action === 'orders.reorder.read' + || action === 'business.favorite-staff.read' + || action === 'staff.reviews.read' + || action.startsWith('client.') + || action.startsWith('billing.') + || action.startsWith('coverage.') + || action.startsWith('hubs.') + || action.startsWith('vendors.') + || action.startsWith('reports.') + ) { + return 'client'; + } + + if ( + action === 'shifts.read' + || action.startsWith('staff.') + || action.startsWith('payments.') + ) { + return 'staff'; + } + + return 'deny'; +} + +async function resolveActorContext(actor) { + if (!actor?.uid) { + return null; + } + if (actor.policyContext) { + return actor.policyContext; + } + const context = await loadActorContext(actor.uid); + actor.policyContext = context; + return context; +} + +function requestScopeMatches(req, context, requiredScope) { + const tenantId = requestField(req, 'tenantId'); + if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) { + return false; + } + + const businessId = requestField(req, 'businessId'); + if ( + requiredScope === 'client' + && businessId + && context?.business?.businessId + && context.business.businessId !== '*' + && context.business.businessId !== businessId + ) { + return false; + } + + return true; +} + +export async function can(action, resource, actor, req) { + void resource; + const context = await resolveActorContext(actor); + const requiredScope = requiredScopeFor(action); + + if (requiredScope === 'deny' || !context?.user) { + return false; + } + + if (requiredScope === 'tenant') { + return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'client') { + return hasClientScope(context) && requestScopeMatches(req, context, requiredScope); + } + + if (requiredScope === 'staff') { + return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope); + } + + return false; } diff --git a/backend/query-api/src/services/query-service.js b/backend/query-api/src/services/query-service.js index 02a5e795..54db3274 100644 --- a/backend/query-api/src/services/query-service.js +++ b/backend/query-api/src/services/query-service.js @@ -233,6 +233,7 @@ export async function getAssignmentAttendance({ tenantId, assignmentId }) { SELECT a.id AS "assignmentId", a.status, + a.business_id AS "businessId", a.shift_id AS "shiftId", a.staff_id AS "staffId", s.title AS "shiftTitle", diff --git a/backend/query-api/test/app.test.js b/backend/query-api/test/app.test.js index f2a5e9d7..35444559 100644 --- a/backend/query-api/test/app.test.js +++ b/backend/query-api/test/app.test.js @@ -37,6 +37,20 @@ test('GET /readyz reports database not configured when no database env is presen assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test.afterEach(() => { + delete process.env.AUTH_BYPASS_CONTEXT; +}); + +test('createApp fails fast in protected env when auth bypass is enabled', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('GET unknown route returns not found envelope', async () => { const app = createApp(); const res = await request(app).get('/query/unknown'); @@ -124,3 +138,28 @@ test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validat assert.equal(res.status, 200); assert.equal(res.body.items[0].staffId, staffId); }); + +test('GET /query/tenants/:tenantId/orders denies mismatched tenant scope before handler execution', async () => { + process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({ + user: { userId: 'test-user' }, + tenant: { tenantId: '99999999-9999-4999-8999-999999999999', role: 'MANAGER' }, + business: { businessId }, + }); + + const app = createApp({ + queryService: { + listOrders: async () => assert.fail('listOrders should not be called'), + getOrderDetail: async () => assert.fail('getOrderDetail should not be called'), + listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'), + getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'), + getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'), + }, + }); + + const res = await request(app) + .get(`/query/tenants/${tenantId}/orders`) + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js index f810029d..428163de 100644 --- a/backend/query-api/test/mobile-routes.test.js +++ b/backend/query-api/test/mobile-routes.test.js @@ -48,6 +48,7 @@ function createMobileQueryService() { listHubs: async () => ([{ hubId: 'hub-1' }]), listIndustries: async () => (['CATERING']), listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), + listAvailableOrders: async () => ([{ orderId: 'order-available-1', roleId: 'role-catalog-1' }]), listOpenShifts: async () => ([{ shiftId: 'open-1' }]), getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }), listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]), @@ -123,6 +124,39 @@ test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () assert.equal(res.body.shiftId, 'shift-1'); }); +test('GET /query/staff/orders/available returns injected order-level opportunities', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/orders/available') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].orderId, 'order-available-1'); + assert.equal(res.body.items[0].roleId, 'role-catalog-1'); +}); + +test('GET /query/client/shifts/scheduled returns injected shift timeline items', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/shifts/scheduled?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].itemId, 'item-1'); +}); + +test('GET /query/client/orders/view remains as deprecated compatibility alias', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/orders/view?startDate=2026-03-13T00:00:00.000Z&endDate=2026-03-20T00:00:00.000Z') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.headers.deprecation, 'true'); + assert.equal(res.headers.link, '; rel="successor-version"'); + assert.equal(res.body.items[0].itemId, 'item-1'); +}); + test('GET /query/client/reports/summary returns injected report summary', async () => { const app = createApp({ mobileQueryService: createMobileQueryService() }); const res = await request(app) diff --git a/backend/query-api/test/policy.test.js b/backend/query-api/test/policy.test.js new file mode 100644 index 00000000..0ac9c357 --- /dev/null +++ b/backend/query-api/test/policy.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { can } from '../src/services/policy.js'; + +test('orders.read requires client scope and matching tenant/business scope', async () => { + const allowed = await can( + 'orders.read', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { params: { tenantId: 'tenant-1' }, query: { businessId: 'business-1' } } + ); + + const denied = await can( + 'orders.read', + 'order', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1', role: 'MANAGER' }, + business: { businessId: 'business-1' }, + }, + }, + { params: { tenantId: 'tenant-2' }, query: { businessId: 'business-1' } } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('shifts.read requires staff scope', async () => { + const allowed = await can( + 'shifts.read', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + staff: { staffId: 'staff-1' }, + }, + }, + { params: {} } + ); + + const denied = await can( + 'shifts.read', + 'shift', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + business: { businessId: 'business-1' }, + }, + }, + { params: {} } + ); + + assert.equal(allowed, true); + assert.equal(denied, false); +}); + +test('attendance.read allows tenant-scoped actor', async () => { + const allowed = await can( + 'attendance.read', + 'attendance', + { + uid: 'user-1', + policyContext: { + user: { userId: 'user-1' }, + tenant: { tenantId: 'tenant-1' }, + }, + }, + { params: { tenantId: 'tenant-1' } } + ); + + assert.equal(allowed, true); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs index d3080cb4..f67e2503 100644 --- a/backend/unified-api/scripts/live-smoke-v2-unified.mjs +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -449,6 +449,16 @@ async function main() { } logStep('client.orders.view.ok', { count: viewedOrders.items.length }); + const scheduledShifts = await apiCall(`/client/shifts/scheduled?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(scheduledShifts.items)); + assert.equal(scheduledShifts.items.length, viewedOrders.items.length); + if (viewedOrders.items[0] && scheduledShifts.items[0]) { + assert.equal(scheduledShifts.items[0].itemId, viewedOrders.items[0].itemId); + } + logStep('client.shifts.scheduled.ok', { count: scheduledShifts.items.length }); + const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { token: ownerSession.sessionToken, }); @@ -814,6 +824,33 @@ async function main() { assert.ok(Array.isArray(assignedShifts.items)); logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length }); + const availableOrders = await apiCall('/staff/orders/available?limit=20', { + token: staffAuth.idToken, + }); + const availableOrder = availableOrders.items.find((item) => item.orderId === createdRecurringOrder.orderId) + || availableOrders.items[0]; + assert.ok(availableOrder); + assert.ok(availableOrder.roleId); + logStep('staff.orders.available.ok', { count: availableOrders.items.length, orderId: availableOrder.orderId }); + + const bookedOrder = await apiCall(`/staff/orders/${availableOrder.orderId}/book`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-order-book'), + body: { + roleId: availableOrder.roleId, + }, + }); + assert.equal(bookedOrder.orderId, availableOrder.orderId); + assert.ok(bookedOrder.assignedShiftCount >= 1); + assert.equal(bookedOrder.status, 'PENDING'); + assert.ok(Array.isArray(bookedOrder.assignedShifts)); + logStep('staff.orders.book.ok', { + orderId: bookedOrder.orderId, + assignedShiftCount: bookedOrder.assignedShiftCount, + status: bookedOrder.status, + }); + const openShifts = await apiCall('/staff/shifts/open', { token: staffAuth.idToken, }); @@ -827,6 +864,9 @@ async function main() { const pendingShifts = await apiCall('/staff/shifts/pending', { token: staffAuth.idToken, }); + assert.ok( + bookedOrder.assignedShifts.some((shift) => pendingShifts.items.some((item) => item.shiftId === shift.shiftId)) + ); const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id) || pendingShifts.items[0]; assert.ok(pendingShift); diff --git a/backend/unified-api/src/app.js b/backend/unified-api/src/app.js index a30f8657..82d23bec 100644 --- a/backend/unified-api/src/app.js +++ b/backend/unified-api/src/app.js @@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createAuthRouter } from './routes/auth.js'; import { createProxyRouter } from './routes/proxy.js'; +import { assertSafeRuntimeConfig } from './lib/runtime-safety.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); export function createApp(options = {}) { + assertSafeRuntimeConfig(); const app = express(); app.use(requestContext); diff --git a/backend/unified-api/src/lib/runtime-safety.js b/backend/unified-api/src/lib/runtime-safety.js new file mode 100644 index 00000000..587d8666 --- /dev/null +++ b/backend/unified-api/src/lib/runtime-safety.js @@ -0,0 +1,35 @@ +function runtimeEnvName() { + return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase(); +} + +function isProtectedEnv() { + return ['staging', 'prod', 'production'].includes(runtimeEnvName()); +} + +export function assertSafeRuntimeConfig() { + if (!isProtectedEnv()) { + return; + } + + const errors = []; + + if (process.env.AUTH_BYPASS === 'true') { + errors.push('AUTH_BYPASS must be disabled'); + } + + if (!process.env.CORE_API_BASE_URL) { + errors.push('CORE_API_BASE_URL is required'); + } + + if (!process.env.COMMAND_API_BASE_URL) { + errors.push('COMMAND_API_BASE_URL is required'); + } + + if (!process.env.QUERY_API_BASE_URL) { + errors.push('QUERY_API_BASE_URL is required'); + } + + if (errors.length > 0) { + throw new Error(`Unsafe unified-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`); + } +} diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js index 02c42355..fb271c47 100644 --- a/backend/unified-api/test/app.test.js +++ b/backend/unified-api/test/app.test.js @@ -29,6 +29,19 @@ test('GET /readyz reports database not configured when env is absent', async () assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); }); +test('createApp fails fast in protected env when upstream config is unsafe', async () => { + process.env.APP_ENV = 'staging'; + process.env.AUTH_BYPASS = 'true'; + delete process.env.CORE_API_BASE_URL; + delete process.env.COMMAND_API_BASE_URL; + delete process.env.QUERY_API_BASE_URL; + + assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/); + + delete process.env.APP_ENV; + process.env.AUTH_BYPASS = 'true'; +}); + test('POST /auth/client/sign-in validates payload', async () => { const app = createApp(); const res = await request(app).post('/auth/client/sign-in').send({ diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md index 1b812994..4d8eb6d6 100644 --- a/docs/BACKEND/API_GUIDES/V2/README.md +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -7,7 +7,7 @@ This is the frontend-facing source of truth for the v2 backend. Frontend should call one public gateway: ```env -API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app ``` Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. @@ -95,14 +95,12 @@ Source-of-truth timestamp fields include: - `startsAt` - `endsAt` -- `startTime` -- `endTime` - `clockInAt` - `clockOutAt` - `createdAt` - `updatedAt` -Helper fields like `date` are UTC-derived helpers and should not replace the raw timestamp fields. +Helper fields like `date`, `startTime`, and `endTime` are display helpers and should not replace the raw timestamp fields. ## 4) Attendance policy and monitoring diff --git a/docs/BACKEND/API_GUIDES/V2/authentication.md b/docs/BACKEND/API_GUIDES/V2/authentication.md index c45cf5de..b644210f 100644 --- a/docs/BACKEND/API_GUIDES/V2/authentication.md +++ b/docs/BACKEND/API_GUIDES/V2/authentication.md @@ -5,7 +5,7 @@ This document is the source of truth for V2 authentication. Base URL: ```env -API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +API_V2_BASE_URL=https://krow-api-v2-e3g6witsvq-uc.a.run.app ``` ## 1) What is implemented diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md index b594eb5b..977b8fa8 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -22,7 +22,7 @@ That includes: The live smoke executed successfully against: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` - Firebase demo users - `krow-sql-v2` - `krow-core-api-v2` diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md index cb3f91d5..a7a2f940 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-coding-agent-spec.md @@ -6,7 +6,7 @@ Use this as the primary implementation brief. Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` Supporting docs: @@ -23,6 +23,7 @@ Supporting docs: - Send `Idempotency-Key` on every write route. - Treat `order`, `shift`, `shiftRole`, and `assignment` as different objects. - For staff shift applications, `roleId` must come from the response of `GET /staff/shifts/open`. +- For staff order booking, `roleId` must come from the response of `GET /staff/orders/available`. - Treat API timestamp fields as UTC and convert them to local time in the app. ## 2) What is implemented now @@ -90,8 +91,10 @@ Do not assume staff auth is a fully backend-managed OTP flow. Rules: - `GET /staff/shifts/open` returns opportunities, not assignments +- `GET /staff/orders/available` returns grouped order opportunities for booking - `GET /staff/shifts/assigned` returns active assigned shifts -- `GET /client/orders/view` is the timeline/read model for client +- `GET /client/shifts/scheduled` is the canonical timeline/read model for client +- `GET /client/orders/view` is now a deprecated compatibility alias - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` apply to future shifts only ## 5) Client app screen mapping @@ -165,7 +168,8 @@ Swap management flow: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -230,12 +234,17 @@ Important: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- use `roleId` from the open-shifts response +- use `roleId` from the order-available response when booking an order +- that `roleId` is the role catalog id for the grouped order booking flow +- use `roleId` from the open-shifts response only for shift-level apply +- that `roleId` is the concrete `shift_roles.id` ### My shifts diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md index a72f198a..a4847d66 100644 --- a/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md +++ b/docs/BACKEND/API_GUIDES/V2/mobile-frontend-implementation-spec.md @@ -4,7 +4,7 @@ This is the shortest path for frontend to implement the v2 mobile clients agains Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` Use this doc together with: @@ -30,7 +30,10 @@ Important consequences: - `GET /staff/shifts/open` returns open shift-role opportunities. - `POST /staff/shifts/:shiftId/apply` must send the `roleId` from that response. -- `GET /client/orders/view` is the timeline/read model for the client app. +- `GET /staff/orders/available` returns grouped order opportunities for atomic booking. +- `POST /staff/orders/:orderId/book` must send the `roleId` from that response. +- `GET /client/shifts/scheduled` is the canonical timeline/read model for the client app. +- `GET /client/orders/view` is a deprecated compatibility alias. - `POST /client/orders/:orderId/edit` and `POST /client/orders/:orderId/cancel` only affect future shifts. ## 3) Auth implementation @@ -122,7 +125,8 @@ Dispatch-priority rule: ### Orders -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated alias - `GET /client/orders/:orderId/reorder-preview` - `POST /client/orders/one-time` - `POST /client/orders/recurring` @@ -175,13 +179,17 @@ Rapid-order flow: ### Find shifts +- `GET /staff/orders/available` +- `POST /staff/orders/:orderId/book` - `GET /staff/shifts/open` - `POST /staff/shifts/:shiftId/apply` Rule: -- send the `roleId` from the open-shifts response -- this is the concrete `shift_roles.id` +- send the `roleId` from the order-available response when booking an order +- this `roleId` is the role catalog id for grouped order booking +- send the `roleId` from the open-shifts response only when applying to one shift +- that route still uses the concrete `shift_roles.id` ### My shifts diff --git a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md index 802cdf75..d881449d 100644 --- a/docs/BACKEND/API_GUIDES/V2/staff-shifts.md +++ b/docs/BACKEND/API_GUIDES/V2/staff-shifts.md @@ -4,10 +4,11 @@ This document is the frontend handoff for the `staff/shifts/*` routes on the uni Base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` ## Read routes +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -17,6 +18,7 @@ Base URL: ## Write routes +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline` @@ -30,6 +32,68 @@ All write routes require: ## Shift lifecycle +### Find work by order + +`GET /staff/orders/available` + +- use this for grouped recurring or permanent work cards +- each item represents one order plus one role +- this feed is already filtered to the current worker context +- `schedule` gives the preview for the whole booking window + +Example response: + +```json +{ + "orderId": "uuid", + "orderType": "RECURRING", + "roleId": "uuid", + "roleCode": "BARISTA", + "roleName": "Barista", + "clientName": "Google Mountain View Cafes", + "location": "Google MV Cafe Clock Point", + "locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA", + "hourlyRateCents": 2300, + "hourlyRate": 23, + "requiredWorkerCount": 1, + "filledCount": 0, + "instantBook": false, + "dispatchTeam": "CORE", + "dispatchPriority": 1, + "schedule": { + "totalShifts": 3, + "startDate": "2026-03-24", + "endDate": "2026-03-28", + "daysOfWeek": ["WED", "FRI"], + "startTime": "09:00", + "endTime": "15:00", + "timezone": "America/Los_Angeles", + "firstShiftStartsAt": "2026-03-25T16:00:00.000Z", + "lastShiftEndsAt": "2026-03-27T22:00:00.000Z" + } +} +``` + +`POST /staff/orders/:orderId/book` + +- use this when the worker books the full order instead of one shift +- booking is atomic across the future shifts in that order for the selected role +- backend returns `PENDING` when the booking is reserved but not instant-booked +- backend returns `CONFIRMED` when every future shift in that booking path is instant-booked + +Example request: + +```json +{ + "roleId": "uuid" +} +``` + +Important: + +- `roleId` for the order-booking flow is the role catalog id returned by `GET /staff/orders/available` +- it is not the same thing as the per-shift `shift_roles.id` + ### Find shifts `GET /staff/shifts/open` diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md index 06153c0f..dcbf40c7 100644 --- a/docs/BACKEND/API_GUIDES/V2/unified-api.md +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -2,7 +2,7 @@ Frontend should use this service as the single base URL: -- `https://krow-api-v2-933560802882.us-central1.run.app` +- `https://krow-api-v2-e3g6witsvq-uc.a.run.app` The gateway keeps backend services separate internally, but frontend should treat it as one API. @@ -54,7 +54,8 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `GET /client/vendors/:vendorId/roles` - `GET /client/hubs/:hubId/managers` - `GET /client/team-members` -- `GET /client/orders/view` +- `GET /client/shifts/scheduled` +- `GET /client/orders/view` deprecated compatibility alias - `GET /client/orders/:orderId/reorder-preview` - `GET /client/reports/summary` - `GET /client/reports/daily-ops` @@ -88,6 +89,12 @@ Full auth behavior, including staff phone flow and refresh rules, is documented - `POST /client/coverage/dispatch-teams/memberships` - `DELETE /client/coverage/dispatch-teams/memberships/:membershipId` +Timeline route naming: + +- `GET /client/shifts/scheduled` is the canonical client timeline route +- it returns shift-level scheduled items, not order headers +- `GET /client/orders/view` still returns the same payload for compatibility, but now emits a deprecation header + Coverage-review request payload may also send: ```json @@ -176,6 +183,7 @@ The manager is created as an invited business membership. If `hubId` is present, - `GET /staff/payments/summary` - `GET /staff/payments/history` - `GET /staff/payments/chart` +- `GET /staff/orders/available` - `GET /staff/shifts/assigned` - `GET /staff/shifts/open` - `GET /staff/shifts/pending` @@ -239,6 +247,14 @@ Example `GET /staff/profile/stats` response: } ``` +Order booking route notes: + +- `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work +- `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage +- `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role +- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow +- the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply + ### Staff writes - `POST /staff/profile/setup` @@ -249,6 +265,7 @@ Example `GET /staff/profile/stats` response: - `POST /staff/location-streams` - `PUT /staff/availability` - `POST /staff/availability/quick-set` +- `POST /staff/orders/:orderId/book` - `POST /staff/shifts/:shiftId/apply` - `POST /staff/shifts/:shiftId/accept` - `POST /staff/shifts/:shiftId/decline`