Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .github/workflows/v2-domain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: V2 domain contracts
on:
pull_request:
paths:
- 'shared/v2/**'
- 'server/v2/**'
- 'tests/v2/**'
- 'tsconfig.v2.json'
- 'package*.json'
- '.github/workflows/v2-domain.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
domain:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci --ignore-scripts
- run: npm run typecheck:v2
- run: npm run test:v2
- run: npm run build
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ lerna-debug.log*
node_modules
dist
dist-ssr
.v2-build
*.local

# Editor directories and files
Expand All @@ -27,4 +28,4 @@ dist-ssr
*.sln
*.sw?

.env
.env
36 changes: 36 additions & 0 deletions docs/v2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# HubCyS V2 domain foundation

This additive foundation establishes validated records and pure domain rules for evidence-backed readiness. It does not change live routes, legacy Firestore collections/rules, identity bootstrap, API authentication, billing, or production behavior.

## Included

- Strict V2 record and command contracts with tenant ownership, stable capability/threat identifiers, separate observations/evidence/findings, remediation, verification, retest, reporting, advisory and audit concepts.
- Deterministic exercise/capability scoring with versioned policy, published criterion manifests, retained inputs and input digest. Unknown or ineligible evidence withholds an unqualified score; advisory, legacy and simulated-artifact records cannot count as participant-performance evidence.
- Central command authorization policy for organization membership, exercise assignments, ownership, independent verification and explicitly scoped provider-customer grants.
- Exercise/remediation state invariant guards and comparable persistent-gap detection. Completion remains separate from verification and does not alter readiness.
- Isolated strict TypeScript compilation and Node tests without Firebase, provider calls, customer records or secrets.

## Run

```sh
npm ci --ignore-scripts
npm run typecheck:v2
npm run test:v2
npm run build
```

Compilation writes temporary test output to the ignored `.v2-build` directory. Existing root lint/typecheck remain separate and must not be represented as repaired by this change.

## Integration boundary

These modules are not exposed as browser APIs and are not wired into legacy workflows. An integration must verify the identity token, resolve memberships/grants/assignments from authoritative server storage, validate all parent-child references, and call the policy before privileged database operations.

The scorer expects the entire published criterion and expected-action manifests, not a caller-selected successful subset. It returns `inputs` separately from aggregate `result`/`capabilities`; persist individual input records and immutable run/result records transactionally, rather than storing only a percentage or a growing JSON blob.

`transitionRemediation` checks local state/evidence/verification invariants but does not establish that a retest executed successfully. A later verification command must validate the retest request, baseline-to-retest obligation mapping, accepted outcomes, comparability, complete finding obligations and verifier authorization before using this guard; never accept a Verification record supplied by a browser.

`authorizeCommand` is an authorization primitive, not JWT verification. Its principal and record arguments are trusted server inputs only; no existing endpoint or Firestore rule becomes protected merely by importing this module.

## Still required

Server identity verification, authoritative membership provisioning, Firestore/Storage policies and emulator tests, transactional commands/audit/outbox, durable exercise runtime, real participant responses, file ingestion, complete verification/retest services, organization readiness projection, reports, migration tooling, and environment-matched end-to-end acceptance remain subsequent review units. No production-readiness claim is made by the unit suite.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"typecheck": "tsc -p ./jsconfig.json",
"typecheck:v2": "tsc -p tsconfig.v2.json --noEmit",
"test:v2": "tsc -p tsconfig.v2.json && node --test .v2-build/tests/v2/*.test.js",
"preview": "vite preview"
},
"dependencies": {
Expand Down
88 changes: 88 additions & 0 deletions server/v2/authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { z } from 'zod';
import {
Id, Instant, Membership, Permission, ProviderGrant, ExerciseParticipant,
ReportClass, type PermissionName,
} from '../../shared/v2/contracts.js';

const permissionSets: Record<z.infer<typeof Membership>['roles'][number], readonly PermissionName[]> = {
organization_admin: ['organization:manage', 'exercise:create', 'exercise:start', 'inject:release', 'response:submit',
'observation:accept', 'remediation:assign', 'remediation:update', 'verification:record', 'report:read', 'report:export'],
readiness_lead: ['exercise:create', 'exercise:start', 'inject:release', 'response:submit', 'observation:accept',
'remediation:assign', 'remediation:update', 'verification:record', 'report:read', 'report:export'],
facilitator: ['exercise:start', 'inject:release', 'response:submit', 'remediation:update', 'report:read'],
evaluator: ['observation:accept', 'verification:record', 'response:submit', 'remediation:update', 'report:read'],
participant: ['response:submit', 'remediation:update'],
observer: ['report:read'],
auditor: ['report:read', 'report:export'],
};

const AuthorizationRequest = z.object({
// The caller of this library MUST derive principal_uid from a verified token.
principal_uid: Id,
organization_id: Id,
organization_status: z.enum(['active', 'suspended']),
permission: Permission,
now: Instant,
membership: Membership,
provider_grant: ProviderGrant.optional(),
exercise_id: Id.optional(),
exercise_participant: ExerciseParticipant.optional(),
action_owner_uid: Id.optional(),
verification_subject_owner_uid: Id.optional(),
report_class: ReportClass.optional(),
}).strict();

export class AuthorizationError extends Error {
constructor() { super('FORBIDDEN'); this.name = 'AuthorizationError'; }
}

/**
* Central policy primitive, not token authentication. Inputs must be loaded
* server-side. Never accept membership/grant/assignment objects from a browser.
*/
export function authorizeCommand(raw: unknown): void {
const parsed = AuthorizationRequest.safeParse(raw);
if (!parsed.success) throw new AuthorizationError();
const request = parsed.data;
const { membership, permission, principal_uid, organization_id, provider_grant: grant } = request;
const deny = () => { throw new AuthorizationError(); };
if (request.organization_status !== 'active' || membership.status !== 'active' || membership.uid !== principal_uid) deny();
if (membership.organization_id !== organization_id) {
if (!grant || grant.status !== 'active' || grant.organization_id !== organization_id ||
grant.provider_organization_id !== membership.organization_id || grant.subject_uid !== principal_uid ||
Date.parse(grant.expires_at) <= Date.parse(request.now) || !grant.permissions.includes(permission)) deny();
} else if (grant) {
// A customer-local member must not smuggle an unrelated provider grant.
deny();
}
if (!membership.roles.some(role => permissionSets[role].includes(permission))) deny();

const needsAssignment = ['exercise:start', 'inject:release', 'response:submit', 'observation:accept'].includes(permission);
if (needsAssignment) {
const assignment = request.exercise_participant;
if (!request.exercise_id || !assignment || assignment.exercise_id !== request.exercise_id ||
assignment.organization_id !== organization_id || assignment.uid !== principal_uid || assignment.status !== 'active') deny();
const requiredRole = permission === 'response:submit' ? 'participant'
: permission === 'observation:accept' ? 'evaluator' : 'facilitator';
if (!assignment?.roles.includes(requiredRole)) deny();
}
if (permission === 'remediation:update') {
const managesWork = membership.roles.includes('organization_admin') || membership.roles.includes('readiness_lead');
if (!managesWork && request.action_owner_uid !== principal_uid) deny();
}
if (permission === 'verification:record') {
if (!request.verification_subject_owner_uid || request.verification_subject_owner_uid === principal_uid) deny();
}
if (permission === 'report:read' || permission === 'report:export') {
if (!request.report_class) deny();
const broadReportAccess = membership.roles.some(role =>
['organization_admin', 'readiness_lead', 'auditor'].includes(role));
if (!broadReportAccess) {
if (membership.roles.includes('observer') && request.report_class === 'executive_readiness') return;
const assignment = request.exercise_participant;
if (request.report_class !== 'exercise_after_action' || !request.exercise_id || !assignment ||
assignment.organization_id !== organization_id || assignment.exercise_id !== request.exercise_id ||
assignment.uid !== principal_uid || assignment.status !== 'active') deny();
}
}
}
142 changes: 142 additions & 0 deletions server/v2/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { z } from 'zod';
import {
ActionState, Evidence, ExerciseState, Id, Instant, RemediationAction, Verification,
assertTenant, uniqueById,
} from '../../shared/v2/contracts.js';

const exerciseTransitions: Record<z.infer<typeof ExerciseState>, readonly z.infer<typeof ExerciseState>[]> = {
draft: ['scheduled', 'cancelled'],
scheduled: ['ready', 'cancelled'],
ready: ['running', 'cancelled'],
running: ['paused', 'review', 'aborted'],
paused: ['running', 'review', 'aborted'],
aborted: ['review'],
review: ['completed'],
completed: ['archived'],
archived: [],
cancelled: [],
};

export function assertExerciseTransition(from: unknown, to: unknown): void {
const current = ExerciseState.parse(from);
const next = ExerciseState.parse(to);
if (!exerciseTransitions[current].includes(next)) throw new Error('INVALID_EXERCISE_TRANSITION');
}

const actionTransitions: Record<z.infer<typeof ActionState>, readonly z.infer<typeof ActionState>[]> = {
open: ['assigned'],
assigned: ['in_progress'],
in_progress: ['blocked', 'ready_for_verification'],
blocked: ['in_progress'],
ready_for_verification: ['verified', 'failed_verification'],
failed_verification: ['in_progress'],
verified: ['closed'],
closed: [],
};
const verificationEvidenceKinds = new Set(['participant_response', 'facilitator_observation', 'system_event', 'file']);
const TransitionRequest = z.object({
action: RemediationAction,
next_state: ActionState,
expected_record_version: z.number().int().nonnegative(),
now: Instant,
actor_uid: Id,
evidence: z.array(Evidence),
verification: Verification.optional(),
}).strict();

/**
* State invariant guard only. Caller must authorize the command and, for a
* targeted retest, independently validate the linked retest outcome. This
* function neither creates verification records nor changes readiness scores.
*/
export function transitionRemediation(raw: unknown) {
const request = TransitionRequest.parse(raw);
const { action, next_state, verification, evidence } = request;
assertTenant(action.organization_id, [...evidence, ...(verification ? [verification] : [])]);
if (action.record_version !== request.expected_record_version) throw new Error('RECORD_VERSION_CONFLICT');
if (!actionTransitions[action.state].includes(next_state)) throw new Error('INVALID_REMEDIATION_TRANSITION');
if (!action.owner_uid) throw new Error('ACTION_OWNER_REQUIRED');
const evidenceMap = uniqueById(evidence);
const validEvidence = (ids: string[]) => ids.length > 0 && ids.every(id => {
const item = evidenceMap.get(id);
return item && item.status === 'accepted' && verificationEvidenceKinds.has(item.source_kind);
});
if (next_state === 'ready_for_verification' && !validEvidence(action.completion_evidence_ids)) {
throw new Error('COMPLETION_EVIDENCE_REQUIRED');
}
if (['verified', 'failed_verification', 'closed'].includes(next_state)) {
if (!verification || verification.action_id !== action.id ||
verification.verifier_uid === action.owner_uid ||
verification.verifier_uid !== request.actor_uid ||
verification.method !== action.verification_method ||
verification.finding_ids.length !== action.finding_ids.length ||
!action.finding_ids.every(id => verification.finding_ids.includes(id)) ||
!validEvidence(verification.evidence_ids)) throw new Error('VALID_INDEPENDENT_VERIFICATION_REQUIRED');
if (Date.parse(verification.completed_at) > Date.parse(request.now)) throw new Error('VERIFICATION_IN_FUTURE');
if (next_state === 'failed_verification' && verification.result !== 'fail') throw new Error('VERIFICATION_RESULT_MISMATCH');
if (next_state !== 'failed_verification' && verification.result !== 'pass') throw new Error('VERIFICATION_RESULT_MISMATCH');
if (verification.method === 'targeted_retest' &&
verification.evidence_ids.some(id => evidenceMap.get(id)?.exercise_id !== verification.retest_exercise_id)) {
throw new Error('RETEST_EVIDENCE_SCOPE_MISMATCH');
}
}
return {
action: {
...action,
state: next_state,
record_version: action.record_version + 1,
},
// Return an event description; persistence must append it transactionally.
transition: {
actor_uid: request.actor_uid,
occurred_at: request.now,
from: action.state,
to: next_state,
verification_id: verification?.id ?? null,
},
};
}

const Occurrence = z.object({
exercise_id: Id,
organization_id: Id,
capability_id: Id,
obligation_key: Id,
scope_key: Id,
context_version_id: Id,
scoring_policy_version: Id,
finalized_at: Instant,
outcome: z.enum(['pass', 'fail', 'unknown']),
}).strict();

/** Caller selects one comparable obligation cohort; never aggregate dissimilar scopes. */
export function persistentGap(raw: unknown) {
const records = z.array(Occurrence).parse(raw);
const ids = new Set<string>();
const cohort = (record: z.infer<typeof Occurrence>) => JSON.stringify([
record.organization_id, record.capability_id, record.obligation_key,
record.scope_key, record.context_version_id, record.scoring_policy_version,
]);
const first = records[0];
for (const record of records) {
if (ids.has(record.exercise_id)) throw new Error('DUPLICATE_EXERCISE_OCCURRENCE');
ids.add(record.exercise_id);
if (first && cohort(record) !== cohort(first)) throw new Error('NON_COMPARABLE_OCCURRENCES');
}
const ordered = [...records].sort((a, b) => {
const delta = Date.parse(a.finalized_at) - Date.parse(b.finalized_at);
return delta || a.exercise_id.localeCompare(b.exercise_id, 'en');
});
for (let i = 1; i < ordered.length; i++) {
const previous = ordered[i - 1]!;
const current = ordered[i]!;
if (Date.parse(previous.finalized_at) === Date.parse(current.finalized_at) &&
previous.outcome !== current.outcome) throw new Error('AMBIGUOUS_OCCURRENCE_ORDER');
}
const failures: string[] = [];
for (const record of ordered) {
if (record.outcome === 'pass') failures.length = 0;
else if (record.outcome === 'fail') failures.push(record.exercise_id);
}
return { persistent: failures.length >= 2, consecutive_failures: failures.length, exercise_ids: failures };
}
Loading
Loading