@flowdular/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ai/agents/README.md +27 -0
- package/.ai/agents/module-executor.md +36 -0
- package/.ai/agents/reviewer.md +23 -0
- package/.ai/agents/sandbox/agentic-engineer.md +31 -0
- package/.ai/agents/sandbox/backend-engineer.md +36 -0
- package/.ai/agents/sandbox/business-manager.md +23 -0
- package/.ai/agents/sandbox/frontend-engineer.md +27 -0
- package/.ai/agents/sandbox/ux-designer.md +23 -0
- package/.ai/agents/spec-author.md +29 -0
- package/.ai/blueprints/add-migration/README.md +5 -0
- package/.ai/blueprints/add-migration/allowed-paths.yaml +23 -0
- package/.ai/blueprints/add-migration/blueprint.json +14 -0
- package/.ai/blueprints/add-migration/examples/invalid/input-destructive.json +6 -0
- package/.ai/blueprints/add-migration/examples/invalid/plan-unnumbered-file.json +9 -0
- package/.ai/blueprints/add-migration/examples/valid/input.json +6 -0
- package/.ai/blueprints/add-migration/examples/valid/plan.json +9 -0
- package/.ai/blueprints/add-migration/gates.yaml +30 -0
- package/.ai/blueprints/add-migration/input.schema.json +23 -0
- package/.ai/blueprints/add-migration/plan.schema.json +54 -0
- package/.ai/blueprints/add-migration/required-files.yaml +18 -0
- package/.ai/blueprints/add-migration/spec-requirements.yaml +13 -0
- package/.ai/blueprints/add-migration/steps.yaml +62 -0
- package/.ai/blueprints/author-spec/README.md +5 -0
- package/.ai/blueprints/author-spec/allowed-paths.yaml +7 -0
- package/.ai/blueprints/author-spec/blueprint.json +14 -0
- package/.ai/blueprints/author-spec/examples/invalid/input-missing-outcome.json +5 -0
- package/.ai/blueprints/author-spec/examples/valid/input.json +6 -0
- package/.ai/blueprints/author-spec/gates.yaml +13 -0
- package/.ai/blueprints/author-spec/input.schema.json +20 -0
- package/.ai/blueprints/author-spec/plan.schema.json +14 -0
- package/.ai/blueprints/author-spec/required-files.yaml +6 -0
- package/.ai/blueprints/author-spec/spec-requirements.yaml +35 -0
- package/.ai/blueprints/author-spec/steps.yaml +28 -0
- package/.ai/blueprints/author-spec/templates/module.yaml +45 -0
- package/.ai/blueprints/bug-fix/README.md +5 -0
- package/.ai/blueprints/bug-fix/allowed-paths.yaml +29 -0
- package/.ai/blueprints/bug-fix/blueprint.json +14 -0
- package/.ai/blueprints/bug-fix/examples/invalid/input-no-symptom.json +4 -0
- package/.ai/blueprints/bug-fix/examples/invalid/plan-no-test.json +8 -0
- package/.ai/blueprints/bug-fix/examples/valid/input.json +6 -0
- package/.ai/blueprints/bug-fix/examples/valid/plan.json +8 -0
- package/.ai/blueprints/bug-fix/gates.yaml +30 -0
- package/.ai/blueprints/bug-fix/input.schema.json +25 -0
- package/.ai/blueprints/bug-fix/plan.schema.json +53 -0
- package/.ai/blueprints/bug-fix/required-files.yaml +7 -0
- package/.ai/blueprints/bug-fix/spec-requirements.yaml +7 -0
- package/.ai/blueprints/bug-fix/steps.yaml +51 -0
- package/.ai/blueprints/core-extend/README.md +5 -0
- package/.ai/blueprints/core-extend/allowed-paths.yaml +49 -0
- package/.ai/blueprints/core-extend/blueprint.json +14 -0
- package/.ai/blueprints/core-extend/examples/invalid/input-unknown-package.json +5 -0
- package/.ai/blueprints/core-extend/examples/invalid/plan-missing-gates.json +7 -0
- package/.ai/blueprints/core-extend/examples/valid/input.json +6 -0
- package/.ai/blueprints/core-extend/examples/valid/plan.json +10 -0
- package/.ai/blueprints/core-extend/gates.yaml +16 -0
- package/.ai/blueprints/core-extend/input.schema.json +54 -0
- package/.ai/blueprints/core-extend/plan.schema.json +39 -0
- package/.ai/blueprints/core-extend/required-files.yaml +36 -0
- package/.ai/blueprints/core-extend/spec-requirements.yaml +17 -0
- package/.ai/blueprints/core-extend/steps.yaml +54 -0
- package/.ai/blueprints/edit-module/README.md +9 -0
- package/.ai/blueprints/edit-module/allowed-paths.yaml +27 -0
- package/.ai/blueprints/edit-module/blueprint.json +20 -0
- package/.ai/blueprints/edit-module/examples/invalid/input-unknown-change.json +5 -0
- package/.ai/blueprints/edit-module/examples/invalid/plan-touches-platform.json +15 -0
- package/.ai/blueprints/edit-module/examples/valid/input.json +5 -0
- package/.ai/blueprints/edit-module/examples/valid/plan.json +25 -0
- package/.ai/blueprints/edit-module/gates.yaml +30 -0
- package/.ai/blueprints/edit-module/input.schema.json +31 -0
- package/.ai/blueprints/edit-module/plan.schema.json +65 -0
- package/.ai/blueprints/edit-module/required-files.yaml +80 -0
- package/.ai/blueprints/edit-module/spec-requirements.yaml +15 -0
- package/.ai/blueprints/edit-module/steps.yaml +115 -0
- package/.ai/blueprints/new-module/README.md +7 -0
- package/.ai/blueprints/new-module/allowed-paths.yaml +27 -0
- package/.ai/blueprints/new-module/blueprint.json +20 -0
- package/.ai/blueprints/new-module/examples/invalid/input-spec-outside-modules.json +4 -0
- package/.ai/blueprints/new-module/examples/invalid/plan-unknown-gate.json +8 -0
- package/.ai/blueprints/new-module/examples/valid/input.json +5 -0
- package/.ai/blueprints/new-module/examples/valid/plan.json +22 -0
- package/.ai/blueprints/new-module/gates.yaml +30 -0
- package/.ai/blueprints/new-module/input.schema.json +21 -0
- package/.ai/blueprints/new-module/plan.schema.json +58 -0
- package/.ai/blueprints/new-module/required-files.yaml +73 -0
- package/.ai/blueprints/new-module/spec-requirements.yaml +30 -0
- package/.ai/blueprints/new-module/steps.yaml +138 -0
- package/.ai/blueprints/release/README.md +5 -0
- package/.ai/blueprints/release/allowed-paths.yaml +19 -0
- package/.ai/blueprints/release/blueprint.json +14 -0
- package/.ai/blueprints/release/examples/invalid/input-bad-version.json +4 -0
- package/.ai/blueprints/release/examples/invalid/plan-bad-branch.json +7 -0
- package/.ai/blueprints/release/examples/valid/input.json +5 -0
- package/.ai/blueprints/release/examples/valid/plan.json +20 -0
- package/.ai/blueprints/release/gates.yaml +20 -0
- package/.ai/blueprints/release/input.schema.json +24 -0
- package/.ai/blueprints/release/plan.schema.json +46 -0
- package/.ai/blueprints/release/required-files.yaml +19 -0
- package/.ai/blueprints/release/spec-requirements.yaml +8 -0
- package/.ai/blueprints/release/steps.yaml +47 -0
- package/.ai/blueprints/security-review/README.md +5 -0
- package/.ai/blueprints/security-review/allowed-paths.yaml +6 -0
- package/.ai/blueprints/security-review/blueprint.json +14 -0
- package/.ai/blueprints/security-review/examples/invalid/input-unknown-kind.json +4 -0
- package/.ai/blueprints/security-review/examples/invalid/plan-finding-without-scenario.json +14 -0
- package/.ai/blueprints/security-review/examples/valid/input.json +4 -0
- package/.ai/blueprints/security-review/examples/valid/plan.json +19 -0
- package/.ai/blueprints/security-review/gates.yaml +22 -0
- package/.ai/blueprints/security-review/input.schema.json +20 -0
- package/.ai/blueprints/security-review/plan.schema.json +65 -0
- package/.ai/blueprints/security-review/required-files.yaml +6 -0
- package/.ai/blueprints/security-review/spec-requirements.yaml +9 -0
- package/.ai/blueprints/security-review/steps.yaml +38 -0
- package/.ai/policies/capabilities.yaml +164 -0
- package/.ai/policies/model-routing.yaml +72 -0
- package/.ai/policies/path-ownership.yaml +65 -0
- package/.ai/policies/task-budgets.yaml +37 -0
- package/.ai/references/catalog/LICENSE +21 -0
- package/.ai/references/catalog/migrations/0001_catalog_core.down.sql +2 -0
- package/.ai/references/catalog/migrations/0001_catalog_core.up.sql +21 -0
- package/.ai/references/catalog/migrations/0002_catalog_history.down.sql +3 -0
- package/.ai/references/catalog/migrations/0002_catalog_history.up.sql +20 -0
- package/.ai/references/catalog/migrations/0003_catalog_history_service_actors.down.sql +3 -0
- package/.ai/references/catalog/migrations/0003_catalog_history_service_actors.up.sql +36 -0
- package/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.down.sql +3 -0
- package/.ai/references/catalog/migrations/0004_catalog_idempotency_ledger.up.sql +19 -0
- package/.ai/references/catalog/migrations/README.md +3 -0
- package/.ai/references/catalog/module.json +27 -0
- package/.ai/references/catalog/package.json +49 -0
- package/.ai/references/catalog/spec/module.yaml +86 -0
- package/.ai/references/catalog/src/acl/permissions.ts +6 -0
- package/.ai/references/catalog/src/agent/tools.ts +164 -0
- package/.ai/references/catalog/src/api/endpoints.ts +243 -0
- package/.ai/references/catalog/src/client/CatalogHistoryDrawer.tsrx +123 -0
- package/.ai/references/catalog/src/client/CatalogItemForm.tsrx +190 -0
- package/.ai/references/catalog/src/client/CatalogView.tsrx +473 -0
- package/.ai/references/catalog/src/client/api.ts +111 -0
- package/.ai/references/catalog/src/client/contribution.tsrx +61 -0
- package/.ai/references/catalog/src/client/index.ts +18 -0
- package/.ai/references/catalog/src/client/navigation-copy.ts +9 -0
- package/.ai/references/catalog/src/client/state.ts +24 -0
- package/.ai/references/catalog/src/domain/types.ts +32 -0
- package/.ai/references/catalog/src/domain/variables.ts +111 -0
- package/.ai/references/catalog/src/index.ts +31 -0
- package/.ai/references/catalog/src/platform.ts +35 -0
- package/.ai/references/catalog/src/server/index.ts +4 -0
- package/.ai/references/catalog/src/server/runtime.ts +86 -0
- package/.ai/references/catalog/src/services/catalog-service.ts +306 -0
- package/.ai/references/catalog/src/services/database-repository.ts +440 -0
- package/.ai/references/catalog/src/services/index.ts +4 -0
- package/.ai/references/catalog/src/services/migration.ts +171 -0
- package/.ai/references/catalog/src/services/repository.ts +36 -0
- package/.ai/references/catalog/src/services/target-idempotency.ts +59 -0
- package/.ai/references/catalog/tests/agent-tools.test.ts +277 -0
- package/.ai/references/catalog/tests/endpoints.test.ts +320 -0
- package/.ai/references/catalog/tests/idempotency.test.ts +297 -0
- package/.ai/references/catalog/tests/migrations.test.ts +149 -0
- package/.ai/references/catalog/tests/module.test.ts +271 -0
- package/.ai/references/catalog/tests/support/database.ts +76 -0
- package/.ai/references/catalog/translations/en.json +101 -0
- package/.ai/references/catalog/translations/pl.json +101 -0
- package/.ai/references/catalog/tsconfig.json +15 -0
- package/.ai/references/catalog/vitest.config.ts +16 -0
- package/.ai/references/catalog.provenance.json +55 -0
- package/.ai/skills/README.md +36 -0
- package/.ai/skills/agent-tool-design/SKILL.md +209 -0
- package/.ai/skills/auth-security-review/SKILL.md +96 -0
- package/.ai/skills/auto-review/SKILL.md +112 -0
- package/.ai/skills/bug-hunt/SKILL.md +110 -0
- package/.ai/skills/business-agent-design/SKILL.md +188 -0
- package/.ai/skills/cli-extension/SKILL.md +114 -0
- package/.ai/skills/core-extend/SKILL.md +104 -0
- package/.ai/skills/database-adapter/SKILL.md +204 -0
- package/.ai/skills/database-adapter/references/first-run-and-matrix.md +105 -0
- package/.ai/skills/migration-authoring/SKILL.md +167 -0
- package/.ai/skills/module-new/SKILL.md +180 -0
- package/.ai/skills/module-update/SKILL.md +100 -0
- package/.ai/skills/perf-audit/SKILL.md +105 -0
- package/.ai/skills/release-eject-pr/SKILL.md +113 -0
- package/.ai/skills/spec-approval/SKILL.md +112 -0
- package/.ai/skills/test-hardening/SKILL.md +86 -0
- package/.ai/skills/translations-i18n/SKILL.md +85 -0
- package/.ai/skills/ux-design/SKILL.md +97 -0
- package/.ai/skills/variables/SKILL.md +164 -0
- package/.ai/skills/workflow-development/SKILL.md +199 -0
- package/AGENTS.md +59 -0
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/docs/agent-contract.md +45 -0
- package/docs/design-system.md +217 -0
- package/modules/agents/migrations/0001_agents_core.down.sql +4 -0
- package/modules/agents/migrations/0001_agents_core.up.sql +106 -0
- package/modules/agents/migrations/0002_provider_connections.down.sql +1 -0
- package/modules/agents/migrations/0002_provider_connections.up.sql +34 -0
- package/modules/agents/migrations/0003_resource_audit.down.sql +1 -0
- package/modules/agents/migrations/0003_resource_audit.up.sql +25 -0
- package/modules/agents/migrations/0004_run_grants.down.sql +1 -0
- package/modules/agents/migrations/0004_run_grants.up.sql +19 -0
- package/modules/agents/migrations/0005_long_running_limits.down.sql +2 -0
- package/modules/agents/migrations/0005_long_running_limits.up.sql +14 -0
- package/modules/agents/migrations/0006_agent_skills.down.sql +3 -0
- package/modules/agents/migrations/0006_agent_skills.up.sql +44 -0
- package/modules/agents/migrations/0007_model_readiness.down.sql +1 -0
- package/modules/agents/migrations/0007_model_readiness.up.sql +18 -0
- package/modules/agents/migrations/0008_output_limits.down.sql +2 -0
- package/modules/agents/migrations/0008_output_limits.up.sql +8 -0
- package/modules/agents/migrations/0009_agent_audit_v3.down.sql +1 -0
- package/modules/agents/migrations/0009_agent_audit_v3.up.sql +25 -0
- package/modules/agents/migrations/0012_agent_run_costs.down.sql +3 -0
- package/modules/agents/migrations/0012_agent_run_costs.up.sql +32 -0
- package/modules/agents/migrations/0013_workflow_prerequisites.down.sql +3 -0
- package/modules/agents/migrations/0013_workflow_prerequisites.up.sql +97 -0
- package/modules/agents/migrations/0014_agent_action_audit.down.sql +1 -0
- package/modules/agents/migrations/0014_agent_action_audit.up.sql +25 -0
- package/modules/agents/migrations/0015_agent_run_actors.down.sql +1 -0
- package/modules/agents/migrations/0015_agent_run_actors.up.sql +17 -0
- package/modules/agents/migrations/0016_module_owned_agents.down.sql +6 -0
- package/modules/agents/migrations/0016_module_owned_agents.up.sql +68 -0
- package/modules/agents/migrations/0017_agent_authorization_subjects.down.sql +2 -0
- package/modules/agents/migrations/0017_agent_authorization_subjects.up.sql +18 -0
- package/modules/agents/migrations/0018_agent_child_tenant_isolation.down.sql +12 -0
- package/modules/agents/migrations/0018_agent_child_tenant_isolation.up.sql +88 -0
- package/modules/agents/migrations/0019_background_recovery_grants.down.sql +6 -0
- package/modules/agents/migrations/0019_background_recovery_grants.up.sql +17 -0
- package/modules/agents/migrations/0020_provider_summary_role.down.sql +2 -0
- package/modules/agents/migrations/0020_provider_summary_role.up.sql +17 -0
- package/modules/agents/migrations/0021_agents_agent_reconciliation_role.down.sql +4 -0
- package/modules/agents/migrations/0021_agents_agent_reconciliation_role.up.sql +7 -0
- package/modules/agents/migrations/README.md +39 -0
- package/modules/agents/module.json +38 -0
- package/modules/agents/package.json +56 -0
- package/modules/agents/spec/module.yaml +271 -0
- package/modules/agents/spec/procedures-compatibility.md +63 -0
- package/modules/agents/src/acl/permissions.ts +13 -0
- package/modules/agents/src/api/endpoints.ts +1122 -0
- package/modules/agents/src/cli/commands.json +29 -0
- package/modules/agents/src/cli/index.ts +162 -0
- package/modules/agents/src/client/AgentDashboardWidget.tsrx +32 -0
- package/modules/agents/src/client/AgentDefinitionForm.tsrx +362 -0
- package/modules/agents/src/client/AgentDefinitionList.tsrx +127 -0
- package/modules/agents/src/client/AgentPlaygroundView.tsrx +246 -0
- package/modules/agents/src/client/AgentProcedureForm.tsrx +182 -0
- package/modules/agents/src/client/AgentProcedureList.tsrx +118 -0
- package/modules/agents/src/client/AgentProceduresView.tsrx +253 -0
- package/modules/agents/src/client/AgentProvidersView.tsrx +248 -0
- package/modules/agents/src/client/AgentRunsView.tsrx +304 -0
- package/modules/agents/src/client/AgentUsageView.tsrx +233 -0
- package/modules/agents/src/client/AgentsView.tsrx +339 -0
- package/modules/agents/src/client/ModuleAgentBindingForm.tsrx +239 -0
- package/modules/agents/src/client/ModuleAgentList.tsrx +122 -0
- package/modules/agents/src/client/ProviderConnectionForm.tsrx +273 -0
- package/modules/agents/src/client/ProviderConnectionList.tsrx +229 -0
- package/modules/agents/src/client/ProviderModelEditor.tsrx +313 -0
- package/modules/agents/src/client/RunTimeline.tsrx +212 -0
- package/modules/agents/src/client/agents.css +349 -0
- package/modules/agents/src/client/api.ts +373 -0
- package/modules/agents/src/client/contribution.tsrx +181 -0
- package/modules/agents/src/client/index.ts +21 -0
- package/modules/agents/src/client/presentation.ts +162 -0
- package/modules/agents/src/client/state.ts +100 -0
- package/modules/agents/src/domain/context-variables.ts +58 -0
- package/modules/agents/src/domain/run-timeline.ts +145 -0
- package/modules/agents/src/domain/types.ts +481 -0
- package/modules/agents/src/index.ts +88 -0
- package/modules/agents/src/platform.ts +82 -0
- package/modules/agents/src/server/action-execution.ts +811 -0
- package/modules/agents/src/server/define-agent.ts +170 -0
- package/modules/agents/src/server/index.ts +67 -0
- package/modules/agents/src/server/run-execution.ts +231 -0
- package/modules/agents/src/server/run-queue.ts +96 -0
- package/modules/agents/src/server/runtime.ts +448 -0
- package/modules/agents/src/server/tools.ts +18 -0
- package/modules/agents/src/services/agent-service.ts +2167 -0
- package/modules/agents/src/services/credential-vault.ts +133 -0
- package/modules/agents/src/services/database-repository.ts +2786 -0
- package/modules/agents/src/services/index.ts +33 -0
- package/modules/agents/src/services/migration.ts +1152 -0
- package/modules/agents/src/services/module-agent-preflight.ts +76 -0
- package/modules/agents/src/services/outbound-policy.ts +136 -0
- package/modules/agents/src/services/provider-broker.ts +80 -0
- package/modules/agents/src/services/provider-repository.ts +589 -0
- package/modules/agents/src/services/provider-service.ts +968 -0
- package/modules/agents/src/services/repository.ts +294 -0
- package/modules/agents/src/services/run-grant.ts +305 -0
- package/modules/agents/src/services/usage-service.ts +119 -0
- package/modules/agents/src/services/worker.ts +442 -0
- package/modules/agents/src/settings.ts +244 -0
- package/modules/agents/translations/en.json +403 -0
- package/modules/agents/translations/pl.json +403 -0
- package/modules/auth/README.md +215 -0
- package/modules/auth/migrations/0001_auth_core.up.sql +108 -0
- package/modules/auth/migrations/0002_bundled_module_scopes.up.sql +26 -0
- package/modules/auth/migrations/0003_agent_core_scopes.up.sql +12 -0
- package/modules/auth/migrations/0004_owner_only_development_scopes.up.sql +8 -0
- package/modules/auth/migrations/0005_agent_provider_scopes.up.sql +13 -0
- package/modules/auth/migrations/0006_tenant_slug.up.sql +6 -0
- package/modules/auth/migrations/0007_sandbox_scopes.up.sql +13 -0
- package/modules/auth/migrations/0008_api_tokens.up.sql +43 -0
- package/modules/auth/migrations/0009_module_settings.up.sql +24 -0
- package/modules/auth/migrations/0010_sign_in_lockout.up.sql +11 -0
- package/modules/auth/migrations/0011_session_ids_and_password_reset.up.sql +7 -0
- package/modules/auth/migrations/0012_roles.up.sql +31 -0
- package/modules/auth/migrations/0013_audit.up.sql +27 -0
- package/modules/auth/migrations/0014_audit_actor.up.sql +4 -0
- package/modules/auth/migrations/0015_account_security.up.sql +81 -0
- package/modules/auth/module.json +26 -0
- package/modules/auth/package.json +61 -0
- package/modules/auth/spec/module.yaml +190 -0
- package/modules/auth/src/acl/scopes.ts +86 -0
- package/modules/auth/src/api/attempt-limiter.ts +42 -0
- package/modules/auth/src/api/client-address.ts +14 -0
- package/modules/auth/src/api/cookies.ts +52 -0
- package/modules/auth/src/api/origin.ts +40 -0
- package/modules/auth/src/cli/commands.json +74 -0
- package/modules/auth/src/cli/database.ts +51 -0
- package/modules/auth/src/cli/greenfield.ts +173 -0
- package/modules/auth/src/cli/index.ts +184 -0
- package/modules/auth/src/cli/provisioning.ts +211 -0
- package/modules/auth/src/client/AuthBackdrop.tsrx +137 -0
- package/modules/auth/src/client/AuthenticationCore.tsrx +630 -0
- package/modules/auth/src/client/PasswordChangeRequired.tsrx +145 -0
- package/modules/auth/src/client/PublicAuthFlows.tsrx +379 -0
- package/modules/auth/src/client/api.ts +464 -0
- package/modules/auth/src/client/audit/AuditView.tsrx +357 -0
- package/modules/auth/src/client/audit/api.ts +164 -0
- package/modules/auth/src/client/audit/audit-columns.tsrx +47 -0
- package/modules/auth/src/client/audit/audit-row.ts +38 -0
- package/modules/auth/src/client/auth.css +483 -0
- package/modules/auth/src/client/contribution.tsrx +100 -0
- package/modules/auth/src/client/index.ts +32 -0
- package/modules/auth/src/client/security/SecurityView.tsrx +253 -0
- package/modules/auth/src/client/settings/PreferencesCard.tsrx +65 -0
- package/modules/auth/src/client/settings/SettingsView.tsrx +212 -0
- package/modules/auth/src/client/settings/WorkspaceCard.tsrx +59 -0
- package/modules/auth/src/client/settings/api.ts +115 -0
- package/modules/auth/src/client/slug.ts +26 -0
- package/modules/auth/src/client/state.ts +115 -0
- package/modules/auth/src/client/tokens/ApiTokenForm.tsrx +153 -0
- package/modules/auth/src/client/tokens/ApiTokenSecret.tsrx +35 -0
- package/modules/auth/src/client/tokens/ApiTokensView.tsrx +216 -0
- package/modules/auth/src/client/tokens/api.ts +59 -0
- package/modules/auth/src/client/tokens/state.ts +26 -0
- package/modules/auth/src/client/tokens/token-columns.tsrx +64 -0
- package/modules/auth/src/client/translations.ts +23 -0
- package/modules/auth/src/domain/types.ts +152 -0
- package/modules/auth/src/index.ts +42 -0
- package/modules/auth/src/middleware/authentication.ts +97 -0
- package/modules/auth/src/platform.ts +0 -0
- package/modules/auth/src/server/actor.ts +12 -0
- package/modules/auth/src/server/audit-endpoints.ts +51 -0
- package/modules/auth/src/server/composition.ts +9 -0
- package/modules/auth/src/server/endpoints.ts +893 -0
- package/modules/auth/src/server/http.ts +153 -0
- package/modules/auth/src/server/index.ts +37 -0
- package/modules/auth/src/server/roles-endpoints.ts +120 -0
- package/modules/auth/src/server/runtime.ts +705 -0
- package/modules/auth/src/server/session-endpoints.ts +86 -0
- package/modules/auth/src/server/session-security.ts +62 -0
- package/modules/auth/src/server/token-endpoints.ts +106 -0
- package/modules/auth/src/services/auth-service-error.ts +11 -0
- package/modules/auth/src/services/auth-service.ts +2309 -0
- package/modules/auth/src/services/database-repository.ts +1825 -0
- package/modules/auth/src/services/mail-delivery.ts +27 -0
- package/modules/auth/src/services/migration.ts +591 -0
- package/modules/auth/src/services/password.ts +115 -0
- package/modules/auth/src/services/repository.ts +395 -0
- package/modules/auth/src/services/settings-store.ts +92 -0
- package/modules/auth/src/services/totp.ts +135 -0
- package/modules/auth/src/services/validation.ts +186 -0
- package/modules/auth/src/settings.ts +150 -0
- package/modules/auth/translations/en.json +263 -0
- package/modules/auth/translations/pl.json +263 -0
- package/modules/automations/migrations/0001_automations_core.down.sql +10 -0
- package/modules/automations/migrations/0001_automations_core.up.sql +76 -0
- package/modules/automations/migrations/0002_automations_targets.down.sql +9 -0
- package/modules/automations/migrations/0002_automations_targets.up.sql +37 -0
- package/modules/automations/migrations/0003_automations_scheduler_role.down.sql +3 -0
- package/modules/automations/migrations/0003_automations_scheduler_role.up.sql +19 -0
- package/modules/automations/migrations/0004_automations_trigger_routing_role.down.sql +3 -0
- package/modules/automations/migrations/0004_automations_trigger_routing_role.up.sql +18 -0
- package/modules/automations/migrations/README.md +3 -0
- package/modules/automations/module.json +31 -0
- package/modules/automations/package.json +55 -0
- package/modules/automations/spec/module.yaml +93 -0
- package/modules/automations/src/acl/permissions.ts +10 -0
- package/modules/automations/src/api/endpoints.ts +480 -0
- package/modules/automations/src/client/AutomationScheduleForm.tsrx +218 -0
- package/modules/automations/src/client/AutomationSchedulesView.tsrx +339 -0
- package/modules/automations/src/client/AutomationTriggerForm.tsrx +190 -0
- package/modules/automations/src/client/AutomationTriggersView.tsrx +329 -0
- package/modules/automations/src/client/api.ts +148 -0
- package/modules/automations/src/client/contribution.tsrx +70 -0
- package/modules/automations/src/client/index.ts +20 -0
- package/modules/automations/src/client/presentation.ts +38 -0
- package/modules/automations/src/client/state.ts +40 -0
- package/modules/automations/src/client/target-selection.ts +44 -0
- package/modules/automations/src/domain/cadence.ts +44 -0
- package/modules/automations/src/domain/types.ts +110 -0
- package/modules/automations/src/domain/variables.ts +178 -0
- package/modules/automations/src/index.ts +39 -0
- package/modules/automations/src/platform.ts +73 -0
- package/modules/automations/src/server/execution.ts +77 -0
- package/modules/automations/src/server/index.ts +39 -0
- package/modules/automations/src/server/runtime.ts +218 -0
- package/modules/automations/src/server/targets.ts +116 -0
- package/modules/automations/src/services/automations-service.ts +10 -0
- package/modules/automations/src/services/database-repository.ts +763 -0
- package/modules/automations/src/services/index.ts +5 -0
- package/modules/automations/src/services/migration.ts +223 -0
- package/modules/automations/src/services/repository.ts +119 -0
- package/modules/automations/src/services/schedule-service.ts +837 -0
- package/modules/automations/src/services/secret-vault.ts +124 -0
- package/modules/automations/src/services/trigger-service.ts +648 -0
- package/modules/automations/src/settings.ts +69 -0
- package/modules/automations/translations/en.json +126 -0
- package/modules/automations/translations/pl.json +126 -0
- package/modules/automations-workflows-integration/module.json +27 -0
- package/modules/automations-workflows-integration/package.json +48 -0
- package/modules/automations-workflows-integration/spec/module.yaml +93 -0
- package/modules/automations-workflows-integration/src/index.ts +14 -0
- package/modules/automations-workflows-integration/src/platform.ts +24 -0
- package/modules/automations-workflows-integration/src/server/adapter.ts +272 -0
- package/modules/automations-workflows-integration/src/server/index.ts +11 -0
- package/modules/automations-workflows-integration/src/server/tools.ts +0 -0
- package/modules/automations-workflows-integration/translations/en.json +21 -0
- package/modules/automations-workflows-integration/translations/pl.json +21 -0
- package/modules/profile/migrations/0001_profile_core.down.sql +1 -0
- package/modules/profile/migrations/0001_profile_core.up.sql +13 -0
- package/modules/profile/migrations/0002_profile_language.down.sql +1 -0
- package/modules/profile/migrations/0002_profile_language.up.sql +12 -0
- package/modules/profile/migrations/README.md +3 -0
- package/modules/profile/module.json +23 -0
- package/modules/profile/package.json +55 -0
- package/modules/profile/spec/module.yaml +136 -0
- package/modules/profile/src/acl/permissions.ts +5 -0
- package/modules/profile/src/api/endpoints.ts +184 -0
- package/modules/profile/src/client/DisplayNameCard.tsrx +59 -0
- package/modules/profile/src/client/LanguageCard.tsrx +89 -0
- package/modules/profile/src/client/PasswordCard.tsrx +79 -0
- package/modules/profile/src/client/ProfileView.tsrx +192 -0
- package/modules/profile/src/client/SessionsCard.tsrx +158 -0
- package/modules/profile/src/client/api.ts +134 -0
- package/modules/profile/src/client/contribution.tsrx +61 -0
- package/modules/profile/src/client/index.ts +15 -0
- package/modules/profile/src/client/password.ts +41 -0
- package/modules/profile/src/client/sessions-api.ts +52 -0
- package/modules/profile/src/client/state.ts +23 -0
- package/modules/profile/src/domain/types.ts +21 -0
- package/modules/profile/src/index.ts +21 -0
- package/modules/profile/src/platform.ts +23 -0
- package/modules/profile/src/server/index.ts +7 -0
- package/modules/profile/src/server/runtime.ts +84 -0
- package/modules/profile/src/services/database-repository.ts +154 -0
- package/modules/profile/src/services/index.ts +6 -0
- package/modules/profile/src/services/migration.ts +58 -0
- package/modules/profile/src/services/profile-service.ts +114 -0
- package/modules/profile/src/services/repository.ts +13 -0
- package/modules/profile/translations/en.json +58 -0
- package/modules/profile/translations/pl.json +58 -0
- package/modules/sandbox/migrations/0001_sandbox_core.down.sql +6 -0
- package/modules/sandbox/migrations/0001_sandbox_core.up.sql +70 -0
- package/modules/sandbox/migrations/0002_sandbox_session_lifecycle.down.sql +36 -0
- package/modules/sandbox/migrations/0002_sandbox_session_lifecycle.up.sql +37 -0
- package/modules/sandbox/migrations/README.md +7 -0
- package/modules/sandbox/module.json +31 -0
- package/modules/sandbox/package.json +56 -0
- package/modules/sandbox/spec/module.yaml +111 -0
- package/modules/sandbox/src/acl/permissions.ts +18 -0
- package/modules/sandbox/src/api/endpoints.ts +439 -0
- package/modules/sandbox/src/cli/commands.json +84 -0
- package/modules/sandbox/src/cli/index.ts +450 -0
- package/modules/sandbox/src/client/SandboxAccessView.tsrx +191 -0
- package/modules/sandbox/src/client/SandboxGrantForm.tsrx +134 -0
- package/modules/sandbox/src/client/SandboxGrantList.tsrx +83 -0
- package/modules/sandbox/src/client/SandboxSessionList.tsrx +56 -0
- package/modules/sandbox/src/client/api.ts +87 -0
- package/modules/sandbox/src/client/contribution.tsrx +47 -0
- package/modules/sandbox/src/client/index.ts +18 -0
- package/modules/sandbox/src/client/presentation.ts +47 -0
- package/modules/sandbox/src/client/state.ts +20 -0
- package/modules/sandbox/src/domain/types.ts +125 -0
- package/modules/sandbox/src/index.ts +33 -0
- package/modules/sandbox/src/platform.ts +28 -0
- package/modules/sandbox/src/server/index.ts +20 -0
- package/modules/sandbox/src/server/runtime.ts +119 -0
- package/modules/sandbox/src/services/database-repository.ts +567 -0
- package/modules/sandbox/src/services/directory.ts +33 -0
- package/modules/sandbox/src/services/index.ts +19 -0
- package/modules/sandbox/src/services/migration.ts +145 -0
- package/modules/sandbox/src/services/repository.ts +55 -0
- package/modules/sandbox/src/services/sandbox-service-error.ts +10 -0
- package/modules/sandbox/src/services/sandbox-service.ts +533 -0
- package/modules/sandbox/translations/en.json +77 -0
- package/modules/sandbox/translations/pl.json +77 -0
- package/modules/system/module.json +18 -0
- package/modules/system/package.json +56 -0
- package/modules/system/spec/module.yaml +104 -0
- package/modules/system/src/acl/permissions.ts +10 -0
- package/modules/system/src/client/ModuleSettingRow.tsrx +191 -0
- package/modules/system/src/client/ModuleSettingsSection.tsrx +155 -0
- package/modules/system/src/client/ModulesView.tsrx +241 -0
- package/modules/system/src/client/api.ts +36 -0
- package/modules/system/src/client/contribution.tsrx +48 -0
- package/modules/system/src/client/index.ts +26 -0
- package/modules/system/src/client/module-columns.tsrx +66 -0
- package/modules/system/src/client/overview-api.ts +30 -0
- package/modules/system/src/client/setting-copy.ts +30 -0
- package/modules/system/src/client/settings-api.ts +57 -0
- package/modules/system/src/client/state.ts +16 -0
- package/modules/system/src/index.ts +11 -0
- package/modules/system/src/platform.ts +11 -0
- package/modules/system/src/server/endpoints.ts +372 -0
- package/modules/system/src/server/index.ts +11 -0
- package/modules/system/src/server/module-catalog.ts +117 -0
- package/modules/system/translations/en.json +64 -0
- package/modules/system/translations/pl.json +64 -0
- package/modules/users/migrations/README.md +3 -0
- package/modules/users/module.json +27 -0
- package/modules/users/package.json +54 -0
- package/modules/users/spec/module.yaml +79 -0
- package/modules/users/src/acl/permissions.ts +6 -0
- package/modules/users/src/api/endpoints.ts +222 -0
- package/modules/users/src/client/InviteMemberForm.tsrx +62 -0
- package/modules/users/src/client/MemberDrawer.tsrx +298 -0
- package/modules/users/src/client/RoleForm.tsrx +140 -0
- package/modules/users/src/client/RolesView.tsrx +288 -0
- package/modules/users/src/client/UserAccountForm.tsrx +86 -0
- package/modules/users/src/client/UsersView.tsrx +486 -0
- package/modules/users/src/client/api.ts +172 -0
- package/modules/users/src/client/contribution.tsrx +92 -0
- package/modules/users/src/client/index.ts +19 -0
- package/modules/users/src/client/member-columns.tsrx +52 -0
- package/modules/users/src/client/roles-api.ts +90 -0
- package/modules/users/src/client/state.ts +42 -0
- package/modules/users/src/index.ts +27 -0
- package/modules/users/src/platform.ts +11 -0
- package/modules/users/src/services/index.ts +2 -0
- package/modules/users/src/services/users-service.ts +142 -0
- package/modules/users/translations/en.json +133 -0
- package/modules/users/translations/pl.json +133 -0
- package/modules/workflows/migrations/0001_workflows_core.down.sql +19 -0
- package/modules/workflows/migrations/0001_workflows_core.up.sql +228 -0
- package/modules/workflows/migrations/0002_workflows_authorization_subject.down.sql +1 -0
- package/modules/workflows/migrations/0002_workflows_authorization_subject.up.sql +9 -0
- package/modules/workflows/migrations/0003_workflows_worker_role.up.sql +34 -0
- package/modules/workflows/migrations/README.md +7 -0
- package/modules/workflows/module.json +31 -0
- package/modules/workflows/package.json +55 -0
- package/modules/workflows/spec/module.yaml +227 -0
- package/modules/workflows/src/acl/permissions.ts +10 -0
- package/modules/workflows/src/api/endpoints.ts +852 -0
- package/modules/workflows/src/client/WorkflowCanvas.tsrx +819 -0
- package/modules/workflows/src/client/WorkflowCreateForm.tsrx +111 -0
- package/modules/workflows/src/client/WorkflowEditor.tsrx +443 -0
- package/modules/workflows/src/client/WorkflowExecutionHistory.tsrx +169 -0
- package/modules/workflows/src/client/WorkflowFixtureDrawer.tsrx +206 -0
- package/modules/workflows/src/client/WorkflowInputForm.tsrx +125 -0
- package/modules/workflows/src/client/WorkflowInputSchemaEditor.tsrx +185 -0
- package/modules/workflows/src/client/WorkflowInspector.tsrx +471 -0
- package/modules/workflows/src/client/WorkflowLiveConfirmation.tsrx +67 -0
- package/modules/workflows/src/client/WorkflowMappingEditor.tsrx +283 -0
- package/modules/workflows/src/client/WorkflowNodePalette.tsrx +50 -0
- package/modules/workflows/src/client/WorkflowPointerField.tsrx +178 -0
- package/modules/workflows/src/client/WorkflowRevisionReview.tsrx +49 -0
- package/modules/workflows/src/client/WorkflowRunInspector.tsrx +143 -0
- package/modules/workflows/src/client/WorkflowTestPanel.tsrx +109 -0
- package/modules/workflows/src/client/WorkflowValidationPanel.tsrx +139 -0
- package/modules/workflows/src/client/WorkflowsView.tsrx +1060 -0
- package/modules/workflows/src/client/api.ts +312 -0
- package/modules/workflows/src/client/canvas-model.ts +472 -0
- package/modules/workflows/src/client/connected-node.ts +71 -0
- package/modules/workflows/src/client/contribution.tsrx +57 -0
- package/modules/workflows/src/client/create-model.ts +15 -0
- package/modules/workflows/src/client/draft-repair.ts +86 -0
- package/modules/workflows/src/client/index.ts +19 -0
- package/modules/workflows/src/client/input-model.ts +124 -0
- package/modules/workflows/src/client/mapping-model.ts +110 -0
- package/modules/workflows/src/client/pointer-model.ts +123 -0
- package/modules/workflows/src/client/presentation.ts +62 -0
- package/modules/workflows/src/client/run-actions.ts +19 -0
- package/modules/workflows/src/client/state.ts +57 -0
- package/modules/workflows/src/client/viewport.ts +107 -0
- package/modules/workflows/src/client/workflows.css +899 -0
- package/modules/workflows/src/domain/events.ts +399 -0
- package/modules/workflows/src/domain/graph.ts +1136 -0
- package/modules/workflows/src/domain/types.ts +563 -0
- package/modules/workflows/src/index.ts +27 -0
- package/modules/workflows/src/platform.ts +49 -0
- package/modules/workflows/src/server/index.ts +13 -0
- package/modules/workflows/src/server/runtime.ts +266 -0
- package/modules/workflows/src/services/cursor-codec.ts +62 -0
- package/modules/workflows/src/services/database-repository.ts +2407 -0
- package/modules/workflows/src/services/index.ts +6 -0
- package/modules/workflows/src/services/migration.ts +375 -0
- package/modules/workflows/src/services/payload-codec.ts +264 -0
- package/modules/workflows/src/services/repository.ts +285 -0
- package/modules/workflows/src/services/simulation.ts +359 -0
- package/modules/workflows/src/services/worker.ts +1196 -0
- package/modules/workflows/src/services/workflows-service.ts +1395 -0
- package/modules/workflows/translations/en.json +348 -0
- package/modules/workflows/translations/pl.json +348 -0
- package/modules.json +41 -0
- package/package.json +125 -0
- package/packages/ai-provider/package.json +46 -0
- package/packages/ai-provider/src/catalog.ts +102 -0
- package/packages/ai-provider/src/errors.ts +110 -0
- package/packages/ai-provider/src/index.ts +33 -0
- package/packages/ai-provider/src/model.ts +198 -0
- package/packages/ai-provider/src/pricing.ts +73 -0
- package/packages/cli-protocol/package.json +40 -0
- package/packages/cli-protocol/src/index.ts +113 -0
- package/packages/client/package.json +46 -0
- package/packages/client/src/ApplicationShell.tsrx +374 -0
- package/packages/client/src/contributions.ts +172 -0
- package/packages/client/src/i18n/index.ts +30 -0
- package/packages/client/src/i18n/locale.ts +25 -0
- package/packages/client/src/i18n/locales/en.json +66 -0
- package/packages/client/src/i18n/locales/pl.json +66 -0
- package/packages/client/src/i18n/runtime.ts +201 -0
- package/packages/client/src/i18n/translations.ts +88 -0
- package/packages/client/src/index.ts +55 -0
- package/packages/client/src/routing.ts +9 -0
- package/packages/client/src/shell/BrandMark.tsrx +10 -0
- package/packages/client/src/shell/CommandPalette.tsrx +79 -0
- package/packages/client/src/shell/ContributionOutlets.tsrx +31 -0
- package/packages/client/src/shell/DashboardView.tsrx +60 -0
- package/packages/client/src/shell/MobileHeader.tsrx +24 -0
- package/packages/client/src/shell/OrganizationSwitcher.tsrx +100 -0
- package/packages/client/src/shell/OverviewCharts.tsrx +167 -0
- package/packages/client/src/shell/PrimaryNavigation.tsrx +75 -0
- package/packages/client/src/shell/Sidebar.tsrx +76 -0
- package/packages/client/src/shell/Topbar.tsrx +44 -0
- package/packages/client/src/shell/UserMenu.tsrx +103 -0
- package/packages/client/src/shell/WorkspaceContent.tsrx +30 -0
- package/packages/client/src/shell/identity.ts +12 -0
- package/packages/client/src/shell/navigation.ts +68 -0
- package/packages/client/src/shell/shell.css +734 -0
- package/packages/client/src/shell/types.ts +29 -0
- package/packages/client/src/state.ts +63 -0
- package/packages/client/src/web.ts +39 -0
- package/packages/coding-agent/package.json +44 -0
- package/packages/coding-agent/src/drivers/byok.ts +322 -0
- package/packages/coding-agent/src/drivers/claude-code.ts +290 -0
- package/packages/coding-agent/src/drivers/codex.ts +281 -0
- package/packages/coding-agent/src/index.ts +56 -0
- package/packages/coding-agent/src/registry.ts +119 -0
- package/packages/coding-agent/src/roles/contract.ts +60 -0
- package/packages/coding-agent/src/roles/defaults.ts +134 -0
- package/packages/coding-agent/src/roles/handoff.ts +41 -0
- package/packages/coding-agent/src/roles/registry.ts +156 -0
- package/packages/coding-agent/src/roles/skills.ts +72 -0
- package/packages/coding-agent/src/roles/sync.ts +88 -0
- package/packages/coding-agent/src/types.ts +102 -0
- package/packages/coding-agent/src/workspace.ts +275 -0
- package/packages/contracts/package.json +38 -0
- package/packages/contracts/schemas/blueprint.schema.json +48 -0
- package/packages/contracts/schemas/cli-extension.schema.json +90 -0
- package/packages/contracts/schemas/module-artifact.schema.json +82 -0
- package/packages/contracts/schemas/module-catalog.schema.json +48 -0
- package/packages/contracts/schemas/module-spec.schema.json +111 -0
- package/packages/contracts/schemas/module.schema.json +156 -0
- package/packages/contracts/schemas/platform-spec.schema.json +17 -0
- package/packages/contracts/schemas/project.schema.json +270 -0
- package/packages/contracts/src/index.ts +191 -0
- package/packages/contracts/src/module-distribution.ts +47 -0
- package/packages/contracts/src/schemas.ts +18 -0
- package/packages/contracts/src/variables.ts +156 -0
- package/packages/contracts/src/web.ts +60 -0
- package/packages/database/package.json +43 -0
- package/packages/database/src/contracts.ts +399 -0
- package/packages/database/src/index.ts +112 -0
- package/packages/database/src/migrations.ts +444 -0
- package/packages/database/src/postgresql.ts +517 -0
- package/packages/database/src/provider.ts +639 -0
- package/packages/database/src/record-history.ts +201 -0
- package/packages/database/src/registry.ts +265 -0
- package/packages/database/src/reset.ts +87 -0
- package/packages/database-pglite/package.json +42 -0
- package/packages/database-pglite/src/driver.ts +168 -0
- package/packages/database-pglite/src/index.ts +2 -0
- package/packages/database-testing/package.json +44 -0
- package/packages/database-testing/src/index.ts +8 -0
- package/packages/database-testing/src/pglite.ts +124 -0
- package/packages/database-testing/src/postgres.ts +184 -0
- package/packages/database-testing/src/provider.ts +43 -0
- package/packages/database-testing/src/vitest-preset.ts +23 -0
- package/packages/dev-console/README.md +6 -0
- package/packages/dev-console/package.json +38 -0
- package/packages/dev-console/src/index.mjs +220 -0
- package/packages/harness/package.json +47 -0
- package/packages/harness/schemas/task-packet.schema.json +48 -0
- package/packages/harness/src/catalog.ts +24 -0
- package/packages/harness/src/errors.ts +9 -0
- package/packages/harness/src/index.ts +53 -0
- package/packages/harness/src/preamble.ts +31 -0
- package/packages/harness/src/runtime.ts +833 -0
- package/packages/harness/src/task-packet.ts +86 -0
- package/packages/harness/src/tool-adapters.ts +106 -0
- package/packages/harness/src/tool-contract.ts +242 -0
- package/packages/harness/src/vercel-ai-provider.ts +198 -0
- package/packages/kernel/package.json +44 -0
- package/packages/kernel/src/acl.ts +20 -0
- package/packages/kernel/src/actor.ts +139 -0
- package/packages/kernel/src/agent-registry.ts +81 -0
- package/packages/kernel/src/capability-registry.ts +28 -0
- package/packages/kernel/src/errors.ts +9 -0
- package/packages/kernel/src/index.ts +90 -0
- package/packages/kernel/src/legacy-local-state.ts +103 -0
- package/packages/kernel/src/module-compatibility.ts +70 -0
- package/packages/kernel/src/module-registry.ts +124 -0
- package/packages/kernel/src/module-settings.ts +403 -0
- package/packages/kernel/src/record-history.ts +146 -0
- package/packages/kernel/src/runtime-config.ts +51 -0
- package/packages/kernel/src/tool-registry.ts +25 -0
- package/packages/kernel/src/variable-registry.ts +339 -0
- package/packages/sandbox/README.md +691 -0
- package/packages/sandbox/bin/flowdular-sandbox.mjs +393 -0
- package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +11 -0
- package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +1 -0
- package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +3 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +4 -0
- package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +3 -0
- package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +1 -0
- package/packages/sandbox/dist/client/favicon.svg +1 -0
- package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +12785 -0
- package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +4125 -0
- package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +24 -0
- package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +388 -0
- package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +55 -0
- package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +3 -0
- package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +494 -0
- package/packages/sandbox/dist/server/entry.js +91671 -0
- package/packages/sandbox/dist/server/favicon.svg +1 -0
- package/packages/sandbox/dist/server/index.html +161 -0
- package/packages/sandbox/index.html +159 -0
- package/packages/sandbox/octane.config.ts +36 -0
- package/packages/sandbox/package.json +83 -0
- package/packages/sandbox/public/favicon.svg +1 -0
- package/packages/sandbox/public/og.png +0 -0
- package/packages/sandbox/src/App.tsrx +924 -0
- package/packages/sandbox/src/client/ApprovalHandoff.tsrx +39 -0
- package/packages/sandbox/src/client/ChatPane.tsrx +694 -0
- package/packages/sandbox/src/client/ConnectView.tsrx +86 -0
- package/packages/sandbox/src/client/DiffPane.tsrx +150 -0
- package/packages/sandbox/src/client/EjectModal.tsrx +571 -0
- package/packages/sandbox/src/client/GateResults.tsrx +60 -0
- package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +219 -0
- package/packages/sandbox/src/client/NewSessionForm.tsrx +214 -0
- package/packages/sandbox/src/client/SessionBar.tsrx +151 -0
- package/packages/sandbox/src/client/SessionPicker.tsrx +472 -0
- package/packages/sandbox/src/client/SpecEditor.tsrx +109 -0
- package/packages/sandbox/src/client/SpecReviewCard.tsrx +321 -0
- package/packages/sandbox/src/client/WorkCard.tsrx +128 -0
- package/packages/sandbox/src/client/WorkbenchModal.tsrx +161 -0
- package/packages/sandbox/src/client/WorkspaceMenu.tsrx +100 -0
- package/packages/sandbox/src/client/api.ts +797 -0
- package/packages/sandbox/src/client/attachments.ts +73 -0
- package/packages/sandbox/src/client/dashboard.ts +46 -0
- package/packages/sandbox/src/client/github-settings.ts +48 -0
- package/packages/sandbox/src/client/highlight.ts +105 -0
- package/packages/sandbox/src/client/i18n.ts +32 -0
- package/packages/sandbox/src/client/locales/en.json +466 -0
- package/packages/sandbox/src/client/locales/pl.json +466 -0
- package/packages/sandbox/src/client/session-labels.ts +77 -0
- package/packages/sandbox/src/client/state.ts +108 -0
- package/packages/sandbox/src/index.ts +10 -0
- package/packages/sandbox/src/preview/PreviewHost.tsrx +373 -0
- package/packages/sandbox/src/preview/load-module.ts +16 -0
- package/packages/sandbox/src/server/attachments.ts +308 -0
- package/packages/sandbox/src/server/auto-review.ts +181 -0
- package/packages/sandbox/src/server/checkpoints.ts +90 -0
- package/packages/sandbox/src/server/config.ts +460 -0
- package/packages/sandbox/src/server/dashboard.ts +245 -0
- package/packages/sandbox/src/server/delivery/configuration.ts +193 -0
- package/packages/sandbox/src/server/delivery/git-pr.ts +1473 -0
- package/packages/sandbox/src/server/delivery/index.ts +83 -0
- package/packages/sandbox/src/server/delivery/local.ts +151 -0
- package/packages/sandbox/src/server/delivery/official-modules.ts +516 -0
- package/packages/sandbox/src/server/delivery/plan.ts +218 -0
- package/packages/sandbox/src/server/delivery/policies.ts +239 -0
- package/packages/sandbox/src/server/delivery/record.ts +41 -0
- package/packages/sandbox/src/server/delivery/steps.ts +464 -0
- package/packages/sandbox/src/server/delivery/types.ts +158 -0
- package/packages/sandbox/src/server/dependencies.ts +88 -0
- package/packages/sandbox/src/server/diff.ts +230 -0
- package/packages/sandbox/src/server/gates.ts +388 -0
- package/packages/sandbox/src/server/index.ts +139 -0
- package/packages/sandbox/src/server/path-guard.ts +282 -0
- package/packages/sandbox/src/server/planning.ts +749 -0
- package/packages/sandbox/src/server/platform-client.ts +189 -0
- package/packages/sandbox/src/server/preview-database-host.ts +340 -0
- package/packages/sandbox/src/server/preview-database-protocol.ts +163 -0
- package/packages/sandbox/src/server/preview-database-proxy.ts +356 -0
- package/packages/sandbox/src/server/preview-database.ts +109 -0
- package/packages/sandbox/src/server/preview-ipc.ts +15 -0
- package/packages/sandbox/src/server/preview-modules.ts +83 -0
- package/packages/sandbox/src/server/preview-revision.ts +44 -0
- package/packages/sandbox/src/server/preview-runtime.ts +467 -0
- package/packages/sandbox/src/server/preview-worker-manager.ts +436 -0
- package/packages/sandbox/src/server/preview-worker.ts +168 -0
- package/packages/sandbox/src/server/reference.ts +251 -0
- package/packages/sandbox/src/server/routes.ts +2258 -0
- package/packages/sandbox/src/server/runtime.ts +188 -0
- package/packages/sandbox/src/server/session-owner.ts +23 -0
- package/packages/sandbox/src/server/sessions.ts +849 -0
- package/packages/sandbox/src/server/spec.ts +471 -0
- package/packages/sandbox/src/server/turn-lifecycle.ts +56 -0
- package/packages/sandbox/src/server/turns.ts +993 -0
- package/packages/sandbox/src/server/workspace-install.ts +266 -0
- package/packages/sandbox/src/server/workspace-root.ts +73 -0
- package/packages/sandbox/src/styles.css +1573 -0
- package/packages/sandbox/vite.config.ts +133 -0
- package/packages/server/package.json +43 -0
- package/packages/server/src/application-routes.ts +21 -0
- package/packages/server/src/composition.ts +48 -0
- package/packages/server/src/endpoint.ts +142 -0
- package/packages/server/src/http.ts +128 -0
- package/packages/server/src/index.ts +46 -0
- package/packages/server/src/response-lifetime.ts +56 -0
- package/packages/server/src/security-headers.ts +130 -0
- package/packages/server/src/web-html.ts +55 -0
- package/packages/server/src/web.ts +361 -0
- package/packages/ui/package.json +46 -0
- package/packages/ui/src/brand/BrandMark.tsrx +66 -0
- package/packages/ui/src/brand/mark.ts +44 -0
- package/packages/ui/src/components/Alert.tsrx +17 -0
- package/packages/ui/src/components/Avatar.tsrx +18 -0
- package/packages/ui/src/components/Button.tsrx +29 -0
- package/packages/ui/src/components/Chart.tsrx +274 -0
- package/packages/ui/src/components/CheckGrid.tsrx +93 -0
- package/packages/ui/src/components/ConfirmDialog.tsrx +63 -0
- package/packages/ui/src/components/Drawer.tsrx +71 -0
- package/packages/ui/src/components/EmptyState.tsrx +26 -0
- package/packages/ui/src/components/Filters.tsrx +39 -0
- package/packages/ui/src/components/FormField.tsrx +27 -0
- package/packages/ui/src/components/Kpi.tsrx +36 -0
- package/packages/ui/src/components/PageHeader.tsrx +26 -0
- package/packages/ui/src/components/ScopeSummary.tsrx +76 -0
- package/packages/ui/src/components/SearchField.tsrx +24 -0
- package/packages/ui/src/components/SettingRow.tsrx +50 -0
- package/packages/ui/src/components/Switch.tsrx +21 -0
- package/packages/ui/src/components/Table.tsrx +267 -0
- package/packages/ui/src/components/TableCard.tsrx +52 -0
- package/packages/ui/src/components/Tag.tsrx +28 -0
- package/packages/ui/src/components/VariableField.tsrx +284 -0
- package/packages/ui/src/components/VariableInput.tsrx +7 -0
- package/packages/ui/src/components/VariableSelect.tsrx +72 -0
- package/packages/ui/src/components/VariableTextarea.tsrx +9 -0
- package/packages/ui/src/components/variable-field.ts +61 -0
- package/packages/ui/src/components/variable-select.ts +69 -0
- package/packages/ui/src/icons/Icon.tsrx +56 -0
- package/packages/ui/src/index.ts +81 -0
- package/packages/ui/src/initials.ts +10 -0
- package/packages/ui/src/styles/base.css +71 -0
- package/packages/ui/src/styles/components.css +1859 -0
- package/packages/ui/src/styles/index.css +3 -0
- package/packages/ui/src/styles/tokens.css +124 -0
- package/tsconfig.base.json +18 -0
|
@@ -0,0 +1,2309 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { userActor, type Actor } from '@flowdular/sdk/kernel';
|
|
3
|
+
import { AUTH_SCOPES, MEMBER_SCOPES, OWNER_SCOPES } from '../acl/scopes.ts';
|
|
4
|
+
import type {
|
|
5
|
+
ApiTokenRecord,
|
|
6
|
+
AuditQuery,
|
|
7
|
+
AuthActor,
|
|
8
|
+
AuthPrincipal,
|
|
9
|
+
AuthSession,
|
|
10
|
+
AuthTenantAccess,
|
|
11
|
+
CreateApiTokenInput,
|
|
12
|
+
CreateRoleInput,
|
|
13
|
+
CreateTenantMemberInput,
|
|
14
|
+
IssuedApiToken,
|
|
15
|
+
IssuedSession,
|
|
16
|
+
SessionSummary,
|
|
17
|
+
SignInInput,
|
|
18
|
+
SignUpInput,
|
|
19
|
+
TenantRole,
|
|
20
|
+
UpdateRoleInput,
|
|
21
|
+
} from '../domain/types.ts';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_PASSWORD_MIN_LENGTH,
|
|
24
|
+
DEFAULT_SESSION_IDLE_MINUTES,
|
|
25
|
+
DEFAULT_SESSION_TTL_HOURS,
|
|
26
|
+
} from '../settings.ts';
|
|
27
|
+
import { AuthServiceError } from './auth-service-error.ts';
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_PASSWORD_HASH_OPTIONS,
|
|
30
|
+
hashPassword,
|
|
31
|
+
verifyPassword,
|
|
32
|
+
type PasswordHashOptions,
|
|
33
|
+
} from './password.ts';
|
|
34
|
+
import {
|
|
35
|
+
DuplicateAccountError,
|
|
36
|
+
DuplicateRoleKeyError,
|
|
37
|
+
DuplicateTenantSlugError,
|
|
38
|
+
type AccountCredential,
|
|
39
|
+
type AuditActorEvent,
|
|
40
|
+
type AuthRepository,
|
|
41
|
+
type TenantMember,
|
|
42
|
+
type TenantSummary,
|
|
43
|
+
} from './repository.ts';
|
|
44
|
+
import type { AuthMailDelivery } from './mail-delivery.ts';
|
|
45
|
+
import {
|
|
46
|
+
createTotpSecret,
|
|
47
|
+
decryptMfaSecret,
|
|
48
|
+
encryptMfaSecret,
|
|
49
|
+
verifyTotp,
|
|
50
|
+
} from './totp.ts';
|
|
51
|
+
import {
|
|
52
|
+
assertPasswordPolicy,
|
|
53
|
+
normalizeEmail,
|
|
54
|
+
validateCreateTenantMember,
|
|
55
|
+
validateDisplayName,
|
|
56
|
+
validateEmailAddress,
|
|
57
|
+
validateRoleDescription,
|
|
58
|
+
validateRoleKey,
|
|
59
|
+
validateRoleName,
|
|
60
|
+
validateSignIn,
|
|
61
|
+
validateSignUp,
|
|
62
|
+
validateWorkspaceName,
|
|
63
|
+
validateWorkspaceSlug,
|
|
64
|
+
} from './validation.ts';
|
|
65
|
+
|
|
66
|
+
export interface AuthPolicy {
|
|
67
|
+
readonly sessionTtlMs: number;
|
|
68
|
+
readonly sessionIdleMs: number;
|
|
69
|
+
readonly passwordMinLength: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* The audit page plus the actor columns added by 0014; assignable wherever an
|
|
73
|
+
AuditPage was expected. */
|
|
74
|
+
export interface AuditActorPage {
|
|
75
|
+
readonly events: readonly AuditActorEvent[];
|
|
76
|
+
readonly nextCursor: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AuthServiceOptions {
|
|
80
|
+
readonly sessionTtlMs?: number;
|
|
81
|
+
/** Live policy; read on every call so settings changes apply immediately. */
|
|
82
|
+
readonly policy?: () => AuthPolicy;
|
|
83
|
+
readonly passwordHash?: PasswordHashOptions;
|
|
84
|
+
readonly now?: () => number;
|
|
85
|
+
/** AES-256-GCM key used only to protect enrolled TOTP secrets at rest. */
|
|
86
|
+
readonly mfaEncryptionKey?: string;
|
|
87
|
+
/** Deployment-owned delivery adapter. auth.core never selects an email vendor. */
|
|
88
|
+
readonly mailDelivery?: AuthMailDelivery;
|
|
89
|
+
/** Public origin used to form opaque, one-time delivery links. */
|
|
90
|
+
readonly publicBaseUrl?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface MfaChallenge extends IssuedSession {
|
|
94
|
+
readonly mfaRequired: true;
|
|
95
|
+
/* This is a short-lived proof of a successful password check, never a session. */
|
|
96
|
+
readonly token: string;
|
|
97
|
+
readonly expiresAt: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface MfaStatus {
|
|
101
|
+
readonly available: boolean;
|
|
102
|
+
readonly enrolled: boolean;
|
|
103
|
+
readonly pending: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How the first credential of a provisioned account reaches the operator. */
|
|
107
|
+
export type OperatorCredentialKind =
|
|
108
|
+
| 'password-setup-link'
|
|
109
|
+
| 'operator-password'
|
|
110
|
+
| 'invitation-link'
|
|
111
|
+
| 'existing-password';
|
|
112
|
+
|
|
113
|
+
export interface OperatorCredential {
|
|
114
|
+
readonly kind: OperatorCredentialKind;
|
|
115
|
+
/* Carried by the link kinds only. Returned once, because only its hash is
|
|
116
|
+
stored and nothing can recover it afterwards. */
|
|
117
|
+
readonly url?: string;
|
|
118
|
+
readonly expiresAt?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface WorkspaceProvisionInput {
|
|
122
|
+
readonly name: string;
|
|
123
|
+
readonly slug: string;
|
|
124
|
+
readonly ownerEmail: string;
|
|
125
|
+
readonly ownerDisplayName: string;
|
|
126
|
+
/* Chosen by the operator and supplied out of band. Leaving it out issues a
|
|
127
|
+
single-use setup link instead, so no password the operator did not choose
|
|
128
|
+
is ever returned. */
|
|
129
|
+
readonly password?: string;
|
|
130
|
+
/** Audit label of the operator, such as `cli:ada`. */
|
|
131
|
+
readonly operator: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface WorkspaceProvisionPlan {
|
|
135
|
+
readonly workspace: { readonly name: string; readonly slug: string };
|
|
136
|
+
readonly owner: {
|
|
137
|
+
readonly email: string;
|
|
138
|
+
readonly displayName: string;
|
|
139
|
+
readonly role: string;
|
|
140
|
+
readonly scopes: readonly string[];
|
|
141
|
+
};
|
|
142
|
+
/* The same field the applied result carries, so a plan and an apply parse
|
|
143
|
+
the same way. Only the applied one adds the link itself. */
|
|
144
|
+
readonly credential: { readonly kind: OperatorCredentialKind };
|
|
145
|
+
readonly operator: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ProvisionedWorkspace {
|
|
149
|
+
readonly workspace: TenantSummary;
|
|
150
|
+
readonly owner: {
|
|
151
|
+
readonly accountId: string;
|
|
152
|
+
readonly email: string;
|
|
153
|
+
readonly displayName: string;
|
|
154
|
+
readonly role: string;
|
|
155
|
+
readonly scopes: readonly string[];
|
|
156
|
+
};
|
|
157
|
+
readonly credential: OperatorCredential;
|
|
158
|
+
readonly operator: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface MemberProvisionInput {
|
|
162
|
+
/** Workspace slug or identifier. */
|
|
163
|
+
readonly workspace: string;
|
|
164
|
+
readonly email: string;
|
|
165
|
+
readonly role: string;
|
|
166
|
+
readonly operator: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface MemberProvisionPlan {
|
|
170
|
+
readonly workspace: TenantSummary;
|
|
171
|
+
readonly email: string;
|
|
172
|
+
readonly role: TenantRole;
|
|
173
|
+
/* An address that already has an account joins immediately; an unknown one
|
|
174
|
+
receives an invitation and creates its own account. */
|
|
175
|
+
readonly action: 'membership' | 'invitation';
|
|
176
|
+
readonly account: {
|
|
177
|
+
readonly accountId: string;
|
|
178
|
+
readonly email: string;
|
|
179
|
+
readonly displayName: string;
|
|
180
|
+
} | null;
|
|
181
|
+
readonly credential: { readonly kind: OperatorCredentialKind };
|
|
182
|
+
readonly operator: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface ProvisionedMember {
|
|
186
|
+
readonly workspace: TenantSummary;
|
|
187
|
+
readonly email: string;
|
|
188
|
+
readonly role: string;
|
|
189
|
+
readonly scopes: readonly string[];
|
|
190
|
+
readonly action: 'membership' | 'invitation';
|
|
191
|
+
readonly accountId: string | null;
|
|
192
|
+
readonly invitationId: string | null;
|
|
193
|
+
readonly credential: OperatorCredential;
|
|
194
|
+
readonly operator: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface SignInContext {
|
|
198
|
+
/** Client address when known; null skips address-based limits. */
|
|
199
|
+
readonly address?: string | null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function hashSessionToken(token: string): string {
|
|
203
|
+
return createHash('sha256').update(token, 'utf8').digest('base64url');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const API_TOKEN_PREFIX = 'clat_';
|
|
207
|
+
const API_TOKEN_PATTERN = /^clat_[A-Za-z0-9_-]{43}$/;
|
|
208
|
+
const MAX_API_TOKEN_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
|
|
209
|
+
const API_TOKEN_TOUCH_INTERVAL_MS = 60_000;
|
|
210
|
+
const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
211
|
+
const SCOPE_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
|
212
|
+
const LOCK_THRESHOLD = 5;
|
|
213
|
+
const LOCK_MS = 15 * 60 * 1000;
|
|
214
|
+
const FAILURE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
215
|
+
const MAX_AUDIT_PAGE = 100;
|
|
216
|
+
const PASSWORD_RESET_TTL_MS = 30 * 60 * 1000;
|
|
217
|
+
const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
218
|
+
/* An operator carries this link to a person by hand, which the 30 minutes of a
|
|
219
|
+
self-service reset does not survive. It stays single use and hashed at rest. */
|
|
220
|
+
const OPERATOR_SETUP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
221
|
+
const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
222
|
+
const RECOVERY_CODE_COUNT = 10;
|
|
223
|
+
|
|
224
|
+
export const AUDIT_ACTIONS = Object.freeze({
|
|
225
|
+
signInSucceeded: 'auth.sign-in.succeeded',
|
|
226
|
+
signInFailed: 'auth.sign-in.failed',
|
|
227
|
+
signInLocked: 'auth.sign-in.locked',
|
|
228
|
+
signOut: 'auth.sign-out',
|
|
229
|
+
sessionRevoked: 'auth.session.revoked',
|
|
230
|
+
passwordChanged: 'auth.password.changed',
|
|
231
|
+
tokenIssued: 'auth.token.issued',
|
|
232
|
+
tokenRevoked: 'auth.token.revoked',
|
|
233
|
+
passwordResetRequested: 'auth.password-reset.requested',
|
|
234
|
+
passwordResetCompleted: 'auth.password-reset.completed',
|
|
235
|
+
invitationCreated: 'auth.invitation.created',
|
|
236
|
+
invitationAccepted: 'auth.invitation.accepted',
|
|
237
|
+
mfaEnrolled: 'auth.mfa.enrolled',
|
|
238
|
+
mfaConfirmed: 'auth.mfa.confirmed',
|
|
239
|
+
mfaChallengeSucceeded: 'auth.mfa.challenge-succeeded',
|
|
240
|
+
tenantRenamed: 'auth.tenant.renamed',
|
|
241
|
+
workspaceProvisioned: 'auth.workspace.provisioned',
|
|
242
|
+
memberCreated: 'users.member.created',
|
|
243
|
+
memberUpdated: 'users.member.updated',
|
|
244
|
+
memberStatus: 'users.member.status',
|
|
245
|
+
memberRemoved: 'users.member.removed',
|
|
246
|
+
memberPasswordReset: 'users.member.password-reset',
|
|
247
|
+
memberScopes: 'users.member.scopes',
|
|
248
|
+
memberRole: 'users.member.role',
|
|
249
|
+
roleCreated: 'auth.role.created',
|
|
250
|
+
roleUpdated: 'auth.role.updated',
|
|
251
|
+
roleDeleted: 'auth.role.deleted',
|
|
252
|
+
settingsUpdated: 'settings.updated',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export const AUDIT_ACTION_LIST = Object.freeze(Object.values(AUDIT_ACTIONS));
|
|
256
|
+
|
|
257
|
+
/* An operator command runs from a deployment shell and belongs to no account.
|
|
258
|
+
The audit actor_kind column accepts only 'user' or 'agent', so the operator
|
|
259
|
+
is recorded as a user whose identifier carries the cli: prefix the label
|
|
260
|
+
filter can search for. */
|
|
261
|
+
function operatorActor(operator: string): Actor {
|
|
262
|
+
return { kind: 'user', id: operator, label: operator };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function member(account: AccountCredential, createdAt: number): TenantMember {
|
|
266
|
+
return {
|
|
267
|
+
accountId: account.accountId,
|
|
268
|
+
email: account.email,
|
|
269
|
+
displayName: account.displayName,
|
|
270
|
+
role: account.role,
|
|
271
|
+
roleId: account.roleId,
|
|
272
|
+
status: account.status,
|
|
273
|
+
scopes: account.scopes,
|
|
274
|
+
passwordChangeRequired: account.passwordChangeRequired,
|
|
275
|
+
createdAt,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export class AuthService {
|
|
280
|
+
readonly #repository: AuthRepository;
|
|
281
|
+
readonly #policy: () => AuthPolicy;
|
|
282
|
+
readonly #passwordHash: PasswordHashOptions;
|
|
283
|
+
readonly #now: () => number;
|
|
284
|
+
readonly #mfaEncryptionKey: string | undefined;
|
|
285
|
+
readonly #mailDelivery: AuthMailDelivery | undefined;
|
|
286
|
+
readonly #publicBaseUrl: string;
|
|
287
|
+
|
|
288
|
+
constructor(repository: AuthRepository, options: AuthServiceOptions = {}) {
|
|
289
|
+
this.#repository = repository;
|
|
290
|
+
const fallback: AuthPolicy = {
|
|
291
|
+
sessionTtlMs:
|
|
292
|
+
options.sessionTtlMs ?? DEFAULT_SESSION_TTL_HOURS * 60 * 60 * 1000,
|
|
293
|
+
sessionIdleMs: DEFAULT_SESSION_IDLE_MINUTES * 60 * 1000,
|
|
294
|
+
passwordMinLength: DEFAULT_PASSWORD_MIN_LENGTH,
|
|
295
|
+
};
|
|
296
|
+
this.#policy = options.policy ?? (() => fallback);
|
|
297
|
+
this.#passwordHash = options.passwordHash ?? DEFAULT_PASSWORD_HASH_OPTIONS;
|
|
298
|
+
this.#now = options.now ?? Date.now;
|
|
299
|
+
this.#mfaEncryptionKey = options.mfaEncryptionKey;
|
|
300
|
+
this.#mailDelivery = options.mailDelivery;
|
|
301
|
+
this.#publicBaseUrl = (options.publicBaseUrl ?? 'http://localhost').replace(
|
|
302
|
+
/\/$/,
|
|
303
|
+
'',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
get policy(): AuthPolicy {
|
|
308
|
+
return this.#policy();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* Audit rows are evidence, never a reason to fail the operation they
|
|
312
|
+
describe; a failing write is reported and swallowed. */
|
|
313
|
+
async #audit(
|
|
314
|
+
tenantId: string,
|
|
315
|
+
actor: Actor,
|
|
316
|
+
action: string,
|
|
317
|
+
subjectType: string,
|
|
318
|
+
subjectId: string,
|
|
319
|
+
metadata: Readonly<Record<string, unknown>> = {},
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
try {
|
|
322
|
+
await this.#repository.appendAudit({
|
|
323
|
+
tenantId,
|
|
324
|
+
actorAccountId: actor.kind === 'user' ? actor.id : null,
|
|
325
|
+
actorLabel: actor.label,
|
|
326
|
+
actorKind: actor.kind,
|
|
327
|
+
actorRunId: actor.kind === 'agent' ? actor.runId : null,
|
|
328
|
+
action,
|
|
329
|
+
subjectType,
|
|
330
|
+
subjectId,
|
|
331
|
+
metadata,
|
|
332
|
+
occurredAt: this.#now(),
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
/* Audit failures must not leak a driver error, whose detail can include
|
|
336
|
+
bound values from the operation being audited. */
|
|
337
|
+
void error;
|
|
338
|
+
console.error('[auth.core] audit write failed');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#actorOf(actor: AuthActor): Actor {
|
|
343
|
+
return userActor(actor);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async #issue(account: AccountCredential): Promise<IssuedSession> {
|
|
347
|
+
const token = randomBytes(32).toString('base64url');
|
|
348
|
+
const csrfToken = randomBytes(24).toString('base64url');
|
|
349
|
+
const createdAt = this.#now();
|
|
350
|
+
const expiresAt = createdAt + this.#policy().sessionTtlMs;
|
|
351
|
+
const sessionId = randomUUID();
|
|
352
|
+
await this.#repository.createSession({
|
|
353
|
+
id: sessionId,
|
|
354
|
+
tokenHash: hashSessionToken(token),
|
|
355
|
+
accountId: account.accountId,
|
|
356
|
+
tenantId: account.tenantId,
|
|
357
|
+
csrfToken,
|
|
358
|
+
createdAt,
|
|
359
|
+
expiresAt,
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
token,
|
|
363
|
+
csrfToken,
|
|
364
|
+
expiresAt,
|
|
365
|
+
sessionId,
|
|
366
|
+
passwordChangeRequired: account.passwordChangeRequired,
|
|
367
|
+
principal: {
|
|
368
|
+
accountId: account.accountId,
|
|
369
|
+
tenantId: account.tenantId,
|
|
370
|
+
email: account.email,
|
|
371
|
+
displayName: account.displayName,
|
|
372
|
+
role: account.role,
|
|
373
|
+
scopes: account.scopes,
|
|
374
|
+
tenants: await this.#repository.listTenantAccess(account.accountId),
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async signUp(raw: SignUpInput): Promise<IssuedSession> {
|
|
380
|
+
const input = validateSignUp(raw, this.#policy().passwordMinLength);
|
|
381
|
+
const passwordHash = await hashPassword(input.password, this.#passwordHash);
|
|
382
|
+
try {
|
|
383
|
+
const account = await this.#repository.createAccountWithTenant({
|
|
384
|
+
accountId: randomUUID(),
|
|
385
|
+
tenantId: randomUUID(),
|
|
386
|
+
email: input.email,
|
|
387
|
+
normalizedEmail: input.email,
|
|
388
|
+
passwordHash,
|
|
389
|
+
displayName: input.displayName,
|
|
390
|
+
organizationName: input.organizationName,
|
|
391
|
+
organizationSlug: input.organizationSlug,
|
|
392
|
+
role: 'owner',
|
|
393
|
+
scopes: OWNER_SCOPES,
|
|
394
|
+
createdAt: this.#now(),
|
|
395
|
+
});
|
|
396
|
+
const issued = await this.#issue(account);
|
|
397
|
+
await this.#audit(
|
|
398
|
+
account.tenantId,
|
|
399
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
400
|
+
AUDIT_ACTIONS.signInSucceeded,
|
|
401
|
+
'session',
|
|
402
|
+
issued.sessionId,
|
|
403
|
+
{ via: 'sign-up' },
|
|
404
|
+
);
|
|
405
|
+
return issued;
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error instanceof DuplicateAccountError) {
|
|
408
|
+
// Same code and status as any other rejected sign-up so the
|
|
409
|
+
// response does not confirm that the address is registered.
|
|
410
|
+
throw new AuthServiceError(
|
|
411
|
+
'SIGN_UP_REJECTED',
|
|
412
|
+
'Sign-up could not be completed with these details. If you already have an account, sign in instead.',
|
|
413
|
+
400,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
if (error instanceof DuplicateTenantSlugError) {
|
|
417
|
+
throw new AuthServiceError('WORKSPACE_SLUG_TAKEN', error.message, 409);
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async checkWorkspaceSlug(raw: string): Promise<{
|
|
424
|
+
readonly slug: string;
|
|
425
|
+
readonly valid: boolean;
|
|
426
|
+
readonly available: boolean;
|
|
427
|
+
readonly message?: string;
|
|
428
|
+
}> {
|
|
429
|
+
let slug: string;
|
|
430
|
+
try {
|
|
431
|
+
slug = validateWorkspaceSlug(raw);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return {
|
|
434
|
+
slug: raw,
|
|
435
|
+
valid: false,
|
|
436
|
+
available: false,
|
|
437
|
+
message:
|
|
438
|
+
error instanceof AuthServiceError
|
|
439
|
+
? error.message
|
|
440
|
+
: 'Workspace id is invalid.',
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
slug,
|
|
445
|
+
valid: true,
|
|
446
|
+
available: !(await this.#repository.isTenantSlugTaken(slug)),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* The lockout is keyed by the submitted address whether or not an account
|
|
451
|
+
exists, so its error never reveals which addresses are registered. */
|
|
452
|
+
async signIn(
|
|
453
|
+
raw: SignInInput,
|
|
454
|
+
context: SignInContext = {},
|
|
455
|
+
): Promise<IssuedSession & { readonly mfaRequired?: true }> {
|
|
456
|
+
const input = validateSignIn(raw);
|
|
457
|
+
const now = this.#now();
|
|
458
|
+
const failure = await this.#repository.findSignInFailure(input.email);
|
|
459
|
+
if (failure?.lockedUntil !== null && (failure?.lockedUntil ?? 0) > now) {
|
|
460
|
+
throw new AuthServiceError(
|
|
461
|
+
'ACCOUNT_LOCKED',
|
|
462
|
+
'Too many failed sign-in attempts. Try again later.',
|
|
463
|
+
423,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const account = await this.#repository.findAccountByEmail(input.email);
|
|
467
|
+
if (!account) {
|
|
468
|
+
await hashPassword(input.password, this.#passwordHash);
|
|
469
|
+
await this.#recordFailure(input.email, null, context);
|
|
470
|
+
throw this.#invalidCredentials();
|
|
471
|
+
}
|
|
472
|
+
const valid = await verifyPassword(input.password, account.passwordHash);
|
|
473
|
+
if (!valid || account.status !== 'active') {
|
|
474
|
+
await this.#recordFailure(input.email, account, context);
|
|
475
|
+
throw this.#invalidCredentials();
|
|
476
|
+
}
|
|
477
|
+
await this.#repository.clearSignInFailures(input.email);
|
|
478
|
+
const mfa = await this.#repository.findMfaTotp(account.accountId);
|
|
479
|
+
if (mfa?.confirmedAt !== null && mfa?.confirmedAt !== undefined)
|
|
480
|
+
return this.#issueMfaChallenge(account, now);
|
|
481
|
+
const issued = await this.#issue(account);
|
|
482
|
+
await this.#audit(
|
|
483
|
+
account.tenantId,
|
|
484
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
485
|
+
AUDIT_ACTIONS.signInSucceeded,
|
|
486
|
+
'session',
|
|
487
|
+
issued.sessionId,
|
|
488
|
+
context.address ? { address: context.address } : {},
|
|
489
|
+
);
|
|
490
|
+
return issued;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async #issueMfaChallenge(
|
|
494
|
+
account: AccountCredential,
|
|
495
|
+
now = this.#now(),
|
|
496
|
+
): Promise<MfaChallenge> {
|
|
497
|
+
const token = randomBytes(32).toString('base64url');
|
|
498
|
+
await this.#repository.createMfaChallenge({
|
|
499
|
+
tokenHash: hashSessionToken(token),
|
|
500
|
+
accountId: account.accountId,
|
|
501
|
+
tenantId: account.tenantId,
|
|
502
|
+
expiresAt: now + MFA_CHALLENGE_TTL_MS,
|
|
503
|
+
createdAt: now,
|
|
504
|
+
});
|
|
505
|
+
/* `token` is a short-lived proof of the first factor, never a session. */
|
|
506
|
+
return {
|
|
507
|
+
mfaRequired: true,
|
|
508
|
+
token,
|
|
509
|
+
csrfToken: '',
|
|
510
|
+
sessionId: '',
|
|
511
|
+
expiresAt: now + MFA_CHALLENGE_TTL_MS,
|
|
512
|
+
passwordChangeRequired: account.passwordChangeRequired,
|
|
513
|
+
principal: {
|
|
514
|
+
accountId: account.accountId,
|
|
515
|
+
tenantId: account.tenantId,
|
|
516
|
+
email: account.email,
|
|
517
|
+
displayName: account.displayName,
|
|
518
|
+
role: account.role,
|
|
519
|
+
scopes: [],
|
|
520
|
+
tenants: [],
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#invalidCredentials(): AuthServiceError {
|
|
526
|
+
return new AuthServiceError(
|
|
527
|
+
'INVALID_CREDENTIALS',
|
|
528
|
+
'Email or password is incorrect.',
|
|
529
|
+
401,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async #recordFailure(
|
|
534
|
+
normalizedEmail: string,
|
|
535
|
+
account: AccountCredential | null,
|
|
536
|
+
context: SignInContext,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
const record = await this.#repository.recordSignInFailure(
|
|
539
|
+
normalizedEmail,
|
|
540
|
+
this.#now(),
|
|
541
|
+
LOCK_THRESHOLD,
|
|
542
|
+
LOCK_MS,
|
|
543
|
+
FAILURE_RETENTION_MS,
|
|
544
|
+
);
|
|
545
|
+
if (!account) return;
|
|
546
|
+
const locked = record.failures >= LOCK_THRESHOLD;
|
|
547
|
+
await this.#audit(
|
|
548
|
+
account.tenantId,
|
|
549
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
550
|
+
locked ? AUDIT_ACTIONS.signInLocked : AUDIT_ACTIONS.signInFailed,
|
|
551
|
+
'account',
|
|
552
|
+
account.accountId,
|
|
553
|
+
{
|
|
554
|
+
failures: record.failures,
|
|
555
|
+
...(context.address ? { address: context.address } : {}),
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async listTenantMembers(tenantId: string): Promise<readonly TenantMember[]> {
|
|
561
|
+
return this.#repository.listTenantMembers(tenantId);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* Scopes are authorization metadata, not credentials. Dependent modules read
|
|
565
|
+
them here instead of interpreting role names or opening the auth database. */
|
|
566
|
+
async listMembershipScopes(
|
|
567
|
+
accountId: string,
|
|
568
|
+
tenantId: string,
|
|
569
|
+
): Promise<readonly string[]> {
|
|
570
|
+
return (
|
|
571
|
+
(await this.#repository.findAccountMembership(accountId, tenantId))
|
|
572
|
+
?.scopes ?? []
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/* Every scope an administrator may hand out in this workspace: the static
|
|
577
|
+
owner template plus whatever later modules granted through sync-scopes. */
|
|
578
|
+
async listGrantableScopes(tenantId: string): Promise<readonly string[]> {
|
|
579
|
+
return [
|
|
580
|
+
...new Set([
|
|
581
|
+
...OWNER_SCOPES,
|
|
582
|
+
...(await this.#repository.listTenantScopes(
|
|
583
|
+
this.#identifier(tenantId, 'tenantId'),
|
|
584
|
+
)),
|
|
585
|
+
]),
|
|
586
|
+
].sort();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/* Self-service credential change. The password never leaves auth.core: a
|
|
590
|
+
dependent module calls this contract instead of hashing anything itself.
|
|
591
|
+
Every other session of the account is revoked on success. */
|
|
592
|
+
async changePassword(input: {
|
|
593
|
+
readonly accountId: string;
|
|
594
|
+
readonly currentPassword: string;
|
|
595
|
+
readonly newPassword: string;
|
|
596
|
+
readonly keepSessionToken?: string | null;
|
|
597
|
+
}): Promise<void> {
|
|
598
|
+
const account = await this.#repository.findAccountCredentialById(
|
|
599
|
+
this.#identifier(input.accountId, 'accountId'),
|
|
600
|
+
);
|
|
601
|
+
if (!account || account.status !== 'active') {
|
|
602
|
+
throw new AuthServiceError(
|
|
603
|
+
'ACCOUNT_NOT_FOUND',
|
|
604
|
+
'The account is not available.',
|
|
605
|
+
404,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const valid = await verifyPassword(
|
|
609
|
+
input.currentPassword,
|
|
610
|
+
account.passwordHash,
|
|
611
|
+
);
|
|
612
|
+
if (!valid) {
|
|
613
|
+
throw new AuthServiceError(
|
|
614
|
+
'INVALID_CREDENTIALS',
|
|
615
|
+
'The current password is incorrect.',
|
|
616
|
+
401,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
assertPasswordPolicy(input.newPassword, this.#policy().passwordMinLength);
|
|
620
|
+
if (input.newPassword === input.currentPassword) {
|
|
621
|
+
throw new AuthServiceError(
|
|
622
|
+
'PASSWORD_UNCHANGED',
|
|
623
|
+
'The new password must differ from the current one.',
|
|
624
|
+
400,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
await this.#repository.updatePasswordHash(
|
|
628
|
+
account.accountId,
|
|
629
|
+
await hashPassword(input.newPassword, this.#passwordHash),
|
|
630
|
+
false,
|
|
631
|
+
);
|
|
632
|
+
await this.#repository.deleteAccountSessions(
|
|
633
|
+
account.accountId,
|
|
634
|
+
input.keepSessionToken ? hashSessionToken(input.keepSessionToken) : null,
|
|
635
|
+
);
|
|
636
|
+
await this.#audit(
|
|
637
|
+
account.tenantId,
|
|
638
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
639
|
+
AUDIT_ACTIONS.passwordChanged,
|
|
640
|
+
'account',
|
|
641
|
+
account.accountId,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/* Adds scopes to an existing membership. A composition root uses it to give
|
|
646
|
+
a workspace the scopes a newly composed module declares; it never widens
|
|
647
|
+
authority on its own, because the caller decides what to grant. */
|
|
648
|
+
async grantMembershipScopes(
|
|
649
|
+
accountId: string,
|
|
650
|
+
tenantId: string,
|
|
651
|
+
scopes: readonly string[],
|
|
652
|
+
): Promise<readonly string[]> {
|
|
653
|
+
const membership = await this.#repository.findAccountMembership(
|
|
654
|
+
this.#identifier(accountId, 'accountId'),
|
|
655
|
+
this.#identifier(tenantId, 'tenantId'),
|
|
656
|
+
);
|
|
657
|
+
if (!membership) {
|
|
658
|
+
throw new AuthServiceError(
|
|
659
|
+
'ACCOUNT_NOT_FOUND',
|
|
660
|
+
'The account is not a member of this workspace.',
|
|
661
|
+
404,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const held = new Set(membership.scopes);
|
|
665
|
+
const added = [...new Set(scopes)]
|
|
666
|
+
.filter((scope) => SCOPE_PATTERN.test(scope) && !held.has(scope))
|
|
667
|
+
.sort();
|
|
668
|
+
if (added.length > 0) {
|
|
669
|
+
await this.#repository.insertMembershipScopes(
|
|
670
|
+
membership.accountId,
|
|
671
|
+
membership.tenantId,
|
|
672
|
+
added,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return added;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* A module that joins the platform brings its own scopes. Nobody holds them
|
|
679
|
+
yet, so enabling it grants them to the workspace owners; members receive
|
|
680
|
+
them through role assignment. The grant is idempotent. */
|
|
681
|
+
async grantModuleScopes(scopes: readonly string[]): Promise<
|
|
682
|
+
readonly {
|
|
683
|
+
readonly tenantId: string;
|
|
684
|
+
readonly accountId: string;
|
|
685
|
+
readonly granted: readonly string[];
|
|
686
|
+
}[]
|
|
687
|
+
> {
|
|
688
|
+
const results: {
|
|
689
|
+
tenantId: string;
|
|
690
|
+
accountId: string;
|
|
691
|
+
granted: readonly string[];
|
|
692
|
+
}[] = [];
|
|
693
|
+
const accepted = [...new Set(scopes)]
|
|
694
|
+
.filter((scope) => SCOPE_PATTERN.test(scope))
|
|
695
|
+
.sort();
|
|
696
|
+
if (accepted.length === 0) return results;
|
|
697
|
+
/* The role and its memberships commit together within each tenant. Future
|
|
698
|
+
owners inherit the role; arbitrary membership grants never become defaults. */
|
|
699
|
+
for (const tenant of await this.#repository.listTenants()) {
|
|
700
|
+
for (const granted of await this.#repository.grantTenantOwnerScopes(
|
|
701
|
+
tenant.tenantId,
|
|
702
|
+
accepted,
|
|
703
|
+
this.#now(),
|
|
704
|
+
)) {
|
|
705
|
+
results.push({ tenantId: tenant.tenantId, ...granted });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return results;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async listTenants(): Promise<readonly TenantSummary[]> {
|
|
712
|
+
return this.#repository.listTenants();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* Tenant lookup by identifier or workspace slug for operator tooling. */
|
|
716
|
+
async findTenant(reference: string): Promise<TenantSummary | null> {
|
|
717
|
+
const normalized = reference.trim().normalize('NFKC');
|
|
718
|
+
if (normalized.length < 1 || normalized.length > 128) return null;
|
|
719
|
+
return (
|
|
720
|
+
(await this.#repository.findTenant(normalized)) ??
|
|
721
|
+
(await this.#repository.findTenant(normalized.toLowerCase()))
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async renameTenant(actor: AuthActor, name: string): Promise<TenantSummary> {
|
|
726
|
+
const renamed = await this.#repository.renameTenant(
|
|
727
|
+
actor.tenantId,
|
|
728
|
+
validateWorkspaceName(name),
|
|
729
|
+
);
|
|
730
|
+
if (!renamed) {
|
|
731
|
+
throw new AuthServiceError(
|
|
732
|
+
'TENANT_NOT_FOUND',
|
|
733
|
+
'The workspace does not exist.',
|
|
734
|
+
404,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
await this.#audit(
|
|
738
|
+
actor.tenantId,
|
|
739
|
+
this.#actorOf(actor),
|
|
740
|
+
AUDIT_ACTIONS.tenantRenamed,
|
|
741
|
+
'tenant',
|
|
742
|
+
actor.tenantId,
|
|
743
|
+
{ name: renamed.name },
|
|
744
|
+
);
|
|
745
|
+
return renamed;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* Safe account lookup for operator tooling. It returns identity and
|
|
749
|
+
membership metadata only, never a password hash or a session token. */
|
|
750
|
+
async findAccountAccess(email: string): Promise<{
|
|
751
|
+
readonly accountId: string;
|
|
752
|
+
readonly email: string;
|
|
753
|
+
readonly displayName: string;
|
|
754
|
+
readonly tenants: readonly AuthTenantAccess[];
|
|
755
|
+
} | null> {
|
|
756
|
+
const account = await this.#repository.findAccountByEmail(
|
|
757
|
+
normalizeEmail(email),
|
|
758
|
+
);
|
|
759
|
+
if (!account) return null;
|
|
760
|
+
return {
|
|
761
|
+
accountId: account.accountId,
|
|
762
|
+
email: account.email,
|
|
763
|
+
displayName: account.displayName,
|
|
764
|
+
tenants: await this.#repository.listTenantAccess(account.accountId),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/* Only an owner may create or promote an owner; every other role is a
|
|
769
|
+
tenant role row whose scopes become the membership's scopes. */
|
|
770
|
+
async #roleForAssignment(
|
|
771
|
+
actor: AuthActor | null,
|
|
772
|
+
roleKey: string,
|
|
773
|
+
): Promise<TenantRole> {
|
|
774
|
+
const tenantId = actor?.tenantId;
|
|
775
|
+
if (roleKey === 'owner' && actor && actor.role !== 'owner') {
|
|
776
|
+
throw new AuthServiceError(
|
|
777
|
+
'OWNER_REQUIRED',
|
|
778
|
+
'Only an owner can grant the owner role.',
|
|
779
|
+
403,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
const role = tenantId
|
|
783
|
+
? await this.#repository.findRoleByKey(tenantId, roleKey)
|
|
784
|
+
: null;
|
|
785
|
+
if (!role) {
|
|
786
|
+
throw new AuthServiceError(
|
|
787
|
+
'ROLE_NOT_FOUND',
|
|
788
|
+
'The role does not exist in this workspace.',
|
|
789
|
+
404,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
return role;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async createTenantMember(
|
|
796
|
+
raw: CreateTenantMemberInput,
|
|
797
|
+
actor: AuthActor | null = null,
|
|
798
|
+
): Promise<TenantMember> {
|
|
799
|
+
const input = validateCreateTenantMember(
|
|
800
|
+
raw,
|
|
801
|
+
this.#policy().passwordMinLength,
|
|
802
|
+
);
|
|
803
|
+
if (actor && actor.tenantId !== input.tenantId) {
|
|
804
|
+
throw new AuthServiceError(
|
|
805
|
+
'TENANT_ACCESS_DENIED',
|
|
806
|
+
'The requested tenant is not available.',
|
|
807
|
+
403,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
const role = actor
|
|
811
|
+
? await this.#roleForAssignment(actor, input.role)
|
|
812
|
+
: await this.#repository.findRoleByKey(input.tenantId, input.role);
|
|
813
|
+
const scopes = role
|
|
814
|
+
? role.scopes
|
|
815
|
+
: input.role === 'owner'
|
|
816
|
+
? OWNER_SCOPES
|
|
817
|
+
: MEMBER_SCOPES;
|
|
818
|
+
const passwordHash = await hashPassword(input.password, this.#passwordHash);
|
|
819
|
+
try {
|
|
820
|
+
const createdAt = this.#now();
|
|
821
|
+
const account = await this.#repository.createAccountInTenant({
|
|
822
|
+
accountId: randomUUID(),
|
|
823
|
+
tenantId: input.tenantId,
|
|
824
|
+
email: input.email,
|
|
825
|
+
normalizedEmail: input.email,
|
|
826
|
+
passwordHash,
|
|
827
|
+
displayName: input.displayName,
|
|
828
|
+
role: input.role,
|
|
829
|
+
roleId: role?.id ?? null,
|
|
830
|
+
scopes,
|
|
831
|
+
createdAt,
|
|
832
|
+
});
|
|
833
|
+
if (actor) {
|
|
834
|
+
await this.#audit(
|
|
835
|
+
actor.tenantId,
|
|
836
|
+
this.#actorOf(actor),
|
|
837
|
+
AUDIT_ACTIONS.memberCreated,
|
|
838
|
+
'account',
|
|
839
|
+
account.accountId,
|
|
840
|
+
{ email: account.email, role: input.role },
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
return member(account, createdAt);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (error instanceof DuplicateAccountError) {
|
|
846
|
+
throw new AuthServiceError('ACCOUNT_EXISTS', error.message, 409);
|
|
847
|
+
}
|
|
848
|
+
throw error;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async #targetMember(
|
|
853
|
+
actor: AuthActor,
|
|
854
|
+
accountId: string,
|
|
855
|
+
options: { readonly allowSelf: boolean },
|
|
856
|
+
): Promise<AccountCredential> {
|
|
857
|
+
const target = await this.#repository.findAccountMembership(
|
|
858
|
+
this.#identifier(accountId, 'accountId'),
|
|
859
|
+
actor.tenantId,
|
|
860
|
+
);
|
|
861
|
+
if (!target) {
|
|
862
|
+
throw new AuthServiceError(
|
|
863
|
+
'ACCOUNT_NOT_FOUND',
|
|
864
|
+
'The account is not a member of this workspace.',
|
|
865
|
+
404,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (!options.allowSelf && target.accountId === actor.accountId) {
|
|
869
|
+
throw new AuthServiceError(
|
|
870
|
+
'SELF_TARGET',
|
|
871
|
+
'Use your profile to change your own account.',
|
|
872
|
+
400,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (target.role === 'owner' && actor.role !== 'owner') {
|
|
876
|
+
throw new AuthServiceError(
|
|
877
|
+
'OWNER_REQUIRED',
|
|
878
|
+
'Only an owner can change another owner.',
|
|
879
|
+
403,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return target;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async #assertOwnerRemains(
|
|
886
|
+
tenantId: string,
|
|
887
|
+
target: AccountCredential,
|
|
888
|
+
): Promise<void> {
|
|
889
|
+
if (
|
|
890
|
+
target.role === 'owner' &&
|
|
891
|
+
target.status === 'active' &&
|
|
892
|
+
(await this.#repository.countActiveOwners(tenantId)) <= 1
|
|
893
|
+
) {
|
|
894
|
+
throw new AuthServiceError(
|
|
895
|
+
'LAST_OWNER',
|
|
896
|
+
'A workspace must keep at least one active owner.',
|
|
897
|
+
409,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async #member(accountId: string, tenantId: string): Promise<TenantMember> {
|
|
903
|
+
const record = (await this.#repository.listTenantMembers(tenantId)).find(
|
|
904
|
+
(entry) => entry.accountId === accountId,
|
|
905
|
+
);
|
|
906
|
+
if (!record) {
|
|
907
|
+
throw new AuthServiceError(
|
|
908
|
+
'ACCOUNT_NOT_FOUND',
|
|
909
|
+
'The account is not a member of this workspace.',
|
|
910
|
+
404,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
return record;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async updateMemberDisplayName(
|
|
917
|
+
actor: AuthActor,
|
|
918
|
+
accountId: string,
|
|
919
|
+
displayName: string,
|
|
920
|
+
): Promise<TenantMember> {
|
|
921
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
922
|
+
allowSelf: true,
|
|
923
|
+
});
|
|
924
|
+
const name = validateDisplayName(displayName);
|
|
925
|
+
await this.#repository.updateAccountDisplayName(target.accountId, name);
|
|
926
|
+
await this.#audit(
|
|
927
|
+
actor.tenantId,
|
|
928
|
+
this.#actorOf(actor),
|
|
929
|
+
AUDIT_ACTIONS.memberUpdated,
|
|
930
|
+
'account',
|
|
931
|
+
target.accountId,
|
|
932
|
+
{ displayName: name },
|
|
933
|
+
);
|
|
934
|
+
return this.#member(target.accountId, actor.tenantId);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async setMemberStatus(
|
|
938
|
+
actor: AuthActor,
|
|
939
|
+
accountId: string,
|
|
940
|
+
status: 'active' | 'disabled',
|
|
941
|
+
): Promise<TenantMember> {
|
|
942
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
943
|
+
allowSelf: false,
|
|
944
|
+
});
|
|
945
|
+
if (status === 'disabled') {
|
|
946
|
+
await this.#assertOwnerRemains(actor.tenantId, target);
|
|
947
|
+
await this.#repository.deleteAccountSessions(target.accountId, null);
|
|
948
|
+
}
|
|
949
|
+
await this.#repository.updateAccountStatus(target.accountId, status);
|
|
950
|
+
await this.#audit(
|
|
951
|
+
actor.tenantId,
|
|
952
|
+
this.#actorOf(actor),
|
|
953
|
+
AUDIT_ACTIONS.memberStatus,
|
|
954
|
+
'account',
|
|
955
|
+
target.accountId,
|
|
956
|
+
{ status },
|
|
957
|
+
);
|
|
958
|
+
return this.#member(target.accountId, actor.tenantId);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* Leaves the account intact when it still belongs to another workspace;
|
|
962
|
+
otherwise the account row goes too, so no orphan credential remains. */
|
|
963
|
+
async removeMember(actor: AuthActor, accountId: string): Promise<void> {
|
|
964
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
965
|
+
allowSelf: false,
|
|
966
|
+
});
|
|
967
|
+
await this.#assertOwnerRemains(actor.tenantId, target);
|
|
968
|
+
await this.#repository.deleteMembership(target.accountId, actor.tenantId);
|
|
969
|
+
if ((await this.#repository.countMemberships(target.accountId)) === 0) {
|
|
970
|
+
await this.#repository.deleteAccount(target.accountId);
|
|
971
|
+
}
|
|
972
|
+
await this.#audit(
|
|
973
|
+
actor.tenantId,
|
|
974
|
+
this.#actorOf(actor),
|
|
975
|
+
AUDIT_ACTIONS.memberRemoved,
|
|
976
|
+
'account',
|
|
977
|
+
target.accountId,
|
|
978
|
+
{ email: target.email },
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/* The administrator sets a temporary password; the member must replace it
|
|
983
|
+
at the next sign-in. Every session of the account is revoked. */
|
|
984
|
+
async resetMemberPassword(
|
|
985
|
+
actor: AuthActor,
|
|
986
|
+
accountId: string,
|
|
987
|
+
temporaryPassword: string,
|
|
988
|
+
): Promise<TenantMember> {
|
|
989
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
990
|
+
allowSelf: false,
|
|
991
|
+
});
|
|
992
|
+
assertPasswordPolicy(temporaryPassword, this.#policy().passwordMinLength);
|
|
993
|
+
await this.#repository.updatePasswordHash(
|
|
994
|
+
target.accountId,
|
|
995
|
+
await hashPassword(temporaryPassword, this.#passwordHash),
|
|
996
|
+
true,
|
|
997
|
+
);
|
|
998
|
+
await this.#repository.deleteAccountSessions(target.accountId, null);
|
|
999
|
+
await this.#repository.clearSignInFailures(normalizeEmail(target.email));
|
|
1000
|
+
await this.#audit(
|
|
1001
|
+
actor.tenantId,
|
|
1002
|
+
this.#actorOf(actor),
|
|
1003
|
+
AUDIT_ACTIONS.memberPasswordReset,
|
|
1004
|
+
'account',
|
|
1005
|
+
target.accountId,
|
|
1006
|
+
);
|
|
1007
|
+
return this.#member(target.accountId, actor.tenantId);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/* A non-owner can only hand out scopes it holds itself; an owner may grant
|
|
1011
|
+
any grantable scope. */
|
|
1012
|
+
async setMembershipScopes(
|
|
1013
|
+
actor: AuthActor,
|
|
1014
|
+
accountId: string,
|
|
1015
|
+
scopes: readonly string[],
|
|
1016
|
+
): Promise<TenantMember> {
|
|
1017
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
1018
|
+
allowSelf: false,
|
|
1019
|
+
});
|
|
1020
|
+
const grantable = new Set(await this.listGrantableScopes(actor.tenantId));
|
|
1021
|
+
const held = new Set(actor.scopes);
|
|
1022
|
+
const accepted = [...new Set(scopes)].sort();
|
|
1023
|
+
for (const scope of accepted) {
|
|
1024
|
+
if (!SCOPE_PATTERN.test(scope) || !grantable.has(scope)) {
|
|
1025
|
+
throw new AuthServiceError(
|
|
1026
|
+
'INVALID_SCOPES',
|
|
1027
|
+
`Scope ${scope} cannot be granted in this workspace.`,
|
|
1028
|
+
400,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
if (actor.role !== 'owner' && !held.has(scope)) {
|
|
1032
|
+
throw new AuthServiceError(
|
|
1033
|
+
'SCOPE_CAP_EXCEEDED',
|
|
1034
|
+
`You cannot grant ${scope} because you do not hold it.`,
|
|
1035
|
+
403,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
await this.#repository.replaceMembershipScopes(
|
|
1040
|
+
target.accountId,
|
|
1041
|
+
actor.tenantId,
|
|
1042
|
+
accepted,
|
|
1043
|
+
);
|
|
1044
|
+
await this.#audit(
|
|
1045
|
+
actor.tenantId,
|
|
1046
|
+
this.#actorOf(actor),
|
|
1047
|
+
AUDIT_ACTIONS.memberScopes,
|
|
1048
|
+
'account',
|
|
1049
|
+
target.accountId,
|
|
1050
|
+
{ scopes: accepted },
|
|
1051
|
+
);
|
|
1052
|
+
return this.#member(target.accountId, actor.tenantId);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/* Assigning a role replaces the membership scopes with the role's scopes and
|
|
1056
|
+
keeps the legacy role string in sync for readers of the principal. */
|
|
1057
|
+
async assignMemberRole(
|
|
1058
|
+
actor: AuthActor,
|
|
1059
|
+
accountId: string,
|
|
1060
|
+
roleKey: string,
|
|
1061
|
+
): Promise<TenantMember> {
|
|
1062
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
1063
|
+
allowSelf: false,
|
|
1064
|
+
});
|
|
1065
|
+
const role = await this.#roleForAssignment(actor, validateRoleKey(roleKey));
|
|
1066
|
+
if (target.role === 'owner' && role.key !== 'owner') {
|
|
1067
|
+
await this.#assertOwnerRemains(actor.tenantId, target);
|
|
1068
|
+
}
|
|
1069
|
+
await this.#repository.updateMembershipRole(
|
|
1070
|
+
target.accountId,
|
|
1071
|
+
actor.tenantId,
|
|
1072
|
+
role.key,
|
|
1073
|
+
role.id,
|
|
1074
|
+
role.scopes,
|
|
1075
|
+
);
|
|
1076
|
+
await this.#audit(
|
|
1077
|
+
actor.tenantId,
|
|
1078
|
+
this.#actorOf(actor),
|
|
1079
|
+
AUDIT_ACTIONS.memberRole,
|
|
1080
|
+
'account',
|
|
1081
|
+
target.accountId,
|
|
1082
|
+
{ role: role.key },
|
|
1083
|
+
);
|
|
1084
|
+
return this.#member(target.accountId, actor.tenantId);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async listRoles(tenantId: string): Promise<readonly TenantRole[]> {
|
|
1088
|
+
return this.#repository.listRoles(this.#identifier(tenantId, 'tenantId'));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async #roleScopes(
|
|
1092
|
+
actor: AuthActor,
|
|
1093
|
+
scopes: readonly string[],
|
|
1094
|
+
): Promise<readonly string[]> {
|
|
1095
|
+
const grantable = new Set(await this.listGrantableScopes(actor.tenantId));
|
|
1096
|
+
const accepted = [...new Set(scopes)].sort();
|
|
1097
|
+
if (accepted.length === 0) {
|
|
1098
|
+
throw new AuthServiceError(
|
|
1099
|
+
'INVALID_SCOPES',
|
|
1100
|
+
'A role must grant at least one scope.',
|
|
1101
|
+
400,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
for (const scope of accepted) {
|
|
1105
|
+
if (!SCOPE_PATTERN.test(scope) || !grantable.has(scope)) {
|
|
1106
|
+
throw new AuthServiceError(
|
|
1107
|
+
'INVALID_SCOPES',
|
|
1108
|
+
`Scope ${scope} cannot be granted in this workspace.`,
|
|
1109
|
+
400,
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return accepted;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async createRole(
|
|
1117
|
+
actor: AuthActor,
|
|
1118
|
+
raw: CreateRoleInput,
|
|
1119
|
+
): Promise<TenantRole> {
|
|
1120
|
+
if (raw.tenantId !== actor.tenantId) {
|
|
1121
|
+
throw new AuthServiceError(
|
|
1122
|
+
'TENANT_ACCESS_DENIED',
|
|
1123
|
+
'The requested tenant is not available.',
|
|
1124
|
+
403,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
const key = validateRoleKey(raw.key);
|
|
1128
|
+
if (key === 'owner' || key === 'member') {
|
|
1129
|
+
throw new AuthServiceError(
|
|
1130
|
+
'ROLE_RESERVED',
|
|
1131
|
+
'The owner and member roles are built in.',
|
|
1132
|
+
409,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
const role = await this.#repository.createRole({
|
|
1137
|
+
id: randomUUID(),
|
|
1138
|
+
tenantId: actor.tenantId,
|
|
1139
|
+
key,
|
|
1140
|
+
name: validateRoleName(raw.name),
|
|
1141
|
+
description: validateRoleDescription(raw.description),
|
|
1142
|
+
scopes: await this.#roleScopes(actor, raw.scopes),
|
|
1143
|
+
builtin: false,
|
|
1144
|
+
createdAt: this.#now(),
|
|
1145
|
+
});
|
|
1146
|
+
await this.#audit(
|
|
1147
|
+
actor.tenantId,
|
|
1148
|
+
this.#actorOf(actor),
|
|
1149
|
+
AUDIT_ACTIONS.roleCreated,
|
|
1150
|
+
'role',
|
|
1151
|
+
role.id,
|
|
1152
|
+
{ key: role.key, scopes: role.scopes },
|
|
1153
|
+
);
|
|
1154
|
+
return role;
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
if (error instanceof DuplicateRoleKeyError) {
|
|
1157
|
+
throw new AuthServiceError('ROLE_EXISTS', error.message, 409);
|
|
1158
|
+
}
|
|
1159
|
+
throw error;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async #editableRole(tenantId: string, id: string): Promise<TenantRole> {
|
|
1164
|
+
const role = await this.#repository.findRole(
|
|
1165
|
+
tenantId,
|
|
1166
|
+
this.#identifier(id, 'id'),
|
|
1167
|
+
);
|
|
1168
|
+
if (!role) {
|
|
1169
|
+
throw new AuthServiceError(
|
|
1170
|
+
'ROLE_NOT_FOUND',
|
|
1171
|
+
'The role does not exist in this workspace.',
|
|
1172
|
+
404,
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
if (role.builtin) {
|
|
1176
|
+
throw new AuthServiceError(
|
|
1177
|
+
'ROLE_BUILTIN',
|
|
1178
|
+
'Built-in roles cannot be changed.',
|
|
1179
|
+
409,
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
return role;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async updateRole(
|
|
1186
|
+
actor: AuthActor,
|
|
1187
|
+
raw: UpdateRoleInput,
|
|
1188
|
+
): Promise<TenantRole> {
|
|
1189
|
+
if (raw.tenantId !== actor.tenantId) {
|
|
1190
|
+
throw new AuthServiceError(
|
|
1191
|
+
'TENANT_ACCESS_DENIED',
|
|
1192
|
+
'The requested tenant is not available.',
|
|
1193
|
+
403,
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
const current = await this.#editableRole(actor.tenantId, raw.id);
|
|
1197
|
+
const scopes =
|
|
1198
|
+
raw.scopes === undefined
|
|
1199
|
+
? current.scopes
|
|
1200
|
+
: await this.#roleScopes(actor, raw.scopes);
|
|
1201
|
+
const updated = (await this.#repository.updateRole(
|
|
1202
|
+
actor.tenantId,
|
|
1203
|
+
current.id,
|
|
1204
|
+
{
|
|
1205
|
+
name:
|
|
1206
|
+
raw.name === undefined ? current.name : validateRoleName(raw.name),
|
|
1207
|
+
description:
|
|
1208
|
+
raw.description === undefined
|
|
1209
|
+
? current.description
|
|
1210
|
+
: validateRoleDescription(raw.description),
|
|
1211
|
+
scopes,
|
|
1212
|
+
},
|
|
1213
|
+
this.#now(),
|
|
1214
|
+
))!;
|
|
1215
|
+
if (raw.scopes !== undefined) {
|
|
1216
|
+
for (const membership of (
|
|
1217
|
+
await this.#repository.listTenantMembers(actor.tenantId)
|
|
1218
|
+
).filter((entry) => entry.roleId === current.id)) {
|
|
1219
|
+
await this.#repository.replaceMembershipScopes(
|
|
1220
|
+
membership.accountId,
|
|
1221
|
+
actor.tenantId,
|
|
1222
|
+
scopes,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
await this.#audit(
|
|
1227
|
+
actor.tenantId,
|
|
1228
|
+
this.#actorOf(actor),
|
|
1229
|
+
AUDIT_ACTIONS.roleUpdated,
|
|
1230
|
+
'role',
|
|
1231
|
+
updated.id,
|
|
1232
|
+
{ key: updated.key, scopes: updated.scopes },
|
|
1233
|
+
);
|
|
1234
|
+
return updated;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async deleteRole(actor: AuthActor, id: string): Promise<void> {
|
|
1238
|
+
const role = await this.#editableRole(actor.tenantId, id);
|
|
1239
|
+
if (
|
|
1240
|
+
(await this.#repository.countRoleMemberships(actor.tenantId, role.id)) > 0
|
|
1241
|
+
) {
|
|
1242
|
+
throw new AuthServiceError(
|
|
1243
|
+
'ROLE_IN_USE',
|
|
1244
|
+
'Reassign every member before deleting this role.',
|
|
1245
|
+
409,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
await this.#repository.deleteRole(actor.tenantId, role.id);
|
|
1249
|
+
await this.#audit(
|
|
1250
|
+
actor.tenantId,
|
|
1251
|
+
this.#actorOf(actor),
|
|
1252
|
+
AUDIT_ACTIONS.roleDeleted,
|
|
1253
|
+
'role',
|
|
1254
|
+
role.id,
|
|
1255
|
+
{ key: role.key },
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/* One password reset token, minted the same way for every caller: random,
|
|
1260
|
+
stored only as a hash, single use, and handed back with its link because
|
|
1261
|
+
the caller decides how it reaches the person. Mail is one such caller and
|
|
1262
|
+
an operator terminal is another; a deployment with no mail adapter still
|
|
1263
|
+
has to be able to hand an owner its first credential. */
|
|
1264
|
+
async #mintPasswordResetToken(
|
|
1265
|
+
accountId: string,
|
|
1266
|
+
ttlMs: number,
|
|
1267
|
+
): Promise<{ readonly url: string; readonly expiresAt: number }> {
|
|
1268
|
+
const now = this.#now();
|
|
1269
|
+
const token = randomBytes(32).toString('base64url');
|
|
1270
|
+
const expiresAt = now + ttlMs;
|
|
1271
|
+
await this.#repository.createPasswordResetToken({
|
|
1272
|
+
tokenHash: hashSessionToken(token),
|
|
1273
|
+
accountId,
|
|
1274
|
+
expiresAt,
|
|
1275
|
+
createdAt: now,
|
|
1276
|
+
});
|
|
1277
|
+
return {
|
|
1278
|
+
url: `${this.#publicBaseUrl}/auth/reset-password?token=${encodeURIComponent(token)}`,
|
|
1279
|
+
expiresAt,
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/* The same contract for an invitation token. */
|
|
1284
|
+
async #mintTenantInvitation(record: {
|
|
1285
|
+
readonly tenantId: string;
|
|
1286
|
+
readonly normalizedEmail: string;
|
|
1287
|
+
readonly roleKey: string;
|
|
1288
|
+
readonly createdBy: string;
|
|
1289
|
+
}): Promise<{
|
|
1290
|
+
readonly id: string;
|
|
1291
|
+
readonly url: string;
|
|
1292
|
+
readonly expiresAt: number;
|
|
1293
|
+
}> {
|
|
1294
|
+
const now = this.#now();
|
|
1295
|
+
const token = randomBytes(32).toString('base64url');
|
|
1296
|
+
const id = randomUUID();
|
|
1297
|
+
const expiresAt = now + INVITATION_TTL_MS;
|
|
1298
|
+
await this.#repository.createTenantInvitation({
|
|
1299
|
+
id,
|
|
1300
|
+
tenantId: record.tenantId,
|
|
1301
|
+
email: record.normalizedEmail,
|
|
1302
|
+
normalizedEmail: record.normalizedEmail,
|
|
1303
|
+
roleKey: record.roleKey,
|
|
1304
|
+
tokenHash: hashSessionToken(token),
|
|
1305
|
+
expiresAt,
|
|
1306
|
+
createdBy: record.createdBy,
|
|
1307
|
+
createdAt: now,
|
|
1308
|
+
});
|
|
1309
|
+
return {
|
|
1310
|
+
id,
|
|
1311
|
+
url: `${this.#publicBaseUrl}/auth/accept-invitation?token=${encodeURIComponent(token)}`,
|
|
1312
|
+
expiresAt,
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/* Public reset requests intentionally have no observable distinction between
|
|
1317
|
+
a known and unknown address. A delivery failure is also treated as accepted
|
|
1318
|
+
so it cannot become an address-enumeration side channel. */
|
|
1319
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
1320
|
+
const normalized = normalizeEmail(email);
|
|
1321
|
+
if (normalized.length > 254) return;
|
|
1322
|
+
const account = await this.#repository.findAccountByEmail(normalized);
|
|
1323
|
+
if (!account || !this.#mailDelivery) return;
|
|
1324
|
+
const reset = await this.#mintPasswordResetToken(
|
|
1325
|
+
account.accountId,
|
|
1326
|
+
PASSWORD_RESET_TTL_MS,
|
|
1327
|
+
);
|
|
1328
|
+
try {
|
|
1329
|
+
await this.#mailDelivery.send({
|
|
1330
|
+
to: account.email,
|
|
1331
|
+
kind: 'password-reset',
|
|
1332
|
+
url: reset.url,
|
|
1333
|
+
});
|
|
1334
|
+
await this.#audit(
|
|
1335
|
+
account.tenantId,
|
|
1336
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1337
|
+
AUDIT_ACTIONS.passwordResetRequested,
|
|
1338
|
+
'account',
|
|
1339
|
+
account.accountId,
|
|
1340
|
+
);
|
|
1341
|
+
} catch {
|
|
1342
|
+
/* The response stays non-enumerating. A deployment-owned adapter owns
|
|
1343
|
+
its delivery retry and observability policy. */
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async completePasswordReset(token: string, password: string): Promise<void> {
|
|
1348
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) {
|
|
1349
|
+
throw new AuthServiceError(
|
|
1350
|
+
'RESET_TOKEN_INVALID',
|
|
1351
|
+
'This password reset link is invalid or has expired.',
|
|
1352
|
+
400,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
assertPasswordPolicy(password, this.#policy().passwordMinLength);
|
|
1356
|
+
const accountId = await this.#repository.consumePasswordResetToken(
|
|
1357
|
+
hashSessionToken(token),
|
|
1358
|
+
this.#now(),
|
|
1359
|
+
);
|
|
1360
|
+
if (!accountId) {
|
|
1361
|
+
throw new AuthServiceError(
|
|
1362
|
+
'RESET_TOKEN_INVALID',
|
|
1363
|
+
'This password reset link is invalid or has expired.',
|
|
1364
|
+
400,
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
const account = await this.#repository.findAccountCredentialById(accountId);
|
|
1368
|
+
if (!account || account.status !== 'active') {
|
|
1369
|
+
throw new AuthServiceError(
|
|
1370
|
+
'RESET_TOKEN_INVALID',
|
|
1371
|
+
'This password reset link is invalid or has expired.',
|
|
1372
|
+
400,
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
await this.#repository.updatePasswordHash(
|
|
1376
|
+
accountId,
|
|
1377
|
+
await hashPassword(password, this.#passwordHash),
|
|
1378
|
+
false,
|
|
1379
|
+
);
|
|
1380
|
+
await this.#repository.deleteAccountSessions(accountId, null);
|
|
1381
|
+
await this.#repository.clearSignInFailures(normalizeEmail(account.email));
|
|
1382
|
+
await this.#audit(
|
|
1383
|
+
account.tenantId,
|
|
1384
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1385
|
+
AUDIT_ACTIONS.passwordResetCompleted,
|
|
1386
|
+
'account',
|
|
1387
|
+
account.accountId,
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async createTenantInvitation(
|
|
1392
|
+
actor: AuthActor,
|
|
1393
|
+
email: string,
|
|
1394
|
+
roleKey: string,
|
|
1395
|
+
): Promise<{ readonly id: string; readonly expiresAt: number }> {
|
|
1396
|
+
const normalized = normalizeEmail(email);
|
|
1397
|
+
if (
|
|
1398
|
+
normalized.length > 254 ||
|
|
1399
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)
|
|
1400
|
+
) {
|
|
1401
|
+
throw new AuthServiceError(
|
|
1402
|
+
'INVALID_INPUT',
|
|
1403
|
+
'Enter a valid email address.',
|
|
1404
|
+
400,
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
/* Owner elevation through invitations follows the same rule as member creation. */
|
|
1408
|
+
await this.#roleForAssignment(actor, validateRoleKey(roleKey));
|
|
1409
|
+
if (!this.#mailDelivery) {
|
|
1410
|
+
throw new AuthServiceError(
|
|
1411
|
+
'MAIL_NOT_CONFIGURED',
|
|
1412
|
+
'Email delivery is not configured for this deployment.',
|
|
1413
|
+
503,
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
const invitation = await this.#mintTenantInvitation({
|
|
1417
|
+
tenantId: actor.tenantId,
|
|
1418
|
+
normalizedEmail: normalized,
|
|
1419
|
+
roleKey: roleKey.trim(),
|
|
1420
|
+
createdBy: actor.accountId,
|
|
1421
|
+
});
|
|
1422
|
+
try {
|
|
1423
|
+
await this.#mailDelivery.send({
|
|
1424
|
+
to: normalized,
|
|
1425
|
+
kind: 'tenant-invitation',
|
|
1426
|
+
url: invitation.url,
|
|
1427
|
+
});
|
|
1428
|
+
} catch {
|
|
1429
|
+
throw new AuthServiceError(
|
|
1430
|
+
'MAIL_DELIVERY_FAILED',
|
|
1431
|
+
'The invitation could not be delivered.',
|
|
1432
|
+
503,
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
await this.#audit(
|
|
1436
|
+
actor.tenantId,
|
|
1437
|
+
this.#actorOf(actor),
|
|
1438
|
+
AUDIT_ACTIONS.invitationCreated,
|
|
1439
|
+
'invitation',
|
|
1440
|
+
invitation.id,
|
|
1441
|
+
{ role: roleKey.trim() },
|
|
1442
|
+
);
|
|
1443
|
+
return { id: invitation.id, expiresAt: invitation.expiresAt };
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async acceptTenantInvitation(input: {
|
|
1447
|
+
readonly token: string;
|
|
1448
|
+
readonly displayName: string;
|
|
1449
|
+
readonly password: string;
|
|
1450
|
+
}): Promise<void> {
|
|
1451
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(input.token)) {
|
|
1452
|
+
throw new AuthServiceError(
|
|
1453
|
+
'INVITATION_INVALID',
|
|
1454
|
+
'This invitation is invalid or has expired.',
|
|
1455
|
+
400,
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
const invitation = await this.#repository.consumeTenantInvitation(
|
|
1459
|
+
hashSessionToken(input.token),
|
|
1460
|
+
this.#now(),
|
|
1461
|
+
);
|
|
1462
|
+
if (!invitation)
|
|
1463
|
+
throw new AuthServiceError(
|
|
1464
|
+
'INVITATION_INVALID',
|
|
1465
|
+
'This invitation is invalid or has expired.',
|
|
1466
|
+
400,
|
|
1467
|
+
);
|
|
1468
|
+
const role = await this.#repository.findRoleByKey(
|
|
1469
|
+
invitation.tenantId,
|
|
1470
|
+
invitation.roleKey,
|
|
1471
|
+
);
|
|
1472
|
+
if (!role)
|
|
1473
|
+
throw new AuthServiceError(
|
|
1474
|
+
'INVITATION_INVALID',
|
|
1475
|
+
'This invitation is invalid or has expired.',
|
|
1476
|
+
400,
|
|
1477
|
+
);
|
|
1478
|
+
const existing = await this.#repository.findAccountByEmail(
|
|
1479
|
+
invitation.normalizedEmail,
|
|
1480
|
+
);
|
|
1481
|
+
if (existing) {
|
|
1482
|
+
if (
|
|
1483
|
+
await this.#repository.findAccountMembership(
|
|
1484
|
+
existing.accountId,
|
|
1485
|
+
invitation.tenantId,
|
|
1486
|
+
)
|
|
1487
|
+
) {
|
|
1488
|
+
throw new AuthServiceError(
|
|
1489
|
+
'INVITATION_INVALID',
|
|
1490
|
+
'This invitation is invalid or has expired.',
|
|
1491
|
+
400,
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
await this.#repository.createMembershipInTenant({
|
|
1495
|
+
accountId: existing.accountId,
|
|
1496
|
+
tenantId: invitation.tenantId,
|
|
1497
|
+
role: invitation.roleKey,
|
|
1498
|
+
roleId: role.id,
|
|
1499
|
+
scopes: role.scopes,
|
|
1500
|
+
createdAt: this.#now(),
|
|
1501
|
+
});
|
|
1502
|
+
} else {
|
|
1503
|
+
await this.createTenantMember({
|
|
1504
|
+
tenantId: invitation.tenantId,
|
|
1505
|
+
email: invitation.email,
|
|
1506
|
+
displayName: input.displayName,
|
|
1507
|
+
password: input.password,
|
|
1508
|
+
role: invitation.roleKey,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
await this.#audit(
|
|
1512
|
+
invitation.tenantId,
|
|
1513
|
+
{
|
|
1514
|
+
kind: 'user',
|
|
1515
|
+
id: existing?.accountId ?? 'invited-account',
|
|
1516
|
+
label: invitation.email,
|
|
1517
|
+
},
|
|
1518
|
+
AUDIT_ACTIONS.invitationAccepted,
|
|
1519
|
+
'invitation',
|
|
1520
|
+
invitation.email,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/* Operator provisioning. A deployment turns public sign-up off, so its first
|
|
1525
|
+
workspace and every later colleague arrive through these calls instead.
|
|
1526
|
+
They write the rows the HTTP paths write, audit trail included, and read
|
|
1527
|
+
nothing from the sign-up setting. */
|
|
1528
|
+
async planWorkspaceProvision(
|
|
1529
|
+
input: WorkspaceProvisionInput,
|
|
1530
|
+
): Promise<WorkspaceProvisionPlan> {
|
|
1531
|
+
const operator = this.#identifier(input.operator, 'operator');
|
|
1532
|
+
const name = validateWorkspaceName(input.name);
|
|
1533
|
+
const slug = validateWorkspaceSlug(input.slug);
|
|
1534
|
+
const displayName = validateDisplayName(input.ownerDisplayName);
|
|
1535
|
+
const email = validateEmailAddress(input.ownerEmail);
|
|
1536
|
+
if (input.password !== undefined) {
|
|
1537
|
+
assertPasswordPolicy(input.password, this.#policy().passwordMinLength);
|
|
1538
|
+
}
|
|
1539
|
+
if (await this.#repository.isTenantSlugTaken(slug)) {
|
|
1540
|
+
throw new AuthServiceError(
|
|
1541
|
+
'WORKSPACE_SLUG_TAKEN',
|
|
1542
|
+
`A workspace with the id "${slug}" already exists.`,
|
|
1543
|
+
409,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
/* An operator holds the deployment shell already, so a precise refusal
|
|
1547
|
+
reveals nothing the public sign-up response has to withhold. */
|
|
1548
|
+
if (await this.#repository.findAccountByEmail(email)) {
|
|
1549
|
+
throw new AuthServiceError(
|
|
1550
|
+
'ACCOUNT_EXISTS',
|
|
1551
|
+
`An account already exists for ${email}. Add that account to a workspace instead of creating a second one.`,
|
|
1552
|
+
409,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
return {
|
|
1556
|
+
workspace: { name, slug },
|
|
1557
|
+
owner: { email, displayName, role: 'owner', scopes: OWNER_SCOPES },
|
|
1558
|
+
credential: {
|
|
1559
|
+
kind:
|
|
1560
|
+
input.password === undefined
|
|
1561
|
+
? 'password-setup-link'
|
|
1562
|
+
: 'operator-password',
|
|
1563
|
+
},
|
|
1564
|
+
operator,
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async provisionWorkspace(
|
|
1569
|
+
input: WorkspaceProvisionInput,
|
|
1570
|
+
): Promise<ProvisionedWorkspace> {
|
|
1571
|
+
const plan = await this.planWorkspaceProvision(input);
|
|
1572
|
+
const actor = operatorActor(plan.operator);
|
|
1573
|
+
/* Without an operator-chosen password the row still needs a hash. A
|
|
1574
|
+
discarded random one leaves the setup link as the only way in. */
|
|
1575
|
+
const passwordHash = await hashPassword(
|
|
1576
|
+
input.password ?? randomBytes(32).toString('base64url'),
|
|
1577
|
+
this.#passwordHash,
|
|
1578
|
+
);
|
|
1579
|
+
const tenantId = randomUUID();
|
|
1580
|
+
let owner: AccountCredential;
|
|
1581
|
+
try {
|
|
1582
|
+
owner = await this.#repository.createAccountWithTenant({
|
|
1583
|
+
accountId: randomUUID(),
|
|
1584
|
+
tenantId,
|
|
1585
|
+
email: plan.owner.email,
|
|
1586
|
+
normalizedEmail: plan.owner.email,
|
|
1587
|
+
passwordHash,
|
|
1588
|
+
displayName: plan.owner.displayName,
|
|
1589
|
+
organizationName: plan.workspace.name,
|
|
1590
|
+
organizationSlug: plan.workspace.slug,
|
|
1591
|
+
role: 'owner',
|
|
1592
|
+
scopes: OWNER_SCOPES,
|
|
1593
|
+
createdAt: this.#now(),
|
|
1594
|
+
});
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
if (error instanceof DuplicateAccountError) {
|
|
1597
|
+
throw new AuthServiceError('ACCOUNT_EXISTS', error.message, 409);
|
|
1598
|
+
}
|
|
1599
|
+
if (error instanceof DuplicateTenantSlugError) {
|
|
1600
|
+
throw new AuthServiceError('WORKSPACE_SLUG_TAKEN', error.message, 409);
|
|
1601
|
+
}
|
|
1602
|
+
throw error;
|
|
1603
|
+
}
|
|
1604
|
+
await this.#audit(
|
|
1605
|
+
tenantId,
|
|
1606
|
+
actor,
|
|
1607
|
+
AUDIT_ACTIONS.workspaceProvisioned,
|
|
1608
|
+
'tenant',
|
|
1609
|
+
tenantId,
|
|
1610
|
+
{ slug: plan.workspace.slug, name: plan.workspace.name },
|
|
1611
|
+
);
|
|
1612
|
+
await this.#audit(
|
|
1613
|
+
tenantId,
|
|
1614
|
+
actor,
|
|
1615
|
+
AUDIT_ACTIONS.memberCreated,
|
|
1616
|
+
'account',
|
|
1617
|
+
owner.accountId,
|
|
1618
|
+
{ email: owner.email, role: owner.role },
|
|
1619
|
+
);
|
|
1620
|
+
return {
|
|
1621
|
+
workspace: {
|
|
1622
|
+
tenantId,
|
|
1623
|
+
name: plan.workspace.name,
|
|
1624
|
+
slug: plan.workspace.slug,
|
|
1625
|
+
},
|
|
1626
|
+
owner: {
|
|
1627
|
+
accountId: owner.accountId,
|
|
1628
|
+
email: owner.email,
|
|
1629
|
+
displayName: owner.displayName,
|
|
1630
|
+
role: owner.role,
|
|
1631
|
+
scopes: owner.scopes,
|
|
1632
|
+
},
|
|
1633
|
+
credential:
|
|
1634
|
+
input.password === undefined
|
|
1635
|
+
? await this.#issuePasswordSetupLink(owner, actor)
|
|
1636
|
+
: { kind: 'operator-password' },
|
|
1637
|
+
operator: plan.operator,
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/* The operator holds a terminal on the deployment, so the link is returned
|
|
1642
|
+
to the command rather than posted. The public reset path cannot serve this:
|
|
1643
|
+
it needs a mail adapter, and a deployment has none until someone composes
|
|
1644
|
+
one. */
|
|
1645
|
+
async #issuePasswordSetupLink(
|
|
1646
|
+
account: AccountCredential,
|
|
1647
|
+
actor: Actor,
|
|
1648
|
+
): Promise<OperatorCredential> {
|
|
1649
|
+
const reset = await this.#mintPasswordResetToken(
|
|
1650
|
+
account.accountId,
|
|
1651
|
+
OPERATOR_SETUP_TTL_MS,
|
|
1652
|
+
);
|
|
1653
|
+
await this.#audit(
|
|
1654
|
+
account.tenantId,
|
|
1655
|
+
actor,
|
|
1656
|
+
AUDIT_ACTIONS.passwordResetRequested,
|
|
1657
|
+
'account',
|
|
1658
|
+
account.accountId,
|
|
1659
|
+
);
|
|
1660
|
+
return { kind: 'password-setup-link', ...reset };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async planMemberProvision(
|
|
1664
|
+
input: MemberProvisionInput,
|
|
1665
|
+
): Promise<MemberProvisionPlan> {
|
|
1666
|
+
const operator = this.#identifier(input.operator, 'operator');
|
|
1667
|
+
const email = validateEmailAddress(input.email);
|
|
1668
|
+
const roleKey = validateRoleKey(input.role);
|
|
1669
|
+
const workspace = await this.findTenant(input.workspace);
|
|
1670
|
+
if (!workspace) {
|
|
1671
|
+
throw new AuthServiceError(
|
|
1672
|
+
'TENANT_NOT_FOUND',
|
|
1673
|
+
`No workspace matches "${input.workspace}".`,
|
|
1674
|
+
404,
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const role = await this.#repository.findRoleByKey(
|
|
1678
|
+
workspace.tenantId,
|
|
1679
|
+
roleKey,
|
|
1680
|
+
);
|
|
1681
|
+
if (!role) {
|
|
1682
|
+
throw new AuthServiceError(
|
|
1683
|
+
'ROLE_NOT_FOUND',
|
|
1684
|
+
`The workspace has no role "${roleKey}".`,
|
|
1685
|
+
404,
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
const account = await this.#repository.findAccountByEmail(email);
|
|
1689
|
+
if (
|
|
1690
|
+
account &&
|
|
1691
|
+
(await this.#repository.findAccountMembership(
|
|
1692
|
+
account.accountId,
|
|
1693
|
+
workspace.tenantId,
|
|
1694
|
+
))
|
|
1695
|
+
) {
|
|
1696
|
+
throw new AuthServiceError(
|
|
1697
|
+
'MEMBER_EXISTS',
|
|
1698
|
+
`${email} is already a member of "${workspace.slug}".`,
|
|
1699
|
+
409,
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
return {
|
|
1703
|
+
workspace,
|
|
1704
|
+
email,
|
|
1705
|
+
role,
|
|
1706
|
+
action: account ? 'membership' : 'invitation',
|
|
1707
|
+
account: account
|
|
1708
|
+
? {
|
|
1709
|
+
accountId: account.accountId,
|
|
1710
|
+
email: account.email,
|
|
1711
|
+
displayName: account.displayName,
|
|
1712
|
+
}
|
|
1713
|
+
: null,
|
|
1714
|
+
credential: {
|
|
1715
|
+
kind: account ? 'existing-password' : 'invitation-link',
|
|
1716
|
+
},
|
|
1717
|
+
operator,
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async provisionMember(
|
|
1722
|
+
input: MemberProvisionInput,
|
|
1723
|
+
): Promise<ProvisionedMember> {
|
|
1724
|
+
const plan = await this.planMemberProvision(input);
|
|
1725
|
+
const actor = operatorActor(plan.operator);
|
|
1726
|
+
if (plan.account) {
|
|
1727
|
+
const membership = await this.#repository.createMembershipInTenant({
|
|
1728
|
+
accountId: plan.account.accountId,
|
|
1729
|
+
tenantId: plan.workspace.tenantId,
|
|
1730
|
+
role: plan.role.key,
|
|
1731
|
+
roleId: plan.role.id,
|
|
1732
|
+
scopes: plan.role.scopes,
|
|
1733
|
+
createdAt: this.#now(),
|
|
1734
|
+
});
|
|
1735
|
+
await this.#audit(
|
|
1736
|
+
plan.workspace.tenantId,
|
|
1737
|
+
actor,
|
|
1738
|
+
AUDIT_ACTIONS.memberCreated,
|
|
1739
|
+
'account',
|
|
1740
|
+
membership.accountId,
|
|
1741
|
+
{ email: membership.email, role: plan.role.key },
|
|
1742
|
+
);
|
|
1743
|
+
return {
|
|
1744
|
+
workspace: plan.workspace,
|
|
1745
|
+
email: membership.email,
|
|
1746
|
+
role: plan.role.key,
|
|
1747
|
+
scopes: membership.scopes,
|
|
1748
|
+
action: 'membership',
|
|
1749
|
+
accountId: membership.accountId,
|
|
1750
|
+
invitationId: null,
|
|
1751
|
+
credential: { kind: 'existing-password' },
|
|
1752
|
+
operator: plan.operator,
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
/* Minted here rather than through createTenantInvitation, which cannot
|
|
1756
|
+
serve an operator: it refuses without a mail adapter and hands its
|
|
1757
|
+
token only to that adapter. */
|
|
1758
|
+
const invitation = await this.#mintTenantInvitation({
|
|
1759
|
+
tenantId: plan.workspace.tenantId,
|
|
1760
|
+
normalizedEmail: plan.email,
|
|
1761
|
+
roleKey: plan.role.key,
|
|
1762
|
+
createdBy: plan.operator,
|
|
1763
|
+
});
|
|
1764
|
+
await this.#audit(
|
|
1765
|
+
plan.workspace.tenantId,
|
|
1766
|
+
actor,
|
|
1767
|
+
AUDIT_ACTIONS.invitationCreated,
|
|
1768
|
+
'invitation',
|
|
1769
|
+
invitation.id,
|
|
1770
|
+
{ role: plan.role.key },
|
|
1771
|
+
);
|
|
1772
|
+
return {
|
|
1773
|
+
workspace: plan.workspace,
|
|
1774
|
+
email: plan.email,
|
|
1775
|
+
role: plan.role.key,
|
|
1776
|
+
scopes: plan.role.scopes,
|
|
1777
|
+
action: 'invitation',
|
|
1778
|
+
accountId: null,
|
|
1779
|
+
invitationId: invitation.id,
|
|
1780
|
+
credential: {
|
|
1781
|
+
kind: 'invitation-link',
|
|
1782
|
+
url: invitation.url,
|
|
1783
|
+
expiresAt: invitation.expiresAt,
|
|
1784
|
+
},
|
|
1785
|
+
operator: plan.operator,
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async enrollTotp(
|
|
1790
|
+
accountId: string,
|
|
1791
|
+
issuer = 'Flowdular',
|
|
1792
|
+
): Promise<{
|
|
1793
|
+
readonly secret: string;
|
|
1794
|
+
readonly otpauthUrl: string;
|
|
1795
|
+
readonly recoveryCodes: readonly string[];
|
|
1796
|
+
}> {
|
|
1797
|
+
const account = await this.#repository.findAccountCredentialById(
|
|
1798
|
+
this.#identifier(accountId, 'accountId'),
|
|
1799
|
+
);
|
|
1800
|
+
if (!account)
|
|
1801
|
+
throw new AuthServiceError(
|
|
1802
|
+
'ACCOUNT_NOT_FOUND',
|
|
1803
|
+
'The account is not available.',
|
|
1804
|
+
404,
|
|
1805
|
+
);
|
|
1806
|
+
if (
|
|
1807
|
+
(await this.#repository.findMfaTotp(account.accountId))?.confirmedAt !=
|
|
1808
|
+
null
|
|
1809
|
+
) {
|
|
1810
|
+
throw new AuthServiceError(
|
|
1811
|
+
'MFA_ALREADY_CONFIGURED',
|
|
1812
|
+
'Multi-factor authentication is already enabled for this account.',
|
|
1813
|
+
409,
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
const secret = createTotpSecret();
|
|
1817
|
+
const recoveryCodes = Array.from({ length: RECOVERY_CODE_COUNT }, () =>
|
|
1818
|
+
randomBytes(10).toString('hex').toUpperCase(),
|
|
1819
|
+
);
|
|
1820
|
+
const now = this.#now();
|
|
1821
|
+
await this.#repository.upsertMfaTotp(
|
|
1822
|
+
account.accountId,
|
|
1823
|
+
encryptMfaSecret(secret, this.#mfaEncryptionKey),
|
|
1824
|
+
now,
|
|
1825
|
+
);
|
|
1826
|
+
await this.#repository.replaceMfaRecoveryCodes(
|
|
1827
|
+
account.accountId,
|
|
1828
|
+
recoveryCodes.map(hashSessionToken),
|
|
1829
|
+
now,
|
|
1830
|
+
);
|
|
1831
|
+
await this.#audit(
|
|
1832
|
+
account.tenantId,
|
|
1833
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1834
|
+
AUDIT_ACTIONS.mfaEnrolled,
|
|
1835
|
+
'account',
|
|
1836
|
+
account.accountId,
|
|
1837
|
+
);
|
|
1838
|
+
return {
|
|
1839
|
+
secret,
|
|
1840
|
+
otpauthUrl: `otpauth://totp/${encodeURIComponent(`${issuer}:${account.email}`)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`,
|
|
1841
|
+
recoveryCodes,
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
async mfaStatus(accountId: string): Promise<MfaStatus> {
|
|
1846
|
+
const account = await this.#repository.findAccountCredentialById(
|
|
1847
|
+
this.#identifier(accountId, 'accountId'),
|
|
1848
|
+
);
|
|
1849
|
+
if (!account)
|
|
1850
|
+
throw new AuthServiceError(
|
|
1851
|
+
'ACCOUNT_NOT_FOUND',
|
|
1852
|
+
'The account is not available.',
|
|
1853
|
+
404,
|
|
1854
|
+
);
|
|
1855
|
+
const factor = await this.#repository.findMfaTotp(account.accountId);
|
|
1856
|
+
return {
|
|
1857
|
+
available: this.#mfaEncryptionKey !== undefined,
|
|
1858
|
+
enrolled: factor?.confirmedAt != null,
|
|
1859
|
+
pending: factor !== null && factor.confirmedAt === null,
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
async confirmTotp(accountId: string, code: string): Promise<void> {
|
|
1864
|
+
const account = await this.#repository.findAccountCredentialById(
|
|
1865
|
+
this.#identifier(accountId, 'accountId'),
|
|
1866
|
+
);
|
|
1867
|
+
const record = account
|
|
1868
|
+
? await this.#repository.findMfaTotp(account.accountId)
|
|
1869
|
+
: null;
|
|
1870
|
+
if (
|
|
1871
|
+
!account ||
|
|
1872
|
+
!record ||
|
|
1873
|
+
!verifyTotp(
|
|
1874
|
+
decryptMfaSecret(record.secretCiphertext, this.#mfaEncryptionKey),
|
|
1875
|
+
code,
|
|
1876
|
+
this.#now(),
|
|
1877
|
+
)
|
|
1878
|
+
) {
|
|
1879
|
+
throw new AuthServiceError(
|
|
1880
|
+
'MFA_CODE_INVALID',
|
|
1881
|
+
'The authentication code is invalid.',
|
|
1882
|
+
400,
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
await this.#repository.confirmMfaTotp(account.accountId, this.#now());
|
|
1886
|
+
await this.#audit(
|
|
1887
|
+
account.tenantId,
|
|
1888
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1889
|
+
AUDIT_ACTIONS.mfaConfirmed,
|
|
1890
|
+
'account',
|
|
1891
|
+
account.accountId,
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
async completeMfaChallenge(
|
|
1896
|
+
token: string,
|
|
1897
|
+
code?: string,
|
|
1898
|
+
recoveryCode?: string,
|
|
1899
|
+
): Promise<IssuedSession> {
|
|
1900
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
1901
|
+
throw new AuthServiceError(
|
|
1902
|
+
'MFA_CHALLENGE_INVALID',
|
|
1903
|
+
'The sign-in challenge is invalid or has expired.',
|
|
1904
|
+
401,
|
|
1905
|
+
);
|
|
1906
|
+
const challenge = await this.#repository.consumeMfaChallenge(
|
|
1907
|
+
hashSessionToken(token),
|
|
1908
|
+
this.#now(),
|
|
1909
|
+
);
|
|
1910
|
+
if (!challenge)
|
|
1911
|
+
throw new AuthServiceError(
|
|
1912
|
+
'MFA_CHALLENGE_INVALID',
|
|
1913
|
+
'The sign-in challenge is invalid or has expired.',
|
|
1914
|
+
401,
|
|
1915
|
+
);
|
|
1916
|
+
const account = await this.#repository.findAccountMembership(
|
|
1917
|
+
challenge.accountId,
|
|
1918
|
+
challenge.tenantId,
|
|
1919
|
+
);
|
|
1920
|
+
const record = account
|
|
1921
|
+
? await this.#repository.findMfaTotp(account.accountId)
|
|
1922
|
+
: null;
|
|
1923
|
+
const valid =
|
|
1924
|
+
account &&
|
|
1925
|
+
record?.confirmedAt !== null &&
|
|
1926
|
+
record !== null &&
|
|
1927
|
+
((typeof code === 'string' &&
|
|
1928
|
+
verifyTotp(
|
|
1929
|
+
decryptMfaSecret(record.secretCiphertext, this.#mfaEncryptionKey),
|
|
1930
|
+
code,
|
|
1931
|
+
this.#now(),
|
|
1932
|
+
)) ||
|
|
1933
|
+
(typeof recoveryCode === 'string' &&
|
|
1934
|
+
/* Twelve-character codes were issued before 0.8.0. Keep them
|
|
1935
|
+
redeemable while all newly issued codes carry 80 bits. */
|
|
1936
|
+
/^(?:[A-F0-9]{12}|[A-F0-9]{20})$/.test(recoveryCode) &&
|
|
1937
|
+
(await this.#repository.consumeMfaRecoveryCode(
|
|
1938
|
+
account.accountId,
|
|
1939
|
+
hashSessionToken(recoveryCode),
|
|
1940
|
+
))));
|
|
1941
|
+
if (!valid)
|
|
1942
|
+
throw new AuthServiceError(
|
|
1943
|
+
'MFA_CODE_INVALID',
|
|
1944
|
+
'The authentication code is invalid.',
|
|
1945
|
+
401,
|
|
1946
|
+
);
|
|
1947
|
+
const issued = await this.#issue(account);
|
|
1948
|
+
await this.#audit(
|
|
1949
|
+
account.tenantId,
|
|
1950
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1951
|
+
AUDIT_ACTIONS.mfaChallengeSucceeded,
|
|
1952
|
+
'session',
|
|
1953
|
+
issued.sessionId,
|
|
1954
|
+
);
|
|
1955
|
+
return issued;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
async signInVerifiedExternalEmail(
|
|
1959
|
+
email: string,
|
|
1960
|
+
): Promise<IssuedSession | MfaChallenge> {
|
|
1961
|
+
const account = await this.#repository.findAccountByEmail(
|
|
1962
|
+
normalizeEmail(email),
|
|
1963
|
+
);
|
|
1964
|
+
if (!account || account.status !== 'active')
|
|
1965
|
+
throw this.#invalidCredentials();
|
|
1966
|
+
if (
|
|
1967
|
+
(await this.#repository.findMfaTotp(account.accountId))?.confirmedAt !=
|
|
1968
|
+
null
|
|
1969
|
+
)
|
|
1970
|
+
return this.#issueMfaChallenge(account);
|
|
1971
|
+
const issued = await this.#issue(account);
|
|
1972
|
+
await this.#audit(
|
|
1973
|
+
account.tenantId,
|
|
1974
|
+
{ kind: 'user', id: account.accountId, label: account.email },
|
|
1975
|
+
AUDIT_ACTIONS.signInSucceeded,
|
|
1976
|
+
'session',
|
|
1977
|
+
issued.sessionId,
|
|
1978
|
+
{ via: 'oidc' },
|
|
1979
|
+
);
|
|
1980
|
+
return issued;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
async resolveSession(token: string | null): Promise<AuthSession | null> {
|
|
1984
|
+
if (!token) return null;
|
|
1985
|
+
return this.#repository.findSession(
|
|
1986
|
+
hashSessionToken(token),
|
|
1987
|
+
this.#now(),
|
|
1988
|
+
this.#policy().sessionIdleMs,
|
|
1989
|
+
SESSION_TOUCH_INTERVAL_MS,
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
async listSessions(accountId: string): Promise<readonly SessionSummary[]> {
|
|
1994
|
+
return this.#repository.listAccountSessions(
|
|
1995
|
+
this.#identifier(accountId, 'accountId'),
|
|
1996
|
+
this.#now(),
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async revokeOwnSession(
|
|
2001
|
+
session: AuthSession,
|
|
2002
|
+
sessionId: string,
|
|
2003
|
+
): Promise<void> {
|
|
2004
|
+
const removed = await this.#repository.deleteSessionById(
|
|
2005
|
+
session.principal.accountId,
|
|
2006
|
+
this.#identifier(sessionId, 'sessionId'),
|
|
2007
|
+
);
|
|
2008
|
+
if (!removed) {
|
|
2009
|
+
throw new AuthServiceError(
|
|
2010
|
+
'SESSION_NOT_FOUND',
|
|
2011
|
+
'The session does not exist.',
|
|
2012
|
+
404,
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
await this.#audit(
|
|
2016
|
+
session.principal.tenantId,
|
|
2017
|
+
{
|
|
2018
|
+
kind: 'user',
|
|
2019
|
+
id: session.principal.accountId,
|
|
2020
|
+
label: session.principal.email,
|
|
2021
|
+
},
|
|
2022
|
+
AUDIT_ACTIONS.sessionRevoked,
|
|
2023
|
+
'session',
|
|
2024
|
+
sessionId,
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async revokeMemberSessions(
|
|
2029
|
+
actor: AuthActor,
|
|
2030
|
+
accountId: string,
|
|
2031
|
+
): Promise<number> {
|
|
2032
|
+
const target = await this.#targetMember(actor, accountId, {
|
|
2033
|
+
allowSelf: false,
|
|
2034
|
+
});
|
|
2035
|
+
const before = (
|
|
2036
|
+
await this.#repository.listAccountSessions(target.accountId, this.#now())
|
|
2037
|
+
).length;
|
|
2038
|
+
await this.#repository.deleteAccountSessions(target.accountId, null);
|
|
2039
|
+
await this.#audit(
|
|
2040
|
+
actor.tenantId,
|
|
2041
|
+
this.#actorOf(actor),
|
|
2042
|
+
AUDIT_ACTIONS.sessionRevoked,
|
|
2043
|
+
'account',
|
|
2044
|
+
target.accountId,
|
|
2045
|
+
{ sessions: before },
|
|
2046
|
+
);
|
|
2047
|
+
return before;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
async deleteExpiredSessions(): Promise<number> {
|
|
2051
|
+
return this.#repository.deleteExpiredSessions(this.#now());
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
async recordSettingsUpdate(
|
|
2055
|
+
actor: AuthActor,
|
|
2056
|
+
moduleId: string,
|
|
2057
|
+
key: string,
|
|
2058
|
+
cleared: boolean,
|
|
2059
|
+
): Promise<void> {
|
|
2060
|
+
await this.#audit(
|
|
2061
|
+
actor.tenantId,
|
|
2062
|
+
this.#actorOf(actor),
|
|
2063
|
+
AUDIT_ACTIONS.settingsUpdated,
|
|
2064
|
+
'setting',
|
|
2065
|
+
`${moduleId}.${key}`,
|
|
2066
|
+
{ cleared },
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async queryAudit(query: AuditQuery): Promise<AuditActorPage> {
|
|
2071
|
+
const limit = Math.min(
|
|
2072
|
+
Math.max(1, Math.trunc(query.limit)),
|
|
2073
|
+
MAX_AUDIT_PAGE,
|
|
2074
|
+
);
|
|
2075
|
+
const events = await this.#repository.queryAudit({
|
|
2076
|
+
...query,
|
|
2077
|
+
tenantId: this.#identifier(query.tenantId, 'tenantId'),
|
|
2078
|
+
limit: limit + 1,
|
|
2079
|
+
});
|
|
2080
|
+
const page = events.slice(0, limit);
|
|
2081
|
+
const last = page[page.length - 1];
|
|
2082
|
+
return {
|
|
2083
|
+
events: page,
|
|
2084
|
+
nextCursor:
|
|
2085
|
+
events.length > limit && last ? `${last.occurredAt}:${last.id}` : null,
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
async switchTenant(token: string, tenantId: string): Promise<IssuedSession> {
|
|
2090
|
+
const current = await this.resolveSession(token);
|
|
2091
|
+
if (!current) {
|
|
2092
|
+
throw new AuthServiceError(
|
|
2093
|
+
'UNAUTHENTICATED',
|
|
2094
|
+
'Authentication is required.',
|
|
2095
|
+
401,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
if (!current.principal.scopes.includes(AUTH_SCOPES.tenantSwitch)) {
|
|
2099
|
+
throw new AuthServiceError(
|
|
2100
|
+
'FORBIDDEN',
|
|
2101
|
+
'Tenant switching was not granted.',
|
|
2102
|
+
403,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
const account = await this.#repository.findAccountMembership(
|
|
2106
|
+
current.principal.accountId,
|
|
2107
|
+
tenantId,
|
|
2108
|
+
);
|
|
2109
|
+
if (!account) {
|
|
2110
|
+
throw new AuthServiceError(
|
|
2111
|
+
'TENANT_ACCESS_DENIED',
|
|
2112
|
+
'The requested tenant is not available.',
|
|
2113
|
+
403,
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
await this.#repository.deleteSession(hashSessionToken(token));
|
|
2117
|
+
return this.#issue(account);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/* API tokens are machine credentials for clients that cannot hold a browser
|
|
2121
|
+
session, such as a sandbox connected to a remote deployment. Only the hash
|
|
2122
|
+
is stored, the raw value is returned exactly once, and effective authority
|
|
2123
|
+
is always re-intersected with the live membership scopes. */
|
|
2124
|
+
async issueApiToken(raw: CreateApiTokenInput): Promise<IssuedApiToken> {
|
|
2125
|
+
const tenantId = this.#identifier(raw.tenantId, 'tenantId');
|
|
2126
|
+
const accountId = this.#identifier(raw.accountId, 'accountId');
|
|
2127
|
+
const createdBy = this.#identifier(raw.createdBy, 'createdBy');
|
|
2128
|
+
const label = raw.label.trim();
|
|
2129
|
+
if (label.length < 2 || label.length > 80) {
|
|
2130
|
+
throw new AuthServiceError(
|
|
2131
|
+
'INVALID_INPUT',
|
|
2132
|
+
'Token label must contain between 2 and 80 characters.',
|
|
2133
|
+
400,
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
const membership = await this.#repository.findAccountMembership(
|
|
2137
|
+
accountId,
|
|
2138
|
+
tenantId,
|
|
2139
|
+
);
|
|
2140
|
+
if (!membership || membership.status !== 'active') {
|
|
2141
|
+
throw new AuthServiceError(
|
|
2142
|
+
'ACCOUNT_NOT_FOUND',
|
|
2143
|
+
'The account is not an active member of this workspace.',
|
|
2144
|
+
404,
|
|
2145
|
+
);
|
|
2146
|
+
}
|
|
2147
|
+
if (raw.scopes.length === 0 || raw.scopes.length > 64) {
|
|
2148
|
+
throw new AuthServiceError(
|
|
2149
|
+
'INVALID_SCOPES',
|
|
2150
|
+
'A token must carry between 1 and 64 scopes.',
|
|
2151
|
+
400,
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
for (const scope of raw.scopes) {
|
|
2155
|
+
if (!SCOPE_PATTERN.test(scope)) {
|
|
2156
|
+
throw new AuthServiceError(
|
|
2157
|
+
'INVALID_SCOPES',
|
|
2158
|
+
`Scope ${scope} is not a valid identifier.`,
|
|
2159
|
+
400,
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
const held = new Set(membership.scopes);
|
|
2164
|
+
const scopes = [...new Set(raw.scopes)]
|
|
2165
|
+
.filter((scope) => held.has(scope))
|
|
2166
|
+
.sort();
|
|
2167
|
+
if (scopes.length === 0) {
|
|
2168
|
+
throw new AuthServiceError(
|
|
2169
|
+
'INVALID_SCOPES',
|
|
2170
|
+
'None of the requested scopes are held by this membership.',
|
|
2171
|
+
409,
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
const createdAt = this.#now();
|
|
2175
|
+
if (raw.expiresAt !== null) {
|
|
2176
|
+
if (
|
|
2177
|
+
!Number.isSafeInteger(raw.expiresAt) ||
|
|
2178
|
+
raw.expiresAt <= createdAt ||
|
|
2179
|
+
raw.expiresAt - createdAt > MAX_API_TOKEN_LIFETIME_MS
|
|
2180
|
+
) {
|
|
2181
|
+
throw new AuthServiceError(
|
|
2182
|
+
'INVALID_EXPIRY',
|
|
2183
|
+
'Token expiry must be a future timestamp within one year.',
|
|
2184
|
+
400,
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const token = `${API_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
|
|
2189
|
+
const record = await this.#repository.createApiToken({
|
|
2190
|
+
id: randomUUID(),
|
|
2191
|
+
tenantId,
|
|
2192
|
+
accountId,
|
|
2193
|
+
label,
|
|
2194
|
+
prefix: token.slice(0, API_TOKEN_PREFIX.length + 6),
|
|
2195
|
+
tokenHash: hashSessionToken(token),
|
|
2196
|
+
scopes,
|
|
2197
|
+
createdBy,
|
|
2198
|
+
createdAt,
|
|
2199
|
+
expiresAt: raw.expiresAt,
|
|
2200
|
+
});
|
|
2201
|
+
await this.#audit(
|
|
2202
|
+
tenantId,
|
|
2203
|
+
{ kind: 'user', id: createdBy, label: membership.email },
|
|
2204
|
+
AUDIT_ACTIONS.tokenIssued,
|
|
2205
|
+
'api-token',
|
|
2206
|
+
record.id,
|
|
2207
|
+
{ label, scopes },
|
|
2208
|
+
);
|
|
2209
|
+
return { record, token };
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
async listApiTokens(tenantId: string): Promise<readonly ApiTokenRecord[]> {
|
|
2213
|
+
return this.#repository.listApiTokens(
|
|
2214
|
+
this.#identifier(tenantId, 'tenantId'),
|
|
2215
|
+
);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
async revokeApiToken(
|
|
2219
|
+
tenantId: string,
|
|
2220
|
+
id: string,
|
|
2221
|
+
revokedBy: string,
|
|
2222
|
+
): Promise<ApiTokenRecord> {
|
|
2223
|
+
const record = await this.#repository.revokeApiToken(
|
|
2224
|
+
this.#identifier(tenantId, 'tenantId'),
|
|
2225
|
+
this.#identifier(id, 'id'),
|
|
2226
|
+
this.#now(),
|
|
2227
|
+
this.#identifier(revokedBy, 'revokedBy'),
|
|
2228
|
+
);
|
|
2229
|
+
if (!record) {
|
|
2230
|
+
throw new AuthServiceError(
|
|
2231
|
+
'TOKEN_NOT_FOUND',
|
|
2232
|
+
'The API token does not exist in this workspace.',
|
|
2233
|
+
404,
|
|
2234
|
+
);
|
|
2235
|
+
}
|
|
2236
|
+
await this.#audit(
|
|
2237
|
+
record.tenantId,
|
|
2238
|
+
{ kind: 'user', id: revokedBy, label: revokedBy },
|
|
2239
|
+
AUDIT_ACTIONS.tokenRevoked,
|
|
2240
|
+
'api-token',
|
|
2241
|
+
record.id,
|
|
2242
|
+
{ label: record.label },
|
|
2243
|
+
);
|
|
2244
|
+
return record;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
async resolveApiToken(raw: string | null): Promise<AuthPrincipal | null> {
|
|
2248
|
+
if (!raw || !API_TOKEN_PATTERN.test(raw)) return null;
|
|
2249
|
+
const record = await this.#repository.findApiTokenByHash(
|
|
2250
|
+
hashSessionToken(raw),
|
|
2251
|
+
);
|
|
2252
|
+
if (!record || record.revokedAt !== null) return null;
|
|
2253
|
+
const now = this.#now();
|
|
2254
|
+
if (record.expiresAt !== null && record.expiresAt <= now) return null;
|
|
2255
|
+
const membership = await this.#repository.findAccountMembership(
|
|
2256
|
+
record.accountId,
|
|
2257
|
+
record.tenantId,
|
|
2258
|
+
);
|
|
2259
|
+
if (!membership || membership.status !== 'active') return null;
|
|
2260
|
+
const held = new Set(membership.scopes);
|
|
2261
|
+
const scopes = record.scopes.filter((scope) => held.has(scope));
|
|
2262
|
+
if (scopes.length === 0) return null;
|
|
2263
|
+
if (
|
|
2264
|
+
record.lastUsedAt === null ||
|
|
2265
|
+
now - record.lastUsedAt > API_TOKEN_TOUCH_INTERVAL_MS
|
|
2266
|
+
) {
|
|
2267
|
+
await this.#repository.touchApiToken(record.tenantId, record.id, now);
|
|
2268
|
+
}
|
|
2269
|
+
return {
|
|
2270
|
+
accountId: membership.accountId,
|
|
2271
|
+
tenantId: membership.tenantId,
|
|
2272
|
+
email: membership.email,
|
|
2273
|
+
displayName: membership.displayName,
|
|
2274
|
+
role: membership.role,
|
|
2275
|
+
scopes,
|
|
2276
|
+
tenants: await this.#repository.listTenantAccess(membership.accountId),
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
#identifier(value: string, field: string): string {
|
|
2281
|
+
const normalized = value.trim();
|
|
2282
|
+
if (normalized.length < 1 || normalized.length > 128) {
|
|
2283
|
+
throw new AuthServiceError(
|
|
2284
|
+
'INVALID_INPUT',
|
|
2285
|
+
`${field} must contain between 1 and 128 characters.`,
|
|
2286
|
+
400,
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
return normalized;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
async signOut(token: string): Promise<void> {
|
|
2293
|
+
const session = await this.resolveSession(token);
|
|
2294
|
+
await this.#repository.deleteSession(hashSessionToken(token));
|
|
2295
|
+
if (session) {
|
|
2296
|
+
await this.#audit(
|
|
2297
|
+
session.principal.tenantId,
|
|
2298
|
+
{
|
|
2299
|
+
kind: 'user',
|
|
2300
|
+
id: session.principal.accountId,
|
|
2301
|
+
label: session.principal.email,
|
|
2302
|
+
},
|
|
2303
|
+
AUDIT_ACTIONS.signOut,
|
|
2304
|
+
'session',
|
|
2305
|
+
session.sessionId,
|
|
2306
|
+
);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|