forgeos 0.1.0-alpha.7 → 0.1.0-alpha.71
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/.agents/skills/forge-agent-fabric/SKILL.md +178 -0
- package/.agents/skills/forge-agent-fabric/references/managed-workflows.md +95 -0
- package/.agents/skills/forge-agent-fabric/references/repository-analysis.md +102 -0
- package/.agents/skills/forge-agent-fabric/scripts/fabric.mjs +46 -0
- package/.agents/skills/forge-agent-fabric/scripts/repository.mjs +44 -0
- package/.npmignore +10 -0
- package/AGENTS.md +142 -90
- package/CHANGELOG.md +870 -9
- package/LICENSE +21 -0
- package/README.md +121 -6
- package/adapters/go/README.md +23 -0
- package/adapters/go/go.mod +3 -0
- package/adapters/go/http.go +149 -0
- package/adapters/go/registry.go +234 -0
- package/adapters/go/types.go +136 -0
- package/adapters/java/README.md +68 -0
- package/adapters/java/pom.xml +47 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Auth.java +20 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Diagnostic.java +16 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Entry.java +38 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/EntryKind.java +16 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ErrorInfo.java +4 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Forge.java +94 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ForgeCall.java +12 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ForgeContext.java +11 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ForgeHandler.java +8 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ForgeHttpHandler.java +179 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ForgeRegistry.java +121 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Json.java +14 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Manifest.java +14 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/RequestEnvelope.java +6 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/ResponseEnvelope.java +25 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Risk.java +18 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Schemas.java +36 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/Service.java +65 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/TransactionMode.java +18 -0
- package/adapters/java/src/main/java/dev/forgeos/adapter/TypedForgeHandler.java +6 -0
- package/adapters/java-spring-boot-starter/README.md +32 -0
- package/adapters/java-spring-boot-starter/pom.xml +49 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeCommand.java +22 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeExternalService.java +15 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeQuery.java +16 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeServiceBeanCondition.java +18 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeSpringAutoConfiguration.java +16 -0
- package/adapters/java-spring-boot-starter/src/main/java/dev/forgeos/adapter/spring/ForgeSpringRuntime.java +104 -0
- package/adapters/java-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +1 -0
- package/bin/forge.mjs +18 -0
- package/docs/agent-fabric-programs.md +151 -0
- package/docs/agent-fabric.md +582 -0
- package/docs/architecture/agent-fabric/PORTABLE_PROJECTS_PLAN.md +136 -0
- package/docs/architecture/agent-fabric/README.md +180 -0
- package/docs/architecture/agent-fabric/REPOSITORY_MANIFEST_PLAN.md +284 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_ADR.md +81 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_CORRECTIONS.md +39 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_CORRECTIONS_EVIDENCE.md +53 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_EVIDENCE.md +49 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_PLAN.md +590 -0
- package/docs/cair-protocol.md +103 -0
- package/docs/changelog.md +1070 -0
- package/docs/forge-protocol.md +189 -0
- package/docs/repository-agent-benchmark.md +179 -0
- package/docs/repository-analysis.md +139 -0
- package/docs/repository-context-quality.md +179 -0
- package/docs/repository-runtime-observation.md +82 -0
- package/examples/agent-fabric-v2/README.md +70 -0
- package/examples/agent-fabric-v2/item-child.workflow.ts +11 -0
- package/examples/agent-fabric-v2/make-request.mjs +4 -0
- package/examples/agent-fabric-v2/map-child.workflow.ts +10 -0
- package/examples/agent-fabric-v2/migrate.workflow.ts +35 -0
- package/examples/agent-fabric-v2/registry.example.json +256 -0
- package/examples/agent-fabric-v2/run-ui-fixture.ts +48 -0
- package/examples/agent-fabric-v2/ui-audit.workflow.ts +28 -0
- package/examples/agent-fabric-v2/ui-fixture-worker.cjs +36 -0
- package/examples/agent-fabric-v2/visual-population.json +13 -0
- package/examples/agent-fabric-v2/workers/check.mjs +2 -0
- package/examples/agent-fabric-v2/workers/common.mjs +7 -0
- package/examples/agent-fabric-v2/workers/discover.mjs +2 -0
- package/examples/agent-fabric-v2/workers/migrate.mjs +4 -0
- package/examples/agent-fabric-v2/workers/review.mjs +3 -0
- package/examples/go-billing/go.mod +7 -0
- package/examples/go-billing/main.go +120 -0
- package/examples/java-billing/pom.xml +60 -0
- package/examples/java-billing/src/main/java/dev/forgeos/examples/billing/CreateInvoiceInput.java +4 -0
- package/examples/java-billing/src/main/java/dev/forgeos/examples/billing/Invoice.java +11 -0
- package/examples/java-billing/src/main/java/dev/forgeos/examples/billing/Main.java +127 -0
- package/package.json +60 -5
- package/schemas/agent-fabric/v0.1/control-event.schema.json +281 -0
- package/schemas/forge-manifest.schema.json +389 -0
- package/scripts/field-test-forgeos.mjs +1021 -0
- package/scripts/install-agent-fabric-skill.mjs +105 -0
- package/src/forge/_generated/releaseManifest.json +1 -2
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-adapters/index.ts +1592 -125
- package/src/forge/agent-adapters/types.ts +225 -1
- package/src/forge/agent-fabric/adapter.ts +131 -0
- package/src/forge/agent-fabric/attached-snapshot.ts +55 -0
- package/src/forge/agent-fabric/attached-task-contract.ts +105 -0
- package/src/forge/agent-fabric/attached-task-service.ts +127 -0
- package/src/forge/agent-fabric/attached-task-store.ts +109 -0
- package/src/forge/agent-fabric/authority.ts +298 -0
- package/src/forge/agent-fabric/canonical.ts +135 -0
- package/src/forge/agent-fabric/codex-adversarial-review.ts +229 -0
- package/src/forge/agent-fabric/codex-sdk-worker.ts +203 -0
- package/src/forge/agent-fabric/conductor.ts +754 -0
- package/src/forge/agent-fabric/dictionary.ts +12 -0
- package/src/forge/agent-fabric/errors.ts +32 -0
- package/src/forge/agent-fabric/hardened-conductor.ts +485 -0
- package/src/forge/agent-fabric/hardened-reducer.ts +211 -0
- package/src/forge/agent-fabric/index.ts +48 -0
- package/src/forge/agent-fabric/journal.ts +116 -0
- package/src/forge/agent-fabric/local-adaptive-digest-worker.mjs +26 -0
- package/src/forge/agent-fabric/local-adaptive-harness.ts +343 -0
- package/src/forge/agent-fabric/local-adaptive-service.ts +398 -0
- package/src/forge/agent-fabric/local-adaptive-worker.ts +146 -0
- package/src/forge/agent-fabric/local-approval-window.ts +211 -0
- package/src/forge/agent-fabric/local-change-review-service.ts +374 -0
- package/src/forge/agent-fabric/local-coding-worker.ts +285 -0
- package/src/forge/agent-fabric/local-control-store.ts +239 -0
- package/src/forge/agent-fabric/local-effect-broker.ts +348 -0
- package/src/forge/agent-fabric/local-evolution-approval.ts +81 -0
- package/src/forge/agent-fabric/local-evolution-profile.ts +87 -0
- package/src/forge/agent-fabric/local-evolution-registry.ts +427 -0
- package/src/forge/agent-fabric/local-evolution-service.ts +200 -0
- package/src/forge/agent-fabric/local-intelligence.ts +281 -0
- package/src/forge/agent-fabric/local-paths.ts +18 -0
- package/src/forge/agent-fabric/local-task-contract.ts +203 -0
- package/src/forge/agent-fabric/local-task-inbox.ts +543 -0
- package/src/forge/agent-fabric/local-task-server.ts +374 -0
- package/src/forge/agent-fabric/local-task-service.ts +982 -0
- package/src/forge/agent-fabric/local-verification.ts +551 -0
- package/src/forge/agent-fabric/managed-environment.ts +205 -0
- package/src/forge/agent-fabric/managed-run-contract.ts +103 -0
- package/src/forge/agent-fabric/managed-run-service.ts +401 -0
- package/src/forge/agent-fabric/managed-run-store.ts +165 -0
- package/src/forge/agent-fabric/managed-workspace.ts +253 -0
- package/src/forge/agent-fabric/p0a.ts +104 -0
- package/src/forge/agent-fabric/p0b-model-adapter.ts +434 -0
- package/src/forge/agent-fabric/planning.ts +155 -0
- package/src/forge/agent-fabric/program-api.ts +37 -0
- package/src/forge/agent-fabric/program-contract.ts +283 -0
- package/src/forge/agent-fabric/program-dsl.ts +126 -0
- package/src/forge/agent-fabric/program-evidence.ts +22 -0
- package/src/forge/agent-fabric/program-image.ts +15 -0
- package/src/forge/agent-fabric/program-lock.ts +50 -0
- package/src/forge/agent-fabric/program-owner.ts +16 -0
- package/src/forge/agent-fabric/program-scheduler.ts +38 -0
- package/src/forge/agent-fabric/program-service.ts +793 -0
- package/src/forge/agent-fabric/program-store.ts +142 -0
- package/src/forge/agent-fabric/program-structure.ts +65 -0
- package/src/forge/agent-fabric/program-types.ts +110 -0
- package/src/forge/agent-fabric/program-view.ts +12 -0
- package/src/forge/agent-fabric/program-worker.ts +108 -0
- package/src/forge/agent-fabric/project-profile.ts +50 -0
- package/src/forge/agent-fabric/project-registry.ts +120 -0
- package/src/forge/agent-fabric/project-runtime.ts +85 -0
- package/src/forge/agent-fabric/readiness.ts +26 -0
- package/src/forge/agent-fabric/reducer.ts +863 -0
- package/src/forge/agent-fabric/repository-context.ts +83 -0
- package/src/forge/agent-fabric/resource-ledger.ts +266 -0
- package/src/forge/agent-fabric/serialized-local-adapter.ts +60 -0
- package/src/forge/agent-fabric/types.ts +424 -0
- package/src/forge/agent-fabric/validation.ts +500 -0
- package/src/forge/agent-fabric/workflow-engine.ts +218 -0
- package/src/forge/agent-fabric/workflow-task-actions.ts +104 -0
- package/src/forge/agent-fabric/workflow-templates.ts +11 -0
- package/src/forge/agent-memory/bridge.ts +1728 -0
- package/src/forge/agent-memory/context-pack.ts +284 -0
- package/src/forge/agent-memory/hook-runner.ts +331 -0
- package/src/forge/agent-memory/mcp.ts +597 -0
- package/src/forge/agent-memory/normalize.ts +498 -0
- package/src/forge/agent-memory/redaction.ts +103 -0
- package/src/forge/agent-memory/sources/claude-code.ts +51 -0
- package/src/forge/agent-memory/sources/codex-hook-runner.mjs +343 -0
- package/src/forge/agent-memory/sources/codex.ts +161 -0
- package/src/forge/agent-memory/sources/cursor.ts +35 -0
- package/src/forge/agent-memory/types.ts +215 -0
- package/src/forge/bench.ts +248 -0
- package/src/forge/brownfield-import/index.ts +896 -0
- package/src/forge/brownfield-import/types.ts +127 -0
- package/src/forge/cair/action-journal.ts +61 -0
- package/src/forge/cair/action-parser.ts +314 -0
- package/src/forge/cair/action-validator.ts +40 -0
- package/src/forge/cair/actions.ts +1818 -0
- package/src/forge/cair/format.ts +77 -0
- package/src/forge/cair/index.ts +118 -0
- package/src/forge/cair/query.ts +478 -0
- package/src/forge/cair/snapshot.ts +315 -0
- package/src/forge/cair/types.ts +255 -0
- package/src/forge/cli/adaptive.ts +51 -0
- package/src/forge/cli/ai.ts +16 -1
- package/src/forge/cli/auth.ts +459 -15
- package/src/forge/cli/authmd.ts +441 -0
- package/src/forge/cli/baseline.ts +112 -0
- package/src/forge/cli/build.ts +20 -4
- package/src/forge/cli/changed.ts +418 -0
- package/src/forge/cli/codex-app-server.ts +877 -0
- package/src/forge/cli/commands.ts +1859 -20
- package/src/forge/cli/db.ts +209 -2
- package/src/forge/cli/deploy.ts +1339 -0
- package/src/forge/cli/deps.ts +150 -23
- package/src/forge/cli/dev.ts +1137 -53
- package/src/forge/cli/docs.ts +265 -0
- package/src/forge/cli/doctor.ts +297 -0
- package/src/forge/cli/env-placeholders.ts +62 -0
- package/src/forge/cli/evolution.ts +40 -0
- package/src/forge/cli/fabric.ts +253 -0
- package/src/forge/cli/field-test.ts +568 -0
- package/src/forge/cli/golden-path.ts +487 -0
- package/src/forge/cli/handoff.ts +336 -0
- package/src/forge/cli/index.ts +1 -0
- package/src/forge/cli/last-run.ts +84 -0
- package/src/forge/cli/main.ts +196 -3
- package/src/forge/cli/new.ts +169 -17
- package/src/forge/cli/next-actions.ts +23 -0
- package/src/forge/cli/output.ts +398 -6
- package/src/forge/cli/parse.ts +1377 -38
- package/src/forge/cli/progress.ts +51 -0
- package/src/forge/cli/query.ts +32 -0
- package/src/forge/cli/release.ts +35 -11
- package/src/forge/cli/repository.ts +151 -0
- package/src/forge/cli/run.ts +42 -0
- package/src/forge/cli/secrets.ts +137 -1
- package/src/forge/cli/seed.ts +581 -0
- package/src/forge/cli/self-host.ts +56 -14
- package/src/forge/cli/studio.ts +2282 -0
- package/src/forge/cli/verify.ts +1458 -32
- package/src/forge/cli/windows.ts +23 -0
- package/src/forge/cli/workos.ts +3509 -0
- package/src/forge/compiler/agent-contract/build.ts +728 -64
- package/src/forge/compiler/agent-contract/types.ts +45 -2
- package/src/forge/compiler/ai-registry/parse.ts +71 -2
- package/src/forge/compiler/api-surface/build.ts +47 -0
- package/src/forge/compiler/app-graph/build.ts +112 -9
- package/src/forge/compiler/app-graph/extract.ts +107 -0
- package/src/forge/compiler/app-graph/module-graph.ts +73 -78
- package/src/forge/compiler/app-graph/parser.ts +24 -24
- package/src/forge/compiler/app-graph/profile.ts +26 -0
- package/src/forge/compiler/app-graph/tsconfig-hash.ts +41 -4
- package/src/forge/compiler/app-graph/versions.ts +1 -1
- package/src/forge/compiler/classifier/capabilities.ts +3 -2
- package/src/forge/compiler/classifier/classify.ts +32 -8
- package/src/forge/compiler/classifier/secrets.ts +3 -2
- package/src/forge/compiler/classifier/signals.ts +91 -1
- package/src/forge/compiler/client-sdk/build-manifest.ts +59 -0
- package/src/forge/compiler/client-sdk/render-client.ts +295 -13
- package/src/forge/compiler/data-graph/build.ts +29 -1
- package/src/forge/compiler/data-graph/parse.ts +20 -4
- package/src/forge/compiler/data-graph/sql/ddl.ts +144 -31
- package/src/forge/compiler/data-graph/sql/serialize.ts +4 -0
- package/src/forge/compiler/data-graph/sql/types.ts +1 -0
- package/src/forge/compiler/diagnostics/codes.ts +38 -0
- package/src/forge/compiler/diagnostics/create.ts +28 -2
- package/src/forge/compiler/diagnostics/index.ts +2 -0
- package/src/forge/compiler/emitter/barrel.ts +3 -0
- package/src/forge/compiler/emitter/render.ts +9 -0
- package/src/forge/compiler/external-manifest/registry.ts +217 -0
- package/src/forge/compiler/external-manifest/types.ts +91 -0
- package/src/forge/compiler/external-manifest/validate.ts +373 -0
- package/src/forge/compiler/frontend-graph/build.ts +142 -14
- package/src/forge/compiler/integration/add.ts +991 -33
- package/src/forge/compiler/integration/plan.ts +43 -12
- package/src/forge/compiler/integration/render.ts +29 -0
- package/src/forge/compiler/integration/snapshot.ts +2 -0
- package/src/forge/compiler/integration/templates/convex.ts +70 -0
- package/src/forge/compiler/integration/templates/index.ts +1 -0
- package/src/forge/compiler/integration/templates/render.ts +38 -0
- package/src/forge/compiler/integration/templates/types.ts +3 -0
- package/src/forge/compiler/integration/templates/workos.ts +1439 -0
- package/src/forge/compiler/make-registry/build.ts +8 -9
- package/src/forge/compiler/orchestrator/fast-check.ts +4 -0
- package/src/forge/compiler/orchestrator/generate-lock.ts +14 -3
- package/src/forge/compiler/orchestrator/manifest.ts +1 -1
- package/src/forge/compiler/orchestrator/plan-profile.ts +23 -0
- package/src/forge/compiler/orchestrator/plan.ts +249 -10
- package/src/forge/compiler/orchestrator/profile.ts +65 -0
- package/src/forge/compiler/orchestrator/run.ts +101 -33
- package/src/forge/compiler/orchestrator/serialize.ts +33 -8
- package/src/forge/compiler/orchestrator/types.ts +1 -1
- package/src/forge/compiler/package-graph/compiler.ts +3 -1
- package/src/forge/compiler/package-graph/constants.ts +1 -1
- package/src/forge/compiler/package-graph/dts-extractor.ts +14 -1
- package/src/forge/compiler/package-manager/adapter.ts +4 -1
- package/src/forge/compiler/package-manager/commands.ts +4 -0
- package/src/forge/compiler/package-manager/executor.ts +30 -1
- package/src/forge/compiler/package-manager/version.ts +36 -12
- package/src/forge/compiler/package-upgrades/planner.ts +155 -18
- package/src/forge/compiler/package-upgrades/types.ts +2 -0
- package/src/forge/compiler/policy-registry/build.ts +47 -2
- package/src/forge/compiler/policy-registry/parse.ts +32 -2
- package/src/forge/compiler/recipes/definitions.ts +63 -0
- package/src/forge/compiler/recipes/index.ts +2 -0
- package/src/forge/compiler/recipes/profiles.ts +81 -0
- package/src/forge/compiler/recipes/registry.ts +6 -0
- package/src/forge/compiler/test-graph/build.ts +11 -3
- package/src/forge/compiler/types/app-graph.ts +8 -2
- package/src/forge/compiler/types/cli.ts +76 -1
- package/src/forge/compiler/types/dev-manifest.ts +4 -0
- package/src/forge/compiler/types/emit.ts +2 -0
- package/src/forge/compiler/types/frontend-graph.ts +2 -2
- package/src/forge/compiler/types/integration.ts +1 -0
- package/src/forge/compiler/types/package-graph.ts +6 -0
- package/src/forge/compiler/types/policy-registry.ts +3 -1
- package/src/forge/delta/broker-runner.mjs +5 -0
- package/src/forge/delta/broker.ts +429 -0
- package/src/forge/delta/classifier.ts +52 -0
- package/src/forge/delta/explain.ts +238 -0
- package/src/forge/delta/git-observer.ts +67 -0
- package/src/forge/delta/ids.ts +44 -0
- package/src/forge/delta/index.ts +25 -0
- package/src/forge/delta/process-identity.ts +37 -0
- package/src/forge/delta/queue-status.ts +190 -0
- package/src/forge/delta/recorder.ts +540 -0
- package/src/forge/delta/redaction.ts +50 -0
- package/src/forge/delta/schema.ts +240 -0
- package/src/forge/delta/session.ts +142 -0
- package/src/forge/delta/status.ts +1139 -0
- package/src/forge/delta/store.ts +3329 -0
- package/src/forge/delta/timeline.ts +178 -0
- package/src/forge/dev/server.ts +1061 -22
- package/src/forge/dev/types.ts +15 -1
- package/src/forge/dev/watch.ts +17 -7
- package/src/forge/dev-console/cycle.ts +285 -33
- package/src/forge/dev-console/types.ts +47 -1
- package/src/forge/impact/index.ts +222 -30
- package/src/forge/impact/types.ts +33 -1
- package/src/forge/intent/index.ts +35 -16
- package/src/forge/make/fields.ts +26 -0
- package/src/forge/make/index.ts +42 -6
- package/src/forge/make/templates.ts +211 -4
- package/src/forge/make/types.ts +2 -1
- package/src/forge/policy.ts +1 -1
- package/src/forge/react/index.ts +160 -2
- package/src/forge/refactor/index.ts +1 -0
- package/src/forge/repair/rules/index.ts +2 -2
- package/src/forge/repository-analysis/adapters/common.ts +72 -0
- package/src/forge/repository-analysis/adapters/docker.ts +185 -0
- package/src/forge/repository-analysis/adapters/java.ts +133 -0
- package/src/forge/repository-analysis/adapters/typescript.ts +350 -0
- package/src/forge/repository-analysis/adapters/vue.ts +71 -0
- package/src/forge/repository-analysis/analyze.ts +434 -0
- package/src/forge/repository-analysis/benchmark.ts +336 -0
- package/src/forge/repository-analysis/cache-gc.ts +154 -0
- package/src/forge/repository-analysis/check-selection.ts +123 -0
- package/src/forge/repository-analysis/context-node.ts +17 -0
- package/src/forge/repository-analysis/context.ts +456 -0
- package/src/forge/repository-analysis/http-resolution.ts +135 -0
- package/src/forge/repository-analysis/identity.ts +45 -0
- package/src/forge/repository-analysis/index.ts +18 -0
- package/src/forge/repository-analysis/nuxt-resolution.ts +188 -0
- package/src/forge/repository-analysis/quality.ts +60 -0
- package/src/forge/repository-analysis/retrieval.ts +309 -0
- package/src/forge/repository-analysis/runtime-artifacts.ts +195 -0
- package/src/forge/repository-analysis/runtime-observation.ts +325 -0
- package/src/forge/repository-analysis/scanner.ts +174 -0
- package/src/forge/repository-analysis/storage.ts +193 -0
- package/src/forge/repository-analysis/test-associations.ts +59 -0
- package/src/forge/repository-analysis/types.ts +99 -0
- package/src/forge/repository-manifest/index.ts +178 -0
- package/src/forge/repository-manifest/types.ts +63 -0
- package/src/forge/review/index.ts +162 -12
- package/src/forge/review/types.ts +15 -0
- package/src/forge/runtime/ai/providers.ts +19 -4
- package/src/forge/runtime/auth/config.ts +17 -0
- package/src/forge/runtime/auth/evaluate.ts +15 -2
- package/src/forge/runtime/auth/resolve.ts +61 -6
- package/src/forge/runtime/auth/types.ts +1 -0
- package/src/forge/runtime/db/factory.ts +1 -3
- package/src/forge/runtime/db/generated-client.ts +13 -2
- package/src/forge/runtime/db/memory-adapter.ts +139 -32
- package/src/forge/runtime/db/pglite-adapter.ts +285 -2
- package/src/forge/runtime/executor.ts +129 -16
- package/src/forge/runtime/external/bridge.ts +663 -0
- package/src/forge/runtime/runner/run-entry.ts +16 -7
- package/src/forge/runtime/telemetry/buffer.ts +4 -1
- package/src/forge/runtime/telemetry/scrubber.ts +35 -5
- package/src/forge/runtime/webhooks/security.ts +12 -7
- package/src/forge/server.ts +71 -7
- package/src/forge/ui/index.ts +831 -19
- package/src/forge/ui/types.ts +3 -1
- package/src/forge/version.ts +1 -1
- package/src/forge/vue/index.ts +422 -0
- package/src/forge/workspace/baseline.ts +112 -0
- package/src/forge/workspace/change-summary.ts +292 -0
- package/src/forge/workspace/forge-cli.ts +42 -0
- package/src/forge/workspace/git-summary.ts +393 -0
- package/templates/agent-workroom/AGENTS.md +29 -0
- package/templates/agent-workroom/README.md +34 -0
- package/templates/agent-workroom/forge.config.ts +3 -0
- package/templates/agent-workroom/package.json +34 -0
- package/templates/agent-workroom/pnpm-workspace.yaml +2 -0
- package/templates/agent-workroom/src/actions/indexAgentSignal.ts +10 -0
- package/templates/agent-workroom/src/commands/openWorkroom.ts +61 -0
- package/templates/agent-workroom/src/commands/recordAgentSignal.ts +119 -0
- package/templates/agent-workroom/src/commands/recordCheckRun.ts +52 -0
- package/templates/agent-workroom/src/forge/schema.ts +54 -0
- package/templates/agent-workroom/src/policies.ts +6 -0
- package/templates/agent-workroom/src/queries/listWorkrooms.ts +11 -0
- package/templates/agent-workroom/src/queries/liveWorkroom.ts +63 -0
- package/templates/agent-workroom/tsconfig.json +16 -0
- package/templates/agent-workroom/web/index.html +13 -0
- package/templates/agent-workroom/web/package.json +21 -0
- package/templates/agent-workroom/web/src/App.tsx +345 -0
- package/templates/agent-workroom/web/src/lib/forge.ts +20 -0
- package/templates/agent-workroom/web/src/main.tsx +13 -0
- package/templates/agent-workroom/web/src/styles.css +545 -0
- package/templates/agent-workroom/web/tsconfig.json +27 -0
- package/templates/agent-workroom/web/vite.config.ts +36 -0
- package/templates/b2b-support-web/package.json +2 -0
- package/templates/b2b-support-web/pnpm-workspace.yaml +2 -0
- package/templates/b2b-support-web/web/app/page.tsx +2 -2
- package/templates/b2b-support-web/web/lib/forge.ts +9 -2
- package/templates/b2b-support-web/web/next.config.ts +24 -0
- package/templates/b2b-support-web/web/package.json +1 -1
- package/templates/minimal-web/package.json +2 -1
- package/templates/minimal-web/pnpm-workspace.yaml +2 -0
- package/templates/minimal-web/web/index.html +1 -0
- package/templates/minimal-web/web/src/App.tsx +12 -6
- package/templates/minimal-web/web/src/lib/forge.ts +9 -2
- package/templates/minimal-web/web/src/styles.css +34 -0
- package/templates/minimal-web/web/vite.config.ts +36 -0
- package/templates/nuxt-web/.vscode/settings.json +14 -0
- package/templates/nuxt-web/.yarnrc.yml +1 -0
- package/templates/nuxt-web/README.md +30 -0
- package/templates/nuxt-web/forge.config.ts +3 -0
- package/templates/nuxt-web/package.json +37 -0
- package/templates/nuxt-web/pnpm-workspace.yaml +2 -0
- package/templates/nuxt-web/src/actions/logNoteCreated.ts +11 -0
- package/templates/nuxt-web/src/commands/createNote.ts +26 -0
- package/templates/nuxt-web/src/forge/schema.ts +12 -0
- package/templates/nuxt-web/src/policies.ts +6 -0
- package/templates/nuxt-web/src/queries/listNotes.ts +8 -0
- package/templates/nuxt-web/src/queries/liveNotes.ts +8 -0
- package/templates/nuxt-web/tsconfig.json +17 -0
- package/templates/nuxt-web/web/app.vue +67 -0
- package/templates/nuxt-web/web/components/LiveNotes.vue +89 -0
- package/templates/nuxt-web/web/components/NoteComposer.vue +100 -0
- package/templates/nuxt-web/web/composables/forge.ts +13 -0
- package/templates/nuxt-web/web/composables/useNotes.ts +24 -0
- package/templates/nuxt-web/web/nuxt.config.ts +11 -0
- package/templates/nuxt-web/web/package.json +22 -0
- package/templates/nuxt-web/web/plugins/forge.client.ts +10 -0
- package/templates/nuxt-web/web/plugins/forge.server.ts +10 -0
- package/templates/nuxt-web/web/server/api/forge-health.get.ts +7 -0
- package/templates/nuxt-web/web/tsconfig.json +6 -0
- package/templates/vendor-access/.vscode/settings.json +14 -0
- package/templates/vendor-access/README.md +30 -0
- package/templates/vendor-access/forge.config.ts +3 -0
- package/templates/vendor-access/package.json +35 -0
- package/templates/vendor-access/pnpm-workspace.yaml +2 -0
- package/templates/vendor-access/src/commands/addEvidence.ts +42 -0
- package/templates/vendor-access/src/commands/approveAccessRequest.ts +43 -0
- package/templates/vendor-access/src/commands/createAccessRequest.ts +49 -0
- package/templates/vendor-access/src/commands/seedVendorAccessDemo.ts +200 -0
- package/templates/vendor-access/src/forge/schema.ts +74 -0
- package/templates/vendor-access/src/policies.ts +11 -0
- package/templates/vendor-access/src/queries/listVendorAccessDashboard.ts +24 -0
- package/templates/vendor-access/src/queries/liveVendorAccessDashboard.ts +24 -0
- package/templates/vendor-access/tsconfig.json +17 -0
- package/templates/vendor-access/web/index.html +13 -0
- package/templates/vendor-access/web/package.json +21 -0
- package/templates/vendor-access/web/src/App.tsx +573 -0
- package/templates/vendor-access/web/src/lib/forge.ts +20 -0
- package/templates/vendor-access/web/src/main.tsx +205 -0
- package/templates/vendor-access/web/src/styles.css +682 -0
- package/templates/vendor-access/web/tsconfig.json +18 -0
- package/templates/vendor-access/web/vite.config.ts +36 -0
- package/src/forge/_generated/actionSubscriptions.json +0 -2
- package/src/forge/_generated/actionSubscriptions.ts +0 -10
- package/src/forge/_generated/agentAdapterManifest.json +0 -2
- package/src/forge/_generated/agentAdapterManifest.ts +0 -73
- package/src/forge/_generated/agentContract.json +0 -2
- package/src/forge/_generated/agentContract.ts +0 -7829
- package/src/forge/_generated/agentQuickstart.md +0 -34
- package/src/forge/_generated/agentTools.json +0 -2
- package/src/forge/_generated/agentTools.md +0 -16
- package/src/forge/_generated/agentTools.ts +0 -12
- package/src/forge/_generated/aiContext.ts +0 -125
- package/src/forge/_generated/aiModels.json +0 -2
- package/src/forge/_generated/aiModels.ts +0 -51
- package/src/forge/_generated/aiProviders.json +0 -2
- package/src/forge/_generated/aiProviders.ts +0 -23
- package/src/forge/_generated/aiRegistry.json +0 -2
- package/src/forge/_generated/aiRegistry.ts +0 -31
- package/src/forge/_generated/api.json +0 -2
- package/src/forge/_generated/api.ts +0 -8
- package/src/forge/_generated/appGraph.json +0 -2
- package/src/forge/_generated/appGraph.ts +0 -14937
- package/src/forge/_generated/appMap.md +0 -55
- package/src/forge/_generated/artifactManifest.json +0 -2
- package/src/forge/_generated/artifactManifest.ts +0 -7
- package/src/forge/_generated/authClaims.json +0 -2
- package/src/forge/_generated/authClaims.ts +0 -13
- package/src/forge/_generated/authConfig.json +0 -2
- package/src/forge/_generated/authConfig.ts +0 -17
- package/src/forge/_generated/authContext.ts +0 -23
- package/src/forge/_generated/authRegistry.json +0 -2
- package/src/forge/_generated/authRegistry.ts +0 -25
- package/src/forge/_generated/buildInfo.json +0 -2
- package/src/forge/_generated/buildInfo.ts +0 -9
- package/src/forge/_generated/capabilityMap.json +0 -2
- package/src/forge/_generated/capabilityMap.md +0 -15
- package/src/forge/_generated/capabilityMap.ts +0 -17
- package/src/forge/_generated/client.ts +0 -282
- package/src/forge/_generated/clientApi.ts +0 -9
- package/src/forge/_generated/clientManifest.json +0 -2
- package/src/forge/_generated/clientManifest.ts +0 -39
- package/src/forge/_generated/clientTypes.ts +0 -78
- package/src/forge/_generated/configRegistry.json +0 -2
- package/src/forge/_generated/configRegistry.ts +0 -4
- package/src/forge/_generated/dataGraph.json +0 -2
- package/src/forge/_generated/dataGraph.ts +0 -8
- package/src/forge/_generated/db.json +0 -2
- package/src/forge/_generated/db.ts +0 -2
- package/src/forge/_generated/dbSecurityManifest.json +0 -2
- package/src/forge/_generated/dbSecurityManifest.ts +0 -15
- package/src/forge/_generated/dbSessionContext.json +0 -2
- package/src/forge/_generated/dbSessionContext.ts +0 -39
- package/src/forge/_generated/deployManifest.json +0 -2
- package/src/forge/_generated/deployManifest.ts +0 -14
- package/src/forge/_generated/devManifest.json +0 -2
- package/src/forge/_generated/devManifest.ts +0 -62
- package/src/forge/_generated/envSchema.json +0 -2
- package/src/forge/_generated/envSchema.ts +0 -59
- package/src/forge/_generated/frontendGraph.json +0 -2
- package/src/forge/_generated/frontendGraph.ts +0 -27
- package/src/forge/_generated/importGuards.json +0 -2
- package/src/forge/_generated/importGuards.ts +0 -686
- package/src/forge/_generated/index.ts +0 -68
- package/src/forge/_generated/liveProductionManifest.json +0 -2
- package/src/forge/_generated/liveProductionManifest.ts +0 -23
- package/src/forge/_generated/liveProtocol.json +0 -2
- package/src/forge/_generated/liveProtocol.ts +0 -21
- package/src/forge/_generated/liveQueryRegistry.json +0 -2
- package/src/forge/_generated/liveQueryRegistry.ts +0 -9
- package/src/forge/_generated/liveTransportConfig.json +0 -2
- package/src/forge/_generated/liveTransportConfig.ts +0 -19
- package/src/forge/_generated/makeRegistry.json +0 -2
- package/src/forge/_generated/makeRegistry.ts +0 -177
- package/src/forge/_generated/makeTemplates.json +0 -2
- package/src/forge/_generated/makeTemplates.ts +0 -66
- package/src/forge/_generated/mockMap.json +0 -2
- package/src/forge/_generated/mockMap.ts +0 -7
- package/src/forge/_generated/operationPlaybooks.md +0 -167
- package/src/forge/_generated/packageGraph.json +0 -2
- package/src/forge/_generated/packageGraph.ts +0 -249334
- package/src/forge/_generated/packageUpgradeRegistry.json +0 -2
- package/src/forge/_generated/packageUpgradeRegistry.ts +0 -15
- package/src/forge/_generated/permissionMatrix.json +0 -2
- package/src/forge/_generated/permissionMatrix.ts +0 -7
- package/src/forge/_generated/policyRegistry.json +0 -2
- package/src/forge/_generated/policyRegistry.ts +0 -11
- package/src/forge/_generated/queryRegistry.json +0 -2
- package/src/forge/_generated/queryRegistry.ts +0 -9
- package/src/forge/_generated/react.d.ts +0 -22
- package/src/forge/_generated/react.ts +0 -29
- package/src/forge/_generated/reactManifest.json +0 -2
- package/src/forge/_generated/reactManifest.ts +0 -19
- package/src/forge/_generated/rlsPolicies.json +0 -2
- package/src/forge/_generated/rlsPolicies.sql +0 -34
- package/src/forge/_generated/rlsPolicies.ts +0 -6
- package/src/forge/_generated/runtimeGraph.json +0 -2
- package/src/forge/_generated/runtimeGraph.ts +0 -8
- package/src/forge/_generated/runtimeMatrix.json +0 -2
- package/src/forge/_generated/runtimeMatrix.ts +0 -334130
- package/src/forge/_generated/runtimeRegistry.ts +0 -2
- package/src/forge/_generated/runtimeRules.md +0 -91
- package/src/forge/_generated/secretRegistry.json +0 -2
- package/src/forge/_generated/secretRegistry.ts +0 -50
- package/src/forge/_generated/secretsContext.ts +0 -11
- package/src/forge/_generated/serverApi.ts +0 -10
- package/src/forge/_generated/sourceMapManifest.json +0 -2
- package/src/forge/_generated/sourceMapManifest.ts +0 -7
- package/src/forge/_generated/sqlPlan.json +0 -2
- package/src/forge/_generated/sqlPlan.ts +0 -88
- package/src/forge/_generated/subscriptionManifest.json +0 -2
- package/src/forge/_generated/subscriptionManifest.ts +0 -7
- package/src/forge/_generated/symbolicationManifest.json +0 -2
- package/src/forge/_generated/symbolicationManifest.ts +0 -17
- package/src/forge/_generated/telemetryRegistry.json +0 -2
- package/src/forge/_generated/telemetryRegistry.ts +0 -9
- package/src/forge/_generated/telemetrySinks.json +0 -2
- package/src/forge/_generated/telemetrySinks.ts +0 -11
- package/src/forge/_generated/tenantScope.json +0 -2
- package/src/forge/_generated/tenantScope.ts +0 -8
- package/src/forge/_generated/testGraph.json +0 -2
- package/src/forge/_generated/testGraph.ts +0 -3452
- package/src/forge/_generated/testPlanRegistry.json +0 -2
- package/src/forge/_generated/testPlanRegistry.ts +0 -33
- package/src/forge/_generated/uiRoutes.json +0 -2
- package/src/forge/_generated/uiRoutes.ts +0 -16
- package/src/forge/_generated/uiScenarios.json +0 -2
- package/src/forge/_generated/uiScenarios.ts +0 -30
- package/src/forge/_generated/uiTestManifest.json +0 -2
- package/src/forge/_generated/uiTestManifest.ts +0 -27
- package/src/forge/_generated/workflowRegistry.json +0 -2
- package/src/forge/_generated/workflowRegistry.ts +0 -9
- package/src/forge/_generated/workflowSubscriptions.json +0 -2
- package/src/forge/_generated/workflowSubscriptions.ts +0 -10
|
@@ -0,0 +1,3509 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
|
|
7
|
+
import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
|
|
8
|
+
|
|
9
|
+
export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup" | "prove" | "env" | "fga";
|
|
10
|
+
export type WorkOSFgaAction = "plan" | "sync" | "prove" | "doctor";
|
|
11
|
+
|
|
12
|
+
export interface WorkOSCommandOptions {
|
|
13
|
+
subcommand: WorkOSSubcommand;
|
|
14
|
+
fgaAction?: WorkOSFgaAction;
|
|
15
|
+
workspaceRoot: string;
|
|
16
|
+
json: boolean;
|
|
17
|
+
file?: string;
|
|
18
|
+
yes: boolean;
|
|
19
|
+
dryRun: boolean;
|
|
20
|
+
real?: boolean;
|
|
21
|
+
write?: boolean;
|
|
22
|
+
writePath?: string;
|
|
23
|
+
clientId?: string;
|
|
24
|
+
skipCliEnv?: boolean;
|
|
25
|
+
commandRunner?: WorkOSCommandRunner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkOSCheck {
|
|
29
|
+
name: string;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
detail: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkOSCommandResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup" | "workos-prove" | "workos-env" | "workos-fga";
|
|
37
|
+
checks: WorkOSCheck[];
|
|
38
|
+
command?: string[];
|
|
39
|
+
applied?: boolean;
|
|
40
|
+
data?: unknown;
|
|
41
|
+
stdout?: string;
|
|
42
|
+
stderr?: string;
|
|
43
|
+
exitCode: 0 | 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorkOSCommandRunner = (
|
|
47
|
+
command: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
options: {
|
|
50
|
+
cwd: string;
|
|
51
|
+
encoding: "utf8";
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"];
|
|
53
|
+
env?: Record<string, string | undefined>;
|
|
54
|
+
},
|
|
55
|
+
) => { status: number | null; stdout: string; stderr: string };
|
|
56
|
+
|
|
57
|
+
const DEFAULT_SEED_FILE = "workos-seed.yml";
|
|
58
|
+
const GENERATED_SEED_FILE = `${GENERATED_DIR}/integrations/workos/workos-seed.yml`;
|
|
59
|
+
const WORKOS_SEED_STATE_FILE = ".workos-seed-state.json";
|
|
60
|
+
const WORKOS_FGA_STATE_FILE = ".workos-fga-state.json";
|
|
61
|
+
const WORKOS_FGA_SETUP_GUIDE_FILE = ".forge/workos-fga-setup.md";
|
|
62
|
+
const WORKOS_FGA_SETUP_PLAN_FILE = ".forge/workos-fga-setup.json";
|
|
63
|
+
|
|
64
|
+
function runExternalCommand(
|
|
65
|
+
command: string[],
|
|
66
|
+
options: WorkOSCommandOptions,
|
|
67
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
68
|
+
const runner = options.commandRunner ?? spawnSync;
|
|
69
|
+
const result = runner(command[0]!, command.slice(1), {
|
|
70
|
+
cwd: options.workspaceRoot,
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
...readRealEnv(options.workspaceRoot),
|
|
76
|
+
WORKOS_MODE: process.env.WORKOS_MODE || "agent",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
status: result.status,
|
|
81
|
+
stdout: typeof result.stdout === "string" ? result.stdout : result.stdout?.toString("utf8") ?? "",
|
|
82
|
+
stderr: typeof result.stderr === "string" ? result.stderr : result.stderr?.toString("utf8") ?? "",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function exists(root: string, path: string): boolean {
|
|
87
|
+
return existsSync(join(root, path));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasWorkOSFgaArtifacts(root: string): boolean {
|
|
91
|
+
return exists(root, `${GENERATED_DIR}/integrations/workos/fga.ts`) ||
|
|
92
|
+
exists(root, `${GENERATED_DIR}/integrations/workos/resource-map.ts`) ||
|
|
93
|
+
exists(root, WORKOS_FGA_STATE_FILE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJson(root: string, path: string): unknown | null {
|
|
97
|
+
const absolute = join(root, path);
|
|
98
|
+
if (!existsSync(absolute)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(stripDeterministicHeader(readFileSync(absolute, "utf8")));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readText(root: string, path: string): string {
|
|
105
|
+
const absolute = join(root, path);
|
|
106
|
+
if (!existsSync(absolute)) {
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
return stripDeterministicHeader(readFileSync(absolute, "utf8"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readRawText(root: string, path: string): string {
|
|
113
|
+
const absolute = join(root, path);
|
|
114
|
+
if (!existsSync(absolute)) {
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
return readFileSync(absolute, "utf8");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function includesAll(haystack: string, needles: string[]): boolean {
|
|
121
|
+
return needles.every((needle) => haystack.includes(needle));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function webAuthSessionProxyConfigured(workspaceRoot: string): boolean {
|
|
125
|
+
const configText = [
|
|
126
|
+
"web/vite.config.ts",
|
|
127
|
+
"web/vite.config.mts",
|
|
128
|
+
"web/vite.config.js",
|
|
129
|
+
"web/vite.config.mjs",
|
|
130
|
+
"web/next.config.ts",
|
|
131
|
+
"web/next.config.mjs",
|
|
132
|
+
"web/next.config.js",
|
|
133
|
+
].map((path) => readText(workspaceRoot, path)).join("\n");
|
|
134
|
+
if (!configText.trim()) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return includesAll(configText, ["/login", "/callback", "/logout", "/session"]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function uniqueSorted(values: Iterable<string>): string[] {
|
|
141
|
+
return [...new Set([...values].filter(Boolean))].sort();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function quotedValues(text: string): string[] {
|
|
145
|
+
return [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]!).filter(Boolean);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface WorkOSSeedSummary {
|
|
149
|
+
exists: boolean;
|
|
150
|
+
valid: boolean;
|
|
151
|
+
path: string;
|
|
152
|
+
permissions: string[];
|
|
153
|
+
roles: string[];
|
|
154
|
+
resourceTypes: string[];
|
|
155
|
+
organizations: string[];
|
|
156
|
+
domains: string[];
|
|
157
|
+
redirectUris: string[];
|
|
158
|
+
corsOrigins: string[];
|
|
159
|
+
homepageUrl?: string;
|
|
160
|
+
webhookEndpoints: Array<{ url: string; events: string[] }>;
|
|
161
|
+
diagnostics: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface WorkOSSeedStateSummary {
|
|
165
|
+
exists: boolean;
|
|
166
|
+
valid: boolean;
|
|
167
|
+
path: string;
|
|
168
|
+
matchesSeedHash: boolean | null;
|
|
169
|
+
seedHash?: string;
|
|
170
|
+
currentSeedHash?: string;
|
|
171
|
+
appliedAt?: string;
|
|
172
|
+
alreadyApplied?: boolean;
|
|
173
|
+
diagnostics: string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseSlug(line: string): string | null {
|
|
177
|
+
const match = /(?:^|\s)-?\s*slug:\s*["']?([^"',\]\s]+)["']?/.exec(line);
|
|
178
|
+
return match?.[1] ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseName(line: string): string | null {
|
|
182
|
+
const match = /(?:^|\s)-?\s*name:\s*["']?([^"'\]]+(?:\s[^"'\]]+)*)["']?/.exec(line);
|
|
183
|
+
return match?.[1]?.trim() ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function parseSeedFile(workspaceRoot: string, preferredPath = DEFAULT_SEED_FILE): WorkOSSeedSummary {
|
|
187
|
+
const seedPath = exists(workspaceRoot, preferredPath)
|
|
188
|
+
? preferredPath
|
|
189
|
+
: exists(workspaceRoot, GENERATED_SEED_FILE)
|
|
190
|
+
? GENERATED_SEED_FILE
|
|
191
|
+
: preferredPath;
|
|
192
|
+
const raw = readText(workspaceRoot, seedPath);
|
|
193
|
+
const permissions = new Set<string>();
|
|
194
|
+
const roles = new Set<string>();
|
|
195
|
+
const resourceTypes = new Set<string>();
|
|
196
|
+
const organizations = new Set<string>();
|
|
197
|
+
const domains = new Set<string>();
|
|
198
|
+
const redirectUris = new Set<string>();
|
|
199
|
+
const corsOrigins = new Set<string>();
|
|
200
|
+
let homepageUrl: string | undefined;
|
|
201
|
+
const webhookEndpoints: Array<{ url: string; events: string[] }> = [];
|
|
202
|
+
const diagnostics: string[] = [];
|
|
203
|
+
let section = "";
|
|
204
|
+
let configKey = "";
|
|
205
|
+
let currentWebhook: { url: string; events: string[] } | undefined;
|
|
206
|
+
|
|
207
|
+
if (!raw.trim()) {
|
|
208
|
+
return {
|
|
209
|
+
exists: false,
|
|
210
|
+
valid: false,
|
|
211
|
+
path: seedPath,
|
|
212
|
+
permissions: [],
|
|
213
|
+
roles: [],
|
|
214
|
+
resourceTypes: [],
|
|
215
|
+
organizations: [],
|
|
216
|
+
domains: [],
|
|
217
|
+
redirectUris: [],
|
|
218
|
+
corsOrigins: [],
|
|
219
|
+
webhookEndpoints: [],
|
|
220
|
+
diagnostics: [`${seedPath} is missing or empty`],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
225
|
+
const rootSection = /^([a-zA-Z_][\w-]*):\s*$/.exec(line);
|
|
226
|
+
if (rootSection) {
|
|
227
|
+
section = rootSection[1]!;
|
|
228
|
+
configKey = "";
|
|
229
|
+
currentWebhook = undefined;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (section === "config") {
|
|
234
|
+
const configEntry = /^\s{2}([a-zA-Z_][\w-]*):\s*(.*)$/.exec(line);
|
|
235
|
+
if (configEntry) {
|
|
236
|
+
configKey = configEntry[1]!;
|
|
237
|
+
const rest = configEntry[2] ?? "";
|
|
238
|
+
if (configKey === "redirect_uris") {
|
|
239
|
+
for (const value of quotedValues(rest)) redirectUris.add(value);
|
|
240
|
+
} else if (configKey === "cors_origins") {
|
|
241
|
+
for (const value of quotedValues(rest)) corsOrigins.add(value);
|
|
242
|
+
} else if (configKey === "homepage_url") {
|
|
243
|
+
homepageUrl = quotedValues(rest)[0] ?? (rest.trim().replace(/^["']|["']$/g, "") || homepageUrl);
|
|
244
|
+
} else if (configKey === "webhook_endpoints") {
|
|
245
|
+
currentWebhook = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const bulletValue = /^\s*-\s*["']?([^"'\]\s]+)["']?\s*$/.exec(line)?.[1];
|
|
249
|
+
if (bulletValue && configKey === "redirect_uris") {
|
|
250
|
+
redirectUris.add(bulletValue);
|
|
251
|
+
} else if (bulletValue && configKey === "cors_origins") {
|
|
252
|
+
corsOrigins.add(bulletValue);
|
|
253
|
+
} else if (bulletValue && configKey === "webhook_events" && currentWebhook) {
|
|
254
|
+
currentWebhook.events.push(bulletValue);
|
|
255
|
+
}
|
|
256
|
+
const webhookUrl = /^\s*-\s*url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1] ??
|
|
257
|
+
/^\s{4}url:\s*["']?([^"']+)["']?\s*$/.exec(line)?.[1];
|
|
258
|
+
if (webhookUrl) {
|
|
259
|
+
currentWebhook = { url: webhookUrl.trim(), events: [] };
|
|
260
|
+
webhookEndpoints.push(currentWebhook);
|
|
261
|
+
configKey = "webhook_endpoints";
|
|
262
|
+
}
|
|
263
|
+
if (/^\s{4}events:\s*/.test(line) && currentWebhook) {
|
|
264
|
+
configKey = "webhook_events";
|
|
265
|
+
for (const value of quotedValues(line)) currentWebhook.events.push(value);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const slug = parseSlug(line);
|
|
270
|
+
if (slug) {
|
|
271
|
+
if (section === "permissions") {
|
|
272
|
+
permissions.add(slug);
|
|
273
|
+
} else if (section === "roles") {
|
|
274
|
+
roles.add(slug);
|
|
275
|
+
} else if (section === "resource_types") {
|
|
276
|
+
resourceTypes.add(slug);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (section === "organizations") {
|
|
281
|
+
const name = parseName(line);
|
|
282
|
+
if (name) {
|
|
283
|
+
organizations.add(name);
|
|
284
|
+
}
|
|
285
|
+
for (const value of quotedValues(line)) {
|
|
286
|
+
if (value.includes(".")) {
|
|
287
|
+
domains.add(value);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const match of line.matchAll(/-\s*["']?([a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+)["']?/g)) {
|
|
293
|
+
permissions.add(match[1]!);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (permissions.size === 0) diagnostics.push("no permission slugs found");
|
|
298
|
+
if (roles.size === 0) diagnostics.push("no role slugs found");
|
|
299
|
+
if (organizations.size === 0) diagnostics.push("no organizations found");
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
exists: true,
|
|
303
|
+
valid: diagnostics.length === 0,
|
|
304
|
+
path: seedPath,
|
|
305
|
+
permissions: uniqueSorted(permissions),
|
|
306
|
+
roles: uniqueSorted(roles),
|
|
307
|
+
resourceTypes: uniqueSorted(resourceTypes),
|
|
308
|
+
organizations: uniqueSorted(organizations),
|
|
309
|
+
domains: uniqueSorted(domains),
|
|
310
|
+
redirectUris: uniqueSorted(redirectUris),
|
|
311
|
+
corsOrigins: uniqueSorted(corsOrigins),
|
|
312
|
+
...(homepageUrl ? { homepageUrl } : {}),
|
|
313
|
+
webhookEndpoints: webhookEndpoints.map((endpoint) => ({
|
|
314
|
+
url: endpoint.url,
|
|
315
|
+
events: uniqueSorted(endpoint.events),
|
|
316
|
+
})),
|
|
317
|
+
diagnostics,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function parseEnvText(text: string): Record<string, string> {
|
|
322
|
+
const values: Record<string, string> = {};
|
|
323
|
+
for (const line of text.split(/\r?\n/)) {
|
|
324
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
|
325
|
+
if (!match) continue;
|
|
326
|
+
const raw = match[2] ?? "";
|
|
327
|
+
values[match[1]!] = raw.trim().replace(/^["']|["']$/g, "");
|
|
328
|
+
}
|
|
329
|
+
return values;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readRealEnv(workspaceRoot: string): Record<string, string> {
|
|
333
|
+
return {
|
|
334
|
+
...parseEnvText(readRawText(workspaceRoot, ".env")),
|
|
335
|
+
...parseEnvText(readRawText(workspaceRoot, "deploy/.env.production")),
|
|
336
|
+
...parseEnvText(readRawText(workspaceRoot, ".env.local")),
|
|
337
|
+
...Object.fromEntries(
|
|
338
|
+
Object.entries(process.env)
|
|
339
|
+
.filter(([key]) => key.startsWith("FORGE_") || key.startsWith("WORKOS_") || key.startsWith("VITE_WORKOS_"))
|
|
340
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
341
|
+
),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function hasValue(env: Record<string, string>, name: string): boolean {
|
|
346
|
+
return typeof env[name] === "string" && env[name]!.trim().length > 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function workosJwksUri(clientId: string | undefined): string {
|
|
350
|
+
return clientId ? `https://api.workos.com/sso/jwks/${clientId}` : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface WorkOSCliAuthSummary {
|
|
354
|
+
required: boolean;
|
|
355
|
+
ok: boolean;
|
|
356
|
+
method: "api-key" | "cli";
|
|
357
|
+
skippedReason?: string;
|
|
358
|
+
statusCommand: string[];
|
|
359
|
+
statusShellCommand?: string;
|
|
360
|
+
loginCommand?: string[];
|
|
361
|
+
loginShellCommand?: string;
|
|
362
|
+
rerunCommand?: string;
|
|
363
|
+
loginAttempted: boolean;
|
|
364
|
+
authenticated?: boolean;
|
|
365
|
+
email?: string;
|
|
366
|
+
userId?: string;
|
|
367
|
+
tokenExpired?: boolean;
|
|
368
|
+
hasRefreshToken?: boolean;
|
|
369
|
+
activeEnvironment?: unknown;
|
|
370
|
+
status?: number | null;
|
|
371
|
+
loginStatus?: number | null;
|
|
372
|
+
detail: string;
|
|
373
|
+
loginInstructions?: {
|
|
374
|
+
url?: string;
|
|
375
|
+
code?: string;
|
|
376
|
+
message?: string;
|
|
377
|
+
};
|
|
378
|
+
nextActions: string[];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
382
|
+
try {
|
|
383
|
+
const parsed = JSON.parse(text);
|
|
384
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
385
|
+
? parsed as Record<string, unknown>
|
|
386
|
+
: null;
|
|
387
|
+
} catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stringField(value: unknown): string | undefined {
|
|
393
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function booleanField(value: unknown): boolean | undefined {
|
|
397
|
+
return typeof value === "boolean" ? value : undefined;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function extractWorkOSLoginInstructions(stdout: string, stderr: string): WorkOSCliAuthSummary["loginInstructions"] | undefined {
|
|
401
|
+
const output = `${stdout}\n${stderr}`.trim();
|
|
402
|
+
if (!output) {
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
const url = output.match(/https?:\/\/[^\s"'<>]+/)?.[0];
|
|
406
|
+
const code = output.match(/(?:code|verification code|user code)[^\w]*([A-Z0-9-]{4,})/i)?.[1]
|
|
407
|
+
?? output.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4}|[A-Z0-9]{6,12})\b/)?.[1];
|
|
408
|
+
const message = output
|
|
409
|
+
.split(/\r?\n/)
|
|
410
|
+
.map((line) => line.trim())
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
.slice(0, 12)
|
|
413
|
+
.join("\n");
|
|
414
|
+
return {
|
|
415
|
+
...(url ? { url } : {}),
|
|
416
|
+
...(code ? { code } : {}),
|
|
417
|
+
...(message ? { message } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function workOSCliAuthCheck(auth: WorkOSCliAuthSummary): WorkOSCheck {
|
|
422
|
+
return {
|
|
423
|
+
name: "workos-cli-auth",
|
|
424
|
+
ok: auth.ok,
|
|
425
|
+
detail: auth.detail,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function workOSCliAuthShellCommand(command: string[]): string {
|
|
430
|
+
return `WORKOS_MODE=agent ${command.join(" ")}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function workOSHostedRerunCommand(options: WorkOSCommandOptions): string {
|
|
434
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
435
|
+
const subcommand = options.subcommand === "setup" ? "setup" : "prove";
|
|
436
|
+
return `forge workos ${subcommand} --real --file ${file} --json`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function ensureWorkOSCliAuthForHosted(options: WorkOSCommandOptions): WorkOSCliAuthSummary {
|
|
440
|
+
const env = readRealEnv(options.workspaceRoot);
|
|
441
|
+
const statusCommand = ["npx", "--yes", "workos@latest", "auth", "status", "--json"];
|
|
442
|
+
const statusShellCommand = workOSCliAuthShellCommand(statusCommand);
|
|
443
|
+
const rerunCommand = workOSHostedRerunCommand(options);
|
|
444
|
+
if (hasValue(env, "WORKOS_API_KEY")) {
|
|
445
|
+
return {
|
|
446
|
+
required: false,
|
|
447
|
+
ok: true,
|
|
448
|
+
method: "api-key",
|
|
449
|
+
skippedReason: "WORKOS_API_KEY is present; WorkOS CLI browser login is optional for hosted setup",
|
|
450
|
+
statusCommand,
|
|
451
|
+
statusShellCommand,
|
|
452
|
+
loginAttempted: false,
|
|
453
|
+
detail: "WORKOS_API_KEY is present; hosted WorkOS setup can use API key authentication",
|
|
454
|
+
nextActions: [],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const status = runExternalCommand(statusCommand, options);
|
|
459
|
+
const parsed = parseJsonObject(status.stdout);
|
|
460
|
+
const authenticated = booleanField(parsed?.authenticated) ?? false;
|
|
461
|
+
const tokenExpired = booleanField(parsed?.tokenExpired) ?? false;
|
|
462
|
+
const hasRefreshToken = booleanField(parsed?.hasRefreshToken) ?? false;
|
|
463
|
+
const email = stringField(parsed?.email);
|
|
464
|
+
const userId = stringField(parsed?.userId);
|
|
465
|
+
const activeEnvironment = parsed?.activeEnvironment;
|
|
466
|
+
if (status.status === 0 && authenticated && (!tokenExpired || hasRefreshToken)) {
|
|
467
|
+
return {
|
|
468
|
+
required: true,
|
|
469
|
+
ok: true,
|
|
470
|
+
method: "cli",
|
|
471
|
+
statusCommand,
|
|
472
|
+
statusShellCommand,
|
|
473
|
+
loginAttempted: false,
|
|
474
|
+
authenticated,
|
|
475
|
+
...(email ? { email } : {}),
|
|
476
|
+
...(userId ? { userId } : {}),
|
|
477
|
+
tokenExpired,
|
|
478
|
+
hasRefreshToken,
|
|
479
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
480
|
+
status: status.status,
|
|
481
|
+
detail: tokenExpired && hasRefreshToken
|
|
482
|
+
? `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}; token is expired but a refresh token is available`
|
|
483
|
+
: `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}`,
|
|
484
|
+
nextActions: [],
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const loginCommand = ["npx", "--yes", "workos@latest", "auth", "login", "--json"];
|
|
489
|
+
const loginShellCommand = workOSCliAuthShellCommand(loginCommand);
|
|
490
|
+
const login = runExternalCommand(loginCommand, options);
|
|
491
|
+
const instructions = extractWorkOSLoginInstructions(login.stdout, login.stderr);
|
|
492
|
+
if (login.status === 0) {
|
|
493
|
+
return {
|
|
494
|
+
required: true,
|
|
495
|
+
ok: true,
|
|
496
|
+
method: "cli",
|
|
497
|
+
statusCommand,
|
|
498
|
+
statusShellCommand,
|
|
499
|
+
loginCommand,
|
|
500
|
+
loginShellCommand,
|
|
501
|
+
loginAttempted: true,
|
|
502
|
+
authenticated: true,
|
|
503
|
+
status: status.status,
|
|
504
|
+
loginStatus: login.status,
|
|
505
|
+
detail: "WorkOS CLI login completed or was already authenticated",
|
|
506
|
+
...(instructions ? { loginInstructions: instructions } : {}),
|
|
507
|
+
nextActions: [],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const nextActions = [
|
|
512
|
+
loginShellCommand,
|
|
513
|
+
"complete the WorkOS CLI OAuth/device-code login shown in loginInstructions",
|
|
514
|
+
`rerun ${rerunCommand}`,
|
|
515
|
+
];
|
|
516
|
+
return {
|
|
517
|
+
required: true,
|
|
518
|
+
ok: false,
|
|
519
|
+
method: "cli",
|
|
520
|
+
statusCommand,
|
|
521
|
+
statusShellCommand,
|
|
522
|
+
loginCommand,
|
|
523
|
+
loginShellCommand,
|
|
524
|
+
rerunCommand,
|
|
525
|
+
loginAttempted: true,
|
|
526
|
+
authenticated,
|
|
527
|
+
...(email ? { email } : {}),
|
|
528
|
+
...(userId ? { userId } : {}),
|
|
529
|
+
tokenExpired,
|
|
530
|
+
hasRefreshToken,
|
|
531
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
532
|
+
status: status.status,
|
|
533
|
+
loginStatus: login.status,
|
|
534
|
+
detail: instructions?.url || instructions?.code
|
|
535
|
+
? "WorkOS CLI login is required; open the URL and enter the code from loginInstructions, then rerun the command"
|
|
536
|
+
: "WorkOS CLI login is required; run WORKOS_MODE=agent npx --yes workos@latest auth login --json and rerun the Forge command",
|
|
537
|
+
...(instructions ? { loginInstructions: instructions } : {}),
|
|
538
|
+
nextActions,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function collectWorkOSRealEnvChecks(workspaceRoot: string, cliAuth?: WorkOSCliAuthSummary): WorkOSCheck[] {
|
|
543
|
+
const env = readRealEnv(workspaceRoot);
|
|
544
|
+
const authMode = env.FORGE_AUTH_MODE;
|
|
545
|
+
const clientId = env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID;
|
|
546
|
+
const required = [
|
|
547
|
+
["WORKOS_CLIENT_ID", "WorkOS client ID is required for AuthKit and JWKS discovery"],
|
|
548
|
+
["WORKOS_COOKIE_PASSWORD", "cookie password is required for AuthKit session signing"],
|
|
549
|
+
["FORGE_AUTH_ISSUER", "Forge OIDC issuer must be https://api.workos.com"],
|
|
550
|
+
["FORGE_AUTH_AUDIENCE", "Forge OIDC audience must match the WorkOS client/application audience"],
|
|
551
|
+
["FORGE_AUTH_JWKS_URI", `Forge JWKS URI must be configured, usually ${workosJwksUri(clientId)}`],
|
|
552
|
+
] as const;
|
|
553
|
+
return [
|
|
554
|
+
{
|
|
555
|
+
name: "real-env-auth-mode",
|
|
556
|
+
ok: authMode === "oidc" || authMode === "jwt",
|
|
557
|
+
detail: authMode === "oidc" || authMode === "jwt"
|
|
558
|
+
? `FORGE_AUTH_MODE=${authMode} is production-capable`
|
|
559
|
+
: "FORGE_AUTH_MODE must be oidc or jwt before running hosted WorkOS proof",
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "real-env-workos_api_key-or-cli-auth",
|
|
563
|
+
ok: hasValue(env, "WORKOS_API_KEY") || cliAuth?.ok === true,
|
|
564
|
+
detail: hasValue(env, "WORKOS_API_KEY")
|
|
565
|
+
? "WORKOS_API_KEY is present"
|
|
566
|
+
: cliAuth?.ok
|
|
567
|
+
? "WORKOS_API_KEY is missing, but WorkOS CLI authentication is available for no-dashboard hosted setup"
|
|
568
|
+
: "WORKOS_API_KEY is missing and WorkOS CLI authentication is not complete; run WorkOS CLI login",
|
|
569
|
+
},
|
|
570
|
+
...required.map(([name, detail]) => ({
|
|
571
|
+
name: `real-env-${name.toLowerCase()}`,
|
|
572
|
+
ok: hasValue(env, name),
|
|
573
|
+
detail: hasValue(env, name) ? `${name} is present` : `${name} is missing: ${detail}`,
|
|
574
|
+
})),
|
|
575
|
+
];
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function renderMergedDotEnv(current: string | null, values: Record<string, string>): string {
|
|
579
|
+
const lines = (current ?? "").split(/\r?\n/);
|
|
580
|
+
const seen = new Set<string>();
|
|
581
|
+
const next = lines.map((line) => {
|
|
582
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
|
|
583
|
+
if (!match || !(match[1]! in values)) {
|
|
584
|
+
return line;
|
|
585
|
+
}
|
|
586
|
+
seen.add(match[1]!);
|
|
587
|
+
return `${match[1]}=${values[match[1]!]}`;
|
|
588
|
+
});
|
|
589
|
+
for (const [key, value] of Object.entries(values)) {
|
|
590
|
+
if (!seen.has(key)) {
|
|
591
|
+
next.push(`${key}=${value}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return `${next.filter((line, index, all) => line.length > 0 || index < all.length - 1).join("\n").trimEnd()}\n`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function syncWorkOSPublicWebEnv(workspaceRoot: string, write: boolean, explicitClientId?: string): { ok: boolean; changed: string[]; path?: string; skipped?: string } {
|
|
598
|
+
const frontendWorkspace = findWorkOSFrontendWorkspace(workspaceRoot);
|
|
599
|
+
if (!frontendWorkspace) {
|
|
600
|
+
return { ok: true, changed: [], skipped: "no web workspace detected" };
|
|
601
|
+
}
|
|
602
|
+
const env = readRealEnv(workspaceRoot);
|
|
603
|
+
const clientId = explicitClientId || env.VITE_WORKOS_CLIENT_ID || env.WORKOS_CLIENT_ID || "";
|
|
604
|
+
const redirectUri = env.VITE_WORKOS_REDIRECT_URI || env.WORKOS_REDIRECT_URI || "http://localhost:5173/callback";
|
|
605
|
+
const values: Record<string, string> = {
|
|
606
|
+
VITE_FORGE_URL: env.VITE_FORGE_URL || "http://localhost:3765",
|
|
607
|
+
VITE_WORKOS_REDIRECT_URI: redirectUri,
|
|
608
|
+
};
|
|
609
|
+
if (clientId) {
|
|
610
|
+
values.VITE_WORKOS_CLIENT_ID = clientId;
|
|
611
|
+
}
|
|
612
|
+
const relPath = join(frontendWorkspace, ".env.local");
|
|
613
|
+
const path = join(workspaceRoot, relPath);
|
|
614
|
+
const current = readRawText(workspaceRoot, relPath);
|
|
615
|
+
const next = renderMergedDotEnv(current, values);
|
|
616
|
+
if (current === next) {
|
|
617
|
+
return { ok: true, changed: [], path: relPath };
|
|
618
|
+
}
|
|
619
|
+
if (write) {
|
|
620
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
621
|
+
writeFileSync(path, next, "utf8");
|
|
622
|
+
}
|
|
623
|
+
return { ok: true, changed: [relPath], path: relPath };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function workOSDefaultCallback(seed: WorkOSSeedSummary): string {
|
|
627
|
+
return seed.redirectUris.find((uri) => uri.includes("/callback")) ??
|
|
628
|
+
seed.redirectUris[0] ??
|
|
629
|
+
"http://localhost:5173/callback";
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function workOSDefaultHome(seed: WorkOSSeedSummary): string {
|
|
633
|
+
return seed.homepageUrl ?? seed.corsOrigins[0] ?? "http://localhost:5173";
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function generatedCookiePassword(): string {
|
|
637
|
+
return randomBytes(32).toString("hex");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function readWorkOSCliEnvSummary(options: WorkOSCommandOptions): unknown {
|
|
641
|
+
if (options.skipCliEnv) {
|
|
642
|
+
return { ok: true, skipped: true, message: "WorkOS CLI env list skipped for local status check" };
|
|
643
|
+
}
|
|
644
|
+
const child = runExternalCommand(["npx", "--yes", "workos@latest", "env", "list", "--json"], options);
|
|
645
|
+
const parsed = parseJsonObject(child.stdout);
|
|
646
|
+
return parsed ?? {
|
|
647
|
+
ok: false,
|
|
648
|
+
status: child.status,
|
|
649
|
+
message: child.stderr.trim() || child.stdout.trim() || "failed to read WorkOS CLI env list",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function envValueStatus(env: Record<string, string>, key: string): "present" | "missing" {
|
|
654
|
+
return hasValue(env, key) ? "present" : "missing";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function workOSEnvNextCommand(clientId?: string): string {
|
|
658
|
+
return clientId
|
|
659
|
+
? `forge workos env --client-id ${clientId} --write --json`
|
|
660
|
+
: "forge workos env --client-id client_... --write --json";
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function runWorkOSEnvCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
664
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
665
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
666
|
+
const env = readRealEnv(options.workspaceRoot);
|
|
667
|
+
const clientId = options.clientId || env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID || "";
|
|
668
|
+
const callback = env.WORKOS_REDIRECT_URI || env.VITE_WORKOS_REDIRECT_URI || workOSDefaultCallback(seed);
|
|
669
|
+
const home = env.WORKOS_POST_LOGIN_REDIRECT_URI || env.WORKOS_POST_LOGOUT_REDIRECT_URI || workOSDefaultHome(seed);
|
|
670
|
+
const rootValues: Record<string, string> = {
|
|
671
|
+
FORGE_AUTH_MODE: env.FORGE_AUTH_MODE || "oidc",
|
|
672
|
+
FORGE_AUTH_ISSUER: env.FORGE_AUTH_ISSUER || "https://api.workos.com",
|
|
673
|
+
WORKOS_REDIRECT_URI: callback,
|
|
674
|
+
WORKOS_POST_LOGIN_REDIRECT_URI: home,
|
|
675
|
+
WORKOS_POST_LOGOUT_REDIRECT_URI: home,
|
|
676
|
+
WORKOS_COOKIE_PASSWORD: env.WORKOS_COOKIE_PASSWORD || generatedCookiePassword(),
|
|
677
|
+
};
|
|
678
|
+
if (clientId) {
|
|
679
|
+
rootValues.WORKOS_CLIENT_ID = clientId;
|
|
680
|
+
rootValues.FORGE_AUTH_AUDIENCE = env.FORGE_AUTH_AUDIENCE || clientId;
|
|
681
|
+
rootValues.FORGE_AUTH_JWKS_URI = env.FORGE_AUTH_JWKS_URI || workosJwksUri(clientId);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const rootEnvPath = ".env.local";
|
|
685
|
+
const current = readRawText(options.workspaceRoot, rootEnvPath);
|
|
686
|
+
const next = renderMergedDotEnv(current, rootValues);
|
|
687
|
+
const changed = current !== next ? [rootEnvPath] : [];
|
|
688
|
+
if (options.write && current !== next) {
|
|
689
|
+
writeFileSync(join(options.workspaceRoot, rootEnvPath), next, "utf8");
|
|
690
|
+
}
|
|
691
|
+
const publicEnvSync = syncWorkOSPublicWebEnv(options.workspaceRoot, Boolean(options.write), clientId);
|
|
692
|
+
const missing = [
|
|
693
|
+
...(!clientId ? ["WORKOS_CLIENT_ID"] : []),
|
|
694
|
+
...(!clientId ? ["FORGE_AUTH_AUDIENCE"] : []),
|
|
695
|
+
...(!clientId ? ["FORGE_AUTH_JWKS_URI"] : []),
|
|
696
|
+
];
|
|
697
|
+
const cliEnv = readWorkOSCliEnvSummary(options);
|
|
698
|
+
const checks: WorkOSCheck[] = [
|
|
699
|
+
{
|
|
700
|
+
name: "env-file",
|
|
701
|
+
ok: options.write ? exists(options.workspaceRoot, rootEnvPath) : true,
|
|
702
|
+
detail: options.write ? `${rootEnvPath} has been written or was already current` : `${rootEnvPath} plan is ready; pass --write to apply`,
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: "env-auth-mode",
|
|
706
|
+
ok: rootValues.FORGE_AUTH_MODE === "oidc" || rootValues.FORGE_AUTH_MODE === "jwt",
|
|
707
|
+
detail: `FORGE_AUTH_MODE=${rootValues.FORGE_AUTH_MODE}`,
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
name: "env-workos-client-id",
|
|
711
|
+
ok: Boolean(clientId),
|
|
712
|
+
detail: clientId
|
|
713
|
+
? "WORKOS_CLIENT_ID is present and will drive audience/JWKS values"
|
|
714
|
+
: "WORKOS_CLIENT_ID is missing; WorkOS CLI may report hasClientId but does not expose the value to ForgeOS",
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
name: "env-cookie-password",
|
|
718
|
+
ok: hasValue(rootValues, "WORKOS_COOKIE_PASSWORD"),
|
|
719
|
+
detail: env.WORKOS_COOKIE_PASSWORD ? "WORKOS_COOKIE_PASSWORD is already present" : "WORKOS_COOKIE_PASSWORD will be generated on write",
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "env-forge-issuer",
|
|
723
|
+
ok: rootValues.FORGE_AUTH_ISSUER === "https://api.workos.com",
|
|
724
|
+
detail: `FORGE_AUTH_ISSUER=${rootValues.FORGE_AUTH_ISSUER}`,
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
name: "env-forge-audience",
|
|
728
|
+
ok: !missing.includes("FORGE_AUTH_AUDIENCE"),
|
|
729
|
+
detail: missing.includes("FORGE_AUTH_AUDIENCE")
|
|
730
|
+
? "FORGE_AUTH_AUDIENCE needs WORKOS_CLIENT_ID"
|
|
731
|
+
: "FORGE_AUTH_AUDIENCE will match WORKOS_CLIENT_ID unless already set",
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: "env-forge-jwks-uri",
|
|
735
|
+
ok: !missing.includes("FORGE_AUTH_JWKS_URI"),
|
|
736
|
+
detail: missing.includes("FORGE_AUTH_JWKS_URI")
|
|
737
|
+
? "FORGE_AUTH_JWKS_URI needs WORKOS_CLIENT_ID"
|
|
738
|
+
: `FORGE_AUTH_JWKS_URI=${rootValues.FORGE_AUTH_JWKS_URI}`,
|
|
739
|
+
},
|
|
740
|
+
];
|
|
741
|
+
const ok = checks.every((check) => check.ok);
|
|
742
|
+
return {
|
|
743
|
+
ok,
|
|
744
|
+
kind: "workos-env",
|
|
745
|
+
checks,
|
|
746
|
+
applied: options.write,
|
|
747
|
+
data: {
|
|
748
|
+
file,
|
|
749
|
+
write: options.write,
|
|
750
|
+
envFile: rootEnvPath,
|
|
751
|
+
changed: [...changed, ...publicEnvSync.changed],
|
|
752
|
+
publicEnvSync,
|
|
753
|
+
values: Object.fromEntries(Object.keys(rootValues).map((key) => [
|
|
754
|
+
key,
|
|
755
|
+
key.includes("PASSWORD") || key.includes("API_KEY") ? "<redacted>" : rootValues[key],
|
|
756
|
+
])),
|
|
757
|
+
requiredStillMissing: missing,
|
|
758
|
+
status: {
|
|
759
|
+
WORKOS_CLIENT_ID: clientId ? "present" : envValueStatus(env, "WORKOS_CLIENT_ID"),
|
|
760
|
+
WORKOS_COOKIE_PASSWORD: env.WORKOS_COOKIE_PASSWORD ? "present" : "generated-on-write",
|
|
761
|
+
FORGE_AUTH_MODE: rootValues.FORGE_AUTH_MODE,
|
|
762
|
+
FORGE_AUTH_AUDIENCE: missing.includes("FORGE_AUTH_AUDIENCE") ? "missing" : "present",
|
|
763
|
+
FORGE_AUTH_JWKS_URI: missing.includes("FORGE_AUTH_JWKS_URI") ? "missing" : "present",
|
|
764
|
+
},
|
|
765
|
+
workosCliEnv: cliEnv,
|
|
766
|
+
nextCommand: ok
|
|
767
|
+
? `forge auth prove --provider workos --real --file ${file} --json`
|
|
768
|
+
: workOSEnvNextCommand(),
|
|
769
|
+
nextActions: [
|
|
770
|
+
...(options.write ? [] : [clientId ? workOSEnvNextCommand(clientId) : workOSEnvNextCommand()]),
|
|
771
|
+
...(!clientId ? ["pass the AuthKit client id with forge workos env --client-id client_... --write --json"] : []),
|
|
772
|
+
...(ok ? [`forge auth prove --provider workos --real --file ${file} --json`] : []),
|
|
773
|
+
],
|
|
774
|
+
},
|
|
775
|
+
exitCode: ok ? 0 : 1,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function findWorkOSFrontendWorkspace(workspaceRoot: string): string | undefined {
|
|
780
|
+
for (const candidate of ["web", "frontend", "client", "apps/web", "packages/web"]) {
|
|
781
|
+
if (existsSync(join(workspaceRoot, candidate, "package.json"))) {
|
|
782
|
+
return candidate;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
export function collectPolicyPermissions(workspaceRoot: string): string[] {
|
|
790
|
+
const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
|
|
791
|
+
policies?: Array<{ permissions?: string[] }>;
|
|
792
|
+
} | null;
|
|
793
|
+
const permissions = new Set<string>();
|
|
794
|
+
for (const policy of registry?.policies ?? []) {
|
|
795
|
+
for (const permission of policy.permissions ?? []) {
|
|
796
|
+
permissions.add(permission);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
for (const path of ["src/policies.ts", "src/policies.workos.ts"]) {
|
|
800
|
+
const text = readText(workspaceRoot, path);
|
|
801
|
+
for (const match of text.matchAll(/canPermission\s*\(([^)]*)\)/g)) {
|
|
802
|
+
for (const value of quotedValues(match[1] ?? "")) {
|
|
803
|
+
permissions.add(value);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return uniqueSorted(permissions);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function singularResourceName(name: string): string {
|
|
811
|
+
if (name.endsWith("ies")) {
|
|
812
|
+
return `${name.slice(0, -3)}y`;
|
|
813
|
+
}
|
|
814
|
+
if (name.endsWith("ses")) {
|
|
815
|
+
return name.slice(0, -2);
|
|
816
|
+
}
|
|
817
|
+
if (name.endsWith("s") && name.length > 1) {
|
|
818
|
+
return name.slice(0, -1);
|
|
819
|
+
}
|
|
820
|
+
return name;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function collectExpectedResourceTypes(workspaceRoot: string): string[] {
|
|
824
|
+
const dataGraph = readJson(workspaceRoot, `${GENERATED_DIR}/dataGraph.json`) as {
|
|
825
|
+
tables?: Array<{ name?: string; fields?: Array<{ name?: string }> }>;
|
|
826
|
+
} | null;
|
|
827
|
+
const agentContract = readJson(workspaceRoot, `${GENERATED_DIR}/agentContract.json`) as {
|
|
828
|
+
auth?: { requiresTenant?: boolean };
|
|
829
|
+
} | null;
|
|
830
|
+
const resourceTypes = new Set<string>();
|
|
831
|
+
const tenantScopedTables = (dataGraph?.tables ?? []).filter((table) =>
|
|
832
|
+
(table.fields ?? []).some((field) => field.name === "tenantId")
|
|
833
|
+
);
|
|
834
|
+
if (agentContract?.auth?.requiresTenant || tenantScopedTables.length > 0) {
|
|
835
|
+
resourceTypes.add("organization");
|
|
836
|
+
}
|
|
837
|
+
for (const table of tenantScopedTables) {
|
|
838
|
+
if (!table.name || ["organizations", "organization", "memberships", "membership"].includes(table.name)) {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
resourceTypes.add(singularResourceName(table.name));
|
|
842
|
+
}
|
|
843
|
+
return uniqueSorted(resourceTypes);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function missingValues(expected: string[], actual: string[]): string[] {
|
|
847
|
+
const actualSet = new Set(actual);
|
|
848
|
+
return expected.filter((value) => !actualSet.has(value));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export interface WorkOSFgaResource {
|
|
852
|
+
externalId: string;
|
|
853
|
+
type: string;
|
|
854
|
+
tenant: string;
|
|
855
|
+
name: string;
|
|
856
|
+
parentExternalId?: string;
|
|
857
|
+
parentType?: string;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export interface WorkOSFgaProofScenario {
|
|
861
|
+
name: string;
|
|
862
|
+
expected: "allow" | "deny";
|
|
863
|
+
permission: string;
|
|
864
|
+
resourceExternalId: string;
|
|
865
|
+
resourceTypeSlug: string;
|
|
866
|
+
organization: string;
|
|
867
|
+
reason: string;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export interface WorkOSFgaManifest {
|
|
871
|
+
schemaVersion: "0.1.0";
|
|
872
|
+
provider: "workos";
|
|
873
|
+
kind: "fga-manifest";
|
|
874
|
+
seedFile: string;
|
|
875
|
+
seedHash?: string;
|
|
876
|
+
manifestHash: string;
|
|
877
|
+
permissions: string[];
|
|
878
|
+
roles: string[];
|
|
879
|
+
resourceTypes: string[];
|
|
880
|
+
organizations: string[];
|
|
881
|
+
resources: WorkOSFgaResource[];
|
|
882
|
+
proofScenarios: WorkOSFgaProofScenario[];
|
|
883
|
+
diagnostics: string[];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export interface WorkOSFgaStateSummary {
|
|
887
|
+
exists: boolean;
|
|
888
|
+
valid: boolean;
|
|
889
|
+
path: string;
|
|
890
|
+
matchesManifestHash: boolean | null;
|
|
891
|
+
manifestHash?: string;
|
|
892
|
+
currentManifestHash?: string;
|
|
893
|
+
syncedAt?: string;
|
|
894
|
+
provedAt?: string;
|
|
895
|
+
mode?: "local" | "real";
|
|
896
|
+
sdkOk?: boolean;
|
|
897
|
+
diagnostics: string[];
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export interface WorkOSFgaMembershipEnvSummary {
|
|
901
|
+
requiredEnv: string[];
|
|
902
|
+
presentEnv: string[];
|
|
903
|
+
missingEnv: string[];
|
|
904
|
+
jsonEnvPresent: boolean;
|
|
905
|
+
complete: boolean;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
export interface WorkOSFgaHostedSetup {
|
|
909
|
+
requiredResourceTypes: string[];
|
|
910
|
+
rootResourceType: "organization";
|
|
911
|
+
missingResourceTypes: string[];
|
|
912
|
+
requiredMembershipEnv: string[];
|
|
913
|
+
managedBy: "hosted-workos";
|
|
914
|
+
resourceTypeAutomation: "not-supported-by-workos-api" | "not-needed";
|
|
915
|
+
cliSupport: "resources-and-checks";
|
|
916
|
+
sdkSupport: "resources-and-checks";
|
|
917
|
+
docs: string[];
|
|
918
|
+
nextActions: string[];
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export interface WorkOSFgaResourceTypeSetup {
|
|
922
|
+
slug: string;
|
|
923
|
+
displayName: string;
|
|
924
|
+
hostedAction: "none" | "configure-resource-type";
|
|
925
|
+
requiredBeforeRealSync: boolean;
|
|
926
|
+
permissions: string[];
|
|
927
|
+
roles: string[];
|
|
928
|
+
parentTypes: string[];
|
|
929
|
+
childTypes: string[];
|
|
930
|
+
exampleExternalIds: string[];
|
|
931
|
+
proofScenarios: string[];
|
|
932
|
+
notes: string[];
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export interface WorkOSFgaSetupGuide {
|
|
936
|
+
schemaVersion: "0.1.0";
|
|
937
|
+
kind: "workos-fga-setup";
|
|
938
|
+
resourceTypes: WorkOSFgaResourceTypeSetup[];
|
|
939
|
+
hostedSetup: WorkOSFgaHostedSetup;
|
|
940
|
+
commands: {
|
|
941
|
+
plan: string;
|
|
942
|
+
syncReal: string;
|
|
943
|
+
proveReal: string;
|
|
944
|
+
deployCheck: string;
|
|
945
|
+
};
|
|
946
|
+
markdown: string;
|
|
947
|
+
docs: string[];
|
|
948
|
+
unsupportedAutomation: string[];
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
export interface WorkOSFgaReadiness {
|
|
952
|
+
real: boolean;
|
|
953
|
+
planReady: boolean;
|
|
954
|
+
seedReady: boolean;
|
|
955
|
+
resourceTypesConfigured: boolean;
|
|
956
|
+
membershipEnvReady: boolean;
|
|
957
|
+
synced: boolean;
|
|
958
|
+
proved: boolean;
|
|
959
|
+
productionReady: boolean;
|
|
960
|
+
nextCommand: string;
|
|
961
|
+
nextActions: string[];
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function slugifyExternalIdPart(value: string): string {
|
|
965
|
+
return value
|
|
966
|
+
.trim()
|
|
967
|
+
.toLowerCase()
|
|
968
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
969
|
+
.replace(/^-+|-+$/g, "") || "demo";
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function preferredParentType(resourceType: string, resourceTypes: string[]): string | undefined {
|
|
973
|
+
const has = (value: string) => resourceTypes.includes(value);
|
|
974
|
+
if (resourceType === "organization") return undefined;
|
|
975
|
+
if (["access_request", "accessRequest", "evidence_document", "evidenceDocument"].includes(resourceType)) {
|
|
976
|
+
if (has("vendor")) return "vendor";
|
|
977
|
+
}
|
|
978
|
+
if (["task", "taskGroup"].includes(resourceType)) {
|
|
979
|
+
if (has("team")) return "team";
|
|
980
|
+
if (has("project")) return "project";
|
|
981
|
+
}
|
|
982
|
+
if (resourceType === "team" && has("project")) return "project";
|
|
983
|
+
return has("organization") ? "organization" : undefined;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function stableJson(value: unknown): string {
|
|
987
|
+
if (Array.isArray(value)) {
|
|
988
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
989
|
+
}
|
|
990
|
+
if (value && typeof value === "object") {
|
|
991
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
992
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
993
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`)
|
|
994
|
+
.join(",")}}`;
|
|
995
|
+
}
|
|
996
|
+
return JSON.stringify(value);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function hashObject(value: unknown): string {
|
|
1000
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
export function collectWorkOSFgaManifest(
|
|
1004
|
+
workspaceRoot: string,
|
|
1005
|
+
preferredSeedPath = DEFAULT_SEED_FILE,
|
|
1006
|
+
): WorkOSFgaManifest {
|
|
1007
|
+
const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
|
|
1008
|
+
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
1009
|
+
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
1010
|
+
const permissions = uniqueSorted([...seed.permissions, ...activePermissions]);
|
|
1011
|
+
const resourceTypes = uniqueSorted([...seed.resourceTypes, ...expectedResourceTypes]);
|
|
1012
|
+
const organizations = seed.organizations.length > 0 ? seed.organizations : ["Demo Organization"];
|
|
1013
|
+
const diagnostics: string[] = [];
|
|
1014
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
1015
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
1016
|
+
if (!seed.exists) diagnostics.push(`${preferredSeedPath} is missing; run forge add auth workos or forge workos seed --dry-run first`);
|
|
1017
|
+
if (missingSeedPermissions.length > 0) diagnostics.push(`seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`);
|
|
1018
|
+
if (missingSeedResources.length > 0) diagnostics.push(`seed missing app resource type(s): ${missingSeedResources.join(", ")}`);
|
|
1019
|
+
if (!resourceTypes.includes("organization")) diagnostics.push("FGA graph should include an organization resource type for tenant roots");
|
|
1020
|
+
if (permissions.length === 0) diagnostics.push("no WorkOS permission slugs were discovered");
|
|
1021
|
+
if (seed.roles.length === 0) diagnostics.push("no WorkOS roles were discovered");
|
|
1022
|
+
|
|
1023
|
+
const resources: WorkOSFgaResource[] = [];
|
|
1024
|
+
for (const organization of organizations) {
|
|
1025
|
+
const orgSlug = slugifyExternalIdPart(organization);
|
|
1026
|
+
const orgExternalId = `organization:${orgSlug}`;
|
|
1027
|
+
if (resourceTypes.includes("organization")) {
|
|
1028
|
+
resources.push({
|
|
1029
|
+
externalId: orgExternalId,
|
|
1030
|
+
type: "organization",
|
|
1031
|
+
tenant: organization,
|
|
1032
|
+
name: organization,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
for (const resourceType of resourceTypes.filter((type) => type !== "organization")) {
|
|
1036
|
+
const parentType = preferredParentType(resourceType, resourceTypes);
|
|
1037
|
+
const parentExternalId = parentType
|
|
1038
|
+
? parentType === "organization"
|
|
1039
|
+
? orgExternalId
|
|
1040
|
+
: `${parentType}:${orgSlug}:demo`
|
|
1041
|
+
: undefined;
|
|
1042
|
+
resources.push({
|
|
1043
|
+
externalId: `${resourceType}:${orgSlug}:demo`,
|
|
1044
|
+
type: resourceType,
|
|
1045
|
+
tenant: organization,
|
|
1046
|
+
name: `${organization} ${resourceType}`,
|
|
1047
|
+
...(parentType ? { parentType } : {}),
|
|
1048
|
+
...(parentExternalId ? { parentExternalId } : {}),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const firstOrg = organizations[0] ?? "Demo Organization";
|
|
1054
|
+
const secondOrg = organizations[1] ?? `${firstOrg} Other`;
|
|
1055
|
+
const firstResource = resources.find((resource) => resource.type !== "organization") ?? resources[0];
|
|
1056
|
+
const firstPermission = permissions[0] ?? "app:read";
|
|
1057
|
+
const proofScenarios: WorkOSFgaProofScenario[] = firstResource
|
|
1058
|
+
? [
|
|
1059
|
+
{
|
|
1060
|
+
name: "allowed-same-tenant",
|
|
1061
|
+
expected: "allow",
|
|
1062
|
+
permission: firstPermission,
|
|
1063
|
+
resourceExternalId: firstResource.externalId,
|
|
1064
|
+
resourceTypeSlug: firstResource.type,
|
|
1065
|
+
organization: firstOrg,
|
|
1066
|
+
reason: "membership, permission, and resource tenant match",
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
name: "cross-tenant-read-denied",
|
|
1070
|
+
expected: "deny",
|
|
1071
|
+
permission: firstPermission,
|
|
1072
|
+
resourceExternalId: firstResource.externalId,
|
|
1073
|
+
resourceTypeSlug: firstResource.type,
|
|
1074
|
+
organization: secondOrg,
|
|
1075
|
+
reason: "resource belongs to a different organization tenant",
|
|
1076
|
+
},
|
|
1077
|
+
]
|
|
1078
|
+
: [];
|
|
1079
|
+
|
|
1080
|
+
const hashInput = {
|
|
1081
|
+
seedFile: seed.path,
|
|
1082
|
+
seedHash: seed.exists ? hashSeedFile(workspaceRoot, seed.path) : undefined,
|
|
1083
|
+
permissions,
|
|
1084
|
+
roles: seed.roles,
|
|
1085
|
+
resourceTypes,
|
|
1086
|
+
organizations,
|
|
1087
|
+
resources,
|
|
1088
|
+
proofScenarios,
|
|
1089
|
+
};
|
|
1090
|
+
return {
|
|
1091
|
+
schemaVersion: "0.1.0",
|
|
1092
|
+
provider: "workos",
|
|
1093
|
+
kind: "fga-manifest",
|
|
1094
|
+
seedFile: seed.path,
|
|
1095
|
+
...(seed.exists ? { seedHash: hashSeedFile(workspaceRoot, seed.path) } : {}),
|
|
1096
|
+
manifestHash: hashObject(hashInput),
|
|
1097
|
+
permissions,
|
|
1098
|
+
roles: seed.roles,
|
|
1099
|
+
resourceTypes,
|
|
1100
|
+
organizations,
|
|
1101
|
+
resources,
|
|
1102
|
+
proofScenarios,
|
|
1103
|
+
diagnostics,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
export function readWorkOSFgaState(
|
|
1108
|
+
workspaceRoot: string,
|
|
1109
|
+
manifest: WorkOSFgaManifest,
|
|
1110
|
+
): WorkOSFgaStateSummary {
|
|
1111
|
+
const path = WORKOS_FGA_STATE_FILE;
|
|
1112
|
+
const absolute = join(workspaceRoot, path);
|
|
1113
|
+
if (!existsSync(absolute)) {
|
|
1114
|
+
return {
|
|
1115
|
+
exists: false,
|
|
1116
|
+
valid: false,
|
|
1117
|
+
path,
|
|
1118
|
+
matchesManifestHash: null,
|
|
1119
|
+
currentManifestHash: manifest.manifestHash,
|
|
1120
|
+
diagnostics: [`${path} is missing; run forge workos fga sync --json`],
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(readFileSync(absolute, "utf8")) as {
|
|
1125
|
+
manifestHash?: unknown;
|
|
1126
|
+
syncedAt?: unknown;
|
|
1127
|
+
provedAt?: unknown;
|
|
1128
|
+
mode?: unknown;
|
|
1129
|
+
sdkOk?: unknown;
|
|
1130
|
+
};
|
|
1131
|
+
const diagnostics: string[] = [];
|
|
1132
|
+
const manifestHash = typeof parsed.manifestHash === "string" ? parsed.manifestHash : undefined;
|
|
1133
|
+
const mode = parsed.mode === "real" ? "real" : parsed.mode === "local" ? "local" : undefined;
|
|
1134
|
+
const sdkOk = typeof parsed.sdkOk === "boolean" ? parsed.sdkOk : undefined;
|
|
1135
|
+
if (!manifestHash) diagnostics.push("FGA state is missing manifestHash");
|
|
1136
|
+
if (typeof parsed.syncedAt !== "string") diagnostics.push("FGA state is missing syncedAt");
|
|
1137
|
+
if (!mode) diagnostics.push("FGA state is missing mode");
|
|
1138
|
+
if (mode === "real" && sdkOk !== true) diagnostics.push("real FGA state is missing sdkOk:true from WorkOS Authorization API sync/proof");
|
|
1139
|
+
return {
|
|
1140
|
+
exists: true,
|
|
1141
|
+
valid: diagnostics.length === 0,
|
|
1142
|
+
path,
|
|
1143
|
+
matchesManifestHash: manifestHash ? manifestHash === manifest.manifestHash : null,
|
|
1144
|
+
...(manifestHash ? { manifestHash } : {}),
|
|
1145
|
+
currentManifestHash: manifest.manifestHash,
|
|
1146
|
+
...(typeof parsed.syncedAt === "string" ? { syncedAt: parsed.syncedAt } : {}),
|
|
1147
|
+
...(typeof parsed.provedAt === "string" ? { provedAt: parsed.provedAt } : {}),
|
|
1148
|
+
...(mode ? { mode } : {}),
|
|
1149
|
+
...(sdkOk !== undefined ? { sdkOk } : {}),
|
|
1150
|
+
diagnostics,
|
|
1151
|
+
};
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
return {
|
|
1154
|
+
exists: true,
|
|
1155
|
+
valid: false,
|
|
1156
|
+
path,
|
|
1157
|
+
matchesManifestHash: null,
|
|
1158
|
+
currentManifestHash: manifest.manifestHash,
|
|
1159
|
+
diagnostics: [`failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}`],
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function writeWorkOSFgaState(input: {
|
|
1165
|
+
workspaceRoot: string;
|
|
1166
|
+
manifest: WorkOSFgaManifest;
|
|
1167
|
+
mode: "local" | "real";
|
|
1168
|
+
proved: boolean;
|
|
1169
|
+
sdkOk?: boolean;
|
|
1170
|
+
sdk?: unknown;
|
|
1171
|
+
}): string {
|
|
1172
|
+
const now = new Date().toISOString();
|
|
1173
|
+
const payload = {
|
|
1174
|
+
schemaVersion: "0.1.0",
|
|
1175
|
+
provider: "workos",
|
|
1176
|
+
kind: "fga-state",
|
|
1177
|
+
mode: input.mode,
|
|
1178
|
+
seedFile: input.manifest.seedFile,
|
|
1179
|
+
seedHash: input.manifest.seedHash,
|
|
1180
|
+
manifestHash: input.manifest.manifestHash,
|
|
1181
|
+
syncedAt: now,
|
|
1182
|
+
...(input.proved ? { provedAt: now } : {}),
|
|
1183
|
+
...(input.sdkOk !== undefined ? { sdkOk: input.sdkOk } : {}),
|
|
1184
|
+
...(input.sdk ? { sdk: input.sdk } : {}),
|
|
1185
|
+
permissions: input.manifest.permissions,
|
|
1186
|
+
roles: input.manifest.roles,
|
|
1187
|
+
resourceTypes: input.manifest.resourceTypes,
|
|
1188
|
+
organizations: input.manifest.organizations,
|
|
1189
|
+
resources: input.manifest.resources,
|
|
1190
|
+
proofScenarios: input.manifest.proofScenarios,
|
|
1191
|
+
};
|
|
1192
|
+
writeFileSync(join(input.workspaceRoot, WORKOS_FGA_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1193
|
+
return WORKOS_FGA_STATE_FILE;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function hostedFgaResourceTypes(manifest: WorkOSFgaManifest): string[] {
|
|
1197
|
+
return manifest.resourceTypes.filter((resourceType) => resourceType !== "organization");
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function fgaMembershipEnvKey(organization: string): string {
|
|
1201
|
+
const suffix = slugifyExternalIdPart(organization).replace(/-/g, "_").toUpperCase();
|
|
1202
|
+
return `WORKOS_FGA_MEMBERSHIP_${suffix}`;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function workOSFgaMembershipEnvSummary(
|
|
1206
|
+
workspaceRoot: string,
|
|
1207
|
+
organizations: string[],
|
|
1208
|
+
): WorkOSFgaMembershipEnvSummary {
|
|
1209
|
+
const env = readRealEnv(workspaceRoot);
|
|
1210
|
+
const requiredEnv = organizations.map(fgaMembershipEnvKey);
|
|
1211
|
+
const jsonEnvPresent = hasValue(env, "WORKOS_FGA_MEMBERSHIPS_JSON") || hasValue(env, "WORKOS_FGA_TEST_MEMBERSHIPS");
|
|
1212
|
+
const presentEnv = requiredEnv.filter((name) => hasValue(env, name));
|
|
1213
|
+
const missingEnv = jsonEnvPresent ? [] : requiredEnv.filter((name) => !presentEnv.includes(name));
|
|
1214
|
+
return {
|
|
1215
|
+
requiredEnv,
|
|
1216
|
+
presentEnv,
|
|
1217
|
+
missingEnv,
|
|
1218
|
+
jsonEnvPresent,
|
|
1219
|
+
complete: jsonEnvPresent || missingEnv.length === 0,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function extractMissingWorkOSFgaResourceTypes(data: unknown, manifest?: WorkOSFgaManifest): string[] {
|
|
1224
|
+
const missing = new Set<string>();
|
|
1225
|
+
const add = (value: unknown) => {
|
|
1226
|
+
if (typeof value === "string" && value && value !== "organization") {
|
|
1227
|
+
missing.add(value);
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
if (data && typeof data === "object") {
|
|
1231
|
+
const errors = (data as { errors?: unknown }).errors;
|
|
1232
|
+
if (Array.isArray(errors)) {
|
|
1233
|
+
for (const error of errors) {
|
|
1234
|
+
if (!error || typeof error !== "object") continue;
|
|
1235
|
+
const record = error as Record<string, unknown>;
|
|
1236
|
+
const message = typeof record.message === "string" ? record.message : "";
|
|
1237
|
+
const status = String(record.status ?? "");
|
|
1238
|
+
if (message.includes("AuthorizationResourceType not found") || status === "404") {
|
|
1239
|
+
add(record.resourceTypeSlug);
|
|
1240
|
+
}
|
|
1241
|
+
const match = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/.exec(message);
|
|
1242
|
+
add(match?.[1]);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
const text = JSON.stringify(data) ?? "";
|
|
1247
|
+
const missingTypePattern = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/g;
|
|
1248
|
+
let match: RegExpExecArray | null;
|
|
1249
|
+
while ((match = missingTypePattern.exec(text))) {
|
|
1250
|
+
add(match[1]);
|
|
1251
|
+
}
|
|
1252
|
+
if (missing.size === 0 && text.includes("AuthorizationResourceType not found") && manifest) {
|
|
1253
|
+
for (const resourceType of hostedFgaResourceTypes(manifest)) {
|
|
1254
|
+
missing.add(resourceType);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return uniqueSorted(missing);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function workOSFgaHostedSetup(manifest: WorkOSFgaManifest, sdkData?: unknown): WorkOSFgaHostedSetup {
|
|
1261
|
+
const requiredResourceTypes = hostedFgaResourceTypes(manifest);
|
|
1262
|
+
const missingResourceTypes = extractMissingWorkOSFgaResourceTypes(sdkData, manifest);
|
|
1263
|
+
const resourceList = (missingResourceTypes.length > 0 ? missingResourceTypes : requiredResourceTypes).join(", ") || "none";
|
|
1264
|
+
const requiredMembershipEnv = manifest.organizations.map(fgaMembershipEnvKey);
|
|
1265
|
+
return {
|
|
1266
|
+
requiredResourceTypes,
|
|
1267
|
+
rootResourceType: "organization",
|
|
1268
|
+
missingResourceTypes,
|
|
1269
|
+
requiredMembershipEnv,
|
|
1270
|
+
managedBy: "hosted-workos",
|
|
1271
|
+
resourceTypeAutomation: requiredResourceTypes.length > 0 ? "not-supported-by-workos-api" : "not-needed",
|
|
1272
|
+
cliSupport: "resources-and-checks",
|
|
1273
|
+
sdkSupport: "resources-and-checks",
|
|
1274
|
+
docs: [
|
|
1275
|
+
"https://workos.com/docs/fga/resource-types",
|
|
1276
|
+
"https://workos.com/docs/fga/resources",
|
|
1277
|
+
"https://workos.com/docs/fga/access-checks",
|
|
1278
|
+
],
|
|
1279
|
+
nextActions: [
|
|
1280
|
+
missingResourceTypes.length > 0
|
|
1281
|
+
? `configure missing WorkOS FGA resource type(s): ${resourceList}`
|
|
1282
|
+
: `confirm WorkOS FGA resource type(s) exist: ${resourceList}`,
|
|
1283
|
+
"treat organization as the WorkOS tenant root; ForgeOS does not create it as an authorization resource",
|
|
1284
|
+
`set WorkOS FGA membership env for real access checks: WORKOS_FGA_MEMBERSHIPS_JSON or ${requiredMembershipEnv.join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>"}`,
|
|
1285
|
+
"rerun forge workos fga sync --real --file workos-seed.yml --json",
|
|
1286
|
+
"rerun forge workos fga prove --real --file workos-seed.yml --json",
|
|
1287
|
+
"rerun forge deploy check --production --json",
|
|
1288
|
+
],
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function displayResourceType(slug: string): string {
|
|
1293
|
+
return slug
|
|
1294
|
+
.replace(/[_-]+/g, " ")
|
|
1295
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function permissionResourcePart(permission: string): string {
|
|
1299
|
+
return permission.split(":")[0] ?? permission;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function permissionMatchesResourceType(permission: string, resourceType: string): boolean {
|
|
1303
|
+
const part = permissionResourcePart(permission);
|
|
1304
|
+
const normalizedPart = singularResourceName(part.replace(/[-_]/g, ""));
|
|
1305
|
+
const normalizedType = singularResourceName(resourceType.replace(/[-_]/g, ""));
|
|
1306
|
+
return part === resourceType ||
|
|
1307
|
+
singularResourceName(part) === resourceType ||
|
|
1308
|
+
normalizedPart === normalizedType ||
|
|
1309
|
+
normalizedType.startsWith(normalizedPart) ||
|
|
1310
|
+
normalizedPart === `${normalizedType}request`;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function permissionsForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
|
|
1314
|
+
if (resourceType === "organization") {
|
|
1315
|
+
return manifest.permissions.filter((permission) => permissionResourcePart(permission) === "organization");
|
|
1316
|
+
}
|
|
1317
|
+
return manifest.permissions.filter((permission) => permissionMatchesResourceType(permission, resourceType));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function roleLooksRelevantToResourceType(role: string, resourceType: string): boolean {
|
|
1321
|
+
const normalizedRole = role.replace(/[-_]/g, "");
|
|
1322
|
+
const normalizedType = resourceType.replace(/[-_]/g, "");
|
|
1323
|
+
return normalizedRole.includes(normalizedType) ||
|
|
1324
|
+
normalizedRole.includes(singularResourceName(normalizedType)) ||
|
|
1325
|
+
["owner", "admin", "manager", "member", "auditor", "reviewer", "requester", "security"].some((shared) => normalizedRole.includes(shared));
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function rolesForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
|
|
1329
|
+
const relevant = manifest.roles.filter((role) => roleLooksRelevantToResourceType(role, resourceType));
|
|
1330
|
+
return relevant.length > 0 ? relevant : manifest.roles;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function workOSFgaSetupGuide(manifest: WorkOSFgaManifest, hostedSetup: WorkOSFgaHostedSetup): WorkOSFgaSetupGuide {
|
|
1334
|
+
const resourcesByType = new Map<string, WorkOSFgaResource[]>();
|
|
1335
|
+
for (const resource of manifest.resources) {
|
|
1336
|
+
resourcesByType.set(resource.type, [...resourcesByType.get(resource.type) ?? [], resource]);
|
|
1337
|
+
}
|
|
1338
|
+
const resourceTypes = manifest.resourceTypes.map((slug): WorkOSFgaResourceTypeSetup => {
|
|
1339
|
+
const resources = resourcesByType.get(slug) ?? [];
|
|
1340
|
+
const parentTypes = uniqueSorted(resources.map((resource) => resource.parentType ?? "").filter(Boolean));
|
|
1341
|
+
const childTypes = uniqueSorted(manifest.resources
|
|
1342
|
+
.filter((resource) => resource.parentType === slug)
|
|
1343
|
+
.map((resource) => resource.type));
|
|
1344
|
+
const proofScenarios = manifest.proofScenarios
|
|
1345
|
+
.filter((scenario) => scenario.resourceTypeSlug === slug)
|
|
1346
|
+
.map((scenario) => `${scenario.name}:${scenario.expected}`);
|
|
1347
|
+
const permissions = permissionsForResourceType(manifest, slug);
|
|
1348
|
+
const roles = rolesForResourceType(manifest, slug);
|
|
1349
|
+
const isRoot = slug === hostedSetup.rootResourceType;
|
|
1350
|
+
return {
|
|
1351
|
+
slug,
|
|
1352
|
+
displayName: displayResourceType(slug),
|
|
1353
|
+
hostedAction: isRoot ? "none" : "configure-resource-type",
|
|
1354
|
+
requiredBeforeRealSync: !isRoot,
|
|
1355
|
+
permissions,
|
|
1356
|
+
roles,
|
|
1357
|
+
parentTypes,
|
|
1358
|
+
childTypes,
|
|
1359
|
+
exampleExternalIds: resources.map((resource) => resource.externalId).slice(0, 4),
|
|
1360
|
+
proofScenarios,
|
|
1361
|
+
notes: isRoot
|
|
1362
|
+
? [
|
|
1363
|
+
"Treat WorkOS organization as the tenant root.",
|
|
1364
|
+
"ForgeOS keeps organization in the graph for parent/tenant reasoning, but real sync does not create it as an authorization resource.",
|
|
1365
|
+
]
|
|
1366
|
+
: [
|
|
1367
|
+
"Create/configure this resource type in hosted WorkOS before real sync.",
|
|
1368
|
+
parentTypes.length > 0
|
|
1369
|
+
? `Expected parent type(s): ${parentTypes.join(", ")}.`
|
|
1370
|
+
: "No parent type inferred from the app graph.",
|
|
1371
|
+
],
|
|
1372
|
+
};
|
|
1373
|
+
});
|
|
1374
|
+
const markdown = [
|
|
1375
|
+
"# WorkOS FGA Setup",
|
|
1376
|
+
"",
|
|
1377
|
+
"ForgeOS derived this resource graph from the app contract, policies, and workos-seed.yml.",
|
|
1378
|
+
"WorkOS resource type configuration is hosted WorkOS configuration. ForgeOS uses the WorkOS CLI/API or SDK to sync resources and prove authorization checks after those resource types exist.",
|
|
1379
|
+
"",
|
|
1380
|
+
"## Automation Boundary",
|
|
1381
|
+
"",
|
|
1382
|
+
"- ForgeOS can derive the FGA graph, generate this setup guide, sync WorkOS FGA resources, and run Authorization API access checks.",
|
|
1383
|
+
"- ForgeOS does not invent WorkOS CLI/API calls for resource type creation. The current public WorkOS automation surface supports resources and checks after resource types exist.",
|
|
1384
|
+
"- Configure each non-organization resource type below in hosted WorkOS, then rerun the real sync/proof commands.",
|
|
1385
|
+
"- The `organization` node is treated as the WorkOS tenant root and is not created as an authorization resource.",
|
|
1386
|
+
"",
|
|
1387
|
+
"## Resource Types",
|
|
1388
|
+
"",
|
|
1389
|
+
...resourceTypes.flatMap((resourceType) => [
|
|
1390
|
+
`### ${resourceType.slug}`,
|
|
1391
|
+
"",
|
|
1392
|
+
`- Display name: ${resourceType.displayName}`,
|
|
1393
|
+
`- Hosted action: ${resourceType.hostedAction}`,
|
|
1394
|
+
`- Required before real sync: ${resourceType.requiredBeforeRealSync ? "yes" : "no"}`,
|
|
1395
|
+
`- Parent types: ${resourceType.parentTypes.join(", ") || "none"}`,
|
|
1396
|
+
`- Child types: ${resourceType.childTypes.join(", ") || "none"}`,
|
|
1397
|
+
`- Permissions to attach/model: ${resourceType.permissions.join(", ") || "none inferred"}`,
|
|
1398
|
+
`- Roles to review for this type: ${resourceType.roles.join(", ") || "none inferred"}`,
|
|
1399
|
+
`- Example external IDs: ${resourceType.exampleExternalIds.join(", ") || "none"}`,
|
|
1400
|
+
`- Proof scenarios: ${resourceType.proofScenarios.join(", ") || "none"}`,
|
|
1401
|
+
...resourceType.notes.map((note) => `- Note: ${note}`),
|
|
1402
|
+
"",
|
|
1403
|
+
]),
|
|
1404
|
+
"## Permission And Role Coverage",
|
|
1405
|
+
"",
|
|
1406
|
+
`- Permissions discovered: ${manifest.permissions.join(", ") || "none"}`,
|
|
1407
|
+
`- Roles discovered: ${manifest.roles.join(", ") || "none"}`,
|
|
1408
|
+
"- In hosted WorkOS, ensure each permission is scoped to the intended resource type and each role includes the permissions needed by your Forge policies.",
|
|
1409
|
+
"- ForgeOS will not claim production readiness until real Authorization API checks pass for the generated proof scenarios.",
|
|
1410
|
+
"",
|
|
1411
|
+
"## Required Membership Environment",
|
|
1412
|
+
"",
|
|
1413
|
+
"- WORKOS_FGA_MEMBERSHIPS_JSON: JSON object mapping organization name to organizationMembershipId",
|
|
1414
|
+
...hostedSetup.requiredMembershipEnv.map((env) => `- ${env}: organizationMembershipId for that organization`),
|
|
1415
|
+
"",
|
|
1416
|
+
"## Commands",
|
|
1417
|
+
"",
|
|
1418
|
+
"```bash",
|
|
1419
|
+
"forge workos fga plan --file workos-seed.yml --write --json",
|
|
1420
|
+
"forge workos fga sync --real --file workos-seed.yml --json",
|
|
1421
|
+
"forge workos fga prove --real --file workos-seed.yml --json",
|
|
1422
|
+
"forge deploy check --production --json",
|
|
1423
|
+
"```",
|
|
1424
|
+
"",
|
|
1425
|
+
].join("\n");
|
|
1426
|
+
return {
|
|
1427
|
+
schemaVersion: "0.1.0",
|
|
1428
|
+
kind: "workos-fga-setup",
|
|
1429
|
+
resourceTypes,
|
|
1430
|
+
hostedSetup,
|
|
1431
|
+
commands: {
|
|
1432
|
+
plan: "forge workos fga plan --file workos-seed.yml --write --json",
|
|
1433
|
+
syncReal: "forge workos fga sync --real --file workos-seed.yml --json",
|
|
1434
|
+
proveReal: "forge workos fga prove --real --file workos-seed.yml --json",
|
|
1435
|
+
deployCheck: "forge deploy check --production --json",
|
|
1436
|
+
},
|
|
1437
|
+
markdown,
|
|
1438
|
+
docs: hostedSetup.docs,
|
|
1439
|
+
unsupportedAutomation: [
|
|
1440
|
+
"ForgeOS does not invent WorkOS CLI/API calls for resource type creation.",
|
|
1441
|
+
"ForgeOS can create/read FGA resources and run authorization checks through the WorkOS CLI/API or SDK after resource types exist.",
|
|
1442
|
+
],
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function resolveWorkOSFgaSetupGuidePath(options: WorkOSCommandOptions): string | undefined {
|
|
1447
|
+
if (!options.write && !options.writePath) {
|
|
1448
|
+
return undefined;
|
|
1449
|
+
}
|
|
1450
|
+
const candidate = options.writePath?.trim();
|
|
1451
|
+
return candidate && candidate !== "true" && !candidate.startsWith("--") ? candidate : WORKOS_FGA_SETUP_GUIDE_FILE;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function workOSFgaSetupPlanPath(markdownPath: string): string {
|
|
1455
|
+
if (markdownPath === WORKOS_FGA_SETUP_GUIDE_FILE) {
|
|
1456
|
+
return WORKOS_FGA_SETUP_PLAN_FILE;
|
|
1457
|
+
}
|
|
1458
|
+
return markdownPath.endsWith(".md")
|
|
1459
|
+
? `${markdownPath.slice(0, -3)}.json`
|
|
1460
|
+
: `${markdownPath}.json`;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function writeWorkOSFgaSetupGuide(
|
|
1464
|
+
workspaceRoot: string,
|
|
1465
|
+
guide: WorkOSFgaSetupGuide,
|
|
1466
|
+
preferredPath = WORKOS_FGA_SETUP_GUIDE_FILE,
|
|
1467
|
+
): { markdownPath: string; jsonPath: string } {
|
|
1468
|
+
const relativePath = preferredPath.startsWith("/") ? preferredPath.slice(1) : preferredPath;
|
|
1469
|
+
const absolutePath = join(workspaceRoot, relativePath);
|
|
1470
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
1471
|
+
writeFileSync(absolutePath, guide.markdown, "utf8");
|
|
1472
|
+
const jsonPath = workOSFgaSetupPlanPath(relativePath);
|
|
1473
|
+
const absoluteJsonPath = join(workspaceRoot, jsonPath);
|
|
1474
|
+
mkdirSync(dirname(absoluteJsonPath), { recursive: true });
|
|
1475
|
+
const { markdown: _markdown, ...jsonGuide } = guide;
|
|
1476
|
+
writeFileSync(absoluteJsonPath, `${JSON.stringify({
|
|
1477
|
+
...jsonGuide,
|
|
1478
|
+
markdownPath: relativePath,
|
|
1479
|
+
jsonPath,
|
|
1480
|
+
}, null, 2)}\n`, "utf8");
|
|
1481
|
+
return { markdownPath: relativePath, jsonPath };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function fgaData(input: {
|
|
1485
|
+
action: WorkOSFgaAction;
|
|
1486
|
+
workspaceRoot: string;
|
|
1487
|
+
manifest: WorkOSFgaManifest;
|
|
1488
|
+
state: WorkOSFgaStateSummary;
|
|
1489
|
+
seedState?: WorkOSSeedStateSummary;
|
|
1490
|
+
real?: boolean;
|
|
1491
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
1492
|
+
workosSdk?: unknown;
|
|
1493
|
+
readiness?: WorkOSFgaReadiness;
|
|
1494
|
+
stateFile?: string;
|
|
1495
|
+
nextCommand?: string;
|
|
1496
|
+
nextActions?: string[];
|
|
1497
|
+
setupGuidePath?: string;
|
|
1498
|
+
setupGuideJsonPath?: string;
|
|
1499
|
+
}): Record<string, unknown> {
|
|
1500
|
+
const hostedSetup = workOSFgaHostedSetup(input.manifest, input.workosSdk);
|
|
1501
|
+
const setupGuide = workOSFgaSetupGuide(input.manifest, hostedSetup);
|
|
1502
|
+
const membershipEnv = workOSFgaMembershipEnvSummary(input.workspaceRoot, input.manifest.organizations);
|
|
1503
|
+
return {
|
|
1504
|
+
action: input.action,
|
|
1505
|
+
real: input.real ?? false,
|
|
1506
|
+
manifest: input.manifest,
|
|
1507
|
+
state: input.state,
|
|
1508
|
+
...(input.seedState ? { seedState: input.seedState } : {}),
|
|
1509
|
+
...(input.readiness ? { readiness: input.readiness } : {}),
|
|
1510
|
+
hostedSetup,
|
|
1511
|
+
membershipEnv,
|
|
1512
|
+
resourceTypeSetup: setupGuide.resourceTypes,
|
|
1513
|
+
setupGuide,
|
|
1514
|
+
...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
|
|
1515
|
+
...(input.workosSdk ? { workosSdk: input.workosSdk } : {}),
|
|
1516
|
+
...(input.stateFile ? { stateFile: input.stateFile } : {}),
|
|
1517
|
+
...(input.setupGuidePath ? { setupGuidePath: input.setupGuidePath } : {}),
|
|
1518
|
+
...(input.setupGuideJsonPath ? { setupGuideJsonPath: input.setupGuideJsonPath } : {}),
|
|
1519
|
+
nextCommand: input.nextCommand,
|
|
1520
|
+
nextActions: input.nextActions ?? hostedSetup.nextActions,
|
|
1521
|
+
notes: [
|
|
1522
|
+
"ForgeOS derives resource graph and proof scenarios from app contract, policies, and workos-seed.yml.",
|
|
1523
|
+
"WorkOS FGA resource types are hosted WorkOS configuration; ForgeOS syncs/proves resources and gates production deploys through .workos-fga-state.json.",
|
|
1524
|
+
],
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function runWorkOSFgaSdk(
|
|
1529
|
+
options: WorkOSCommandOptions,
|
|
1530
|
+
manifest: WorkOSFgaManifest,
|
|
1531
|
+
action: "sync" | "prove",
|
|
1532
|
+
): { ok: boolean; command: string[]; data: Record<string, unknown>; status: number | null; stdout?: string; stderr?: string } {
|
|
1533
|
+
const script = String.raw`
|
|
1534
|
+
const { spawnSync } = await import("node:child_process");
|
|
1535
|
+
const payload = JSON.parse(process.env.FORGE_WORKOS_FGA_PAYLOAD || "{}");
|
|
1536
|
+
const out = { ok: true, action: payload.action, resources: [], checks: [], skipped: [], errors: [] };
|
|
1537
|
+
function message(error) {
|
|
1538
|
+
return error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
|
1539
|
+
}
|
|
1540
|
+
function status(error) {
|
|
1541
|
+
return error && typeof error === "object" && "status" in error ? error.status : error && typeof error === "object" && "statusCode" in error ? error.statusCode : undefined;
|
|
1542
|
+
}
|
|
1543
|
+
async function listAll(page) {
|
|
1544
|
+
if (!page) return [];
|
|
1545
|
+
if (Array.isArray(page.data)) return page.data;
|
|
1546
|
+
if (Symbol.asyncIterator in Object(page)) {
|
|
1547
|
+
const values = [];
|
|
1548
|
+
for await (const item of page) values.push(item);
|
|
1549
|
+
return values;
|
|
1550
|
+
}
|
|
1551
|
+
return [];
|
|
1552
|
+
}
|
|
1553
|
+
function slugify(value) {
|
|
1554
|
+
return String(value || "")
|
|
1555
|
+
.trim()
|
|
1556
|
+
.toLowerCase()
|
|
1557
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1558
|
+
.replace(/^-+|-+$/g, "") || "demo";
|
|
1559
|
+
}
|
|
1560
|
+
function membershipEnvKey(organization) {
|
|
1561
|
+
return "WORKOS_FGA_MEMBERSHIP_" + slugify(organization).replace(/-/g, "_").toUpperCase();
|
|
1562
|
+
}
|
|
1563
|
+
function readMembershipMap(organizations) {
|
|
1564
|
+
const memberships = new Map();
|
|
1565
|
+
for (const name of ["WORKOS_FGA_MEMBERSHIPS_JSON", "WORKOS_FGA_TEST_MEMBERSHIPS"]) {
|
|
1566
|
+
const raw = process.env[name];
|
|
1567
|
+
if (!raw) continue;
|
|
1568
|
+
try {
|
|
1569
|
+
const parsed = JSON.parse(raw);
|
|
1570
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1571
|
+
for (const [organization, membershipId] of Object.entries(parsed)) {
|
|
1572
|
+
if (typeof membershipId === "string" && membershipId.trim()) {
|
|
1573
|
+
memberships.set(organization, membershipId.trim());
|
|
1574
|
+
memberships.set(slugify(organization), membershipId.trim());
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
out.ok = false;
|
|
1580
|
+
out.errors.push({ operation: "membership.env", env: name, message: "failed to parse JSON membership map: " + message(error) });
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
for (const organization of organizations || []) {
|
|
1584
|
+
const envKey = membershipEnvKey(organization);
|
|
1585
|
+
const membershipId = process.env[envKey];
|
|
1586
|
+
if (membershipId && membershipId.trim()) {
|
|
1587
|
+
memberships.set(organization, membershipId.trim());
|
|
1588
|
+
memberships.set(slugify(organization), membershipId.trim());
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const legacy = process.env.WORKOS_FGA_TEST_MEMBERSHIP_ID || process.env.WORKOS_FGA_ORGANIZATION_MEMBERSHIP_ID;
|
|
1592
|
+
if (legacy && legacy.trim() && organizations && organizations.length === 1) {
|
|
1593
|
+
memberships.set(organizations[0], legacy.trim());
|
|
1594
|
+
memberships.set(slugify(organizations[0]), legacy.trim());
|
|
1595
|
+
}
|
|
1596
|
+
return memberships;
|
|
1597
|
+
}
|
|
1598
|
+
function parseCliJson(text) {
|
|
1599
|
+
try {
|
|
1600
|
+
const parsed = JSON.parse(text || "{}");
|
|
1601
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
return { parseError: message(error), raw: text };
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function cliApi(path, options = {}) {
|
|
1607
|
+
const args = ["--yes", "workos@latest", "api", path, "--method", options.method || "GET", "--json"];
|
|
1608
|
+
if (options.data) args.push("--data", JSON.stringify(options.data));
|
|
1609
|
+
if (options.yes) args.push("--yes");
|
|
1610
|
+
const child = spawnSync("npx", args, {
|
|
1611
|
+
encoding: "utf8",
|
|
1612
|
+
env: { ...process.env, WORKOS_MODE: process.env.WORKOS_MODE || "agent" },
|
|
1613
|
+
});
|
|
1614
|
+
const parsed = parseCliJson(child.stdout);
|
|
1615
|
+
return {
|
|
1616
|
+
ok: child.status === 0,
|
|
1617
|
+
status: child.status,
|
|
1618
|
+
data: parsed,
|
|
1619
|
+
stdout: child.stdout,
|
|
1620
|
+
stderr: child.stderr,
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
function dataList(payload) {
|
|
1624
|
+
if (Array.isArray(payload)) return payload;
|
|
1625
|
+
if (Array.isArray(payload?.data)) return payload.data;
|
|
1626
|
+
if (Array.isArray(payload?.list)) return payload.list;
|
|
1627
|
+
return [];
|
|
1628
|
+
}
|
|
1629
|
+
function encodePathPart(value) {
|
|
1630
|
+
return encodeURIComponent(String(value));
|
|
1631
|
+
}
|
|
1632
|
+
async function runWithWorkOSCli() {
|
|
1633
|
+
const orgResponse = cliApi("/organizations", { method: "GET" });
|
|
1634
|
+
if (!orgResponse.ok) {
|
|
1635
|
+
out.ok = false;
|
|
1636
|
+
out.errors.push({ operation: "cli.organizations.list", status: orgResponse.status, message: orgResponse.stderr || JSON.stringify(orgResponse.data) });
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const orgs = dataList(orgResponse.data);
|
|
1640
|
+
const orgByName = new Map(orgs.map((org) => [org.name, org]));
|
|
1641
|
+
const sortedResources = [...payload.manifest.resources].sort((a, b) => {
|
|
1642
|
+
if (a.type === "organization") return -1;
|
|
1643
|
+
if (b.type === "organization") return 1;
|
|
1644
|
+
if (!a.parentExternalId && b.parentExternalId) return -1;
|
|
1645
|
+
if (a.parentExternalId && !b.parentExternalId) return 1;
|
|
1646
|
+
return a.externalId.localeCompare(b.externalId);
|
|
1647
|
+
});
|
|
1648
|
+
for (const resource of sortedResources) {
|
|
1649
|
+
if (resource.type === "organization") {
|
|
1650
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
const org = orgByName.get(resource.tenant);
|
|
1654
|
+
if (!org?.id) {
|
|
1655
|
+
out.ok = false;
|
|
1656
|
+
out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
const getPath = "/authorization/organizations/" + encodePathPart(org.id) + "/resources/" + encodePathPart(resource.type) + "/" + encodePathPart(resource.externalId);
|
|
1660
|
+
const existing = cliApi(getPath, { method: "GET" });
|
|
1661
|
+
if (existing.ok) {
|
|
1662
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.data?.id });
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
const created = cliApi("/authorization/resources", {
|
|
1666
|
+
method: "POST",
|
|
1667
|
+
yes: true,
|
|
1668
|
+
data: {
|
|
1669
|
+
organization_id: org.id,
|
|
1670
|
+
resource_type_slug: resource.type,
|
|
1671
|
+
external_id: resource.externalId,
|
|
1672
|
+
name: resource.name,
|
|
1673
|
+
...(resource.parentExternalId && resource.parentType !== "organization" ? { parent_resource_external_id: resource.parentExternalId, parent_resource_type_slug: resource.parentType } : {}),
|
|
1674
|
+
},
|
|
1675
|
+
});
|
|
1676
|
+
if (!created.ok) {
|
|
1677
|
+
out.ok = false;
|
|
1678
|
+
out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: created.status, message: created.stderr || JSON.stringify(created.data) });
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.data?.id });
|
|
1682
|
+
}
|
|
1683
|
+
if (payload.action === "prove") {
|
|
1684
|
+
const memberships = readMembershipMap(payload.manifest.organizations);
|
|
1685
|
+
for (const scenario of payload.manifest.proofScenarios) {
|
|
1686
|
+
const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
|
|
1687
|
+
if (!membershipId) {
|
|
1688
|
+
out.ok = false;
|
|
1689
|
+
out.errors.push({
|
|
1690
|
+
operation: "authorization.check",
|
|
1691
|
+
scenario: scenario.name,
|
|
1692
|
+
organization: scenario.organization,
|
|
1693
|
+
expected: scenario.expected,
|
|
1694
|
+
message: "missing organizationMembershipId for WorkOS FGA proof scenario",
|
|
1695
|
+
env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
|
|
1696
|
+
});
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
const checked = cliApi("/authorization/organization_memberships/" + encodePathPart(membershipId) + "/check", {
|
|
1700
|
+
method: "POST",
|
|
1701
|
+
yes: true,
|
|
1702
|
+
data: {
|
|
1703
|
+
permission_slug: scenario.permission,
|
|
1704
|
+
resource_external_id: scenario.resourceExternalId,
|
|
1705
|
+
resource_type_slug: scenario.resourceTypeSlug,
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
if (!checked.ok) {
|
|
1709
|
+
out.ok = false;
|
|
1710
|
+
out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: checked.status, message: checked.stderr || JSON.stringify(checked.data) });
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
const expectedAuthorized = scenario.expected === "allow";
|
|
1714
|
+
const passed = Boolean(checked.data?.authorized) === expectedAuthorized;
|
|
1715
|
+
if (!passed) out.ok = false;
|
|
1716
|
+
out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(checked.data?.authorized), ok: passed });
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
try {
|
|
1721
|
+
if (!process.env.WORKOS_API_KEY) {
|
|
1722
|
+
await runWithWorkOSCli();
|
|
1723
|
+
console.log(JSON.stringify(out));
|
|
1724
|
+
process.exit(out.ok ? 0 : 1);
|
|
1725
|
+
}
|
|
1726
|
+
const mod = await import("@workos-inc/node");
|
|
1727
|
+
const WorkOS = mod.WorkOS || mod.default?.WorkOS;
|
|
1728
|
+
if (!WorkOS) throw new Error("@workos-inc/node did not export WorkOS");
|
|
1729
|
+
const workos = new WorkOS(process.env.WORKOS_API_KEY);
|
|
1730
|
+
const orgs = await listAll(await workos.organizations.listOrganizations());
|
|
1731
|
+
const orgByName = new Map(orgs.map((org) => [org.name, org]));
|
|
1732
|
+
const sortedResources = [...payload.manifest.resources].sort((a, b) => {
|
|
1733
|
+
if (a.type === "organization") return -1;
|
|
1734
|
+
if (b.type === "organization") return 1;
|
|
1735
|
+
if (!a.parentExternalId && b.parentExternalId) return -1;
|
|
1736
|
+
if (a.parentExternalId && !b.parentExternalId) return 1;
|
|
1737
|
+
return a.externalId.localeCompare(b.externalId);
|
|
1738
|
+
});
|
|
1739
|
+
for (const resource of sortedResources) {
|
|
1740
|
+
if (resource.type === "organization") {
|
|
1741
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
const org = orgByName.get(resource.tenant);
|
|
1745
|
+
if (!org) {
|
|
1746
|
+
out.ok = false;
|
|
1747
|
+
out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
try {
|
|
1751
|
+
const existing = await workos.authorization.getResourceByExternalId({
|
|
1752
|
+
organizationId: org.id,
|
|
1753
|
+
resourceTypeSlug: resource.type,
|
|
1754
|
+
externalId: resource.externalId,
|
|
1755
|
+
});
|
|
1756
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.id });
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
if (![404, "404"].includes(status(error))) {
|
|
1759
|
+
out.ok = false;
|
|
1760
|
+
out.errors.push({ operation: "resource.get", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(error), message: message(error) });
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
try {
|
|
1764
|
+
const created = await workos.authorization.createResource({
|
|
1765
|
+
organizationId: org.id,
|
|
1766
|
+
resourceTypeSlug: resource.type,
|
|
1767
|
+
externalId: resource.externalId,
|
|
1768
|
+
name: resource.name,
|
|
1769
|
+
...(resource.parentExternalId && resource.parentType !== "organization" ? { parentResourceExternalId: resource.parentExternalId, parentResourceTypeSlug: resource.parentType } : {}),
|
|
1770
|
+
});
|
|
1771
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.id });
|
|
1772
|
+
} catch (createError) {
|
|
1773
|
+
out.ok = false;
|
|
1774
|
+
out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(createError), message: message(createError) });
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (payload.action === "prove") {
|
|
1779
|
+
const memberships = readMembershipMap(payload.manifest.organizations);
|
|
1780
|
+
for (const scenario of payload.manifest.proofScenarios) {
|
|
1781
|
+
const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
|
|
1782
|
+
if (!membershipId) {
|
|
1783
|
+
out.ok = false;
|
|
1784
|
+
out.errors.push({
|
|
1785
|
+
operation: "authorization.check",
|
|
1786
|
+
scenario: scenario.name,
|
|
1787
|
+
organization: scenario.organization,
|
|
1788
|
+
expected: scenario.expected,
|
|
1789
|
+
message: "missing organizationMembershipId for WorkOS FGA proof scenario",
|
|
1790
|
+
env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
|
|
1791
|
+
});
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
try {
|
|
1795
|
+
const check = await workos.authorization.check({
|
|
1796
|
+
organizationMembershipId: membershipId,
|
|
1797
|
+
permissionSlug: scenario.permission,
|
|
1798
|
+
resourceExternalId: scenario.resourceExternalId,
|
|
1799
|
+
resourceTypeSlug: scenario.resourceTypeSlug,
|
|
1800
|
+
});
|
|
1801
|
+
const expectedAuthorized = scenario.expected === "allow";
|
|
1802
|
+
const passed = Boolean(check.authorized) === expectedAuthorized;
|
|
1803
|
+
if (!passed) out.ok = false;
|
|
1804
|
+
out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(check.authorized), ok: passed });
|
|
1805
|
+
} catch (checkError) {
|
|
1806
|
+
out.ok = false;
|
|
1807
|
+
out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: status(checkError), message: message(checkError) });
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
out.ok = false;
|
|
1813
|
+
out.errors.push({ operation: "sdk", message: message(error), status: status(error) });
|
|
1814
|
+
}
|
|
1815
|
+
console.log(JSON.stringify(out));
|
|
1816
|
+
process.exit(out.ok ? 0 : 1);
|
|
1817
|
+
`;
|
|
1818
|
+
const command = ["node", "--input-type=module", "-e", script];
|
|
1819
|
+
const displayCommand = ["node", "--input-type=module", "-e", "<forge-workos-fga-sdk>"];
|
|
1820
|
+
const previousPayload = process.env.FORGE_WORKOS_FGA_PAYLOAD;
|
|
1821
|
+
let child: { status: number | null; stdout: string; stderr: string };
|
|
1822
|
+
try {
|
|
1823
|
+
process.env.FORGE_WORKOS_FGA_PAYLOAD = JSON.stringify({ action, manifest });
|
|
1824
|
+
child = runExternalCommand(command, {
|
|
1825
|
+
...options,
|
|
1826
|
+
commandRunner: options.commandRunner ?? spawnSync,
|
|
1827
|
+
});
|
|
1828
|
+
} finally {
|
|
1829
|
+
if (previousPayload === undefined) {
|
|
1830
|
+
delete process.env.FORGE_WORKOS_FGA_PAYLOAD;
|
|
1831
|
+
} else {
|
|
1832
|
+
process.env.FORGE_WORKOS_FGA_PAYLOAD = previousPayload;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
const parsed = parseJsonObject(child.stdout);
|
|
1836
|
+
return {
|
|
1837
|
+
ok: child.status === 0 && parsed?.ok === true,
|
|
1838
|
+
command: displayCommand,
|
|
1839
|
+
data: parsed ?? {
|
|
1840
|
+
ok: false,
|
|
1841
|
+
errors: [{ operation: "sdk.parse", message: "failed to parse WorkOS FGA SDK output" }],
|
|
1842
|
+
},
|
|
1843
|
+
status: child.status,
|
|
1844
|
+
stdout: child.stdout,
|
|
1845
|
+
stderr: child.stderr,
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function workOSFgaSdkFailureDetail(data: unknown, manifest?: WorkOSFgaManifest): string {
|
|
1850
|
+
const text = JSON.stringify(data);
|
|
1851
|
+
if (text.includes("AuthorizationResourceType not found")) {
|
|
1852
|
+
const missing = extractMissingWorkOSFgaResourceTypes(data, manifest);
|
|
1853
|
+
const suffix = missing.length > 0 ? `: ${missing.join(", ")}` : "";
|
|
1854
|
+
return `WorkOS Authorization API returned missing FGA resource type(s)${suffix}; configure them in hosted WorkOS, then rerun forge workos fga sync --real --json`;
|
|
1855
|
+
}
|
|
1856
|
+
if (text.includes("Cannot add resource to organization resource type")) {
|
|
1857
|
+
return "WorkOS treats organizations as tenant roots, not creatable authorization resources; Forge will keep organization in the graph and skip resource creation for it";
|
|
1858
|
+
}
|
|
1859
|
+
if (text.includes("missing organizationMembershipId") || text.includes("WORKOS_FGA_MEMBERSHIPS_JSON")) {
|
|
1860
|
+
const env = manifest?.organizations.map(fgaMembershipEnvKey).join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>";
|
|
1861
|
+
return `WorkOS FGA real proof requires organizationMembershipId values per organization; set WORKOS_FGA_MEMBERSHIPS_JSON or ${env}`;
|
|
1862
|
+
}
|
|
1863
|
+
return "WorkOS Authorization API resource sync failed; inspect workosSdk.errors";
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function workOSFgaSdkProofComplete(data: unknown, manifest: WorkOSFgaManifest): boolean {
|
|
1867
|
+
if (!data || typeof data !== "object") return false;
|
|
1868
|
+
const record = data as { ok?: unknown; checks?: unknown; skipped?: unknown; errors?: unknown };
|
|
1869
|
+
if (record.ok !== true) return false;
|
|
1870
|
+
if (Array.isArray(record.errors) && record.errors.length > 0) return false;
|
|
1871
|
+
if (Array.isArray(record.skipped) && record.skipped.length > 0) return false;
|
|
1872
|
+
if (!Array.isArray(record.checks)) return false;
|
|
1873
|
+
const checks = record.checks as Array<{ name?: unknown; ok?: unknown }>;
|
|
1874
|
+
const checkByName = new Map(checks.map((check) => [String(check.name ?? ""), check]));
|
|
1875
|
+
return manifest.proofScenarios.every((scenario) => checkByName.get(scenario.name)?.ok === true);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
export interface WorkOSDoctorData {
|
|
1879
|
+
seed: WorkOSSeedSummary;
|
|
1880
|
+
seedState: WorkOSSeedStateSummary;
|
|
1881
|
+
fgaManifest: WorkOSFgaManifest;
|
|
1882
|
+
fgaState: WorkOSFgaStateSummary;
|
|
1883
|
+
activePermissions: string[];
|
|
1884
|
+
expectedResourceTypes: string[];
|
|
1885
|
+
missingSeedPermissions: string[];
|
|
1886
|
+
missingSeedResources: string[];
|
|
1887
|
+
unusedSeedPermissions: string[];
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function collectWorkOSDoctorData(
|
|
1891
|
+
workspaceRoot: string,
|
|
1892
|
+
preferredSeedPath = DEFAULT_SEED_FILE,
|
|
1893
|
+
): WorkOSDoctorData {
|
|
1894
|
+
const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
|
|
1895
|
+
const seedState = readWorkOSSeedState(workspaceRoot, seed);
|
|
1896
|
+
const fgaManifest = collectWorkOSFgaManifest(workspaceRoot, preferredSeedPath);
|
|
1897
|
+
const fgaState = readWorkOSFgaState(workspaceRoot, fgaManifest);
|
|
1898
|
+
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
1899
|
+
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
1900
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
1901
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
1902
|
+
const unusedSeedPermissions = activePermissions.length === 0
|
|
1903
|
+
? []
|
|
1904
|
+
: seed.permissions.filter((permission) => !activePermissions.includes(permission));
|
|
1905
|
+
return {
|
|
1906
|
+
seed,
|
|
1907
|
+
seedState,
|
|
1908
|
+
fgaManifest,
|
|
1909
|
+
fgaState,
|
|
1910
|
+
activePermissions,
|
|
1911
|
+
expectedResourceTypes,
|
|
1912
|
+
missingSeedPermissions,
|
|
1913
|
+
missingSeedResources,
|
|
1914
|
+
unusedSeedPermissions,
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_SEED_FILE): WorkOSCheck[] {
|
|
1919
|
+
const packageJson = readJson(workspaceRoot, "package.json") as {
|
|
1920
|
+
dependencies?: Record<string, string>;
|
|
1921
|
+
devDependencies?: Record<string, string>;
|
|
1922
|
+
} | null;
|
|
1923
|
+
const authRegistry = readJson(workspaceRoot, `${GENERATED_DIR}/authRegistry.json`) as {
|
|
1924
|
+
claims?: Record<string, string | undefined>;
|
|
1925
|
+
} | null;
|
|
1926
|
+
const secretRegistry = readJson(workspaceRoot, `${GENERATED_DIR}/secretRegistry.json`) as {
|
|
1927
|
+
secrets?: Array<{ envVar?: string; name?: string }>;
|
|
1928
|
+
} | null;
|
|
1929
|
+
const secretNames = new Set((secretRegistry?.secrets ?? []).map((secret) => secret.envVar ?? secret.name));
|
|
1930
|
+
const deps = {
|
|
1931
|
+
...(packageJson?.dependencies ?? {}),
|
|
1932
|
+
...(packageJson?.devDependencies ?? {}),
|
|
1933
|
+
};
|
|
1934
|
+
const webPackageJson = readJson(workspaceRoot, "web/package.json") as {
|
|
1935
|
+
dependencies?: Record<string, string>;
|
|
1936
|
+
devDependencies?: Record<string, string>;
|
|
1937
|
+
} | null;
|
|
1938
|
+
const webDeps = {
|
|
1939
|
+
...(webPackageJson?.dependencies ?? {}),
|
|
1940
|
+
...(webPackageJson?.devDependencies ?? {}),
|
|
1941
|
+
};
|
|
1942
|
+
const hasWeb = webPackageJson !== null;
|
|
1943
|
+
const {
|
|
1944
|
+
seed,
|
|
1945
|
+
seedState,
|
|
1946
|
+
fgaManifest,
|
|
1947
|
+
fgaState,
|
|
1948
|
+
activePermissions,
|
|
1949
|
+
expectedResourceTypes,
|
|
1950
|
+
missingSeedPermissions,
|
|
1951
|
+
missingSeedResources,
|
|
1952
|
+
unusedSeedPermissions,
|
|
1953
|
+
} = collectWorkOSDoctorData(workspaceRoot, preferredSeedPath);
|
|
1954
|
+
const realEnv = readRealEnv(workspaceRoot);
|
|
1955
|
+
const authMode = realEnv.FORGE_AUTH_MODE;
|
|
1956
|
+
const productionAuthEnabled = authMode === "oidc" || authMode === "jwt";
|
|
1957
|
+
const issuerConfigured = hasValue(realEnv, "FORGE_AUTH_ISSUER");
|
|
1958
|
+
const jwksConfigured = hasValue(realEnv, "FORGE_AUTH_JWKS_URI");
|
|
1959
|
+
const clientId = realEnv.WORKOS_CLIENT_ID || realEnv.VITE_WORKOS_CLIENT_ID;
|
|
1960
|
+
const authRoutes = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/auth-routes.ts`);
|
|
1961
|
+
const fga = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/fga.ts`);
|
|
1962
|
+
const resourceMap = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/resource-map.ts`);
|
|
1963
|
+
const fgaEnabled = hasWorkOSFgaArtifacts(workspaceRoot);
|
|
1964
|
+
const httpHandler = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/http-handler.ts`);
|
|
1965
|
+
const policies = readText(workspaceRoot, "src/policies.workos.ts");
|
|
1966
|
+
const session = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/session.ts`);
|
|
1967
|
+
const webhook = readText(workspaceRoot, `${GENERATED_DIR}/integrations/workos/webhook.ts`);
|
|
1968
|
+
const generatedFrontendAuthBridge = readText(workspaceRoot, "web/src/lib/workos-auth.tsx");
|
|
1969
|
+
const frontendAppShell = [
|
|
1970
|
+
readText(workspaceRoot, "web/src/main.tsx"),
|
|
1971
|
+
readText(workspaceRoot, "web/src/App.tsx"),
|
|
1972
|
+
].join("\n");
|
|
1973
|
+
const appShellUsesWorkOSProvider = frontendAppShell.includes("ForgeWorkOSAuthProvider");
|
|
1974
|
+
const authBridgeUsesBackendRoutes =
|
|
1975
|
+
generatedFrontendAuthBridge.includes("workOSApiUrl") &&
|
|
1976
|
+
["/login", "/logout", "/session"].every((route) => generatedFrontendAuthBridge.includes(route));
|
|
1977
|
+
const authBridgeProvidesSessionClaims =
|
|
1978
|
+
includesAll(generatedFrontendAuthBridge, ["useForgeWorkOSSession", "/session", "claims"]);
|
|
1979
|
+
const authBridgePassesClaimsToForge =
|
|
1980
|
+
includesAll(generatedFrontendAuthBridge, [
|
|
1981
|
+
"workOSSessionToForgeAuth",
|
|
1982
|
+
"forgeTenantIdForWorkOSOrganization",
|
|
1983
|
+
"workOSAuthProvider",
|
|
1984
|
+
"auth={workOSAuthProvider}",
|
|
1985
|
+
"permissions: claims.permissions",
|
|
1986
|
+
"organizationId: forgeTenantId",
|
|
1987
|
+
]);
|
|
1988
|
+
const authSessionProxyConfigured = webAuthSessionProxyConfigured(workspaceRoot);
|
|
1989
|
+
|
|
1990
|
+
return [
|
|
1991
|
+
{
|
|
1992
|
+
name: "package",
|
|
1993
|
+
ok: "@workos-inc/node" in deps,
|
|
1994
|
+
detail: "@workos-inc/node is present in package dependencies",
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
name: "auth-registry",
|
|
1998
|
+
ok: authRegistry?.claims?.tenantId === "organization_id" && authRegistry?.claims?.userId === "sub",
|
|
1999
|
+
detail: "authRegistry maps sub and organization_id claims",
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
name: "secrets",
|
|
2003
|
+
ok: ["WORKOS_API_KEY", "WORKOS_CLIENT_ID", "WORKOS_COOKIE_PASSWORD"].every((name) => secretNames.has(name)),
|
|
2004
|
+
detail: "required WorkOS secret names are registered",
|
|
2005
|
+
},
|
|
2006
|
+
{
|
|
2007
|
+
name: "env-example",
|
|
2008
|
+
ok: exists(workspaceRoot, ".env.example") &&
|
|
2009
|
+
includesAll(readRawText(workspaceRoot, ".env.example"), [
|
|
2010
|
+
"FORGE_AUTH_MODE=oidc",
|
|
2011
|
+
"FORGE_AUTH_ISSUER=https://api.workos.com",
|
|
2012
|
+
"FORGE_AUTH_JWKS_URI=",
|
|
2013
|
+
"VITE_WORKOS_CLIENT_ID=",
|
|
2014
|
+
"VITE_WORKOS_REDIRECT_URI=",
|
|
2015
|
+
]),
|
|
2016
|
+
detail: ".env.example exists with Forge OIDC and browser AuthKit variables",
|
|
2017
|
+
},
|
|
2018
|
+
{
|
|
2019
|
+
name: "production-auth-readiness",
|
|
2020
|
+
ok: !productionAuthEnabled || (issuerConfigured && jwksConfigured),
|
|
2021
|
+
detail: !productionAuthEnabled
|
|
2022
|
+
? "production OIDC/JWT env not enabled in .env/.env.local"
|
|
2023
|
+
: issuerConfigured && jwksConfigured
|
|
2024
|
+
? "FORGE_AUTH_MODE uses production auth and issuer/JWKS are configured"
|
|
2025
|
+
: `FORGE_AUTH_MODE=${authMode} requires FORGE_AUTH_ISSUER=https://api.workos.com and FORGE_AUTH_JWKS_URI=${workosJwksUri(clientId)}`,
|
|
2026
|
+
},
|
|
2027
|
+
{
|
|
2028
|
+
name: "browser-authkit-env",
|
|
2029
|
+
ok: !hasWeb || (hasValue(realEnv, "VITE_WORKOS_CLIENT_ID") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_CLIENT_ID=")) &&
|
|
2030
|
+
(hasValue(realEnv, "VITE_WORKOS_REDIRECT_URI") || readRawText(workspaceRoot, ".env.example").includes("VITE_WORKOS_REDIRECT_URI=")),
|
|
2031
|
+
detail: hasWeb
|
|
2032
|
+
? "web workspace has browser WorkOS client ID and redirect URI guidance for backend-owned AuthKit routes"
|
|
2033
|
+
: "no web workspace detected",
|
|
2034
|
+
},
|
|
2035
|
+
{
|
|
2036
|
+
name: "browser-authkit-package",
|
|
2037
|
+
ok: !hasWeb || "@workos-inc/authkit-react" in webDeps,
|
|
2038
|
+
detail: hasWeb
|
|
2039
|
+
? "@workos-inc/authkit-react is present in web/package.json"
|
|
2040
|
+
: "no web workspace detected",
|
|
2041
|
+
},
|
|
2042
|
+
{
|
|
2043
|
+
name: "browser-authkit-bridge",
|
|
2044
|
+
ok: !hasWeb || generatedFrontendAuthBridge.includes("ForgeProvider") &&
|
|
2045
|
+
authBridgeUsesBackendRoutes &&
|
|
2046
|
+
authBridgeProvidesSessionClaims &&
|
|
2047
|
+
authBridgePassesClaimsToForge,
|
|
2048
|
+
detail: hasWeb
|
|
2049
|
+
? "generated web/src/lib/workos-auth.tsx bridge uses backend-owned AuthKit routes and passes normalized WorkOS claims into ForgeProvider auth"
|
|
2050
|
+
: "no web workspace detected",
|
|
2051
|
+
},
|
|
2052
|
+
{
|
|
2053
|
+
name: "browser-authkit-session-proxy",
|
|
2054
|
+
ok: !hasWeb || authSessionProxyConfigured,
|
|
2055
|
+
detail: hasWeb
|
|
2056
|
+
? authSessionProxyConfigured
|
|
2057
|
+
? "web dev config proxies /login, /callback, /logout, and /session to the Forge API runtime"
|
|
2058
|
+
: "web dev config should proxy /session with /login, /callback, and /logout so AuthKit UI can read Forge-normalized claims"
|
|
2059
|
+
: "no web workspace detected",
|
|
2060
|
+
},
|
|
2061
|
+
{
|
|
2062
|
+
name: "browser-authkit-provider",
|
|
2063
|
+
ok: !hasWeb || appShellUsesWorkOSProvider,
|
|
2064
|
+
detail: hasWeb
|
|
2065
|
+
? "web app shell mounts ForgeWorkOSAuthProvider"
|
|
2066
|
+
: "no web workspace detected",
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
name: "seed-file",
|
|
2070
|
+
ok: seed.exists,
|
|
2071
|
+
detail: seed.exists
|
|
2072
|
+
? `${seed.path} exists with ${seed.permissions.length} permission(s), ${seed.roles.length} role(s), ${seed.resourceTypes.length} optional FGA resource type(s)`
|
|
2073
|
+
: `${DEFAULT_SEED_FILE} or ${GENERATED_SEED_FILE} is required`,
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
name: "seed-organizations",
|
|
2077
|
+
ok: seed.organizations.length > 0,
|
|
2078
|
+
detail: seed.organizations.length > 0
|
|
2079
|
+
? `seed file contains organization(s): ${seed.organizations.join(", ")}`
|
|
2080
|
+
: "seed file should contain at least one demo organization",
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
name: "seed-roles-permissions",
|
|
2084
|
+
ok: seed.roles.length > 0 &&
|
|
2085
|
+
(activePermissions.length === 0 ? seed.permissions.length > 0 : missingSeedPermissions.length === 0),
|
|
2086
|
+
detail: missingSeedPermissions.length === 0
|
|
2087
|
+
? `seed covers ${activePermissions.length} active policy permission(s) with role(s): ${seed.roles.join(", ")}`
|
|
2088
|
+
: `seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`,
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
name: "seed-unused-permissions",
|
|
2092
|
+
ok: true,
|
|
2093
|
+
detail: activePermissions.length === 0
|
|
2094
|
+
? "no active policy permissions were discovered; unused seed permission check skipped"
|
|
2095
|
+
: unusedSeedPermissions.length === 0
|
|
2096
|
+
? "seed permissions are all referenced by active policies"
|
|
2097
|
+
: `seed includes permission(s) not referenced by active policies: ${unusedSeedPermissions.join(", ")}`,
|
|
2098
|
+
},
|
|
2099
|
+
{
|
|
2100
|
+
name: "seed-resource-types",
|
|
2101
|
+
ok: !fgaEnabled || (seed.resourceTypes.length > 0 &&
|
|
2102
|
+
(expectedResourceTypes.length === 0 || missingSeedResources.length === 0)),
|
|
2103
|
+
detail: !fgaEnabled
|
|
2104
|
+
? "FGA is not enabled; resource_types are optional for AuthKit/RBAC apps"
|
|
2105
|
+
: missingSeedResources.length === 0
|
|
2106
|
+
? `seed resource_types cover app graph: ${expectedResourceTypes.length > 0 ? expectedResourceTypes.join(", ") : seed.resourceTypes.join(", ")}`
|
|
2107
|
+
: `seed missing resource_type(s) for app graph: ${missingSeedResources.join(", ")}`,
|
|
2108
|
+
},
|
|
2109
|
+
{
|
|
2110
|
+
name: "seed-auth-config",
|
|
2111
|
+
ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
|
|
2112
|
+
detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
|
|
2113
|
+
? `seed config contains ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), and homepage URL`
|
|
2114
|
+
: "seed config should include redirect_uris, cors_origins, and homepage_url for no-dashboard setup",
|
|
2115
|
+
},
|
|
2116
|
+
{
|
|
2117
|
+
name: "seed-state",
|
|
2118
|
+
ok: true,
|
|
2119
|
+
detail: !seedState.exists
|
|
2120
|
+
? `${WORKOS_SEED_STATE_FILE} not found; hosted WorkOS seed has not been proven locally yet`
|
|
2121
|
+
: seedState.matchesSeedHash
|
|
2122
|
+
? `${WORKOS_SEED_STATE_FILE} matches ${seed.path}${seedState.alreadyApplied ? " and records an already-applied hosted seed" : ""}`
|
|
2123
|
+
: seedState.valid
|
|
2124
|
+
? `${WORKOS_SEED_STATE_FILE} exists but does not match current ${seed.path}; rerun forge workos seed --file ${seed.path} --json after reviewing seed changes`
|
|
2125
|
+
: `${WORKOS_SEED_STATE_FILE} exists but is invalid: ${seedState.diagnostics.join("; ")}`,
|
|
2126
|
+
},
|
|
2127
|
+
{
|
|
2128
|
+
name: "fga-plan",
|
|
2129
|
+
ok: !fgaEnabled || fgaManifest.diagnostics.length === 0,
|
|
2130
|
+
detail: !fgaEnabled
|
|
2131
|
+
? "FGA is not enabled; run forge add auth workos --with-fga for resource-level WorkOS Authorization"
|
|
2132
|
+
: fgaManifest.diagnostics.length === 0
|
|
2133
|
+
? `FGA plan covers ${fgaManifest.resourceTypes.length} resource type(s), ${fgaManifest.resources.length} resource(s), and ${fgaManifest.proofScenarios.length} proof scenario(s)`
|
|
2134
|
+
: `FGA plan has gap(s): ${fgaManifest.diagnostics.join("; ")}`,
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
name: "fga-state",
|
|
2138
|
+
ok: true,
|
|
2139
|
+
detail: !fgaEnabled
|
|
2140
|
+
? "FGA is not enabled; no FGA state file is required"
|
|
2141
|
+
: !fgaState.exists
|
|
2142
|
+
? `${WORKOS_FGA_STATE_FILE} not found; run forge workos fga sync --json before production deploy`
|
|
2143
|
+
: fgaState.matchesManifestHash
|
|
2144
|
+
? `${WORKOS_FGA_STATE_FILE} matches current FGA manifest${fgaState.mode === "real" ? " in real mode" : ""}`
|
|
2145
|
+
: fgaState.valid
|
|
2146
|
+
? `${WORKOS_FGA_STATE_FILE} exists but does not match current FGA manifest; rerun forge workos fga sync --json`
|
|
2147
|
+
: `${WORKOS_FGA_STATE_FILE} exists but is invalid: ${fgaState.diagnostics.join("; ")}`,
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
name: "authkit-routes",
|
|
2151
|
+
ok: includesAll(authRoutes, ["handleWorkOSAuthRequest", "/login", "/callback", "/logout", "/session"]),
|
|
2152
|
+
detail: "AuthKit Request/Response route helper exists for login, callback, logout, and session",
|
|
2153
|
+
},
|
|
2154
|
+
{
|
|
2155
|
+
name: "authkit-session",
|
|
2156
|
+
ok: includesAll(session, ["encodeWorkOSSession", "decodeWorkOSSession", "workOSSessionToClaims"]),
|
|
2157
|
+
detail: "AuthKit session helper signs cookies and maps sessions to Forge claims",
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
name: "webhook-helper",
|
|
2161
|
+
ok: includesAll(webhook, ["verifyWorkOSWebhook", "handleWorkOSWebhook", 'provider: "workos"']),
|
|
2162
|
+
detail: "WorkOS webhook verifier/helper exists and uses WorkOS signature verification",
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
name: "webhook-http-handler",
|
|
2166
|
+
ok: includesAll(httpHandler, ["handleWorkOSWebhookRequest", "workosWebhookHttpRoute", "/webhooks/workos"]),
|
|
2167
|
+
detail: "WorkOS Request/Response webhook handler exists for POST /webhooks/workos",
|
|
2168
|
+
},
|
|
2169
|
+
{
|
|
2170
|
+
name: "fga-bridge",
|
|
2171
|
+
ok: !fgaEnabled || (includesAll(resourceMap, [
|
|
2172
|
+
"canWorkOS",
|
|
2173
|
+
"assertWorkOSResourceTenant",
|
|
2174
|
+
"FORGE_WORKOS_CROSS_TENANT_RESOURCE",
|
|
2175
|
+
"syncWorkOSResourceGraph",
|
|
2176
|
+
"workOSResourceRecords",
|
|
2177
|
+
"ForgeWorkOSFgaDecisionCache",
|
|
2178
|
+
"permissionSlug",
|
|
2179
|
+
"resourceExternalId",
|
|
2180
|
+
]) &&
|
|
2181
|
+
includesAll(fga, ["forgeWorkOSResourceTypes"])),
|
|
2182
|
+
detail: fgaEnabled
|
|
2183
|
+
? "WorkOS FGA resource-map bridge exists with sync, cache, telemetry, official check shape, and cross-tenant guard"
|
|
2184
|
+
: "FGA bridge is optional and not enabled for this WorkOS AuthKit/RBAC app",
|
|
2185
|
+
},
|
|
2186
|
+
{
|
|
2187
|
+
name: "policies",
|
|
2188
|
+
ok: exists(workspaceRoot, "src/policies.workos.ts") &&
|
|
2189
|
+
policies.includes("canPermission") &&
|
|
2190
|
+
missingValues(activePermissions, quotedValues(policies)).length === 0,
|
|
2191
|
+
detail: activePermissions.length === 0
|
|
2192
|
+
? "WorkOS-derived Forge policy template exists and is permission-first"
|
|
2193
|
+
: `WorkOS-derived policy template covers active permission(s): ${activePermissions.join(", ")}`,
|
|
2194
|
+
},
|
|
2195
|
+
];
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function prepareSeedFileForWorkOSCli(
|
|
2199
|
+
workspaceRoot: string,
|
|
2200
|
+
file: string,
|
|
2201
|
+
): { file: string; sanitized: boolean; cleanup: () => void } {
|
|
2202
|
+
const raw = readRawText(workspaceRoot, file);
|
|
2203
|
+
const stripped = stripDeterministicHeader(raw);
|
|
2204
|
+
if (!raw || raw === stripped) {
|
|
2205
|
+
return { file, sanitized: false, cleanup: () => undefined };
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const tempDir = mkdtempSync(join(tmpdir(), "forge-workos-seed-"));
|
|
2209
|
+
const preparedFile = join(tempDir, basename(file));
|
|
2210
|
+
writeFileSync(preparedFile, stripped, "utf8");
|
|
2211
|
+
return {
|
|
2212
|
+
file: preparedFile,
|
|
2213
|
+
sanitized: true,
|
|
2214
|
+
cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function isWorkOSSeedAlreadyApplied(stdout: string, stderr: string): boolean {
|
|
2219
|
+
const output = `${stdout}\n${stderr}`;
|
|
2220
|
+
return /(?:permission|role|resource type|organization|slug|domain)[^\n]*(?:already in use|already exists|exists already)|already in use/i
|
|
2221
|
+
.test(output);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function seedData(input: {
|
|
2225
|
+
seed: WorkOSSeedSummary;
|
|
2226
|
+
activePermissions: string[];
|
|
2227
|
+
expectedResourceTypes: string[];
|
|
2228
|
+
unusedSeedPermissions: string[];
|
|
2229
|
+
seedState?: WorkOSSeedStateSummary;
|
|
2230
|
+
dryRun?: boolean;
|
|
2231
|
+
seedFileSanitized?: boolean;
|
|
2232
|
+
seedAlreadyApplied?: boolean;
|
|
2233
|
+
seedAlreadyAppliedReason?: string;
|
|
2234
|
+
seedStateFile?: string;
|
|
2235
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
2236
|
+
workosCli?: Record<string, unknown>;
|
|
2237
|
+
configActions?: WorkOSConfigActionResult[];
|
|
2238
|
+
nextCommand?: string;
|
|
2239
|
+
}): Record<string, unknown> {
|
|
2240
|
+
return {
|
|
2241
|
+
seed: input.seed,
|
|
2242
|
+
activePermissions: input.activePermissions,
|
|
2243
|
+
expectedResourceTypes: input.expectedResourceTypes,
|
|
2244
|
+
unusedSeedPermissions: input.unusedSeedPermissions,
|
|
2245
|
+
...(input.seedState ? { seedState: input.seedState } : {}),
|
|
2246
|
+
dryRun: input.dryRun ?? false,
|
|
2247
|
+
seedFileSanitized: input.seedFileSanitized ?? false,
|
|
2248
|
+
seedAlreadyApplied: input.seedAlreadyApplied ?? false,
|
|
2249
|
+
...(input.seedAlreadyAppliedReason ? { seedAlreadyAppliedReason: input.seedAlreadyAppliedReason } : {}),
|
|
2250
|
+
...(input.seedStateFile ? { seedStateFile: input.seedStateFile } : {}),
|
|
2251
|
+
...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
|
|
2252
|
+
...(input.workosCli ? { workosCli: input.workosCli } : {}),
|
|
2253
|
+
configActions: input.configActions ?? [],
|
|
2254
|
+
...(input.nextCommand ? { nextCommand: input.nextCommand } : {}),
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
function hashSeedFile(workspaceRoot: string, path: string): string {
|
|
2259
|
+
return createHash("sha256")
|
|
2260
|
+
.update(readText(workspaceRoot, path))
|
|
2261
|
+
.digest("hex");
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function readWorkOSSeedState(workspaceRoot: string, seed: WorkOSSeedSummary): WorkOSSeedStateSummary {
|
|
2265
|
+
const path = WORKOS_SEED_STATE_FILE;
|
|
2266
|
+
const absolute = join(workspaceRoot, path);
|
|
2267
|
+
if (!existsSync(absolute)) {
|
|
2268
|
+
return {
|
|
2269
|
+
exists: false,
|
|
2270
|
+
valid: false,
|
|
2271
|
+
path,
|
|
2272
|
+
matchesSeedHash: null,
|
|
2273
|
+
diagnostics: [`${path} is missing; run forge workos seed --file ${seed.path} --json after dry-run validation`],
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
try {
|
|
2277
|
+
const parsed = JSON.parse(readFileSync(absolute, "utf8")) as {
|
|
2278
|
+
seedHash?: unknown;
|
|
2279
|
+
appliedAt?: unknown;
|
|
2280
|
+
alreadyApplied?: unknown;
|
|
2281
|
+
};
|
|
2282
|
+
const diagnostics: string[] = [];
|
|
2283
|
+
const seedHash = typeof parsed.seedHash === "string" ? parsed.seedHash : undefined;
|
|
2284
|
+
const currentSeedHash = seed.exists ? hashSeedFile(workspaceRoot, seed.path) : undefined;
|
|
2285
|
+
if (!seedHash) diagnostics.push("seed state is missing seedHash");
|
|
2286
|
+
if (typeof parsed.appliedAt !== "string") diagnostics.push("seed state is missing appliedAt");
|
|
2287
|
+
if (typeof parsed.alreadyApplied !== "boolean") diagnostics.push("seed state is missing alreadyApplied");
|
|
2288
|
+
return {
|
|
2289
|
+
exists: true,
|
|
2290
|
+
valid: diagnostics.length === 0,
|
|
2291
|
+
path,
|
|
2292
|
+
matchesSeedHash: seedHash && currentSeedHash ? seedHash === currentSeedHash : null,
|
|
2293
|
+
...(seedHash ? { seedHash } : {}),
|
|
2294
|
+
...(currentSeedHash ? { currentSeedHash } : {}),
|
|
2295
|
+
...(typeof parsed.appliedAt === "string" ? { appliedAt: parsed.appliedAt } : {}),
|
|
2296
|
+
...(typeof parsed.alreadyApplied === "boolean" ? { alreadyApplied: parsed.alreadyApplied } : {}),
|
|
2297
|
+
diagnostics,
|
|
2298
|
+
};
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
return {
|
|
2301
|
+
exists: true,
|
|
2302
|
+
valid: false,
|
|
2303
|
+
path,
|
|
2304
|
+
matchesSeedHash: null,
|
|
2305
|
+
diagnostics: [`failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}`],
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function writeWorkOSSeedState(input: {
|
|
2311
|
+
workspaceRoot: string;
|
|
2312
|
+
seed: WorkOSSeedSummary;
|
|
2313
|
+
command: string[];
|
|
2314
|
+
alreadyApplied: boolean;
|
|
2315
|
+
status: number | null;
|
|
2316
|
+
}): string {
|
|
2317
|
+
const payload = {
|
|
2318
|
+
schemaVersion: "0.1.0",
|
|
2319
|
+
provider: "workos",
|
|
2320
|
+
kind: "seed-state",
|
|
2321
|
+
seedFile: input.seed.path,
|
|
2322
|
+
seedHash: hashSeedFile(input.workspaceRoot, input.seed.path),
|
|
2323
|
+
appliedAt: new Date().toISOString(),
|
|
2324
|
+
alreadyApplied: input.alreadyApplied,
|
|
2325
|
+
exitStatus: input.status,
|
|
2326
|
+
command: input.command,
|
|
2327
|
+
permissions: input.seed.permissions,
|
|
2328
|
+
roles: input.seed.roles,
|
|
2329
|
+
resourceTypes: input.seed.resourceTypes,
|
|
2330
|
+
organizations: input.seed.organizations,
|
|
2331
|
+
redirectUris: input.seed.redirectUris,
|
|
2332
|
+
corsOrigins: input.seed.corsOrigins,
|
|
2333
|
+
};
|
|
2334
|
+
writeFileSync(
|
|
2335
|
+
join(input.workspaceRoot, WORKOS_SEED_STATE_FILE),
|
|
2336
|
+
`${JSON.stringify(payload, null, 2)}\n`,
|
|
2337
|
+
"utf8",
|
|
2338
|
+
);
|
|
2339
|
+
return WORKOS_SEED_STATE_FILE;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
export interface WorkOSConfigActionResult {
|
|
2343
|
+
name: string;
|
|
2344
|
+
command?: string[];
|
|
2345
|
+
ok: boolean;
|
|
2346
|
+
skipped: boolean;
|
|
2347
|
+
reason?: string;
|
|
2348
|
+
stdout?: string;
|
|
2349
|
+
stderr?: string;
|
|
2350
|
+
status?: number | null;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
function configActionsForSeed(seed: WorkOSSeedSummary): Array<{ name: string; command: string[]; skipReason?: string }> {
|
|
2354
|
+
const actions: Array<{ name: string; command: string[]; skipReason?: string }> = [];
|
|
2355
|
+
for (const uri of seed.redirectUris) {
|
|
2356
|
+
actions.push({
|
|
2357
|
+
name: "redirect-uri",
|
|
2358
|
+
command: ["npx", "--yes", "workos@latest", "config", "redirect", "add", uri],
|
|
2359
|
+
});
|
|
2360
|
+
}
|
|
2361
|
+
for (const origin of seed.corsOrigins) {
|
|
2362
|
+
actions.push({
|
|
2363
|
+
name: "cors-origin",
|
|
2364
|
+
command: ["npx", "--yes", "workos@latest", "config", "cors", "add", origin],
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
if (seed.homepageUrl) {
|
|
2368
|
+
actions.push({
|
|
2369
|
+
name: "homepage-url",
|
|
2370
|
+
command: ["npx", "--yes", "workos@latest", "config", "homepage-url", "set", seed.homepageUrl],
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
for (const endpoint of seed.webhookEndpoints) {
|
|
2374
|
+
const events = endpoint.events.length > 0 ? endpoint.events.join(",") : "user.created,organization_membership.updated";
|
|
2375
|
+
actions.push({
|
|
2376
|
+
name: "webhook",
|
|
2377
|
+
command: ["npx", "--yes", "workos@latest", "webhook", "create", "--url", endpoint.url, "--events", events],
|
|
2378
|
+
skipReason: endpoint.url.startsWith("https://")
|
|
2379
|
+
? undefined
|
|
2380
|
+
: "WorkOS hosted webhook endpoints require HTTPS; use a tunnel or production URL for this endpoint.",
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
return actions;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function runWorkOSConfigActions(
|
|
2387
|
+
seed: WorkOSSeedSummary,
|
|
2388
|
+
options: WorkOSCommandOptions,
|
|
2389
|
+
dryRun: boolean,
|
|
2390
|
+
): WorkOSConfigActionResult[] {
|
|
2391
|
+
return configActionsForSeed(seed).map((action) => {
|
|
2392
|
+
if (action.skipReason) {
|
|
2393
|
+
return {
|
|
2394
|
+
name: action.name,
|
|
2395
|
+
command: action.command,
|
|
2396
|
+
ok: true,
|
|
2397
|
+
skipped: true,
|
|
2398
|
+
reason: action.skipReason,
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
if (dryRun) {
|
|
2402
|
+
return {
|
|
2403
|
+
name: action.name,
|
|
2404
|
+
command: action.command,
|
|
2405
|
+
ok: true,
|
|
2406
|
+
skipped: true,
|
|
2407
|
+
reason: "dry-run",
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
const child = runExternalCommand(action.command, options);
|
|
2411
|
+
return {
|
|
2412
|
+
name: action.name,
|
|
2413
|
+
command: action.command,
|
|
2414
|
+
ok: child.status === 0 || isWorkOSSeedAlreadyApplied(child.stdout, child.stderr),
|
|
2415
|
+
skipped: false,
|
|
2416
|
+
stdout: child.stdout,
|
|
2417
|
+
stderr: child.stderr,
|
|
2418
|
+
status: child.status,
|
|
2419
|
+
...(child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr)
|
|
2420
|
+
? { reason: "already-applied" }
|
|
2421
|
+
: {}),
|
|
2422
|
+
};
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
export function runWorkOSDoctorCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2427
|
+
const seedFile = options.file ?? DEFAULT_SEED_FILE;
|
|
2428
|
+
const checks = collectWorkOSChecks(options.workspaceRoot, seedFile);
|
|
2429
|
+
const data = collectWorkOSDoctorData(options.workspaceRoot, seedFile);
|
|
2430
|
+
const ok = checks.every((check) => check.ok);
|
|
2431
|
+
const command = ["npx", "--yes", "workos@latest", "doctor"];
|
|
2432
|
+
if (!ok) {
|
|
2433
|
+
return {
|
|
2434
|
+
ok: false,
|
|
2435
|
+
kind: "workos-doctor",
|
|
2436
|
+
checks,
|
|
2437
|
+
command,
|
|
2438
|
+
applied: false,
|
|
2439
|
+
data,
|
|
2440
|
+
exitCode: 1,
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
if (!options.yes || options.dryRun) {
|
|
2444
|
+
return {
|
|
2445
|
+
ok: true,
|
|
2446
|
+
kind: "workos-doctor",
|
|
2447
|
+
checks,
|
|
2448
|
+
command,
|
|
2449
|
+
applied: false,
|
|
2450
|
+
data,
|
|
2451
|
+
exitCode: 0,
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
const child = runExternalCommand(command, options);
|
|
2455
|
+
return {
|
|
2456
|
+
ok: child.status === 0,
|
|
2457
|
+
kind: "workos-doctor",
|
|
2458
|
+
checks,
|
|
2459
|
+
command,
|
|
2460
|
+
applied: child.status === 0,
|
|
2461
|
+
data,
|
|
2462
|
+
stdout: child.stdout,
|
|
2463
|
+
stderr: child.stderr,
|
|
2464
|
+
exitCode: child.status === 0 ? 0 : 1,
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
export function runWorkOSInstallCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2469
|
+
const checks = collectWorkOSChecks(options.workspaceRoot);
|
|
2470
|
+
const ok = checks.every((check) => check.ok);
|
|
2471
|
+
const command = ["npx", "--yes", "workos@latest", "install"];
|
|
2472
|
+
if (!ok) {
|
|
2473
|
+
return {
|
|
2474
|
+
ok: false,
|
|
2475
|
+
kind: "workos-install",
|
|
2476
|
+
checks,
|
|
2477
|
+
command,
|
|
2478
|
+
applied: false,
|
|
2479
|
+
exitCode: 1,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
if (!options.yes || options.dryRun) {
|
|
2483
|
+
return {
|
|
2484
|
+
ok: true,
|
|
2485
|
+
kind: "workos-install",
|
|
2486
|
+
checks,
|
|
2487
|
+
command,
|
|
2488
|
+
applied: false,
|
|
2489
|
+
exitCode: 0,
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
const child = runExternalCommand(command, options);
|
|
2493
|
+
return {
|
|
2494
|
+
ok: child.status === 0,
|
|
2495
|
+
kind: "workos-install",
|
|
2496
|
+
checks,
|
|
2497
|
+
command,
|
|
2498
|
+
applied: child.status === 0,
|
|
2499
|
+
stdout: child.stdout,
|
|
2500
|
+
stderr: child.stderr,
|
|
2501
|
+
exitCode: child.status === 0 ? 0 : 1,
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2506
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
2507
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
2508
|
+
const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
|
|
2509
|
+
const activePermissions = collectPolicyPermissions(options.workspaceRoot);
|
|
2510
|
+
const expectedResourceTypes = collectExpectedResourceTypes(options.workspaceRoot);
|
|
2511
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
2512
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
2513
|
+
const unusedSeedPermissions = seed.permissions.filter((permission) => !activePermissions.includes(permission));
|
|
2514
|
+
const fgaEnabled = hasWorkOSFgaArtifacts(options.workspaceRoot);
|
|
2515
|
+
const checks = [
|
|
2516
|
+
{
|
|
2517
|
+
name: "seed-file",
|
|
2518
|
+
ok: seed.exists,
|
|
2519
|
+
detail: seed.exists ? `${seed.path} exists` : `${file} exists`,
|
|
2520
|
+
},
|
|
2521
|
+
{
|
|
2522
|
+
name: "seed-yaml-shape",
|
|
2523
|
+
ok: seed.valid,
|
|
2524
|
+
detail: seed.valid
|
|
2525
|
+
? `seed contains permissions, roles, organizations${seed.resourceTypes.length > 0 ? ", and optional FGA resource_types" : ""}`
|
|
2526
|
+
: seed.diagnostics.join("; "),
|
|
2527
|
+
},
|
|
2528
|
+
{
|
|
2529
|
+
name: "seed-policy-coverage",
|
|
2530
|
+
ok: missingSeedPermissions.length === 0,
|
|
2531
|
+
detail: missingSeedPermissions.length === 0
|
|
2532
|
+
? `seed covers ${activePermissions.length} active permission(s)`
|
|
2533
|
+
: `seed missing active permission(s): ${missingSeedPermissions.join(", ")}`,
|
|
2534
|
+
},
|
|
2535
|
+
{
|
|
2536
|
+
name: "seed-unused-permissions",
|
|
2537
|
+
ok: true,
|
|
2538
|
+
detail: activePermissions.length === 0
|
|
2539
|
+
? "no active policy permissions were discovered; unused seed permission check skipped"
|
|
2540
|
+
: unusedSeedPermissions.length === 0
|
|
2541
|
+
? "seed permissions are all referenced by active policies"
|
|
2542
|
+
: `seed includes permission(s) not referenced by active policies: ${unusedSeedPermissions.join(", ")}`,
|
|
2543
|
+
},
|
|
2544
|
+
{
|
|
2545
|
+
name: "seed-resource-coverage",
|
|
2546
|
+
ok: !fgaEnabled || missingSeedResources.length === 0,
|
|
2547
|
+
detail: !fgaEnabled
|
|
2548
|
+
? "FGA is not enabled; seed resource type coverage is not required"
|
|
2549
|
+
: missingSeedResources.length === 0
|
|
2550
|
+
? `seed covers app resource type(s): ${expectedResourceTypes.join(", ") || "none required"}`
|
|
2551
|
+
: `seed missing resource type(s): ${missingSeedResources.join(", ")}`,
|
|
2552
|
+
},
|
|
2553
|
+
{
|
|
2554
|
+
name: "seed-hosted-config",
|
|
2555
|
+
ok: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl),
|
|
2556
|
+
detail: seed.redirectUris.length > 0 && seed.corsOrigins.length > 0 && Boolean(seed.homepageUrl)
|
|
2557
|
+
? `seed includes hosted config: ${seed.redirectUris.length} redirect URI(s), ${seed.corsOrigins.length} CORS origin(s), homepage ${seed.homepageUrl}`
|
|
2558
|
+
: "seed should include config.redirect_uris, config.cors_origins, and config.homepage_url",
|
|
2559
|
+
},
|
|
2560
|
+
];
|
|
2561
|
+
const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
|
|
2562
|
+
if (!checks.every((check) => check.ok)) {
|
|
2563
|
+
return {
|
|
2564
|
+
ok: false,
|
|
2565
|
+
kind: "workos-seed",
|
|
2566
|
+
checks,
|
|
2567
|
+
command,
|
|
2568
|
+
applied: false,
|
|
2569
|
+
data: seedData({ seed, activePermissions, expectedResourceTypes, unusedSeedPermissions, seedState }),
|
|
2570
|
+
exitCode: 1,
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
if (options.dryRun) {
|
|
2574
|
+
const configActions = runWorkOSConfigActions(seed, options, true);
|
|
2575
|
+
return {
|
|
2576
|
+
ok: true,
|
|
2577
|
+
kind: "workos-seed",
|
|
2578
|
+
checks,
|
|
2579
|
+
command,
|
|
2580
|
+
applied: false,
|
|
2581
|
+
data: seedData({
|
|
2582
|
+
seed,
|
|
2583
|
+
activePermissions,
|
|
2584
|
+
expectedResourceTypes,
|
|
2585
|
+
unusedSeedPermissions,
|
|
2586
|
+
seedState,
|
|
2587
|
+
dryRun: true,
|
|
2588
|
+
configActions,
|
|
2589
|
+
nextCommand: `forge workos seed --file ${file} --json`,
|
|
2590
|
+
}),
|
|
2591
|
+
exitCode: 0,
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
const cliAuth = ensureWorkOSCliAuthForHosted(options);
|
|
2595
|
+
if (!cliAuth.ok) {
|
|
2596
|
+
return {
|
|
2597
|
+
ok: false,
|
|
2598
|
+
kind: "workos-seed",
|
|
2599
|
+
checks: [...checks, workOSCliAuthCheck(cliAuth)],
|
|
2600
|
+
command: cliAuth.loginCommand ?? cliAuth.statusCommand ?? command,
|
|
2601
|
+
applied: false,
|
|
2602
|
+
data: seedData({
|
|
2603
|
+
seed,
|
|
2604
|
+
activePermissions,
|
|
2605
|
+
expectedResourceTypes,
|
|
2606
|
+
unusedSeedPermissions,
|
|
2607
|
+
seedState,
|
|
2608
|
+
cliAuth,
|
|
2609
|
+
nextCommand: `forge workos seed --file ${file} --json`,
|
|
2610
|
+
}),
|
|
2611
|
+
exitCode: 1,
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
const preparedSeed = prepareSeedFileForWorkOSCli(options.workspaceRoot, seed.path);
|
|
2615
|
+
const delegatedCommand = [
|
|
2616
|
+
"npx",
|
|
2617
|
+
"--yes",
|
|
2618
|
+
"workos@latest",
|
|
2619
|
+
"seed",
|
|
2620
|
+
"--file",
|
|
2621
|
+
preparedSeed.file,
|
|
2622
|
+
];
|
|
2623
|
+
try {
|
|
2624
|
+
const configActions = runWorkOSConfigActions(seed, options, false);
|
|
2625
|
+
const configOk = configActions.every((action) => action.ok);
|
|
2626
|
+
if (!configOk) {
|
|
2627
|
+
return {
|
|
2628
|
+
ok: false,
|
|
2629
|
+
kind: "workos-seed",
|
|
2630
|
+
checks,
|
|
2631
|
+
command: configActions.find((action) => !action.ok)?.command ?? delegatedCommand,
|
|
2632
|
+
applied: false,
|
|
2633
|
+
data: seedData({
|
|
2634
|
+
seed,
|
|
2635
|
+
activePermissions,
|
|
2636
|
+
expectedResourceTypes,
|
|
2637
|
+
unusedSeedPermissions,
|
|
2638
|
+
seedState,
|
|
2639
|
+
cliAuth,
|
|
2640
|
+
seedFileSanitized: preparedSeed.sanitized,
|
|
2641
|
+
configActions,
|
|
2642
|
+
}),
|
|
2643
|
+
exitCode: 1,
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
const child = runExternalCommand(delegatedCommand, options);
|
|
2647
|
+
const seedAlreadyApplied =
|
|
2648
|
+
child.status !== 0 && isWorkOSSeedAlreadyApplied(child.stdout, child.stderr);
|
|
2649
|
+
const seedAlreadyAppliedReason = seedAlreadyApplied
|
|
2650
|
+
? "workos-cli-existing-resource"
|
|
2651
|
+
: undefined;
|
|
2652
|
+
const seedStateFile = child.status === 0 || seedAlreadyApplied
|
|
2653
|
+
? writeWorkOSSeedState({
|
|
2654
|
+
workspaceRoot: options.workspaceRoot,
|
|
2655
|
+
seed,
|
|
2656
|
+
command: delegatedCommand,
|
|
2657
|
+
alreadyApplied: seedAlreadyApplied,
|
|
2658
|
+
status: child.status,
|
|
2659
|
+
})
|
|
2660
|
+
: undefined;
|
|
2661
|
+
const latestSeedState = seedStateFile
|
|
2662
|
+
? readWorkOSSeedState(options.workspaceRoot, seed)
|
|
2663
|
+
: seedState;
|
|
2664
|
+
return {
|
|
2665
|
+
ok: child.status === 0 || seedAlreadyApplied,
|
|
2666
|
+
kind: "workos-seed",
|
|
2667
|
+
checks,
|
|
2668
|
+
command: delegatedCommand,
|
|
2669
|
+
applied: child.status === 0,
|
|
2670
|
+
data: seedData({
|
|
2671
|
+
seed,
|
|
2672
|
+
activePermissions,
|
|
2673
|
+
expectedResourceTypes,
|
|
2674
|
+
unusedSeedPermissions,
|
|
2675
|
+
seedState: latestSeedState,
|
|
2676
|
+
cliAuth,
|
|
2677
|
+
seedFileSanitized: preparedSeed.sanitized,
|
|
2678
|
+
seedAlreadyApplied,
|
|
2679
|
+
seedAlreadyAppliedReason,
|
|
2680
|
+
seedStateFile,
|
|
2681
|
+
workosCli: {
|
|
2682
|
+
status: child.status,
|
|
2683
|
+
idempotentConflict: seedAlreadyApplied,
|
|
2684
|
+
stderrSuppressed: seedAlreadyApplied && Boolean(child.stderr.trim()),
|
|
2685
|
+
},
|
|
2686
|
+
configActions,
|
|
2687
|
+
}),
|
|
2688
|
+
stdout: child.stdout,
|
|
2689
|
+
stderr: seedAlreadyApplied ? undefined : child.stderr,
|
|
2690
|
+
exitCode: child.status === 0 || seedAlreadyApplied ? 0 : 1,
|
|
2691
|
+
};
|
|
2692
|
+
} finally {
|
|
2693
|
+
preparedSeed.cleanup();
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2698
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
2699
|
+
const checks = collectWorkOSChecks(options.workspaceRoot);
|
|
2700
|
+
const cliAuth = options.real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
2701
|
+
const realEnvChecks = options.real ? collectWorkOSRealEnvChecks(options.workspaceRoot, cliAuth) : [];
|
|
2702
|
+
const allChecks = [...checks, ...realEnvChecks];
|
|
2703
|
+
if (options.real && cliAuth && !cliAuth.ok) {
|
|
2704
|
+
allChecks.push(workOSCliAuthCheck(cliAuth));
|
|
2705
|
+
}
|
|
2706
|
+
const localOk = allChecks.every((check) => check.ok);
|
|
2707
|
+
const realEnvMissing = realEnvChecks.some((check) => !check.ok);
|
|
2708
|
+
const envCommand = hasValue(readRealEnv(options.workspaceRoot), "WORKOS_CLIENT_ID") ||
|
|
2709
|
+
hasValue(readRealEnv(options.workspaceRoot), "VITE_WORKOS_CLIENT_ID")
|
|
2710
|
+
? "forge workos env --write --json"
|
|
2711
|
+
: workOSEnvNextCommand();
|
|
2712
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
2713
|
+
const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
|
|
2714
|
+
const setupDryRun = options.dryRun || !options.real;
|
|
2715
|
+
const publicEnvSync = syncWorkOSPublicWebEnv(options.workspaceRoot, localOk && !setupDryRun);
|
|
2716
|
+
const configActions = runWorkOSConfigActions(seed, options, true);
|
|
2717
|
+
const command = ["npx", "--yes", "workos@latest", "seed", "--file", file];
|
|
2718
|
+
if (!localOk) {
|
|
2719
|
+
return {
|
|
2720
|
+
ok: false,
|
|
2721
|
+
kind: "workos-setup",
|
|
2722
|
+
checks: allChecks,
|
|
2723
|
+
command,
|
|
2724
|
+
applied: false,
|
|
2725
|
+
data: {
|
|
2726
|
+
dryRun: setupDryRun,
|
|
2727
|
+
real: options.real ?? false,
|
|
2728
|
+
seed,
|
|
2729
|
+
seedState,
|
|
2730
|
+
publicEnvSync,
|
|
2731
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2732
|
+
configActions,
|
|
2733
|
+
nextCommand: realEnvMissing
|
|
2734
|
+
? envCommand
|
|
2735
|
+
: cliAuth && !cliAuth.ok
|
|
2736
|
+
? `forge workos setup --real --file ${file} --json`
|
|
2737
|
+
: "forge workos doctor --json",
|
|
2738
|
+
nextActions: [
|
|
2739
|
+
...(realEnvMissing ? [envCommand] : []),
|
|
2740
|
+
...(realEnvMissing ? ["pass the AuthKit client id with forge workos env --client-id client_... --write --json if ForgeOS cannot infer it"] : []),
|
|
2741
|
+
...(cliAuth && !cliAuth.ok ? cliAuth.nextActions : []),
|
|
2742
|
+
],
|
|
2743
|
+
},
|
|
2744
|
+
exitCode: 1,
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
if (setupDryRun) {
|
|
2748
|
+
return {
|
|
2749
|
+
ok: true,
|
|
2750
|
+
kind: "workos-setup",
|
|
2751
|
+
checks,
|
|
2752
|
+
command,
|
|
2753
|
+
applied: false,
|
|
2754
|
+
data: {
|
|
2755
|
+
dryRun: true,
|
|
2756
|
+
real: false,
|
|
2757
|
+
seed,
|
|
2758
|
+
seedState,
|
|
2759
|
+
publicEnvSync,
|
|
2760
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2761
|
+
configActions,
|
|
2762
|
+
nextCommand: `forge workos setup --real --file ${file} --json`,
|
|
2763
|
+
},
|
|
2764
|
+
exitCode: 0,
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
const seedResult = runWorkOSSeedCommand({
|
|
2768
|
+
...options,
|
|
2769
|
+
subcommand: "seed",
|
|
2770
|
+
dryRun: false,
|
|
2771
|
+
file,
|
|
2772
|
+
});
|
|
2773
|
+
const seedResultData = seedResult.data && typeof seedResult.data === "object"
|
|
2774
|
+
? seedResult.data as { seedState?: WorkOSSeedStateSummary }
|
|
2775
|
+
: {};
|
|
2776
|
+
return {
|
|
2777
|
+
ok: seedResult.ok,
|
|
2778
|
+
kind: "workos-setup",
|
|
2779
|
+
checks: [...seedResult.checks, ...realEnvChecks],
|
|
2780
|
+
command: seedResult.command,
|
|
2781
|
+
applied: seedResult.ok,
|
|
2782
|
+
data: {
|
|
2783
|
+
real: true,
|
|
2784
|
+
seed,
|
|
2785
|
+
publicEnvSync,
|
|
2786
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2787
|
+
seedState: seedResultData.seedState ?? readWorkOSSeedState(options.workspaceRoot, seed),
|
|
2788
|
+
seedResult: seedResult.data,
|
|
2789
|
+
nextCommand: "forge workos doctor --json",
|
|
2790
|
+
},
|
|
2791
|
+
stdout: seedResult.stdout,
|
|
2792
|
+
stderr: seedResult.stderr,
|
|
2793
|
+
exitCode: seedResult.exitCode,
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
export function runWorkOSProveCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2798
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
2799
|
+
const doctor = runWorkOSDoctorCommand({
|
|
2800
|
+
...options,
|
|
2801
|
+
subcommand: "doctor",
|
|
2802
|
+
yes: false,
|
|
2803
|
+
dryRun: true,
|
|
2804
|
+
file,
|
|
2805
|
+
});
|
|
2806
|
+
const seed = runWorkOSSeedCommand({
|
|
2807
|
+
...options,
|
|
2808
|
+
subcommand: "seed",
|
|
2809
|
+
dryRun: true,
|
|
2810
|
+
file,
|
|
2811
|
+
});
|
|
2812
|
+
const setup = runWorkOSSetupCommand({
|
|
2813
|
+
...options,
|
|
2814
|
+
subcommand: "setup",
|
|
2815
|
+
dryRun: !options.real,
|
|
2816
|
+
real: options.real ?? false,
|
|
2817
|
+
file,
|
|
2818
|
+
});
|
|
2819
|
+
const setupBlockingChecks = setup.checks
|
|
2820
|
+
.filter((check) => !check.ok)
|
|
2821
|
+
.map((check) => ({ ...check, name: `setup:${check.name}` }));
|
|
2822
|
+
const checks: WorkOSCheck[] = [
|
|
2823
|
+
...doctor.checks.map((check) => ({ ...check, name: `doctor:${check.name}` })),
|
|
2824
|
+
{
|
|
2825
|
+
name: "seed:dry-run",
|
|
2826
|
+
ok: seed.ok,
|
|
2827
|
+
detail: seed.ok
|
|
2828
|
+
? `${file} is valid, app-aware, and ready for hosted application`
|
|
2829
|
+
: `${file} failed seed validation before hosted application`,
|
|
2830
|
+
},
|
|
2831
|
+
...setupBlockingChecks,
|
|
2832
|
+
{
|
|
2833
|
+
name: options.real ? "setup:real" : "setup:dry-run",
|
|
2834
|
+
ok: setup.ok,
|
|
2835
|
+
detail: options.real
|
|
2836
|
+
? setup.ok
|
|
2837
|
+
? "WorkOS hosted setup and seed were applied or already present"
|
|
2838
|
+
: "WorkOS hosted setup failed; inspect setup.seedResult/configActions"
|
|
2839
|
+
: setup.ok
|
|
2840
|
+
? "No-dashboard WorkOS setup plan is complete; pass --real to apply hosted changes"
|
|
2841
|
+
: "No-dashboard WorkOS setup plan is incomplete",
|
|
2842
|
+
},
|
|
2843
|
+
];
|
|
2844
|
+
const ok = checks.every((check) => check.ok);
|
|
2845
|
+
const setupData = setup.data && typeof setup.data === "object" ? setup.data as { nextCommand?: string; nextActions?: string[] } : {};
|
|
2846
|
+
return {
|
|
2847
|
+
ok,
|
|
2848
|
+
kind: "workos-prove",
|
|
2849
|
+
checks,
|
|
2850
|
+
command: setup.command ?? seed.command ?? doctor.command,
|
|
2851
|
+
applied: Boolean(options.real && setup.ok && setup.applied),
|
|
2852
|
+
data: {
|
|
2853
|
+
real: options.real ?? false,
|
|
2854
|
+
file,
|
|
2855
|
+
doctor: doctor.data,
|
|
2856
|
+
seed: seed.data,
|
|
2857
|
+
setup: setup.data,
|
|
2858
|
+
nextCommand: !ok && setupData.nextCommand
|
|
2859
|
+
? setupData.nextCommand
|
|
2860
|
+
: options.real
|
|
2861
|
+
? "forge workos doctor --json"
|
|
2862
|
+
: `forge workos prove --real --file ${file} --json`,
|
|
2863
|
+
nextActions: setupData.nextActions ?? [],
|
|
2864
|
+
},
|
|
2865
|
+
stdout: setup.stdout ?? seed.stdout ?? doctor.stdout,
|
|
2866
|
+
stderr: setup.stderr ?? seed.stderr ?? doctor.stderr,
|
|
2867
|
+
exitCode: ok ? 0 : 1,
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
function collectWorkOSFgaChecks(input: {
|
|
2872
|
+
manifest: WorkOSFgaManifest;
|
|
2873
|
+
state: WorkOSFgaStateSummary;
|
|
2874
|
+
requireState?: boolean;
|
|
2875
|
+
requireRealState?: boolean;
|
|
2876
|
+
requireProof?: boolean;
|
|
2877
|
+
}): WorkOSCheck[] {
|
|
2878
|
+
return [
|
|
2879
|
+
{
|
|
2880
|
+
name: "fga-manifest",
|
|
2881
|
+
ok: input.manifest.diagnostics.length === 0,
|
|
2882
|
+
detail: input.manifest.diagnostics.length === 0
|
|
2883
|
+
? `manifest ${input.manifest.manifestHash.slice(0, 12)} covers ${input.manifest.resourceTypes.length} resource type(s) and ${input.manifest.resources.length} resource(s)`
|
|
2884
|
+
: input.manifest.diagnostics.join("; "),
|
|
2885
|
+
},
|
|
2886
|
+
{
|
|
2887
|
+
name: "fga-resource-types",
|
|
2888
|
+
ok: input.manifest.resourceTypes.length > 0 && input.manifest.resourceTypes.includes("organization"),
|
|
2889
|
+
detail: input.manifest.resourceTypes.length > 0
|
|
2890
|
+
? `resource types: ${input.manifest.resourceTypes.join(", ")}`
|
|
2891
|
+
: "at least organization plus app resource types are required",
|
|
2892
|
+
},
|
|
2893
|
+
{
|
|
2894
|
+
name: "fga-proof-scenarios",
|
|
2895
|
+
ok: input.manifest.proofScenarios.some((scenario) => scenario.expected === "allow") &&
|
|
2896
|
+
input.manifest.proofScenarios.some((scenario) => scenario.expected === "deny"),
|
|
2897
|
+
detail: `proof scenarios: ${input.manifest.proofScenarios.map((scenario) => `${scenario.name}:${scenario.expected}`).join(", ") || "none"}`,
|
|
2898
|
+
},
|
|
2899
|
+
{
|
|
2900
|
+
name: "fga-state",
|
|
2901
|
+
ok: !input.requireState || Boolean(input.state.exists && input.state.valid && input.state.matchesManifestHash === true),
|
|
2902
|
+
detail: !input.state.exists
|
|
2903
|
+
? `${WORKOS_FGA_STATE_FILE} is missing`
|
|
2904
|
+
: !input.state.valid
|
|
2905
|
+
? `${WORKOS_FGA_STATE_FILE} is invalid: ${input.state.diagnostics.join("; ")}`
|
|
2906
|
+
: input.state.matchesManifestHash
|
|
2907
|
+
? `${WORKOS_FGA_STATE_FILE} matches current manifest`
|
|
2908
|
+
: `${WORKOS_FGA_STATE_FILE} is stale or invalid: ${input.state.diagnostics.join("; ") || "manifest hash mismatch"}`,
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
name: "fga-real-state",
|
|
2912
|
+
ok: !input.requireRealState || input.state.mode === "real",
|
|
2913
|
+
detail: input.state.mode === "real"
|
|
2914
|
+
? `${WORKOS_FGA_STATE_FILE} records real sync/proof mode`
|
|
2915
|
+
: input.requireRealState
|
|
2916
|
+
? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga sync --real --json`
|
|
2917
|
+
: `${WORKOS_FGA_STATE_FILE} real mode not required for this command`,
|
|
2918
|
+
},
|
|
2919
|
+
{
|
|
2920
|
+
name: "fga-proof-state",
|
|
2921
|
+
ok: !input.requireProof || Boolean(input.state.provedAt),
|
|
2922
|
+
detail: input.state.provedAt
|
|
2923
|
+
? input.state.mode === "real"
|
|
2924
|
+
? `${WORKOS_FGA_STATE_FILE} records real proof at ${input.state.provedAt}`
|
|
2925
|
+
: `${WORKOS_FGA_STATE_FILE} records local proof at ${input.state.provedAt}`
|
|
2926
|
+
: input.requireProof
|
|
2927
|
+
? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga prove --real --json`
|
|
2928
|
+
: `${WORKOS_FGA_STATE_FILE} proof timestamp not required for this command`,
|
|
2929
|
+
},
|
|
2930
|
+
];
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
function workOSFgaReadiness(input: {
|
|
2934
|
+
workspaceRoot: string;
|
|
2935
|
+
file: string;
|
|
2936
|
+
manifest: WorkOSFgaManifest;
|
|
2937
|
+
state: WorkOSFgaStateSummary;
|
|
2938
|
+
seedState: WorkOSSeedStateSummary;
|
|
2939
|
+
real: boolean;
|
|
2940
|
+
}): WorkOSFgaReadiness {
|
|
2941
|
+
const membershipEnv = workOSFgaMembershipEnvSummary(input.workspaceRoot, input.manifest.organizations);
|
|
2942
|
+
const planReady = input.manifest.diagnostics.length === 0 &&
|
|
2943
|
+
input.manifest.resourceTypes.includes("organization") &&
|
|
2944
|
+
input.manifest.proofScenarios.some((scenario) => scenario.expected === "allow") &&
|
|
2945
|
+
input.manifest.proofScenarios.some((scenario) => scenario.expected === "deny");
|
|
2946
|
+
const seedReady = !input.real || Boolean(input.seedState.exists && input.seedState.valid && input.seedState.matchesSeedHash === true);
|
|
2947
|
+
const synced = Boolean(input.state.exists && input.state.valid && input.state.matchesManifestHash === true);
|
|
2948
|
+
const resourceTypesConfigured = !input.real || Boolean(synced && input.state.mode === "real" && input.state.sdkOk === true);
|
|
2949
|
+
const membershipEnvReady = !input.real || membershipEnv.complete;
|
|
2950
|
+
const proved = !input.real
|
|
2951
|
+
? Boolean(input.state.provedAt)
|
|
2952
|
+
: Boolean(synced && input.state.mode === "real" && input.state.sdkOk === true && input.state.provedAt);
|
|
2953
|
+
const productionReady = Boolean(planReady && seedReady && resourceTypesConfigured && membershipEnvReady && proved);
|
|
2954
|
+
let nextCommand = `forge workos fga plan --file ${input.file} --write --json`;
|
|
2955
|
+
if (planReady && input.real && !seedReady) {
|
|
2956
|
+
nextCommand = `forge workos prove --real --file ${input.file} --json`;
|
|
2957
|
+
} else if (planReady && input.real && !resourceTypesConfigured) {
|
|
2958
|
+
nextCommand = `forge workos fga sync --real --file ${input.file} --write --json`;
|
|
2959
|
+
} else if (planReady && input.real && !membershipEnvReady) {
|
|
2960
|
+
nextCommand = `forge workos fga prove --real --file ${input.file} --json`;
|
|
2961
|
+
} else if (planReady && input.real && !proved) {
|
|
2962
|
+
nextCommand = `forge workos fga prove --real --file ${input.file} --json`;
|
|
2963
|
+
} else if (planReady && !input.real && !synced) {
|
|
2964
|
+
nextCommand = `forge workos fga sync --file ${input.file} --json`;
|
|
2965
|
+
} else if (planReady && !input.real && !proved) {
|
|
2966
|
+
nextCommand = `forge workos fga prove --file ${input.file} --json`;
|
|
2967
|
+
} else if (productionReady || (planReady && !input.real)) {
|
|
2968
|
+
nextCommand = "forge deploy check --production --json";
|
|
2969
|
+
}
|
|
2970
|
+
const nextActions = [
|
|
2971
|
+
...(planReady ? [] : [`repair FGA manifest gaps, then run forge workos fga plan --file ${input.file} --write --json`]),
|
|
2972
|
+
...(input.real && !seedReady ? [`apply/prove hosted WorkOS seed: forge workos prove --real --file ${input.file} --json`] : []),
|
|
2973
|
+
...(input.real && !resourceTypesConfigured
|
|
2974
|
+
? [
|
|
2975
|
+
"configure hosted WorkOS FGA resource types listed in resourceTypeSetup",
|
|
2976
|
+
`sync real WorkOS FGA resources: forge workos fga sync --real --file ${input.file} --write --json`,
|
|
2977
|
+
]
|
|
2978
|
+
: []),
|
|
2979
|
+
...(input.real && !membershipEnvReady
|
|
2980
|
+
? [`set WORKOS_FGA_MEMBERSHIPS_JSON or ${membershipEnv.missingEnv.join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>"} before real access checks`]
|
|
2981
|
+
: []),
|
|
2982
|
+
...(input.real && membershipEnvReady && resourceTypesConfigured && !proved
|
|
2983
|
+
? [`prove real WorkOS FGA access checks: forge workos fga prove --real --file ${input.file} --json`]
|
|
2984
|
+
: []),
|
|
2985
|
+
...(productionReady ? ["rerun forge deploy check --production --json"] : []),
|
|
2986
|
+
...(!input.real && planReady && !synced ? [`run forge workos fga sync --file ${input.file} --json`] : []),
|
|
2987
|
+
...(!input.real && planReady && synced && !proved ? [`run forge workos fga prove --file ${input.file} --json`] : []),
|
|
2988
|
+
];
|
|
2989
|
+
return {
|
|
2990
|
+
real: input.real,
|
|
2991
|
+
planReady,
|
|
2992
|
+
seedReady,
|
|
2993
|
+
resourceTypesConfigured,
|
|
2994
|
+
membershipEnvReady,
|
|
2995
|
+
synced,
|
|
2996
|
+
proved,
|
|
2997
|
+
productionReady,
|
|
2998
|
+
nextCommand,
|
|
2999
|
+
nextActions,
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function collectWorkOSFgaDoctorChecks(input: {
|
|
3004
|
+
workspaceRoot: string;
|
|
3005
|
+
file: string;
|
|
3006
|
+
manifest: WorkOSFgaManifest;
|
|
3007
|
+
state: WorkOSFgaStateSummary;
|
|
3008
|
+
seedState: WorkOSSeedStateSummary;
|
|
3009
|
+
real: boolean;
|
|
3010
|
+
}): WorkOSCheck[] {
|
|
3011
|
+
const readiness = workOSFgaReadiness(input);
|
|
3012
|
+
const membershipEnv = workOSFgaMembershipEnvSummary(input.workspaceRoot, input.manifest.organizations);
|
|
3013
|
+
return [
|
|
3014
|
+
...collectWorkOSFgaChecks({
|
|
3015
|
+
manifest: input.manifest,
|
|
3016
|
+
state: input.state,
|
|
3017
|
+
requireState: input.real,
|
|
3018
|
+
requireRealState: input.real,
|
|
3019
|
+
requireProof: input.real,
|
|
3020
|
+
}),
|
|
3021
|
+
{
|
|
3022
|
+
name: "fga-seed-state",
|
|
3023
|
+
ok: !input.real || readiness.seedReady,
|
|
3024
|
+
detail: !input.real
|
|
3025
|
+
? "hosted seed evidence is only required for --real doctor"
|
|
3026
|
+
: readiness.seedReady
|
|
3027
|
+
? `${WORKOS_SEED_STATE_FILE} matches ${input.file}`
|
|
3028
|
+
: `real FGA proof requires hosted seed evidence matching ${input.file}; run forge workos prove --real --file ${input.file} --json`,
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
name: "fga-hosted-resource-types",
|
|
3032
|
+
ok: !input.real || readiness.resourceTypesConfigured,
|
|
3033
|
+
detail: !input.real
|
|
3034
|
+
? "hosted WorkOS resource type existence is only verified by --real sync/prove"
|
|
3035
|
+
: readiness.resourceTypesConfigured
|
|
3036
|
+
? `${WORKOS_FGA_STATE_FILE} records successful real Authorization API resource sync`
|
|
3037
|
+
: "real FGA requires hosted WorkOS resource types for every non-organization resource; run forge workos fga plan --write and configure any listed resource types before sync",
|
|
3038
|
+
},
|
|
3039
|
+
{
|
|
3040
|
+
name: "fga-membership-env",
|
|
3041
|
+
ok: !input.real || readiness.membershipEnvReady,
|
|
3042
|
+
detail: !input.real
|
|
3043
|
+
? "organizationMembershipId env is only required for real WorkOS access checks"
|
|
3044
|
+
: readiness.membershipEnvReady
|
|
3045
|
+
? `membership env is present${membershipEnv.jsonEnvPresent ? " through WORKOS_FGA_MEMBERSHIPS_JSON" : ` through ${membershipEnv.presentEnv.join(", ")}`}`
|
|
3046
|
+
: `missing organizationMembershipId env for real checks: WORKOS_FGA_MEMBERSHIPS_JSON or ${membershipEnv.missingEnv.join(", ")}`,
|
|
3047
|
+
},
|
|
3048
|
+
{
|
|
3049
|
+
name: "fga-production-readiness",
|
|
3050
|
+
ok: !input.real || readiness.productionReady,
|
|
3051
|
+
detail: !input.real
|
|
3052
|
+
? "run forge workos fga doctor --real --json for production FGA gates"
|
|
3053
|
+
: readiness.productionReady
|
|
3054
|
+
? "real WorkOS FGA seed, resource sync, membership env, and proof are current"
|
|
3055
|
+
: `real WorkOS FGA is not production-ready; next command: ${readiness.nextCommand}`,
|
|
3056
|
+
},
|
|
3057
|
+
];
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
export function runWorkOSFgaCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
3061
|
+
const action = options.fgaAction ?? "doctor";
|
|
3062
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
3063
|
+
const manifest = collectWorkOSFgaManifest(options.workspaceRoot, file);
|
|
3064
|
+
const initialHostedSetup = workOSFgaHostedSetup(manifest);
|
|
3065
|
+
const setupGuidePath = resolveWorkOSFgaSetupGuidePath(options);
|
|
3066
|
+
const writtenSetupGuide = setupGuidePath
|
|
3067
|
+
? writeWorkOSFgaSetupGuide(options.workspaceRoot, workOSFgaSetupGuide(manifest, initialHostedSetup), setupGuidePath)
|
|
3068
|
+
: undefined;
|
|
3069
|
+
const writtenSetupGuideData = writtenSetupGuide
|
|
3070
|
+
? { setupGuidePath: writtenSetupGuide.markdownPath, setupGuideJsonPath: writtenSetupGuide.jsonPath }
|
|
3071
|
+
: {};
|
|
3072
|
+
let state = readWorkOSFgaState(options.workspaceRoot, manifest);
|
|
3073
|
+
const command = ["forge", "workos", "fga", action, "--file", file];
|
|
3074
|
+
const real = options.real ?? false;
|
|
3075
|
+
const requireState = action === "prove" || action === "doctor";
|
|
3076
|
+
const requireRealState = real && action !== "plan";
|
|
3077
|
+
const requireProof = real && action === "doctor";
|
|
3078
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
3079
|
+
const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
|
|
3080
|
+
const checks: WorkOSCheck[] = collectWorkOSFgaChecks({
|
|
3081
|
+
manifest,
|
|
3082
|
+
state,
|
|
3083
|
+
requireState,
|
|
3084
|
+
requireRealState,
|
|
3085
|
+
requireProof,
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
if (action === "plan") {
|
|
3089
|
+
const ok = checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state").every((check) => check.ok);
|
|
3090
|
+
return {
|
|
3091
|
+
ok,
|
|
3092
|
+
kind: "workos-fga",
|
|
3093
|
+
checks,
|
|
3094
|
+
command,
|
|
3095
|
+
applied: false,
|
|
3096
|
+
data: fgaData({
|
|
3097
|
+
action,
|
|
3098
|
+
workspaceRoot: options.workspaceRoot,
|
|
3099
|
+
manifest,
|
|
3100
|
+
state,
|
|
3101
|
+
seedState,
|
|
3102
|
+
real,
|
|
3103
|
+
...writtenSetupGuideData,
|
|
3104
|
+
nextCommand: `forge workos fga sync --file ${file} --json`,
|
|
3105
|
+
}),
|
|
3106
|
+
exitCode: ok ? 0 : 1,
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
if (action === "sync") {
|
|
3111
|
+
const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
3112
|
+
const realChecks: WorkOSCheck[] = real
|
|
3113
|
+
? [
|
|
3114
|
+
...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
|
|
3115
|
+
{
|
|
3116
|
+
name: "fga-seed-state",
|
|
3117
|
+
ok: Boolean(seedState.exists && seedState.valid && seedState.matchesSeedHash === true),
|
|
3118
|
+
detail: seedState.matchesSeedHash
|
|
3119
|
+
? `${WORKOS_SEED_STATE_FILE} matches ${seed.path}`
|
|
3120
|
+
: `real FGA sync requires hosted seed evidence; run forge workos prove --real --file ${file} --json first`,
|
|
3121
|
+
},
|
|
3122
|
+
]
|
|
3123
|
+
: [];
|
|
3124
|
+
const allChecks = [...checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state"), ...realChecks];
|
|
3125
|
+
if (!allChecks.every((check) => check.ok)) {
|
|
3126
|
+
return {
|
|
3127
|
+
ok: false,
|
|
3128
|
+
kind: "workos-fga",
|
|
3129
|
+
checks: allChecks,
|
|
3130
|
+
command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
|
|
3131
|
+
applied: false,
|
|
3132
|
+
data: fgaData({
|
|
3133
|
+
action,
|
|
3134
|
+
workspaceRoot: options.workspaceRoot,
|
|
3135
|
+
manifest,
|
|
3136
|
+
state,
|
|
3137
|
+
seedState,
|
|
3138
|
+
real,
|
|
3139
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
3140
|
+
...writtenSetupGuideData,
|
|
3141
|
+
nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga plan --file ${file} --json`,
|
|
3142
|
+
}),
|
|
3143
|
+
exitCode: 1,
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
if (options.dryRun) {
|
|
3147
|
+
return {
|
|
3148
|
+
ok: true,
|
|
3149
|
+
kind: "workos-fga",
|
|
3150
|
+
checks: allChecks,
|
|
3151
|
+
command,
|
|
3152
|
+
applied: false,
|
|
3153
|
+
data: fgaData({
|
|
3154
|
+
action,
|
|
3155
|
+
workspaceRoot: options.workspaceRoot,
|
|
3156
|
+
manifest,
|
|
3157
|
+
state,
|
|
3158
|
+
seedState,
|
|
3159
|
+
real,
|
|
3160
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
3161
|
+
...writtenSetupGuideData,
|
|
3162
|
+
nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga sync --file ${file} --json`,
|
|
3163
|
+
}),
|
|
3164
|
+
exitCode: 0,
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
const sdk = real ? runWorkOSFgaSdk(options, manifest, "sync") : undefined;
|
|
3168
|
+
if (sdk && !sdk.ok) {
|
|
3169
|
+
return {
|
|
3170
|
+
ok: false,
|
|
3171
|
+
kind: "workos-fga",
|
|
3172
|
+
checks: [
|
|
3173
|
+
...allChecks,
|
|
3174
|
+
{
|
|
3175
|
+
name: "fga-real-sdk-sync",
|
|
3176
|
+
ok: false,
|
|
3177
|
+
detail: workOSFgaSdkFailureDetail(sdk.data, manifest),
|
|
3178
|
+
},
|
|
3179
|
+
],
|
|
3180
|
+
command: sdk.command,
|
|
3181
|
+
applied: false,
|
|
3182
|
+
data: fgaData({
|
|
3183
|
+
action,
|
|
3184
|
+
workspaceRoot: options.workspaceRoot,
|
|
3185
|
+
manifest,
|
|
3186
|
+
state,
|
|
3187
|
+
seedState,
|
|
3188
|
+
real,
|
|
3189
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
3190
|
+
workosSdk: sdk.data,
|
|
3191
|
+
...writtenSetupGuideData,
|
|
3192
|
+
nextCommand: `forge workos fga sync --real --file ${file} --json`,
|
|
3193
|
+
nextActions: workOSFgaHostedSetup(manifest, sdk.data).nextActions,
|
|
3194
|
+
}),
|
|
3195
|
+
stdout: sdk.stdout,
|
|
3196
|
+
stderr: sdk.stderr,
|
|
3197
|
+
exitCode: 1,
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
const stateFile = writeWorkOSFgaState({
|
|
3201
|
+
workspaceRoot: options.workspaceRoot,
|
|
3202
|
+
manifest,
|
|
3203
|
+
mode: real ? "real" : "local",
|
|
3204
|
+
proved: false,
|
|
3205
|
+
...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
|
|
3206
|
+
});
|
|
3207
|
+
state = readWorkOSFgaState(options.workspaceRoot, manifest);
|
|
3208
|
+
return {
|
|
3209
|
+
ok: true,
|
|
3210
|
+
kind: "workos-fga",
|
|
3211
|
+
checks: collectWorkOSFgaChecks({ manifest, state, requireState: true, requireRealState: real }),
|
|
3212
|
+
command,
|
|
3213
|
+
applied: true,
|
|
3214
|
+
data: fgaData({
|
|
3215
|
+
action,
|
|
3216
|
+
workspaceRoot: options.workspaceRoot,
|
|
3217
|
+
manifest,
|
|
3218
|
+
state,
|
|
3219
|
+
seedState,
|
|
3220
|
+
real,
|
|
3221
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
3222
|
+
...(sdk ? { workosSdk: sdk.data } : {}),
|
|
3223
|
+
stateFile,
|
|
3224
|
+
...writtenSetupGuideData,
|
|
3225
|
+
nextCommand: real ? `forge workos fga prove --real --file ${file} --json` : `forge workos fga prove --file ${file} --json`,
|
|
3226
|
+
}),
|
|
3227
|
+
exitCode: 0,
|
|
3228
|
+
};
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
if (action === "prove") {
|
|
3232
|
+
const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
3233
|
+
const sdk = real && state.exists && state.valid && state.matchesManifestHash === true && state.mode === "real"
|
|
3234
|
+
? runWorkOSFgaSdk(options, manifest, "prove")
|
|
3235
|
+
: undefined;
|
|
3236
|
+
const sdkProofComplete = sdk ? workOSFgaSdkProofComplete(sdk.data, manifest) : false;
|
|
3237
|
+
const proofChecks: WorkOSCheck[] = [
|
|
3238
|
+
...checks,
|
|
3239
|
+
...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
|
|
3240
|
+
...(sdk
|
|
3241
|
+
? [
|
|
3242
|
+
{
|
|
3243
|
+
name: "fga-real-sdk-proof",
|
|
3244
|
+
ok: sdk.ok && sdkProofComplete,
|
|
3245
|
+
detail: sdk.ok && sdkProofComplete
|
|
3246
|
+
? "WorkOS Authorization API resource sync/check proof completed for every scenario"
|
|
3247
|
+
: workOSFgaSdkFailureDetail(sdk.data, manifest),
|
|
3248
|
+
},
|
|
3249
|
+
]
|
|
3250
|
+
: real
|
|
3251
|
+
? [
|
|
3252
|
+
{
|
|
3253
|
+
name: "fga-real-sdk-proof",
|
|
3254
|
+
ok: false,
|
|
3255
|
+
detail: "real FGA proof requires a fresh real FGA state from forge workos fga sync --real --json",
|
|
3256
|
+
},
|
|
3257
|
+
]
|
|
3258
|
+
: []),
|
|
3259
|
+
{
|
|
3260
|
+
name: "fga-cross-tenant-proof",
|
|
3261
|
+
ok: manifest.proofScenarios.some((scenario) => scenario.name.includes("cross-tenant") && scenario.expected === "deny"),
|
|
3262
|
+
detail: "FGA proof includes cross-tenant denial scenario using resourceExternalId and resourceTypeSlug",
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
name: "fga-authorization-api-shape",
|
|
3266
|
+
ok: true,
|
|
3267
|
+
detail: "proof contract uses organizationMembershipId, resourceExternalId, resourceTypeSlug, and permission slugs",
|
|
3268
|
+
},
|
|
3269
|
+
];
|
|
3270
|
+
const ok = proofChecks.every((check) => check.ok);
|
|
3271
|
+
const stateFile = ok && !options.dryRun
|
|
3272
|
+
? writeWorkOSFgaState({
|
|
3273
|
+
workspaceRoot: options.workspaceRoot,
|
|
3274
|
+
manifest,
|
|
3275
|
+
mode: real ? "real" : "local",
|
|
3276
|
+
proved: true,
|
|
3277
|
+
...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
|
|
3278
|
+
})
|
|
3279
|
+
: undefined;
|
|
3280
|
+
state = stateFile ? readWorkOSFgaState(options.workspaceRoot, manifest) : state;
|
|
3281
|
+
return {
|
|
3282
|
+
ok,
|
|
3283
|
+
kind: "workos-fga",
|
|
3284
|
+
checks: proofChecks,
|
|
3285
|
+
command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
|
|
3286
|
+
applied: Boolean(ok && !options.dryRun),
|
|
3287
|
+
data: fgaData({
|
|
3288
|
+
action,
|
|
3289
|
+
workspaceRoot: options.workspaceRoot,
|
|
3290
|
+
manifest,
|
|
3291
|
+
state,
|
|
3292
|
+
seedState,
|
|
3293
|
+
real,
|
|
3294
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
3295
|
+
...(sdk ? { workosSdk: sdk.data } : {}),
|
|
3296
|
+
...(stateFile ? { stateFile } : {}),
|
|
3297
|
+
...writtenSetupGuideData,
|
|
3298
|
+
nextCommand: ok ? "forge deploy check --production --json" : `forge workos fga sync${real ? " --real" : ""} --file ${file} --json`,
|
|
3299
|
+
}),
|
|
3300
|
+
stdout: sdk?.stdout,
|
|
3301
|
+
stderr: sdk?.stderr,
|
|
3302
|
+
exitCode: ok ? 0 : 1,
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
const doctorChecks = collectWorkOSFgaDoctorChecks({
|
|
3307
|
+
workspaceRoot: options.workspaceRoot,
|
|
3308
|
+
file,
|
|
3309
|
+
manifest,
|
|
3310
|
+
state,
|
|
3311
|
+
seedState,
|
|
3312
|
+
real,
|
|
3313
|
+
});
|
|
3314
|
+
const readiness = workOSFgaReadiness({
|
|
3315
|
+
workspaceRoot: options.workspaceRoot,
|
|
3316
|
+
file,
|
|
3317
|
+
manifest,
|
|
3318
|
+
state,
|
|
3319
|
+
seedState,
|
|
3320
|
+
real,
|
|
3321
|
+
});
|
|
3322
|
+
const ok = doctorChecks.every((check) => check.ok);
|
|
3323
|
+
return {
|
|
3324
|
+
ok,
|
|
3325
|
+
kind: "workos-fga",
|
|
3326
|
+
checks: doctorChecks,
|
|
3327
|
+
command,
|
|
3328
|
+
applied: false,
|
|
3329
|
+
data: fgaData({
|
|
3330
|
+
action,
|
|
3331
|
+
workspaceRoot: options.workspaceRoot,
|
|
3332
|
+
manifest,
|
|
3333
|
+
state,
|
|
3334
|
+
seedState,
|
|
3335
|
+
readiness,
|
|
3336
|
+
real,
|
|
3337
|
+
...writtenSetupGuideData,
|
|
3338
|
+
nextCommand: readiness.nextCommand,
|
|
3339
|
+
nextActions: readiness.nextActions,
|
|
3340
|
+
}),
|
|
3341
|
+
exitCode: ok ? 0 : 1,
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
export function runWorkOSCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
3346
|
+
if (options.subcommand === "fga") {
|
|
3347
|
+
return runWorkOSFgaCommand(options);
|
|
3348
|
+
}
|
|
3349
|
+
if (options.subcommand === "install") {
|
|
3350
|
+
return runWorkOSInstallCommand(options);
|
|
3351
|
+
}
|
|
3352
|
+
if (options.subcommand === "setup") {
|
|
3353
|
+
return runWorkOSSetupCommand(options);
|
|
3354
|
+
}
|
|
3355
|
+
if (options.subcommand === "prove") {
|
|
3356
|
+
return runWorkOSProveCommand(options);
|
|
3357
|
+
}
|
|
3358
|
+
if (options.subcommand === "env") {
|
|
3359
|
+
return runWorkOSEnvCommand(options);
|
|
3360
|
+
}
|
|
3361
|
+
return options.subcommand === "doctor"
|
|
3362
|
+
? runWorkOSDoctorCommand(options)
|
|
3363
|
+
: runWorkOSSeedCommand(options);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
export function formatWorkOSJson(result: WorkOSCommandResult): string {
|
|
3367
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
export function formatWorkOSHuman(result: WorkOSCommandResult): string {
|
|
3371
|
+
const lines = [
|
|
3372
|
+
result.ok ? "WorkOS: ok" : "WorkOS: needs attention",
|
|
3373
|
+
...result.checks.map((check) => `${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}`),
|
|
3374
|
+
];
|
|
3375
|
+
const dataObject = result.data && typeof result.data === "object"
|
|
3376
|
+
? result.data as {
|
|
3377
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
3378
|
+
seedResult?: { cliAuth?: WorkOSCliAuthSummary };
|
|
3379
|
+
setup?: { cliAuth?: WorkOSCliAuthSummary; seedResult?: { cliAuth?: WorkOSCliAuthSummary } };
|
|
3380
|
+
}
|
|
3381
|
+
: {};
|
|
3382
|
+
const cliAuth = dataObject.cliAuth ?? dataObject.seedResult?.cliAuth ?? dataObject.setup?.cliAuth ?? dataObject.setup?.seedResult?.cliAuth;
|
|
3383
|
+
if (cliAuth) {
|
|
3384
|
+
if (cliAuth.ok && cliAuth.method === "cli") {
|
|
3385
|
+
lines.push(`WorkOS CLI authenticated${cliAuth.email ? ` as ${cliAuth.email}` : ""}; hosted setup can proceed without opening the dashboard.`);
|
|
3386
|
+
} else if (!cliAuth.ok && cliAuth.loginInstructions) {
|
|
3387
|
+
lines.push("WorkOS CLI login required before hosted setup can proceed.");
|
|
3388
|
+
if (cliAuth.loginShellCommand) {
|
|
3389
|
+
lines.push(`login: ${cliAuth.loginShellCommand}`);
|
|
3390
|
+
}
|
|
3391
|
+
if (cliAuth.loginInstructions.url) {
|
|
3392
|
+
lines.push(`open: ${cliAuth.loginInstructions.url}`);
|
|
3393
|
+
}
|
|
3394
|
+
if (cliAuth.loginInstructions.code) {
|
|
3395
|
+
lines.push(`code: ${cliAuth.loginInstructions.code}`);
|
|
3396
|
+
}
|
|
3397
|
+
if (cliAuth.rerunCommand) {
|
|
3398
|
+
lines.push(`rerun: ${cliAuth.rerunCommand}`);
|
|
3399
|
+
}
|
|
3400
|
+
} else if (cliAuth.method === "api-key") {
|
|
3401
|
+
lines.push("WorkOS hosted setup will use WORKOS_API_KEY; CLI browser login is optional.");
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
if (result.command) {
|
|
3405
|
+
lines.push(`command: ${result.command.join(" ")}`);
|
|
3406
|
+
}
|
|
3407
|
+
if (result.kind === "workos-install" && !result.applied) {
|
|
3408
|
+
lines.push("AuthKit install not applied; pass --yes to execute the WorkOS CLI command");
|
|
3409
|
+
}
|
|
3410
|
+
if (result.kind === "workos-doctor" && !result.applied) {
|
|
3411
|
+
lines.push("external WorkOS doctor not run; pass --yes to execute the WorkOS CLI command");
|
|
3412
|
+
}
|
|
3413
|
+
if (result.kind === "workos-seed" && !result.applied) {
|
|
3414
|
+
const data = result.data && typeof result.data === "object"
|
|
3415
|
+
? result.data as { dryRun?: boolean; seedAlreadyApplied?: boolean; seedStateFile?: string; nextCommand?: string }
|
|
3416
|
+
: {};
|
|
3417
|
+
if (data.seedAlreadyApplied) {
|
|
3418
|
+
lines.push(`seed already appears applied; WorkOS reported existing resources${data.seedStateFile ? ` and Forge wrote ${data.seedStateFile}` : ""}`);
|
|
3419
|
+
} else if (data.dryRun) {
|
|
3420
|
+
lines.push(`seed dry-run only; run ${data.nextCommand ?? "forge workos seed --file workos-seed.yml --json"} to execute the WorkOS CLI command`);
|
|
3421
|
+
} else {
|
|
3422
|
+
lines.push("seed not applied; inspect stdout/stderr from the WorkOS CLI command");
|
|
3423
|
+
}
|
|
3424
|
+
} else if (result.kind === "workos-seed" && result.applied) {
|
|
3425
|
+
const data = result.data && typeof result.data === "object"
|
|
3426
|
+
? result.data as { seedStateFile?: string }
|
|
3427
|
+
: {};
|
|
3428
|
+
if (data.seedStateFile) {
|
|
3429
|
+
lines.push(`seed applied; Forge wrote ${data.seedStateFile}`);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
if (result.kind === "workos-setup" && !result.applied) {
|
|
3433
|
+
const data = result.data && typeof result.data === "object"
|
|
3434
|
+
? result.data as { dryRun?: boolean; nextCommand?: string }
|
|
3435
|
+
: {};
|
|
3436
|
+
if (data.dryRun) {
|
|
3437
|
+
lines.push(`setup dry-run only; run ${data.nextCommand ?? "forge workos setup --real --file workos-seed.yml --json"} to apply hosted config`);
|
|
3438
|
+
} else {
|
|
3439
|
+
lines.push("setup not applied; inspect WorkOS checks and seed/config action output");
|
|
3440
|
+
}
|
|
3441
|
+
} else if (result.kind === "workos-setup" && result.applied) {
|
|
3442
|
+
const data = result.data && typeof result.data === "object"
|
|
3443
|
+
? result.data as { seedState?: { path?: string; matchesSeedHash?: boolean | null } }
|
|
3444
|
+
: {};
|
|
3445
|
+
if (data.seedState?.path) {
|
|
3446
|
+
lines.push(
|
|
3447
|
+
data.seedState.matchesSeedHash === true
|
|
3448
|
+
? `setup applied; ${data.seedState.path} matches the current seed`
|
|
3449
|
+
: `setup applied; inspect ${data.seedState.path} because it does not prove the current seed`,
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
if (result.kind === "workos-prove") {
|
|
3454
|
+
const data = result.data && typeof result.data === "object"
|
|
3455
|
+
? result.data as { real?: boolean; nextCommand?: string }
|
|
3456
|
+
: {};
|
|
3457
|
+
if (result.ok && data.real) {
|
|
3458
|
+
lines.push("WorkOS real proof passed; hosted setup and seed evidence are recorded.");
|
|
3459
|
+
} else if (result.ok) {
|
|
3460
|
+
lines.push(`WorkOS proof dry-run passed; run ${data.nextCommand ?? "forge workos prove --real --file workos-seed.yml --json"} to apply hosted setup.`);
|
|
3461
|
+
} else {
|
|
3462
|
+
lines.push("WorkOS proof failed; inspect doctor, seed, and setup details.");
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
if (result.kind === "workos-env") {
|
|
3466
|
+
const data = result.data && typeof result.data === "object"
|
|
3467
|
+
? result.data as { write?: boolean; envFile?: string; changed?: string[]; requiredStillMissing?: string[]; nextActions?: string[] }
|
|
3468
|
+
: {};
|
|
3469
|
+
if (result.ok && data.write) {
|
|
3470
|
+
lines.push(`WorkOS env written${data.envFile ? ` to ${data.envFile}` : ""}.`);
|
|
3471
|
+
} else if (result.ok) {
|
|
3472
|
+
lines.push("WorkOS env plan is complete; pass --write to apply it.");
|
|
3473
|
+
} else {
|
|
3474
|
+
lines.push(`WorkOS env still needs: ${(data.requiredStillMissing ?? []).join(", ") || "required values"}.`);
|
|
3475
|
+
}
|
|
3476
|
+
for (const path of data.changed ?? []) {
|
|
3477
|
+
lines.push(`changed: ${path}`);
|
|
3478
|
+
}
|
|
3479
|
+
for (const action of data.nextActions ?? []) {
|
|
3480
|
+
lines.push(` - ${action}`);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
if (result.kind === "workos-fga") {
|
|
3484
|
+
const data = result.data && typeof result.data === "object"
|
|
3485
|
+
? result.data as { action?: string; real?: boolean; nextCommand?: string; stateFile?: string; setupGuidePath?: string; setupGuideJsonPath?: string; nextActions?: string[] }
|
|
3486
|
+
: {};
|
|
3487
|
+
if (data.setupGuidePath) {
|
|
3488
|
+
lines.push(`FGA setup guide: ${data.setupGuidePath}`);
|
|
3489
|
+
}
|
|
3490
|
+
if (data.setupGuideJsonPath) {
|
|
3491
|
+
lines.push(`FGA setup JSON: ${data.setupGuideJsonPath}`);
|
|
3492
|
+
}
|
|
3493
|
+
if (result.ok && data.action === "plan") {
|
|
3494
|
+
lines.push(`WorkOS FGA plan passed; run ${data.nextCommand ?? "forge workos fga sync --json"}.`);
|
|
3495
|
+
} else if (result.ok && data.action === "sync") {
|
|
3496
|
+
lines.push(`WorkOS FGA sync recorded${data.stateFile ? ` in ${data.stateFile}` : ""}; run ${data.nextCommand ?? "forge workos fga prove --json"}.`);
|
|
3497
|
+
} else if (result.ok && data.action === "prove") {
|
|
3498
|
+
lines.push(`WorkOS FGA proof passed${data.real ? " for real-mode state" : " locally"}; production deploy gates can inspect the FGA state.`);
|
|
3499
|
+
} else if (result.ok && data.action === "doctor") {
|
|
3500
|
+
lines.push(`WorkOS FGA doctor passed${data.real ? " for real production gates" : " for local planning"}; run ${data.nextCommand ?? "forge workos fga sync --json"} if you need the next proof step.`);
|
|
3501
|
+
} else if (!result.ok) {
|
|
3502
|
+
lines.push("WorkOS FGA check failed; inspect manifest diagnostics, seed coverage, and FGA state.");
|
|
3503
|
+
for (const action of data.nextActions ?? []) {
|
|
3504
|
+
lines.push(` - ${action}`);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
return `${lines.join("\n")}\n`;
|
|
3509
|
+
}
|