|
| 1 | +/* |
| 2 | +Toolbox Aid |
| 3 | +David Quesenberry |
| 4 | +06/02/2026 |
| 5 | +projectContract.js |
| 6 | +*/ |
| 7 | +import { |
| 8 | + IDENTITY_PERMISSION_SCOPES, |
| 9 | + IDENTITY_PERMISSIONS, |
| 10 | + IDENTITY_ROLES, |
| 11 | + IDENTITY_VISIBILITY_STATES, |
| 12 | + canActorPerformPermission, |
| 13 | + isIdentityPermission, |
| 14 | +} from "./identityPermissionsContract.js"; |
| 15 | + |
| 16 | +export const PROJECT_CONTRACT_ID = "gamefoundrystudio.project.lifecycle"; |
| 17 | +export const PROJECT_CONTRACT_VERSION = "1.0.0"; |
| 18 | + |
| 19 | +export const PROJECT_STATES = Object.freeze({ |
| 20 | + DRAFT: "draft", |
| 21 | + ACTIVE: "active", |
| 22 | + ARCHIVED: "archived", |
| 23 | + PUBLISHED: "published", |
| 24 | + MARKETPLACE: "marketplace", |
| 25 | + RETIRED: "retired", |
| 26 | +}); |
| 27 | + |
| 28 | +export const PROJECT_STATE_LIST = Object.freeze([ |
| 29 | + PROJECT_STATES.DRAFT, |
| 30 | + PROJECT_STATES.ACTIVE, |
| 31 | + PROJECT_STATES.ARCHIVED, |
| 32 | + PROJECT_STATES.PUBLISHED, |
| 33 | + PROJECT_STATES.MARKETPLACE, |
| 34 | + PROJECT_STATES.RETIRED, |
| 35 | +]); |
| 36 | + |
| 37 | +export const PROJECT_ROLES = Object.freeze({ |
| 38 | + OWNER: "owner", |
| 39 | + COLLABORATOR: "collaborator", |
| 40 | + VIEWER: "viewer", |
| 41 | +}); |
| 42 | + |
| 43 | +export const PROJECT_ROLE_LIST = Object.freeze([ |
| 44 | + PROJECT_ROLES.OWNER, |
| 45 | + PROJECT_ROLES.COLLABORATOR, |
| 46 | + PROJECT_ROLES.VIEWER, |
| 47 | +]); |
| 48 | + |
| 49 | +export const PROJECT_VISIBILITY_STATES = Object.freeze({ |
| 50 | + PRIVATE: "private", |
| 51 | + PROJECT: "project", |
| 52 | + UNLISTED: "unlisted", |
| 53 | + PUBLIC: "public", |
| 54 | +}); |
| 55 | + |
| 56 | +export const PROJECT_VISIBILITY_LIST = Object.freeze([ |
| 57 | + PROJECT_VISIBILITY_STATES.PRIVATE, |
| 58 | + PROJECT_VISIBILITY_STATES.PROJECT, |
| 59 | + PROJECT_VISIBILITY_STATES.UNLISTED, |
| 60 | + PROJECT_VISIBILITY_STATES.PUBLIC, |
| 61 | +]); |
| 62 | + |
| 63 | +export const PROJECT_RELATIONSHIPS = Object.freeze({ |
| 64 | + TOOL_STATES: "tool-states", |
| 65 | + ASSETS: "assets", |
| 66 | + PALETTES: "palettes", |
| 67 | + GAME_MANIFEST: "game-manifest", |
| 68 | + RELEASES: "releases", |
| 69 | + MARKETPLACE_ITEMS: "marketplace-items", |
| 70 | +}); |
| 71 | + |
| 72 | +export const PROJECT_RELATIONSHIP_LIST = Object.freeze([ |
| 73 | + PROJECT_RELATIONSHIPS.TOOL_STATES, |
| 74 | + PROJECT_RELATIONSHIPS.ASSETS, |
| 75 | + PROJECT_RELATIONSHIPS.PALETTES, |
| 76 | + PROJECT_RELATIONSHIPS.GAME_MANIFEST, |
| 77 | + PROJECT_RELATIONSHIPS.RELEASES, |
| 78 | + PROJECT_RELATIONSHIPS.MARKETPLACE_ITEMS, |
| 79 | +]); |
| 80 | + |
| 81 | +export const PROJECT_ROLE_PERMISSION_GRANTS = Object.freeze({ |
| 82 | + [PROJECT_ROLES.OWNER]: Object.freeze([ |
| 83 | + IDENTITY_PERMISSIONS.VIEW, |
| 84 | + IDENTITY_PERMISSIONS.CREATE, |
| 85 | + IDENTITY_PERMISSIONS.EDIT, |
| 86 | + IDENTITY_PERMISSIONS.DELETE, |
| 87 | + IDENTITY_PERMISSIONS.SHARE, |
| 88 | + IDENTITY_PERMISSIONS.PUBLISH, |
| 89 | + ]), |
| 90 | + [PROJECT_ROLES.COLLABORATOR]: Object.freeze([ |
| 91 | + IDENTITY_PERMISSIONS.VIEW, |
| 92 | + IDENTITY_PERMISSIONS.EDIT, |
| 93 | + ]), |
| 94 | + [PROJECT_ROLES.VIEWER]: Object.freeze([ |
| 95 | + IDENTITY_PERMISSIONS.VIEW, |
| 96 | + ]), |
| 97 | +}); |
| 98 | + |
| 99 | +export const PROJECT_INACTIVE_STATES = Object.freeze([ |
| 100 | + PROJECT_STATES.ARCHIVED, |
| 101 | + PROJECT_STATES.RETIRED, |
| 102 | +]); |
| 103 | + |
| 104 | +export const PROJECT_CONTRACT_ERRORS = Object.freeze({ |
| 105 | + OWNER_REQUIRED: "PROJECT_OWNER_REQUIRED", |
| 106 | + VISIBILITY_REQUIRED: "PROJECT_VISIBILITY_REQUIRED", |
| 107 | + VISIBILITY_INVALID: "PROJECT_VISIBILITY_INVALID", |
| 108 | + STATE_REQUIRED: "PROJECT_STATE_REQUIRED", |
| 109 | + STATE_INVALID: "PROJECT_STATE_INVALID", |
| 110 | + ROLE_INVALID: "PROJECT_ROLE_INVALID", |
| 111 | + RELATIONSHIP_INVALID: "PROJECT_RELATIONSHIP_INVALID", |
| 112 | +}); |
| 113 | + |
| 114 | +export function isProjectState(value) { |
| 115 | + return PROJECT_STATE_LIST.includes(value); |
| 116 | +} |
| 117 | + |
| 118 | +export function isProjectRole(value) { |
| 119 | + return PROJECT_ROLE_LIST.includes(value); |
| 120 | +} |
| 121 | + |
| 122 | +export function isProjectVisibility(value) { |
| 123 | + return PROJECT_VISIBILITY_LIST.includes(value); |
| 124 | +} |
| 125 | + |
| 126 | +export function isProjectRelationship(value) { |
| 127 | + return PROJECT_RELATIONSHIP_LIST.includes(value); |
| 128 | +} |
| 129 | + |
| 130 | +export function validateProjectContract(project) { |
| 131 | + const errors = []; |
| 132 | + |
| 133 | + if (!hasNonEmptyString(project?.ownerId)) { |
| 134 | + errors.push(createContractError( |
| 135 | + PROJECT_CONTRACT_ERRORS.OWNER_REQUIRED, |
| 136 | + "Project records require ownerId.", |
| 137 | + "ownerId" |
| 138 | + )); |
| 139 | + } |
| 140 | + |
| 141 | + if (!hasNonEmptyString(project?.visibility)) { |
| 142 | + errors.push(createContractError( |
| 143 | + PROJECT_CONTRACT_ERRORS.VISIBILITY_REQUIRED, |
| 144 | + "Project records require explicit visibility.", |
| 145 | + "visibility" |
| 146 | + )); |
| 147 | + } else if (!isProjectVisibility(project.visibility)) { |
| 148 | + errors.push(createContractError( |
| 149 | + PROJECT_CONTRACT_ERRORS.VISIBILITY_INVALID, |
| 150 | + "Project visibility must be an allowed project visibility state.", |
| 151 | + "visibility" |
| 152 | + )); |
| 153 | + } |
| 154 | + |
| 155 | + if (!hasNonEmptyString(project?.state)) { |
| 156 | + errors.push(createContractError( |
| 157 | + PROJECT_CONTRACT_ERRORS.STATE_REQUIRED, |
| 158 | + "Project records require explicit state.", |
| 159 | + "state" |
| 160 | + )); |
| 161 | + } else if (!isProjectState(project.state)) { |
| 162 | + errors.push(createContractError( |
| 163 | + PROJECT_CONTRACT_ERRORS.STATE_INVALID, |
| 164 | + "Project state must be an allowed project lifecycle state.", |
| 165 | + "state" |
| 166 | + )); |
| 167 | + } |
| 168 | + |
| 169 | + if (Array.isArray(project?.members)) { |
| 170 | + project.members.forEach((member, index) => { |
| 171 | + if (!isProjectRole(member?.role)) { |
| 172 | + errors.push(createContractError( |
| 173 | + PROJECT_CONTRACT_ERRORS.ROLE_INVALID, |
| 174 | + "Project member role must be an allowed project role.", |
| 175 | + `members[${index}].role` |
| 176 | + )); |
| 177 | + } |
| 178 | + }); |
| 179 | + } |
| 180 | + |
| 181 | + if (Array.isArray(project?.relationships)) { |
| 182 | + project.relationships.forEach((relationship, index) => { |
| 183 | + if (!isProjectRelationship(relationship)) { |
| 184 | + errors.push(createContractError( |
| 185 | + PROJECT_CONTRACT_ERRORS.RELATIONSHIP_INVALID, |
| 186 | + "Project relationship must be an allowed project relationship.", |
| 187 | + `relationships[${index}]` |
| 188 | + )); |
| 189 | + } |
| 190 | + }); |
| 191 | + } |
| 192 | + |
| 193 | + return Object.freeze({ |
| 194 | + valid: errors.length === 0, |
| 195 | + errors: Object.freeze(errors), |
| 196 | + }); |
| 197 | +} |
| 198 | + |
| 199 | +export function isMarketplaceProject(project) { |
| 200 | + return project?.state === PROJECT_STATES.MARKETPLACE; |
| 201 | +} |
| 202 | + |
| 203 | +export function canEditProjectState(project, policy = {}) { |
| 204 | + if (project?.state === PROJECT_STATES.ARCHIVED) { |
| 205 | + return policy.allowArchivedProjectEdit === true; |
| 206 | + } |
| 207 | + |
| 208 | + if (project?.state === PROJECT_STATES.RETIRED) { |
| 209 | + return policy.allowRetiredProjectEdit === true; |
| 210 | + } |
| 211 | + |
| 212 | + return isProjectState(project?.state); |
| 213 | +} |
| 214 | + |
| 215 | +export function canActorAccessProject({ |
| 216 | + actorId, |
| 217 | + projectRole, |
| 218 | + permission, |
| 219 | + project, |
| 220 | + grantedProjectIds = [], |
| 221 | + grantedScopes = [], |
| 222 | + policy = {}, |
| 223 | +} = {}) { |
| 224 | + if (!isProjectRole(projectRole) || !isIdentityPermission(permission) || !isProjectVisibleToActor(actorId, project, grantedProjectIds)) { |
| 225 | + return false; |
| 226 | + } |
| 227 | + |
| 228 | + if (permission === IDENTITY_PERMISSIONS.EDIT && !canEditProjectState(project, policy)) { |
| 229 | + return false; |
| 230 | + } |
| 231 | + |
| 232 | + if (projectRole === PROJECT_ROLES.OWNER) { |
| 233 | + return actorId === project?.ownerId |
| 234 | + && PROJECT_ROLE_PERMISSION_GRANTS[PROJECT_ROLES.OWNER].includes(permission); |
| 235 | + } |
| 236 | + |
| 237 | + if (projectRole === PROJECT_ROLES.VIEWER) { |
| 238 | + return permission === IDENTITY_PERMISSIONS.VIEW; |
| 239 | + } |
| 240 | + |
| 241 | + if (projectRole === PROJECT_ROLES.COLLABORATOR && permission === IDENTITY_PERMISSIONS.EDIT) { |
| 242 | + return canActorPerformPermission({ |
| 243 | + actorId, |
| 244 | + role: IDENTITY_ROLES.CONTRIBUTOR, |
| 245 | + permission, |
| 246 | + scope: IDENTITY_PERMISSION_SCOPES.PROJECT, |
| 247 | + grantedScopes, |
| 248 | + object: { |
| 249 | + ownerId: project?.ownerId, |
| 250 | + visibility: mapProjectVisibilityToIdentityVisibility(project?.visibility), |
| 251 | + }, |
| 252 | + }); |
| 253 | + } |
| 254 | + |
| 255 | + return PROJECT_ROLE_PERMISSION_GRANTS[projectRole].includes(permission); |
| 256 | +} |
| 257 | + |
| 258 | +export function isProjectVisibleToActor(actorId, project, grantedProjectIds = []) { |
| 259 | + if (!hasNonEmptyString(actorId) || !project) { |
| 260 | + return false; |
| 261 | + } |
| 262 | + |
| 263 | + if (actorId === project.ownerId) { |
| 264 | + return true; |
| 265 | + } |
| 266 | + |
| 267 | + if (project.visibility === PROJECT_VISIBILITY_STATES.PUBLIC || project.visibility === PROJECT_VISIBILITY_STATES.UNLISTED) { |
| 268 | + return true; |
| 269 | + } |
| 270 | + |
| 271 | + return Array.isArray(grantedProjectIds) && grantedProjectIds.includes(project.id); |
| 272 | +} |
| 273 | + |
| 274 | +function mapProjectVisibilityToIdentityVisibility(visibility) { |
| 275 | + if (visibility === PROJECT_VISIBILITY_STATES.PROJECT) { |
| 276 | + return IDENTITY_VISIBILITY_STATES.PROJECT; |
| 277 | + } |
| 278 | + |
| 279 | + if (visibility === PROJECT_VISIBILITY_STATES.UNLISTED) { |
| 280 | + return IDENTITY_VISIBILITY_STATES.UNLISTED; |
| 281 | + } |
| 282 | + |
| 283 | + if (visibility === PROJECT_VISIBILITY_STATES.PUBLIC) { |
| 284 | + return IDENTITY_VISIBILITY_STATES.PUBLIC; |
| 285 | + } |
| 286 | + |
| 287 | + return IDENTITY_VISIBILITY_STATES.PRIVATE; |
| 288 | +} |
| 289 | + |
| 290 | +function hasNonEmptyString(value) { |
| 291 | + return typeof value === "string" && value.trim().length > 0; |
| 292 | +} |
| 293 | + |
| 294 | +function createContractError(code, message, path) { |
| 295 | + return Object.freeze({ code, message, path }); |
| 296 | +} |
0 commit comments