canopycms 0.0.67-int.90 → 0.0.67
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/README.md +30 -3
- package/dist/ai/generate.js +1 -27
- package/dist/ai/handler.d.ts +0 -4
- package/dist/ai/handler.js +0 -11
- package/dist/ai/index.d.ts +1 -8
- package/dist/ai/index.js +1 -8
- package/dist/ai/json-to-markdown.js +15 -58
- package/dist/ai/resolve-branch.d.ts +2 -7
- package/dist/ai/resolve-branch.js +2 -8
- package/dist/ai/strip-mdx.d.ts +5 -13
- package/dist/ai/strip-mdx.js +5 -17
- package/dist/ai/to-plain-text.d.ts +10 -28
- package/dist/ai/to-plain-text.js +10 -28
- package/dist/ai/transform-components.d.ts +2 -4
- package/dist/ai/transform-components.js +8 -19
- package/dist/ai/types.d.ts +5 -10
- package/dist/api/admin-branch-health.d.ts +4 -12
- package/dist/api/admin-branch-health.js +26 -66
- package/dist/api/admin.d.ts +4 -12
- package/dist/api/admin.js +10 -26
- package/dist/api/assets.d.ts +0 -25
- package/dist/api/assets.js +50 -91
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch-merge.js +0 -2
- package/dist/api/branch-review.d.ts +0 -8
- package/dist/api/branch-review.js +0 -13
- package/dist/api/branch-status.d.ts +0 -3
- package/dist/api/branch-status.js +8 -33
- package/dist/api/branch-withdraw.d.ts +0 -1
- package/dist/api/branch-withdraw.js +6 -9
- package/dist/api/branch.d.ts +7 -12
- package/dist/api/branch.js +7 -44
- package/dist/api/client.d.ts +71 -224
- package/dist/api/client.js +82 -233
- package/dist/api/comments.d.ts +2 -4
- package/dist/api/comments.js +0 -17
- package/dist/api/content.d.ts +0 -6
- package/dist/api/content.js +66 -142
- package/dist/api/entries-constants.d.ts +5 -9
- package/dist/api/entries-constants.js +5 -9
- package/dist/api/entries.d.ts +8 -13
- package/dist/api/entries.js +20 -51
- package/dist/api/github-sync.d.ts +2 -4
- package/dist/api/github-sync.js +4 -8
- package/dist/api/groups.d.ts +3 -0
- package/dist/api/groups.js +8 -21
- package/dist/api/guards.d.ts +4 -16
- package/dist/api/guards.js +3 -22
- package/dist/api/index.d.ts +4 -4
- package/dist/api/permissions.d.ts +2 -0
- package/dist/api/permissions.js +18 -40
- package/dist/api/reference-options.d.ts +0 -1
- package/dist/api/reference-options.js +3 -11
- package/dist/api/request-body-hash.d.ts +12 -23
- package/dist/api/request-body-hash.js +12 -23
- package/dist/api/resolve-references.d.ts +0 -1
- package/dist/api/resolve-references.js +13 -28
- package/dist/api/route-builder.d.ts +3 -7
- package/dist/api/route-builder.js +1 -1
- package/dist/api/routes.d.ts +12 -0
- package/dist/api/routes.js +47 -0
- package/dist/api/schema.d.ts +23 -33
- package/dist/api/schema.js +20 -74
- package/dist/api/settings-helpers.js +4 -5
- package/dist/api/types.d.ts +0 -5
- package/dist/api/user.js +0 -5
- package/dist/api/validators.d.ts +14 -52
- package/dist/api/validators.js +14 -52
- package/dist/assets/asset-prefixes.d.ts +4 -8
- package/dist/assets/asset-prefixes.js +4 -8
- package/dist/assets/asset-src.d.ts +5 -22
- package/dist/assets/asset-src.js +5 -21
- package/dist/assets/factory.d.ts +1 -2
- package/dist/assets/factory.js +1 -2
- package/dist/assets/finalize.d.ts +3 -1
- package/dist/assets/finalize.js +3 -1
- package/dist/assets/index.d.ts +3 -3
- package/dist/assets/index.js +1 -1
- package/dist/assets/keys.d.ts +5 -3
- package/dist/assets/keys.js +6 -3
- package/dist/assets/pipeline.d.ts +3 -2
- package/dist/assets/pipeline.js +22 -56
- package/dist/assets/sharp-loader.d.ts +2 -10
- package/dist/assets/sharp-loader.js +2 -10
- package/dist/assets/store-local.d.ts +1 -2
- package/dist/assets/store-local.js +1 -2
- package/dist/assets/store-s3.d.ts +3 -4
- package/dist/assets/svg-sanitizer.d.ts +6 -14
- package/dist/assets/svg-sanitizer.js +6 -14
- package/dist/assets/transform-directives.d.ts +3 -4
- package/dist/assets/transform-directives.js +1 -1
- package/dist/assets/transform.d.ts +19 -30
- package/dist/assets/transform.js +18 -31
- package/dist/auth/cache.d.ts +2 -3
- package/dist/auth/cache.js +2 -3
- package/dist/auth/caching-auth-plugin.d.ts +11 -22
- package/dist/auth/caching-auth-plugin.js +12 -14
- package/dist/auth/context-helpers.d.ts +6 -20
- package/dist/auth/context-helpers.js +5 -17
- package/dist/auth/file-based-auth-cache.d.ts +10 -21
- package/dist/auth/file-based-auth-cache.js +23 -51
- package/dist/auth/plugin.d.ts +29 -61
- package/dist/auth/plugin.js +9 -13
- package/dist/auth/types.d.ts +11 -20
- package/dist/authorization/branch.d.ts +30 -49
- package/dist/authorization/branch.js +16 -42
- package/dist/authorization/content.d.ts +11 -40
- package/dist/authorization/content.js +13 -45
- package/dist/authorization/groups/index.d.ts +0 -3
- package/dist/authorization/groups/index.js +0 -3
- package/dist/authorization/groups/loader.d.ts +14 -28
- package/dist/authorization/groups/loader.js +14 -38
- package/dist/authorization/groups/schema.d.ts +0 -6
- package/dist/authorization/groups/schema.js +4 -7
- package/dist/authorization/helpers.d.ts +9 -28
- package/dist/authorization/helpers.js +9 -28
- package/dist/authorization/index.d.ts +3 -25
- package/dist/authorization/index.js +3 -35
- package/dist/authorization/path.d.ts +6 -13
- package/dist/authorization/path.js +8 -25
- package/dist/authorization/permissions/index.d.ts +0 -3
- package/dist/authorization/permissions/index.js +0 -3
- package/dist/authorization/permissions/loader.d.ts +14 -27
- package/dist/authorization/permissions/loader.js +14 -32
- package/dist/authorization/permissions/schema.d.ts +1 -6
- package/dist/authorization/permissions/schema.js +6 -13
- package/dist/authorization/protected-branch.d.ts +36 -65
- package/dist/authorization/protected-branch.js +29 -41
- package/dist/authorization/settings-file-store.d.ts +38 -81
- package/dist/authorization/settings-file-store.js +38 -79
- package/dist/authorization/types.d.ts +4 -26
- package/dist/authorization/types.js +0 -6
- package/dist/authorization/validation.d.ts +1 -23
- package/dist/authorization/validation.js +1 -24
- package/dist/branch-health.d.ts +39 -67
- package/dist/branch-health.js +27 -43
- package/dist/branch-metadata-file.d.ts +16 -32
- package/dist/branch-metadata-file.js +13 -26
- package/dist/branch-metadata.d.ts +49 -80
- package/dist/branch-metadata.js +57 -87
- package/dist/branch-registry.d.ts +26 -64
- package/dist/branch-registry.js +44 -93
- package/dist/branch-schema-cache.d.ts +34 -124
- package/dist/branch-schema-cache.js +49 -155
- package/dist/branch-workspace.d.ts +4 -4
- package/dist/branch-workspace.js +15 -19
- package/dist/build/generate-ai-content.d.ts +1 -0
- package/dist/build/generate-ai-content.js +12 -25
- package/dist/build-canopy.d.ts +17 -27
- package/dist/build-canopy.js +16 -23
- package/dist/build-mode.d.ts +25 -27
- package/dist/build-mode.js +25 -29
- package/dist/cli/cli.d.ts +15 -26
- package/dist/cli/cli.js +601 -978
- package/dist/cli/generate-ai-content.js +351 -628
- package/dist/cli/github-app-manifest.d.ts +84 -0
- package/dist/cli/github-app-manifest.js +161 -0
- package/dist/cli/init-github-app.d.ts +110 -444
- package/dist/cli/init-github-app.js +236 -773
- package/dist/cli/init.js +39 -80
- package/dist/cli/migrate.d.ts +13 -10
- package/dist/cli/migrate.js +22 -30
- package/dist/cli/project-detect.d.ts +17 -31
- package/dist/cli/project-detect.js +31 -42
- package/dist/cli/project-root.d.ts +4 -11
- package/dist/cli/project-root.js +4 -11
- package/dist/cli/prompt.d.ts +10 -0
- package/dist/cli/prompt.js +67 -0
- package/dist/cli/sync.d.ts +0 -8
- package/dist/cli/sync.js +8 -28
- package/dist/cli/template-files/middleware.ts.template +15 -29
- package/dist/cli/templates.d.ts +2 -12
- package/dist/cli/templates.js +4 -4
- package/dist/client.d.ts +1 -1
- package/dist/comment-store.d.ts +32 -53
- package/dist/comment-store.js +32 -52
- package/dist/config/flatten.d.ts +0 -3
- package/dist/config/flatten.js +2 -12
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +1 -4
- package/dist/config/schemas/collection.d.ts +0 -8
- package/dist/config/schemas/collection.js +0 -10
- package/dist/config/schemas/config.d.ts +6 -78
- package/dist/config/schemas/config.js +38 -54
- package/dist/config/schemas/field.d.ts +2 -242
- package/dist/config/schemas/field.js +9 -20
- package/dist/config/schemas/media.js +1 -1
- package/dist/config/schemas/permissions.js +0 -2
- package/dist/config/schemas/url.d.ts +5 -6
- package/dist/config/schemas/url.js +5 -6
- package/dist/config/types.d.ts +45 -33
- package/dist/config/types.js +0 -1
- package/dist/config.d.ts +1 -8
- package/dist/config.js +1 -9
- package/dist/content-id-index.d.ts +76 -188
- package/dist/content-id-index.js +79 -230
- package/dist/content-index-generation.d.ts +27 -48
- package/dist/content-index-generation.js +35 -84
- package/dist/content-index-registry.d.ts +15 -24
- package/dist/content-index-registry.js +5 -9
- package/dist/content-listing.d.ts +64 -107
- package/dist/content-listing.js +68 -118
- package/dist/content-reader.d.ts +20 -29
- package/dist/content-reader.js +10 -18
- package/dist/content-store.d.ts +210 -336
- package/dist/content-store.js +413 -657
- package/dist/content-tree.d.ts +44 -86
- package/dist/content-tree.js +26 -54
- package/dist/context.d.ts +67 -94
- package/dist/context.js +53 -72
- package/dist/dev-content-watcher.d.ts +18 -18
- package/dist/dev-content-watcher.js +41 -44
- package/dist/editor/BranchManager.d.ts +3 -6
- package/dist/editor/BranchManager.js +24 -44
- package/dist/editor/CanopyEditor.js +5 -2
- package/dist/editor/CommentsPanel.js +0 -2
- package/dist/editor/Editor.js +19 -76
- package/dist/editor/EditorAuthGate.d.ts +27 -0
- package/dist/editor/EditorAuthGate.js +140 -0
- package/dist/editor/EntryNavigator.js +0 -20
- package/dist/editor/FormRenderer.d.ts +1 -2
- package/dist/editor/FormRenderer.js +8 -23
- package/dist/editor/admin/SystemHealthPanel.js +20 -37
- package/dist/editor/admin/useSystemHealth.d.ts +2 -3
- package/dist/editor/admin/useSystemHealth.js +10 -12
- package/dist/editor/client-reference-resolver.d.ts +2 -5
- package/dist/editor/client-reference-resolver.js +2 -19
- package/dist/editor/comments/BranchComments.js +0 -4
- package/dist/editor/comments/EntryComments.js +0 -4
- package/dist/editor/comments/FieldWrapper.js +0 -4
- package/dist/editor/comments/InlineCommentThread.js +0 -2
- package/dist/editor/comments/ThreadCarousel.d.ts +0 -58
- package/dist/editor/comments/ThreadCarousel.js +0 -12
- package/dist/editor/components/EditorFooter.d.ts +0 -8
- package/dist/editor/components/EditorFooter.js +0 -5
- package/dist/editor/components/EditorHeader.d.ts +0 -31
- package/dist/editor/components/EditorHeader.js +21 -71
- package/dist/editor/components/EditorSidebar.d.ts +0 -19
- package/dist/editor/components/EditorSidebar.js +0 -16
- package/dist/editor/components/EntryCreateModal.d.ts +4 -4
- package/dist/editor/components/EntryCreateModal.js +7 -22
- package/dist/editor/components/RenameEntryModal.js +0 -4
- package/dist/editor/components/UserBadge.js +0 -10
- package/dist/editor/components/index.js +0 -2
- package/dist/editor/context/ApiClientContext.d.ts +8 -7
- package/dist/editor/context/ApiClientContext.js +39 -11
- package/dist/editor/context/AssetContext.d.ts +7 -17
- package/dist/editor/context/AssetContext.js +7 -17
- package/dist/editor/context/EditorIdentityContext.d.ts +13 -0
- package/dist/editor/context/EditorIdentityContext.js +7 -0
- package/dist/editor/context/EditorStateContext.d.ts +10 -41
- package/dist/editor/context/EditorStateContext.js +7 -23
- package/dist/editor/context/SWRProvider.d.ts +3 -6
- package/dist/editor/context/index.d.ts +1 -20
- package/dist/editor/context/index.js +1 -20
- package/dist/editor/editor-utils.d.ts +8 -47
- package/dist/editor/editor-utils.js +19 -72
- package/dist/editor/fields/BlockField.d.ts +2 -2
- package/dist/editor/fields/BlockField.js +1 -2
- package/dist/editor/fields/CodeField.d.ts +0 -1
- package/dist/editor/fields/CodeField.js +0 -1
- package/dist/editor/fields/DateTimeField.d.ts +15 -22
- package/dist/editor/fields/DateTimeField.js +17 -26
- package/dist/editor/fields/ImageField.d.ts +2 -2
- package/dist/editor/fields/ImageField.js +8 -14
- package/dist/editor/fields/InlineGroupField.d.ts +0 -1
- package/dist/editor/fields/InlineGroupField.js +0 -1
- package/dist/editor/fields/MarkdownField.d.ts +0 -1
- package/dist/editor/fields/MarkdownField.js +6 -11
- package/dist/editor/fields/MdxImageDialog.d.ts +1 -1
- package/dist/editor/fields/MdxImageDialog.js +0 -1
- package/dist/editor/fields/NumberField.d.ts +9 -19
- package/dist/editor/fields/NumberField.js +11 -22
- package/dist/editor/fields/NumberListField.d.ts +7 -10
- package/dist/editor/fields/NumberListField.js +7 -10
- package/dist/editor/fields/ObjectField.d.ts +5 -8
- package/dist/editor/fields/ObjectField.js +0 -1
- package/dist/editor/fields/ReferenceField.d.ts +1 -1
- package/dist/editor/fields/ReferenceField.js +11 -16
- package/dist/editor/fields/SelectField.d.ts +2 -2
- package/dist/editor/fields/SelectField.js +0 -1
- package/dist/editor/fields/StringListField.d.ts +0 -4
- package/dist/editor/fields/StringListField.js +4 -9
- package/dist/editor/fields/TextField.d.ts +0 -1
- package/dist/editor/fields/TextField.js +0 -1
- package/dist/editor/fields/ToggleField.d.ts +0 -1
- package/dist/editor/fields/ToggleField.js +0 -1
- package/dist/editor/fields/entry-link/EntryLinkContext.js +2 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +0 -3
- package/dist/editor/fields/entry-link/InsertEntryLink.js +0 -4
- package/dist/editor/group-manager/ExternalGroupsTab.d.ts +0 -3
- package/dist/editor/group-manager/ExternalGroupsTab.js +0 -1
- package/dist/editor/group-manager/GroupCard.d.ts +0 -3
- package/dist/editor/group-manager/GroupForm.d.ts +0 -3
- package/dist/editor/group-manager/InternalGroupsTab.d.ts +0 -3
- package/dist/editor/group-manager/InternalGroupsTab.js +0 -2
- package/dist/editor/group-manager/MemberList.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useExternalGroupSearch.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useExternalGroupSearch.js +0 -3
- package/dist/editor/group-manager/hooks/useGroupState.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useGroupState.js +0 -5
- package/dist/editor/group-manager/hooks/useUserSearch.d.ts +0 -3
- package/dist/editor/group-manager/hooks/useUserSearch.js +0 -3
- package/dist/editor/group-manager/index.d.ts +0 -6
- package/dist/editor/group-manager/index.js +0 -10
- package/dist/editor/group-manager/types.d.ts +2 -5
- package/dist/editor/group-manager/types.js +0 -3
- package/dist/editor/hooks/index.d.ts +6 -11
- package/dist/editor/hooks/index.js +6 -12
- package/dist/editor/hooks/useBranchActions.d.ts +0 -14
- package/dist/editor/hooks/useBranchActions.js +0 -22
- package/dist/editor/hooks/useBranchManager.d.ts +2 -48
- package/dist/editor/hooks/useBranchManager.js +4 -64
- package/dist/editor/hooks/useBranchesData.d.ts +7 -6
- package/dist/editor/hooks/useBranchesData.js +7 -6
- package/dist/editor/hooks/useCommentSystem.d.ts +0 -49
- package/dist/editor/hooks/useCommentSystem.js +1 -36
- package/dist/editor/hooks/useDraftManager.d.ts +2 -35
- package/dist/editor/hooks/useDraftManager.js +23 -64
- package/dist/editor/hooks/useEditorLayout.d.ts +0 -20
- package/dist/editor/hooks/useEditorLayout.js +0 -23
- package/dist/editor/hooks/useEntriesData.d.ts +1 -0
- package/dist/editor/hooks/useEntriesData.js +2 -3
- package/dist/editor/hooks/useEntryLinkResolution.d.ts +2 -2
- package/dist/editor/hooks/useEntryLinkResolution.js +2 -2
- package/dist/editor/hooks/useEntryManager.d.ts +2 -27
- package/dist/editor/hooks/useEntryManager.js +29 -78
- package/dist/editor/hooks/useGroupManager.d.ts +0 -21
- package/dist/editor/hooks/useGroupManager.js +0 -21
- package/dist/editor/hooks/usePermissionManager.d.ts +0 -20
- package/dist/editor/hooks/usePermissionManager.js +0 -20
- package/dist/editor/hooks/useReferenceResolution.d.ts +2 -33
- package/dist/editor/hooks/useReferenceResolution.js +1 -48
- package/dist/editor/hooks/useSchemaManager.d.ts +8 -27
- package/dist/editor/hooks/useSchemaManager.js +0 -19
- package/dist/editor/hooks/useUserContext.d.ts +2 -9
- package/dist/editor/hooks/useUserContext.js +10 -10
- package/dist/editor/hooks/useUserMetadata.d.ts +0 -2
- package/dist/editor/hooks/useUserMetadata.js +0 -3
- package/dist/editor/media/AssetCard.d.ts +0 -1
- package/dist/editor/media/AssetCard.js +1 -2
- package/dist/editor/media/CropStep.d.ts +0 -1
- package/dist/editor/media/CropStep.js +0 -1
- package/dist/editor/media/MediaLibrary.d.ts +1 -2
- package/dist/editor/media/MediaLibrary.js +1 -2
- package/dist/editor/media/MediaLibraryBody.d.ts +0 -1
- package/dist/editor/media/MediaLibraryBody.js +0 -1
- package/dist/editor/media/crop-math.d.ts +1 -1
- package/dist/editor/media/crop-math.js +1 -1
- package/dist/editor/permission-manager/GroupSelector.d.ts +0 -3
- package/dist/editor/permission-manager/PermissionEditor.d.ts +0 -4
- package/dist/editor/permission-manager/PermissionEditor.js +0 -1
- package/dist/editor/permission-manager/PermissionLevelBadge.d.ts +0 -7
- package/dist/editor/permission-manager/PermissionLevelBadge.js +0 -4
- package/dist/editor/permission-manager/PermissionTree.d.ts +0 -4
- package/dist/editor/permission-manager/UserSelector.d.ts +0 -3
- package/dist/editor/permission-manager/constants.d.ts +0 -3
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.d.ts +0 -3
- package/dist/editor/permission-manager/hooks/useGroupsAndUsers.js +0 -10
- package/dist/editor/permission-manager/hooks/usePermissionTree.d.ts +0 -14
- package/dist/editor/permission-manager/hooks/usePermissionTree.js +0 -11
- package/dist/editor/permission-manager/index.d.ts +0 -7
- package/dist/editor/permission-manager/index.js +0 -16
- package/dist/editor/permission-manager/types.d.ts +2 -12
- package/dist/editor/permission-manager/types.js +0 -3
- package/dist/editor/permission-manager/utils.d.ts +0 -28
- package/dist/editor/permission-manager/utils.js +9 -23
- package/dist/editor/preview-bridge.d.ts +5 -1
- package/dist/editor/preview-bridge.js +4 -4
- package/dist/editor/schema-editor/CollectionEditor.d.ts +0 -5
- package/dist/editor/schema-editor/CollectionEditor.js +9 -31
- package/dist/editor/schema-editor/EntryTypeEditor.d.ts +0 -6
- package/dist/editor/schema-editor/EntryTypeEditor.js +3 -24
- package/dist/editor/schema-editor/index.d.ts +0 -7
- package/dist/editor/schema-editor/index.js +0 -7
- package/dist/editor/utils/env.d.ts +3 -18
- package/dist/editor/utils/env.js +6 -25
- package/dist/entry-link-resolver.d.ts +13 -35
- package/dist/entry-link-resolver.js +20 -53
- package/dist/entry-schema-registry.d.ts +20 -60
- package/dist/entry-schema-registry.js +20 -69
- package/dist/entry-schema.d.ts +118 -223
- package/dist/entry-schema.js +46 -91
- package/dist/git-manager.d.ts +101 -200
- package/dist/git-manager.js +211 -384
- package/dist/github-service.d.ts +34 -75
- package/dist/github-service.js +37 -91
- package/dist/http/handler.d.ts +6 -37
- package/dist/http/handler.js +65 -120
- package/dist/http/router.d.ts +15 -32
- package/dist/http/router.js +34 -99
- package/dist/http/types.d.ts +15 -40
- package/dist/http/types.js +3 -7
- package/dist/id.d.ts +4 -10
- package/dist/id.js +4 -10
- package/dist/index.js +6 -9
- package/dist/operating-mode/client-safe-strategy.d.ts +7 -14
- package/dist/operating-mode/client-safe-strategy.js +8 -28
- package/dist/operating-mode/client-unsafe-strategy.d.ts +6 -16
- package/dist/operating-mode/client-unsafe-strategy.js +9 -34
- package/dist/operating-mode/client.d.ts +3 -10
- package/dist/operating-mode/client.js +3 -8
- package/dist/operating-mode/deployment-name-fixtures.d.ts +14 -18
- package/dist/operating-mode/deployment-name-fixtures.js +14 -18
- package/dist/operating-mode/deployment-name.d.ts +19 -29
- package/dist/operating-mode/deployment-name.js +29 -47
- package/dist/operating-mode/index.d.ts +4 -25
- package/dist/operating-mode/index.js +9 -31
- package/dist/operating-mode/mode-env.d.ts +32 -54
- package/dist/operating-mode/mode-env.js +36 -58
- package/dist/operating-mode/types.d.ts +24 -72
- package/dist/operating-mode/types.js +1 -7
- package/dist/paths/branch-name.d.ts +30 -40
- package/dist/paths/branch-name.js +30 -40
- package/dist/paths/branch.d.ts +4 -18
- package/dist/paths/branch.js +6 -22
- package/dist/paths/index.d.ts +2 -13
- package/dist/paths/index.js +3 -23
- package/dist/paths/normalize-server.d.ts +3 -15
- package/dist/paths/normalize-server.js +3 -15
- package/dist/paths/normalize.d.ts +13 -33
- package/dist/paths/normalize.js +13 -33
- package/dist/paths/resolve.d.ts +4 -17
- package/dist/paths/resolve.js +4 -22
- package/dist/paths/types.d.ts +12 -34
- package/dist/paths/types.js +5 -7
- package/dist/paths/validation.d.ts +22 -119
- package/dist/paths/validation.js +30 -148
- package/dist/reference-resolver.d.ts +0 -13
- package/dist/reference-resolver.js +0 -18
- package/dist/resolve-canopy-user.d.ts +16 -18
- package/dist/resolve-canopy-user.js +17 -26
- package/dist/resource-generation.d.ts +16 -25
- package/dist/resource-generation.js +34 -97
- package/dist/schema/meta-loader.d.ts +2 -14
- package/dist/schema/meta-loader.js +15 -64
- package/dist/schema/resolver.d.ts +1 -4
- package/dist/schema/resolver.js +1 -6
- package/dist/schema/schema-store-types.d.ts +0 -12
- package/dist/schema/schema-store.d.ts +61 -138
- package/dist/schema/schema-store.js +85 -282
- package/dist/schema/types.d.ts +0 -4
- package/dist/server.d.ts +62 -93
- package/dist/server.js +63 -94
- package/dist/services.d.ts +23 -47
- package/dist/services.js +55 -98
- package/dist/settings-workspace.d.ts +17 -30
- package/dist/settings-workspace.js +51 -81
- package/dist/static/index.d.ts +26 -52
- package/dist/static/index.js +33 -67
- package/dist/{worker/task-queue.d.ts → task-queue/cms-task-queue.d.ts} +7 -9
- package/dist/task-queue/cms-task-queue.js +13 -0
- package/dist/task-queue/task-queue-config.d.ts +3 -0
- package/dist/{worker → task-queue}/task-queue-config.js +1 -6
- package/dist/task-queue/task-queue.d.ts +16 -22
- package/dist/task-queue/task-queue.js +20 -44
- package/dist/task-queue/types.d.ts +0 -1
- package/dist/task-queue/worker-status.d.ts +35 -0
- package/dist/task-queue/worker-status.js +40 -0
- package/dist/types.d.ts +40 -51
- package/dist/url-collision.d.ts +26 -40
- package/dist/url-collision.js +47 -63
- package/dist/url-exclusivity-fixtures.d.ts +15 -20
- package/dist/url-exclusivity-fixtures.js +14 -19
- package/dist/url-path-resolver.d.ts +8 -16
- package/dist/url-path-resolver.js +19 -29
- package/dist/user.d.ts +8 -21
- package/dist/user.js +10 -19
- package/dist/utils/async-mutex.js +4 -6
- package/dist/utils/atomic-write.d.ts +2 -11
- package/dist/utils/atomic-write.js +2 -11
- package/dist/utils/body-field.d.ts +8 -15
- package/dist/utils/body-field.js +8 -15
- package/dist/utils/content-serialize.d.ts +18 -24
- package/dist/utils/content-serialize.js +109 -160
- package/dist/utils/content-write-lock.d.ts +51 -106
- package/dist/utils/content-write-lock.js +66 -111
- package/dist/utils/debug.d.ts +7 -23
- package/dist/utils/debug.js +7 -23
- package/dist/utils/entry-url.d.ts +11 -23
- package/dist/utils/entry-url.js +11 -26
- package/dist/utils/error.d.ts +25 -90
- package/dist/utils/error.js +60 -147
- package/dist/utils/flatten-group-fields.d.ts +5 -7
- package/dist/utils/flatten-group-fields.js +5 -7
- package/dist/utils/format.d.ts +1 -5
- package/dist/utils/format.js +1 -5
- package/dist/utils/fs.d.ts +3 -4
- package/dist/utils/fs.js +3 -4
- package/dist/utils/git.d.ts +52 -83
- package/dist/utils/git.js +79 -125
- package/dist/utils/logger.d.ts +35 -50
- package/dist/utils/logger.js +35 -52
- package/dist/utils/provisioning-lock.d.ts +23 -35
- package/dist/utils/provisioning-lock.js +49 -69
- package/dist/utils/sanitize-href.d.ts +5 -10
- package/dist/utils/sanitize-href.js +8 -18
- package/dist/utils/title-field.d.ts +17 -20
- package/dist/utils/title-field.js +18 -27
- package/dist/utils/typed-filename.d.ts +20 -48
- package/dist/utils/typed-filename.js +25 -61
- package/dist/utils/url-prefix.d.ts +3 -3
- package/dist/utils/url-prefix.js +3 -3
- package/dist/validation/block-structural-keys.d.ts +16 -31
- package/dist/validation/block-structural-keys.js +19 -29
- package/dist/validation/deletion-checker.d.ts +0 -34
- package/dist/validation/deletion-checker.js +1 -43
- package/dist/validation/entry-link-validator.d.ts +6 -13
- package/dist/validation/entry-link-validator.js +4 -13
- package/dist/validation/entry-type-reference-validator.d.ts +13 -18
- package/dist/validation/entry-type-reference-validator.js +13 -18
- package/dist/validation/entry-validator.d.ts +13 -25
- package/dist/validation/entry-validator.js +15 -32
- package/dist/validation/field-traversal.d.ts +3 -30
- package/dist/validation/field-traversal.js +1 -32
- package/dist/validation/reference-validator.d.ts +5 -24
- package/dist/validation/reference-validator.js +5 -30
- package/dist/worker/cms-worker.d.ts +172 -232
- package/dist/worker/cms-worker.js +241 -333
- package/dist/worker/git-sync.d.ts +32 -133
- package/dist/worker/git-sync.js +129 -191
- package/dist/worker/github-auth.d.ts +13 -19
- package/dist/worker/github-auth.js +13 -19
- package/dist/worker/history-rewrite.d.ts +53 -66
- package/dist/worker/history-rewrite.js +54 -60
- package/dist/worker/log.d.ts +24 -41
- package/dist/worker/log.js +25 -42
- package/dist/worker/rebase.d.ts +20 -110
- package/dist/worker/rebase.js +279 -354
- package/dist/worker/task-runner.d.ts +32 -66
- package/dist/worker/task-runner.js +126 -162
- package/dist/worker/worker-context.d.ts +41 -81
- package/package.json +4 -4
- package/dist/cli/template-files/middleware-clerk.ts.template +0 -37
- package/dist/worker/task-queue-config.d.ts +0 -8
- package/dist/worker/task-queue.js +0 -19
- package/dist/worker/worker-status.d.ts +0 -47
- package/dist/worker/worker-status.js +0 -52
|
@@ -1,93 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `canopycms init-github-app <create|verify>` — registers the GitHub App the
|
|
3
|
-
* worker authenticates as, and reads its grant back.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* into a warning by design (`github-service.ts`), leaving the PR stuck as a
|
|
26
|
-
* draft. Both surface hours after setup. `verify` moves that discovery to setup
|
|
27
|
-
* time, which is the only cheap place to find it.
|
|
28
|
-
*
|
|
29
|
-
* ONE APP PER SITE. NOT ONE APP PER ORGANISATION.
|
|
30
|
-
*
|
|
31
|
-
* Worth stating at length because the obvious economy is to register one App and
|
|
32
|
-
* install it on every repository that needs it, and for a READ-ONLY App whose key
|
|
33
|
-
* never leaves GitHub that is right. It is wrong here, and the reason is
|
|
34
|
-
* structural rather than a matter of taste:
|
|
35
|
-
*
|
|
36
|
-
* **A GitHub App's private key is App-level. Installation scoping is not a
|
|
37
|
-
* boundary GitHub enforces against whoever holds the key — it is a choice the
|
|
38
|
-
* key-holder makes at mint time.** Anyone with the key can sign a JWT, call
|
|
39
|
-
* `GET /app/installations` to enumerate every installation, and mint an access
|
|
40
|
-
* token for any of them.
|
|
41
|
-
*
|
|
42
|
-
* Our key does not stay in one place: each site's worker reads it at runtime,
|
|
43
|
-
* from that site's own secret store, on that site's own host. So one shared App
|
|
44
|
-
* with `contents: write` would mean that reading the marketing site's secret
|
|
45
|
-
* store hands you write access to the documentation site's repository. One App
|
|
46
|
-
* per site, one installation each, one key each — blast radius contained to the
|
|
47
|
-
* site that leaked.
|
|
48
|
-
*
|
|
49
|
-
* The cost is real and is not pretended away: N sites means N Apps, N keys and N
|
|
50
|
-
* things to rotate. That is affordable only because creating one is cheap, which
|
|
51
|
-
* is most of why this command exists.
|
|
52
|
-
*
|
|
53
|
-
* WHAT THE MANIFEST FLOW BUYS, WHICH IS MORE THAN IT LOOKS
|
|
54
|
-
*
|
|
55
|
-
* `redirect_url` is the point of it. GitHub redirects there with a one-hour code,
|
|
56
|
-
* and `POST /app-manifests/{code}/conversions` exchanges that code for the App's
|
|
57
|
-
* id, client id and **the private key itself**. So `create` runs a one-shot
|
|
58
|
-
* loopback server, captures the key in memory, and hands it straight to a
|
|
59
|
-
* destination the operator named. Sent to a command's stdin — the recommended
|
|
60
|
-
* route — **the key never touches disk**, and nobody has to find a downloaded
|
|
61
|
-
* `.pem`. `--key-out` deliberately does write it to a file, for an operator with
|
|
62
|
-
* no such command; that is the trade, not an oversight.
|
|
63
|
-
*
|
|
64
|
-
* MEASURED (2026-09-05, in a sibling project) and it contradicts the
|
|
65
|
-
* documentation: GitHub's REST docs list `redirect_url` as OPTIONAL, and the
|
|
66
|
-
* organisation App-creation form refuses a manifest without it — "Invalid GitHub
|
|
67
|
-
* App configuration ... Error 'redirect_url' wasn't supplied."
|
|
68
|
-
*
|
|
69
|
-
* THIS FILE TALKS TO GITHUB WITH `fetch`, NOT OCTOKIT, DELIBERATELY
|
|
70
|
-
*
|
|
71
|
-
* Two reasons. An App JWT requires `Authorization: Bearer <jwt>`, and a client
|
|
72
|
-
* that sends `token <value>` is answered with `401 A JSON web token could not be
|
|
73
|
-
* decoded` — measured in the sibling project, where it presented as "no
|
|
74
|
-
* installation found" and sent the operator to check a page that was correct.
|
|
75
|
-
* And Octokit here would add `octokit.apps.*` call sites to this package, which
|
|
76
|
-
* the guard in `github-app-permission-drift.test.ts` would then have to carve an
|
|
77
|
-
* exception for — an exception being exactly how a guard goes blind.
|
|
78
|
-
*
|
|
79
|
-
* The JWT is hand-rolled over `node:crypto` for a third reason that is not
|
|
80
|
-
* optional: `.dependency-cruiser.mjs`'s `core-no-github-app-auth` rule scopes to
|
|
81
|
-
* every package's own `src/`, so importing `@octokit/auth-app` here is a lint
|
|
82
|
-
* error, same as anywhere else in this package.
|
|
83
|
-
*
|
|
84
|
-
* THE KEY'S DESTINATION IS NOT THIS TOOL'S BUSINESS
|
|
85
|
-
*
|
|
86
|
-
* `create` takes either a command to pipe the PEM into (`-- <cmd>`) or a file to
|
|
87
|
-
* write (`--key-out`), and asks for a file path if that first one fails. It
|
|
88
|
-
* knows nothing about AWS, or any other secret store: an adopter may not deploy
|
|
89
|
-
* to AWS at all, and a setup tool that hardcodes one cloud is a setup tool for
|
|
90
|
-
* one adopter. `docs/deploying-to-aws.md` carries the worked invocation.
|
|
2
|
+
* `canopycms init-github-app <create|verify>` — registers the GitHub App the
|
|
3
|
+
* CMS worker authenticates as, and reads its grant back. A PAT's scope is
|
|
4
|
+
* unauditable — checkboxes somebody ticked, nowhere recorded.
|
|
5
|
+
* `CANOPY_APP_PERMISSIONS` in github-app-manifest.ts is the machine-checkable statement of what
|
|
6
|
+
* this App needs instead, checked against the call sites that force each
|
|
7
|
+
* entry (see the constant) and what an installation holds (see `verify`). A
|
|
8
|
+
* missing permission fails silently — a GraphQL denial answers HTTP 200 with
|
|
9
|
+
* no numeric status — so `verify` exists to catch that at setup time.
|
|
10
|
+
* One App per site, never shared across an organisation: a GitHub App's
|
|
11
|
+
* private key is App-level — GitHub does not enforce per-installation
|
|
12
|
+
* scoping against the key-holder — so anyone holding it can mint a token for
|
|
13
|
+
* every installation. Each site's worker reads its own key from its own
|
|
14
|
+
* secret store, so sharing one App would let one leaked store reach every
|
|
15
|
+
* other site's repository.
|
|
16
|
+
* `create` runs a one-shot loopback server, exchanges GitHub's redirect code
|
|
17
|
+
* for the App's credentials including the private key, then hands it to the
|
|
18
|
+
* destination the operator named — over a command's stdin so it never
|
|
19
|
+
* touches disk, or to a file with `--key-out`. That destination is not this
|
|
20
|
+
* tool's business: it knows nothing about AWS or any other secret store.
|
|
21
|
+
* This file talks to GitHub with `fetch`, not Octokit: an App JWT needs
|
|
22
|
+
* `Authorization: Bearer <jwt>` (sent as `token <value>` it gets a misleading
|
|
23
|
+
* 401), and `.dependency-cruiser.mjs` forbids importing `@octokit/auth-app`
|
|
24
|
+
* from this package's `src/` anyway.
|
|
91
25
|
*/
|
|
92
26
|
import { spawn } from 'node:child_process';
|
|
93
27
|
import { createSign, randomUUID } from 'node:crypto';
|
|
@@ -95,140 +29,19 @@ import { createServer } from 'node:http';
|
|
|
95
29
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
96
30
|
import { tmpdir } from 'node:os';
|
|
97
31
|
import path from 'node:path';
|
|
98
|
-
import { createInterface } from 'node:readline';
|
|
99
32
|
import { Readable } from 'node:stream';
|
|
100
33
|
import { getErrorMessage, isNodeError, redactCredentials } from '../utils/error.js';
|
|
101
34
|
import { normalizeGitHubAppPrivateKey } from '../worker/github-auth.js';
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* The App's entire security surface, and the derivation behind it.
|
|
106
|
-
*
|
|
107
|
-
* Every entry names the call site that forces it, because a permission whose
|
|
108
|
-
* justification lives only in a commit message cannot be re-checked. Enumerated
|
|
109
|
-
* from the only two modules in this package that issue GitHub calls —
|
|
110
|
-
* `github-service.ts` and `worker/{task-runner,rebase}.ts` — plus every git
|
|
111
|
-
* operation that reaches github.com. `canopycms-cdk` makes no REST calls at all.
|
|
112
|
-
*
|
|
113
|
-
* - `contents: write`
|
|
114
|
-
* - git over HTTPS: the first-boot bare clone (`worker/cms-worker.ts`), the
|
|
115
|
-
* per-cycle fetch of all branches (`worker/git-sync.ts`), and the branch
|
|
116
|
-
* pushes including `--force-with-lease` (`worker/task-runner.ts`).
|
|
117
|
-
* - `octokit.git.deleteRef` (`github-service.ts`, `worker/task-runner.ts`).
|
|
118
|
-
* GitHub's permissions reference puts `DELETE /repos/{o}/{r}/git/refs/{ref}`
|
|
119
|
-
* under Contents/write — NOT `administration`, which is the plausible wrong
|
|
120
|
-
* guess. It is also dead code today: nothing enqueues `delete-remote-branch`
|
|
121
|
-
* and `GitHubService.deleteBranch`'s only call site is commented out, so it
|
|
122
|
-
* forces nothing that pushing does not already force. Declared anyway
|
|
123
|
-
* because the handler is live the moment a producer appears.
|
|
124
|
-
*
|
|
125
|
-
* - `pull_requests: write`
|
|
126
|
-
* - `pulls.create` and `pulls.update` (`github-service.ts`,
|
|
127
|
-
* `worker/task-runner.ts`). GitHub's reference: Create and Update a pull
|
|
128
|
-
* request are Pull requests/write. `pulls.list` and `pulls.get` are
|
|
129
|
-
* Pull requests/read, subsumed by this.
|
|
130
|
-
* - the GraphQL mutations `markPullRequestReadyForReview` and
|
|
131
|
-
* `convertPullRequestToDraft`. **This is the one entry with no
|
|
132
|
-
* documentation line behind it** — GitHub's permissions reference
|
|
133
|
-
* enumerates REST endpoints only — so it is derived by analogy with the
|
|
134
|
-
* REST PR mutations. A live run is the oracle, and until one has happened
|
|
135
|
-
* this comment is the honest state of it.
|
|
136
|
-
*
|
|
137
|
-
* - `metadata: read` — implied: GitHub grants it alongside any repository
|
|
138
|
-
* permission. Declared explicitly so this object states the whole surface
|
|
139
|
-
* rather than the part that is not automatic.
|
|
140
|
-
*
|
|
141
|
-
* Nothing else. No `issues` (there are no `issues.*` calls — nothing sets labels
|
|
142
|
-
* or assignees, which is the usual reason a PR bot needs it), no `administration`,
|
|
143
|
-
* no `actions`, and no organisation permissions. No `workflows`: nothing in the
|
|
144
|
-
* worker edits `.github/workflows/`, but GitHub may still refuse to push rebased
|
|
145
|
-
* history that carries a base-branch change to it — see
|
|
146
|
-
* .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md, an
|
|
147
|
-
* open P1.
|
|
148
|
-
*/
|
|
149
|
-
export const CANOPY_APP_PERMISSIONS = {
|
|
150
|
-
contents: 'write',
|
|
151
|
-
pull_requests: 'write',
|
|
152
|
-
metadata: 'read',
|
|
153
|
-
};
|
|
154
|
-
/**
|
|
155
|
-
* GitHub's limit on an App's display name.
|
|
156
|
-
*
|
|
157
|
-
* MEASURED 2026-09-06 in a sibling project, by having a 39-character name
|
|
158
|
-
* refused while a 33-character one was accepted; the documentation states no
|
|
159
|
-
* limit at all. So the true bound is somewhere in 33..38 and 34 is the safe
|
|
160
|
-
* direction: a wrongly-refused name costs the operator one `--name` flag, while a
|
|
161
|
-
* wrongly-accepted one costs a browser round trip to find out. Re-measure when a
|
|
162
|
-
* real App is created from this file.
|
|
163
|
-
*/
|
|
164
|
-
export const APP_NAME_MAX_LENGTH = 34;
|
|
165
|
-
/**
|
|
166
|
-
* How much of a description the account's App LIST renders before truncating —
|
|
167
|
-
* mid-word, with an ellipsis.
|
|
168
|
-
*
|
|
169
|
-
* MEASURED 2026-09-06 in the same project: a description opening "Read-only,
|
|
170
|
-
* organisation-wide. Lets th…" was cut at 37 characters. The list appears to
|
|
171
|
-
* truncate by CHARACTER rather than by line, so a newline does not rescue a long
|
|
172
|
-
* opening sentence. Hence the shape of `appDescription`: a standalone summary
|
|
173
|
-
* inside this budget, a blank line, then the detail only the App's own page
|
|
174
|
-
* shows.
|
|
175
|
-
*/
|
|
176
|
-
export const APP_SUMMARY_MAX_LENGTH = 37;
|
|
35
|
+
import { APP_NAME_MAX_LENGTH, CANOPY_APP_PERMISSIONS, appDescription, appName, appSlug, readbackVerdict, } from './github-app-manifest.js';
|
|
36
|
+
import { askLine, pressEnter } from './prompt.js';
|
|
177
37
|
/** How long `create` waits for the browser round trip before giving up. */
|
|
178
|
-
|
|
38
|
+
const CALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
179
39
|
/** How long any single call to api.github.com may take. */
|
|
180
40
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
181
41
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* an App named after the organisation would suggest it is shared, which is the
|
|
186
|
-
* arrangement this file exists to avoid.
|
|
187
|
-
*
|
|
188
|
-
* GitHub App names are unique across the WHOLE of GitHub, not merely within an
|
|
189
|
-
* account, so a short generic name is plausibly already taken by somebody else.
|
|
190
|
-
* `create` pre-checks what it can and `--name` overrides; see
|
|
191
|
-
* `checkNameAvailable`.
|
|
192
|
-
*/
|
|
193
|
-
export function appName(repo) {
|
|
194
|
-
return `${repo} CanopyCMS`;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* GitHub's slug derivation, as far as this needs it: lowercase, and runs of
|
|
198
|
-
* anything that is not alphanumeric collapse to single hyphens.
|
|
199
|
-
*
|
|
200
|
-
* Pinned by a test. If GitHub ever derives differently the name pre-check goes
|
|
201
|
-
* blind rather than loud, so this is not something to assume.
|
|
202
|
-
*/
|
|
203
|
-
export function appSlug(name) {
|
|
204
|
-
return name
|
|
205
|
-
.toLowerCase()
|
|
206
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
207
|
-
.replace(/^-|-$/g, '');
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* A short summary that survives the App list's truncation, then the detail.
|
|
211
|
-
*
|
|
212
|
-
* Two audiences, two parts. Deliberately generic in both: this text is stored ON
|
|
213
|
-
* GITHUB rather than here, so anything in it that can go stale goes stale
|
|
214
|
-
* silently, with nothing in this repository able to detect or fix it. It
|
|
215
|
-
* therefore says only what stays true of the App itself — what it does, that it
|
|
216
|
-
* is installed on one repository, and that it is registered per site.
|
|
217
|
-
*/
|
|
218
|
-
export function appDescription() {
|
|
219
|
-
return [
|
|
220
|
-
'Commits content edits, opens PRs',
|
|
221
|
-
'',
|
|
222
|
-
'Lets CanopyCMS publish edits made in its editor: it pushes content branches to this ' +
|
|
223
|
-
'repository and opens or updates the pull request that carries them. Installed on one ' +
|
|
224
|
-
'repository, and registered per site — so this key cannot reach another site’s repository.',
|
|
225
|
-
].join('\n');
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Where the manifest form posts, which differs by account type.
|
|
229
|
-
*
|
|
230
|
-
* A solo adopter who owns the content repository personally needs the user-level
|
|
231
|
-
* URL; posting an organisation manifest to it (or the reverse) fails at the form.
|
|
42
|
+
* Where the manifest form posts: user-owned and organisation repositories use
|
|
43
|
+
* different URLs, and posting the wrong one fails at the form.
|
|
44
|
+
* @internal Exported for tests.
|
|
232
45
|
*/
|
|
233
46
|
export function manifestPostUrl(target, state) {
|
|
234
47
|
const base = target.isOrganization
|
|
@@ -240,12 +53,10 @@ export function manifestPostUrl(target, state) {
|
|
|
240
53
|
return `${base}?state=${encodeURIComponent(state)}`;
|
|
241
54
|
}
|
|
242
55
|
/**
|
|
243
|
-
* The App as GitHub's manifest flow takes it.
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* it. `default_events` is empty and the webhook is inactive: nothing is ever
|
|
248
|
-
* delivered to this App — it is only ever assumed outward by the worker.
|
|
56
|
+
* The App as GitHub's manifest flow takes it. `default_permissions` is the
|
|
57
|
+
* entire security surface, defined in `CANOPY_APP_PERMISSIONS`. The webhook is
|
|
58
|
+
* declared but inactive: nothing is ever delivered to this App.
|
|
59
|
+
* @internal Exported for tests.
|
|
249
60
|
*/
|
|
250
61
|
export function appManifest(target, redirectUrl, name = appName(target.repo)) {
|
|
251
62
|
return {
|
|
@@ -269,12 +80,10 @@ function escapeHtml(value) {
|
|
|
269
80
|
.replace(/'/g, ''');
|
|
270
81
|
}
|
|
271
82
|
/**
|
|
272
|
-
* The auto-submitting form that carries the manifest to GitHub
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* adopter-supplied owner and repository names, and this string is written to a
|
|
277
|
-
* file that a browser then executes.
|
|
83
|
+
* The auto-submitting form that carries the manifest to GitHub — a manifest
|
|
84
|
+
* can only be delivered as a browser form POST. Both interpolations are
|
|
85
|
+
* escaped: this embeds adopter-supplied names into a file a browser executes.
|
|
86
|
+
* @internal Exported for tests.
|
|
278
87
|
*/
|
|
279
88
|
export function creationForm(target, manifest) {
|
|
280
89
|
return `<!doctype html><meta charset="utf-8"><title>Create the CanopyCMS App</title>
|
|
@@ -287,12 +96,11 @@ export function creationForm(target, manifest) {
|
|
|
287
96
|
</body>`;
|
|
288
97
|
}
|
|
289
98
|
/**
|
|
290
|
-
* A short-lived App JWT, signed RS256 with `node:crypto`.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* ten-minute maximum for no benefit.
|
|
99
|
+
* A short-lived App JWT, signed RS256 with `node:crypto`. `iat` is backdated
|
|
100
|
+
* 60s because GitHub rejects a JWT whose `iat` is in its own future, and a
|
|
101
|
+
* second of clock skew is ordinary; `exp` leaves headroom rather than sitting
|
|
102
|
+
* on GitHub's stated ten-minute maximum for no benefit.
|
|
103
|
+
* @internal Exported for tests.
|
|
296
104
|
*/
|
|
297
105
|
export function appJwt(issuer, privateKey, now = Math.floor(Date.now() / 1000)) {
|
|
298
106
|
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
@@ -306,15 +114,12 @@ export function appJwt(issuer, privateKey, now = Math.floor(Date.now() / 1000))
|
|
|
306
114
|
return `${body}.${signer.sign(privateKey).toString('base64url')}`;
|
|
307
115
|
}
|
|
308
116
|
/**
|
|
309
|
-
* One call to api.github.com.
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* authenticate" and "this App is not installed on that repository" — send an
|
|
314
|
-
* operator to entirely different pages. Collapsing them is measured to have sent
|
|
315
|
-
* a real run's operator to check a page that was correct.
|
|
117
|
+
* One call to api.github.com. Returns the status and body rather than
|
|
118
|
+
* collapsing failure to `null`: "cannot authenticate" and "not installed"
|
|
119
|
+
* send an operator to different pages, and collapsing them has sent an
|
|
120
|
+
* operator to check a page that was correct.
|
|
316
121
|
*/
|
|
317
|
-
|
|
122
|
+
async function githubRequest(apiPath, init = {}) {
|
|
318
123
|
const headers = {
|
|
319
124
|
accept: 'application/vnd.github+json',
|
|
320
125
|
'x-github-api-version': '2022-11-28',
|
|
@@ -337,11 +142,10 @@ export async function githubRequest(apiPath, init = {}) {
|
|
|
337
142
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
338
143
|
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
339
144
|
});
|
|
340
|
-
// Reading the BODY is inside the try
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// held. Every failure this function can have is a returned value.
|
|
145
|
+
// Reading the BODY is inside the try too, not just the fetch: a connection
|
|
146
|
+
// reset or the timeout above can still reject here, and letting that escape
|
|
147
|
+
// would propagate past the point in `create` where the key is still held.
|
|
148
|
+
// Every failure this function can have is a returned value, not a throw.
|
|
345
149
|
text = await response.text();
|
|
346
150
|
}
|
|
347
151
|
catch (err) {
|
|
@@ -354,146 +158,34 @@ export async function githubRequest(apiPath, init = {}) {
|
|
|
354
158
|
catch {
|
|
355
159
|
body = null;
|
|
356
160
|
}
|
|
357
|
-
// Computed only for a FAILURE
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// would not save a truncated `client_secret`.
|
|
161
|
+
// Computed only for a FAILURE: on success the body is App credentials, not
|
|
162
|
+
// something to hand a caller that might print it. `redactCredentials` covers
|
|
163
|
+
// a PEM and a `ghs_` token but would not save a truncated `client_secret`.
|
|
361
164
|
const asRecord = body;
|
|
362
165
|
const message = response.ok
|
|
363
166
|
? ''
|
|
364
167
|
: redactCredentials(typeof asRecord?.message === 'string' ? asRecord.message : text.slice(0, 200));
|
|
365
168
|
return { ok: response.ok, status: response.status, body, message };
|
|
366
169
|
}
|
|
367
|
-
function isPermissionLevel(value) {
|
|
368
|
-
return PERMISSION_LEVELS.includes(value);
|
|
369
|
-
}
|
|
370
|
-
function rank(level) {
|
|
371
|
-
return PERMISSION_LEVELS.indexOf(level);
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Compare what an installation actually holds against what this package needs.
|
|
375
|
-
*
|
|
376
|
-
* Pure, so the whole judgement is testable without a live App — which matters,
|
|
377
|
-
* because everything around it can only ever be exercised by hand.
|
|
378
|
-
*
|
|
379
|
-
* Reports in BOTH directions. A credential does not usually fail by being
|
|
380
|
-
* unusable; that gets noticed immediately. It fails by being one permission too
|
|
381
|
-
* wide, which works perfectly and is never noticed. So an extra permission, or
|
|
382
|
-
* an installation scoped to every repository in the account, is an error here
|
|
383
|
-
* and not a shrug.
|
|
384
|
-
*/
|
|
385
|
-
export function readbackVerdict(installation, desired = CANOPY_APP_PERMISSIONS) {
|
|
386
|
-
const findings = [];
|
|
387
|
-
const held = installation.permissions ?? {};
|
|
388
|
-
if (installation.suspended_at) {
|
|
389
|
-
findings.push({
|
|
390
|
-
severity: 'error',
|
|
391
|
-
message: `the installation is SUSPENDED (since ${installation.suspended_at}). ` +
|
|
392
|
-
'A suspended installation authenticates and then refuses everything, so this ' +
|
|
393
|
-
'presents as a permission problem that no permission change fixes.',
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
// `undefined` is NOT treated as `selected`. An absent field means "GitHub did
|
|
397
|
-
// not tell us", and reporting an unknown scope as the narrow one would be the
|
|
398
|
-
// check quietly passing itself.
|
|
399
|
-
if (installation.repository_selection !== 'selected') {
|
|
400
|
-
findings.push({
|
|
401
|
-
severity: 'error',
|
|
402
|
-
message: `repository_selection is ${JSON.stringify(installation.repository_selection ?? null)}, ` +
|
|
403
|
-
'expected "selected". This App is registered per site: an installation covering every ' +
|
|
404
|
-
'repository in the account means its key reaches repositories it was never meant to.',
|
|
405
|
-
});
|
|
406
|
-
}
|
|
407
|
-
for (const [name, level] of Object.entries(desired)) {
|
|
408
|
-
const actual = held[name];
|
|
409
|
-
if (actual === undefined) {
|
|
410
|
-
findings.push({
|
|
411
|
-
severity: 'error',
|
|
412
|
-
message: `missing permission ${name}: ${level} — the installation holds no ${name} grant at all.`,
|
|
413
|
-
});
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
if (actual === level)
|
|
417
|
-
continue;
|
|
418
|
-
// An exact match is the only pass. A level GitHub adds later is reported
|
|
419
|
-
// rather than ranked against levels this code happens to know, because
|
|
420
|
-
// guessing where an unknown level sits is exactly how a check quietly
|
|
421
|
-
// starts passing things it was written to catch.
|
|
422
|
-
if (!isPermissionLevel(actual)) {
|
|
423
|
-
findings.push({
|
|
424
|
-
severity: 'error',
|
|
425
|
-
message: `permission ${name} is "${actual}", which is not a level this check knows ` +
|
|
426
|
-
`(${PERMISSION_LEVELS.join('/')}). Compare it against "${level}" by hand.`,
|
|
427
|
-
});
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
if (rank(actual) < rank(level)) {
|
|
431
|
-
findings.push({
|
|
432
|
-
severity: 'error',
|
|
433
|
-
message: `permission ${name} is "${actual}", which is weaker than the required "${level}".`,
|
|
434
|
-
});
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
// Stronger than required is still wider than intended, and is reported for
|
|
438
|
-
// the same reason an extra permission is: it works perfectly, so nothing
|
|
439
|
-
// else will ever notice it.
|
|
440
|
-
findings.push({
|
|
441
|
-
severity: 'error',
|
|
442
|
-
message: `permission ${name} is "${actual}", which is STRONGER than the required "${level}". ` +
|
|
443
|
-
'Wider than intended is the failure that works perfectly and is never noticed.',
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
for (const [name, level] of Object.entries(held)) {
|
|
447
|
-
if (desired[name] === undefined) {
|
|
448
|
-
findings.push({
|
|
449
|
-
severity: 'error',
|
|
450
|
-
message: `permission ${name}: ${level} is held but NOT needed. Wider than intended is the ` +
|
|
451
|
-
'failure that works perfectly and is never noticed — remove it from the App.',
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return findings;
|
|
456
|
-
}
|
|
457
170
|
/** Whether a destination command is fed through `KEY_INPUT_BRIDGE`: everywhere but Windows. */
|
|
458
171
|
const BRIDGE_KEY_INPUT = process.platform !== 'win32';
|
|
459
172
|
/**
|
|
460
|
-
* The script `/bin/sh` runs to give a destination command the key on a REAL
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* program looked up on PATH, as the direct spawn did. MEASURED in macOS
|
|
477
|
-
* `/bin/sh` (bash 3.2) and `/bin/dash`: with a bare `exec "$@"`, bash took a
|
|
478
|
-
* first word of `-c` as `exec`'s own option, ran nothing and exited 0; without
|
|
479
|
-
* `exec`, a first word of `eval` would run the rest as shell code. Through
|
|
480
|
-
* `env --`, both shells report `-c`, `eval` and `set` as "No such file or
|
|
481
|
-
* directory", exit 127. `--` goes to `env` because dash's `exec` rejects it
|
|
482
|
-
* ("exec: --: not found").
|
|
483
|
-
*
|
|
484
|
-
* WHY `cat`'S STATUS COMES BACK ON fd 3. A pipeline's exit status is its LAST
|
|
485
|
-
* command's, so `cat | cmd` alone reports only `cmd` — and hides `cat` dying of
|
|
486
|
-
* SIGPIPE because `cmd` closed its input before the whole key was written. The
|
|
487
|
-
* direct spawn could see that case only as a write error on the child's stdin;
|
|
488
|
-
* through the bridge, `sh` itself holds that socket open until it exits, so
|
|
489
|
-
* `cat` is the witness left. `set -o pipefail` would expose it but is not something every
|
|
490
|
-
* `/bin/sh` has: macOS `/bin/dash` rejects it ("set: Illegal option -o
|
|
491
|
-
* pipefail"), and dash is `/bin/sh` on Debian and Ubuntu. So the left side
|
|
492
|
-
* writes `cat`'s own status to fd 3, a channel back to Node, and the verdict in
|
|
493
|
-
* `handOffKey` requires it to read `0`. A status that never arrives is not a
|
|
494
|
-
* `0`. `3>&-` closes fd 3 in the destination — MEASURED closed there in both
|
|
495
|
-
* shells — so nothing it leaves running can hold that channel open, or write a
|
|
496
|
-
* status into it.
|
|
173
|
+
* The script `/bin/sh` runs to give a destination command the key on a REAL
|
|
174
|
+
* pipe. Node's `stdio: 'pipe'` is a SOCKET pair on Unix, not a real pipe:
|
|
175
|
+
* `cat` reads it like anything else and writes into a real one. On Linux a
|
|
176
|
+
* socket can't even be opened as `/dev/stdin`, which would break
|
|
177
|
+
* `--secret-string file:///dev/stdin`. No shell parses the operator's argv:
|
|
178
|
+
* `sh -c SCRIPT sh <argv…>` makes argv the positional parameters, and `"$@"`
|
|
179
|
+
* hands them on quoted with no word-splitting or globbing. It is
|
|
180
|
+
* `exec env -- "$@"` rather than a bare `"$@"` so the first word is always
|
|
181
|
+
* looked up on PATH, not interpreted as a shell built-in.
|
|
182
|
+
*
|
|
183
|
+
* A pipeline's exit status is its LAST command's, so `cat | cmd` alone would
|
|
184
|
+
* hide `cat` dying of SIGPIPE if `cmd` closed its input early, and `set -o
|
|
185
|
+
* pipefail` isn't available in every `/bin/sh` (`/bin/dash`, `/bin/sh` on
|
|
186
|
+
* Debian/Ubuntu, rejects it). So the left side writes `cat`'s own status to
|
|
187
|
+
* fd 3, a channel `handOffKey` requires to read `0`; `3>&-` closes fd 3 in
|
|
188
|
+
* the destination so nothing it leaves running can hold that channel open.
|
|
497
189
|
*/
|
|
498
190
|
const KEY_INPUT_BRIDGE = '{ cat; echo "$?" >&3; } | exec env -- "$@" 3>&-';
|
|
499
191
|
function spawnKeyDestination(command, args) {
|
|
@@ -505,11 +197,9 @@ function spawnKeyDestination(command, args) {
|
|
|
505
197
|
});
|
|
506
198
|
}
|
|
507
199
|
/**
|
|
508
|
-
* The detail for a destination command that exited non-zero. Through the
|
|
509
|
-
* `env` reports
|
|
510
|
-
*
|
|
511
|
-
* either code on its own account, so these say how the code is reported rather
|
|
512
|
-
* than asserting which happened.
|
|
200
|
+
* The detail for a destination command that exited non-zero. Through the
|
|
201
|
+
* bridge, `env` reports "not found" as 127 and "not executable" as 126 — but
|
|
202
|
+
* a command may exit either code on its own, so this states how it's reported.
|
|
513
203
|
*/
|
|
514
204
|
function destinationExitDetail(command, code) {
|
|
515
205
|
if (BRIDGE_KEY_INPUT && code === 127) {
|
|
@@ -523,48 +213,30 @@ function destinationExitDetail(command, code) {
|
|
|
523
213
|
return `\`${command}\` exited ${code ?? 'by signal'}`;
|
|
524
214
|
}
|
|
525
215
|
/**
|
|
526
|
-
* Send the captured private key to the destination the operator named.
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* went wrong writing to it. `cat` exiting 0 means every byte of the key went into
|
|
545
|
-
* the pipe. It still does NOT prove the command READ them, and cannot: a pipe
|
|
546
|
-
* write lands in the kernel's buffer, and a key this size fits in one write, so a
|
|
547
|
-
* command that exits without reading races `cat`'s write and can lose or win.
|
|
548
|
-
* MEASURED on macOS with a real 1.7KB key, 100 runs each through this function,
|
|
549
|
-
* with the same result before the bridge and after it: `true`, `head -c 10`,
|
|
550
|
-
* `sh -c 'exec 0<&-; exit 0'` and `sh -c 'head -c 5 >/dev/null; exit 0'` were
|
|
551
|
-
* each reported stored 100 times of 100. What IS caught, whatever the timing,
|
|
552
|
-
* is a command that leaves more unread than a pipe buffer holds: ~100KB into
|
|
553
|
-
* `true` was reported not stored 20 times of 20, by `cat` exiting 141 — the
|
|
554
|
-
* same case the direct spawn caught as a write EPIPE.
|
|
555
|
-
*
|
|
556
|
-
* So the command's EXIT CODE is still the contract, and the operator is still
|
|
557
|
-
* responsible for naming a command that fails loudly. That is stated here rather
|
|
558
|
-
* than papered over, because the tempting fix — waiting for the stream to flush
|
|
559
|
-
* — measures the kernel buffer rather than the command, and would look like a
|
|
560
|
-
* check while being one.
|
|
216
|
+
* Send the captured private key to the destination the operator named. For a
|
|
217
|
+
* command, the PEM goes over **stdin**, never argv — an argument is visible
|
|
218
|
+
* in `ps` and lands in shell history (see `KEY_INPUT_BRIDGE` for how `sh`
|
|
219
|
+
* runs it without parsing that argv itself). Stdout/stderr are INHERITED,
|
|
220
|
+
* not captured: `aws secretsmanager create-secret` prints the ARN the
|
|
221
|
+
* operator needs, which this function must not swallow. The `'error'`
|
|
222
|
+
* listener on `child.stdin` is not optional: a child that exits or closes
|
|
223
|
+
* stdin early can fail the write with EPIPE, an unhandled stream error that
|
|
224
|
+
* would take the process — and the key — down with it.
|
|
225
|
+
* Everywhere but Windows, `stored: true` means the command exited 0, `cat`
|
|
226
|
+
* exited 0, and nothing went wrong writing — every byte reached the pipe. It
|
|
227
|
+
* does NOT prove the command read them: a write lands in the kernel buffer,
|
|
228
|
+
* and a key this size fits in one write, so a command that exits without
|
|
229
|
+
* reading races `cat`'s write and can win or lose silently. What IS caught is
|
|
230
|
+
* a command leaving more unread than the buffer holds, which fails loudly as
|
|
231
|
+
* `cat` exiting on SIGPIPE — so the exit code is still the contract, and
|
|
232
|
+
* naming a command that fails loudly is the operator's job.
|
|
233
|
+
* @internal Exported for tests.
|
|
561
234
|
*/
|
|
562
235
|
export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination) {
|
|
563
236
|
if (destination.kind === 'file') {
|
|
564
237
|
try {
|
|
565
|
-
// `wx` so an existing file is never clobbered:
|
|
566
|
-
//
|
|
567
|
-
// credential with another is not a recoverable mistake.
|
|
238
|
+
// `wx` so an existing file is never clobbered: overwriting one credential
|
|
239
|
+
// with another, at a path that may already hold a key, is not recoverable.
|
|
568
240
|
await writeFile(destination.filePath, pem, { mode: 0o600, flag: 'wx' });
|
|
569
241
|
return { stored: true, detail: `written to ${destination.filePath} (mode 0600)` };
|
|
570
242
|
}
|
|
@@ -584,11 +256,9 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
584
256
|
if (destination.argv.length === 0) {
|
|
585
257
|
return { stored: false, detail: 'no command was given to send the key to' };
|
|
586
258
|
}
|
|
587
|
-
// Refused before spawning
|
|
588
|
-
//
|
|
589
|
-
// command
|
|
590
|
-
// it print the environment and exit 0 -- the key reported stored, and gone.
|
|
591
|
-
// A direct spawn of such a word always failed with ENOENT instead.
|
|
259
|
+
// Refused before spawning: `env` reads a leading word containing `=` as a
|
|
260
|
+
// variable assignment, not a command, so `-- FOO=bar` (or a mis-quoted whole
|
|
261
|
+
// command) would print the environment, exit 0, and report the key stored.
|
|
592
262
|
if (destination.argv[0].includes('=')) {
|
|
593
263
|
return {
|
|
594
264
|
stored: false,
|
|
@@ -616,17 +286,15 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
616
286
|
resolve(result);
|
|
617
287
|
};
|
|
618
288
|
// Recorded rather than settled on, so the child's EXIT CODE stays the
|
|
619
|
-
// authority
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
// means the child cannot have received the whole PEM, whatever it claims.
|
|
289
|
+
// authority: a child that stored the key and exited 0 must not be reported
|
|
290
|
+
// failed because its pipe errored on the way down. It does veto a zero
|
|
291
|
+
// exit, though — an incomplete write means the child cannot have received
|
|
292
|
+
// the whole PEM, whatever it claims.
|
|
624
293
|
let writeError;
|
|
625
294
|
// `cat`'s exit status as the bridge reports it on fd 3 (see
|
|
626
|
-
// `KEY_INPUT_BRIDGE`)
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
// channel is missing, which the verdict counts as no `0`.
|
|
295
|
+
// `KEY_INPUT_BRIDGE`), read for the verdict on 'close' — by then this
|
|
296
|
+
// channel has closed too, since 'close' waits for every stdio stream after
|
|
297
|
+
// stdin. Left empty (counted as no `0`) if the channel is missing.
|
|
630
298
|
let catStatus = '';
|
|
631
299
|
if (BRIDGE_KEY_INPUT) {
|
|
632
300
|
const channel = child.stdio?.[3];
|
|
@@ -635,8 +303,8 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
635
303
|
channel.on('data', (chunk) => {
|
|
636
304
|
catStatus += chunk;
|
|
637
305
|
});
|
|
638
|
-
// Not optional,
|
|
639
|
-
//
|
|
306
|
+
// Not optional, same reason as the stdin listener below: an error
|
|
307
|
+
// cannot fake a status, at worst leaving this short of a `0`.
|
|
640
308
|
channel.on('error', () => { });
|
|
641
309
|
}
|
|
642
310
|
}
|
|
@@ -645,11 +313,10 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
645
313
|
settle({ stored: false, detail: `could not run \`${command}\`: ${getErrorMessage(err)}` });
|
|
646
314
|
});
|
|
647
315
|
child.on('close', (code) => {
|
|
648
|
-
// Deferred one turn of the event loop so a stdin error
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
// while the write that carried it had failed.
|
|
316
|
+
// Deferred one turn of the event loop so a pending stdin error is
|
|
317
|
+
// recorded BEFORE the verdict: a broken pipe and the child's exit are
|
|
318
|
+
// independent events, and `close` can win that race and report success
|
|
319
|
+
// on an exit code whose write actually failed.
|
|
653
320
|
setImmediate(() => {
|
|
654
321
|
if (writeError) {
|
|
655
322
|
settle({
|
|
@@ -684,9 +351,8 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
684
351
|
settle({ stored: false, detail: `\`${command}\` has no stdin to write the key to` });
|
|
685
352
|
return;
|
|
686
353
|
}
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
// with it the only copy of a private key for an App that already exists.
|
|
354
|
+
// Not optional: without it an EPIPE from an early-closing child is an
|
|
355
|
+
// unhandled stream error that takes the process down, and the key with it.
|
|
690
356
|
stdin.on('error', (err) => {
|
|
691
357
|
writeError = err;
|
|
692
358
|
});
|
|
@@ -694,24 +360,18 @@ export async function handOffKey(pem, destination, spawnFn = spawnKeyDestination
|
|
|
694
360
|
});
|
|
695
361
|
}
|
|
696
362
|
/**
|
|
697
|
-
* One request, then closed
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
* the
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* the CSRF guard the manifest flow provides, but rejecting the promise on a
|
|
706
|
-
* mismatch ends the run on the first stray loopback request — a browser
|
|
707
|
-
* prefetch, an extension, a retried tab — possibly seconds before GitHub's real
|
|
708
|
-
* redirect arrives, and AFTER the App has been created. Aborting there leaves an
|
|
709
|
-
* App nobody holds a key for, over a request that was never GitHub's.
|
|
363
|
+
* One request, then closed by the handler itself, bound to loopback only —
|
|
364
|
+
* the standard CLI pattern (`gh auth login` does the same) so the key
|
|
365
|
+
* arrives over the redirect, not a downloads folder. A rejected request is
|
|
366
|
+
* answered and ignored, never fatal: the `state` check
|
|
367
|
+
* is the CSRF guard the manifest flow provides, but rejecting the promise on
|
|
368
|
+
* a mismatch would end the run on a stray loopback request, possibly before
|
|
369
|
+
* GitHub's real redirect, leaving an App nobody holds a key for.
|
|
370
|
+
* @internal Exported for tests.
|
|
710
371
|
*/
|
|
711
372
|
export function startCallbackServer(state, timeoutMs = CALLBACK_TIMEOUT_MS,
|
|
712
373
|
// Injectable ONLY so the bind-failure path is testable. The host stays
|
|
713
|
-
// hardcoded to 127.0.0.1 below, so this seam cannot
|
|
714
|
-
// somewhere reachable.
|
|
374
|
+
// hardcoded to 127.0.0.1 below, so this seam cannot bind somewhere reachable.
|
|
715
375
|
createServerFn = createServer) {
|
|
716
376
|
let settle = () => { };
|
|
717
377
|
let fail = () => { };
|
|
@@ -720,28 +380,21 @@ createServerFn = createServer) {
|
|
|
720
380
|
fail = reject;
|
|
721
381
|
});
|
|
722
382
|
// Marks `code` as handled without consuming it: `.catch()` returns a NEW
|
|
723
|
-
// promise
|
|
724
|
-
// the bind below can fail before anyone has awaited `code`,
|
|
725
|
-
//
|
|
726
|
-
// actual problem is "the port could not be bound".
|
|
383
|
+
// promise, leaving this one rejectable for the real caller. Needed because
|
|
384
|
+
// the bind below can fail before anyone has awaited `code`, which would
|
|
385
|
+
// otherwise report as an unhandled rejection instead of a bind failure.
|
|
727
386
|
code.catch(() => { });
|
|
728
387
|
const page = (title, detail) => `<!doctype html><meta charset="utf-8"><title>${escapeHtml(title)}</title>` +
|
|
729
388
|
`<body style="font:16px system-ui;padding:3rem"><h1>${escapeHtml(title)}</h1>` +
|
|
730
389
|
`<p>${escapeHtml(detail)}</p></body>`;
|
|
731
|
-
// Assigned once
|
|
732
|
-
//
|
|
733
|
-
// for the caller's `finally`. Declared out here only so both can reach it.
|
|
390
|
+
// Assigned once listening, and CALLED by the handler once it has the code, so
|
|
391
|
+
// the listener closes itself rather than waiting on the caller's `finally`.
|
|
734
392
|
let stopListening = () => { };
|
|
735
393
|
const server = createServerFn((req, res) => {
|
|
736
|
-
// EVERY path through this handler is inside the try
|
|
737
|
-
// 'request' listener
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
// `new URL` is the specific hazard, and it is not hypothetical: Node's HTTP
|
|
742
|
-
// parser accepts request targets `new URL` rejects, so `GET //[x HTTP/1.1`
|
|
743
|
-
// arrives as `req.url === '//[x'` and throws ERR_INVALID_URL. Verified
|
|
744
|
-
// directly — `//` alone throws too.
|
|
394
|
+
// EVERY path through this handler is inside the try, since a throw in a
|
|
395
|
+
// 'request' listener would kill the process while it holds the App's
|
|
396
|
+
// private key — including `new URL`, which rejects some request targets
|
|
397
|
+
// Node's HTTP parser accepts (e.g. `GET //[x HTTP/1.1`).
|
|
745
398
|
try {
|
|
746
399
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
747
400
|
if (url.pathname !== '/callback') {
|
|
@@ -767,11 +420,9 @@ createServerFn = createServer) {
|
|
|
767
420
|
.writeHead(200, { 'content-type': 'text/html', connection: 'close' })
|
|
768
421
|
.end(page('App created', 'The private key was captured. Return to the terminal.'));
|
|
769
422
|
settle(received);
|
|
770
|
-
// Closed HERE, not in the caller's `finally
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
// connections — loopback is not per-user, so on a shared host another
|
|
774
|
-
// UID can reach it.
|
|
423
|
+
// Closed HERE, not in the caller's `finally`: that window spans a human
|
|
424
|
+
// install step of unbounded length, during which loopback — not
|
|
425
|
+
// per-user — would keep accepting connections another UID could reach.
|
|
775
426
|
stopListening();
|
|
776
427
|
}
|
|
777
428
|
catch {
|
|
@@ -787,10 +438,9 @@ createServerFn = createServer) {
|
|
|
787
438
|
});
|
|
788
439
|
return new Promise((resolve, reject) => {
|
|
789
440
|
let listening = false;
|
|
790
|
-
// Without this
|
|
791
|
-
// the process
|
|
792
|
-
//
|
|
793
|
-
// measured: a sandbox that denies listen() produced exactly that.
|
|
441
|
+
// Without this, a bind failure is an unhandled 'error' event that crashes
|
|
442
|
+
// the process, and the promise below never settles — `create` would hang
|
|
443
|
+
// instead of reporting that the port could not be bound.
|
|
794
444
|
server.on('error', (err) => {
|
|
795
445
|
if (listening) {
|
|
796
446
|
fail(err);
|
|
@@ -817,17 +467,13 @@ createServerFn = createServer) {
|
|
|
817
467
|
});
|
|
818
468
|
}
|
|
819
469
|
/**
|
|
820
|
-
* The exchange. Unauthenticated by design: the one-hour code IS the
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
*
|
|
824
|
-
* is
|
|
825
|
-
* Failures collapse to `null` and nothing from the response is surfaced, for
|
|
826
|
-
* the same reason: the code itself is exchangeable for the private key for a
|
|
827
|
-
* full hour. That also drops the HTTP status, which the caller does not report
|
|
828
|
-
* (.claude/future-tasks/init-github-app-review-round-1-lows.md, item 2).
|
|
470
|
+
* The exchange. Unauthenticated by design: the one-hour code IS the
|
|
471
|
+
* credential. Only the four fields this tool needs are lifted out — the
|
|
472
|
+
* response also carries `client_secret`/`webhook_secret`, never logged or
|
|
473
|
+
* surfaced. Failures collapse to `null` for the same reason: the code itself
|
|
474
|
+
* is exchangeable for the private key for a full hour.
|
|
829
475
|
*/
|
|
830
|
-
|
|
476
|
+
async function convertManifest(code) {
|
|
831
477
|
const response = await githubRequest(`/app-manifests/${encodeURIComponent(code)}/conversions`, { method: 'POST' });
|
|
832
478
|
if (!response.ok || !response.body)
|
|
833
479
|
return null;
|
|
@@ -837,31 +483,26 @@ export async function convertManifest(code) {
|
|
|
837
483
|
return { id, slug, clientId, pem };
|
|
838
484
|
}
|
|
839
485
|
/**
|
|
840
|
-
* Whether the account is an organisation, which decides where the manifest
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* guessing — guessing wrong sends the operator to a form that refuses the
|
|
845
|
-
* manifest with no useful explanation.
|
|
486
|
+
* Whether the account is an organisation, which decides where the manifest
|
|
487
|
+
* posts. Unauthenticated, since nothing has a credential yet. `null` means
|
|
488
|
+
* "could not tell", and the caller says so rather than guessing — a wrong
|
|
489
|
+
* guess sends the operator to a form that refuses the manifest silently.
|
|
846
490
|
*/
|
|
847
|
-
|
|
491
|
+
async function detectAccountType(owner) {
|
|
848
492
|
const response = await githubRequest(`/users/${encodeURIComponent(owner)}`);
|
|
849
493
|
if (!response.ok || !response.body?.type)
|
|
850
494
|
return null;
|
|
851
495
|
return response.body.type === 'Organization';
|
|
852
496
|
}
|
|
853
497
|
/**
|
|
854
|
-
* Best-effort check that the App name is not already taken.
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
* blind. This exists because name collisions can otherwise only be discovered by
|
|
861
|
-
* the creation form — with the CLI already sitting on a loopback server waiting
|
|
862
|
-
* for a redirect that will never arrive.
|
|
498
|
+
* Best-effort check that the App name is not already taken. `true`/`false`/
|
|
499
|
+
* `null` (could not check) are kept apart: a PRIVATE App with this slug also
|
|
500
|
+
* answers 404, so 404 isn't proof of availability, and reporting "could not
|
|
501
|
+
* check" as "free" is how a guard goes blind. Exists because a name collision
|
|
502
|
+
* would otherwise surface only at the creation form, with the CLI already on
|
|
503
|
+
* a loopback server waiting for a redirect that will never arrive.
|
|
863
504
|
*/
|
|
864
|
-
|
|
505
|
+
async function checkNameAvailable(slug) {
|
|
865
506
|
const response = await githubRequest(`/apps/${encodeURIComponent(slug)}`);
|
|
866
507
|
if (response.status === 200)
|
|
867
508
|
return false;
|
|
@@ -870,148 +511,29 @@ export async function checkNameAvailable(slug) {
|
|
|
870
511
|
return null;
|
|
871
512
|
}
|
|
872
513
|
/**
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
* pipe like `printf 'abc'` with no trailing newline, or a TTY operator typing
|
|
895
|
-
* text and pressing Ctrl-D twice) answers the prompt normally — `answered` is
|
|
896
|
-
* true when `'close'` fires. The stream having also ended in that same moment
|
|
897
|
-
* used to go unrecorded, because the `'close'` handler only ever set this flag
|
|
898
|
-
* on the `!answered` branch. The next prompt then created a readline interface
|
|
899
|
-
* on an already-ended stream that never emits, and — with nothing else keeping
|
|
900
|
-
* it alive — the event loop emptied and node exited 0 silently: no "Giving up",
|
|
901
|
-
* no "Stopping here … GITHUB_APP_ID" block, no readback, no `finally`.
|
|
902
|
-
* MEASURED with a scratch driver: `printf 'abc' | node --import tsx <driver>`
|
|
903
|
-
* resolved the first prompt to `"abc"` and left the second prompt printed but
|
|
904
|
-
* never resolved, node exiting 0 with nothing after it.
|
|
905
|
-
*
|
|
906
|
-
* So the flag must be set from whether the STREAM ended, not from whether THIS
|
|
907
|
-
* prompt got an answer — the two are independent. `readableEnded` is checked
|
|
908
|
-
* unconditionally in the `'close'` handler below, before the `answered` branch,
|
|
909
|
-
* so it applies whether or not a line came back. This still tells apart the
|
|
910
|
-
* ordinary case from this one: our OWN `rl.close()` call after a line, by
|
|
911
|
-
* itself, fires `'close'` while stdin usually remains open and `readableEnded`
|
|
912
|
-
* stays false — only an input stream that has actually finished sets it.
|
|
913
|
-
*
|
|
914
|
-
* Every prompt below consults this first. Nothing else may create a readline
|
|
915
|
-
* interface on `process.stdin` in this file.
|
|
916
|
-
*/
|
|
917
|
-
let stdinEnded = false;
|
|
918
|
-
function readLineOnce(prompt) {
|
|
919
|
-
if (stdinEnded) {
|
|
920
|
-
// Already at EOF: answer immediately rather than waiting for input that can
|
|
921
|
-
// never arrive.
|
|
922
|
-
console.log(prompt);
|
|
923
|
-
return Promise.resolve(null);
|
|
924
|
-
}
|
|
925
|
-
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
926
|
-
return new Promise((resolve) => {
|
|
927
|
-
console.log(prompt);
|
|
928
|
-
let answered = false;
|
|
929
|
-
rl.once('line', (line) => {
|
|
930
|
-
answered = true;
|
|
931
|
-
rl.close();
|
|
932
|
-
resolve(line);
|
|
933
|
-
});
|
|
934
|
-
rl.once('close', () => {
|
|
935
|
-
// Checked FIRST and unconditionally: `'close'` fires both when WE call
|
|
936
|
-
// `rl.close()` after a line (stdin can still be open) and when the
|
|
937
|
-
// stream itself ends — including the case where a final partial line was
|
|
938
|
-
// just flushed as `'line'`, so `answered` is true but the stream is
|
|
939
|
-
// ALSO finished. `readableEnded` is the one signal that distinguishes
|
|
940
|
-
// "we closed the interface" from "the stream is actually done", and by
|
|
941
|
-
// the time readline's own `'close'` fires, the input stream has already
|
|
942
|
-
// finished emitting `'end'` (readline listens for it internally before
|
|
943
|
-
// closing itself), so the flag is reliable here.
|
|
944
|
-
if (process.stdin.readableEnded) {
|
|
945
|
-
stdinEnded = true;
|
|
946
|
-
}
|
|
947
|
-
if (answered)
|
|
948
|
-
return;
|
|
949
|
-
stdinEnded = true;
|
|
950
|
-
resolve(null);
|
|
951
|
-
});
|
|
952
|
-
});
|
|
953
|
-
}
|
|
954
|
-
/** Wait for the operator to continue. Returns at once if stdin has ended. */
|
|
955
|
-
export async function pressEnter(prompt) {
|
|
956
|
-
await readLineOnce(prompt);
|
|
957
|
-
}
|
|
958
|
-
/** Read one line from the operator. `null` when stdin ended instead. */
|
|
959
|
-
export async function askLine(prompt) {
|
|
960
|
-
const line = await readLineOnce(prompt);
|
|
961
|
-
return line === null ? null : line.trim();
|
|
962
|
-
}
|
|
963
|
-
/** Reset between tests. Not used by the command itself. */
|
|
964
|
-
export function resetStdinStateForTesting() {
|
|
965
|
-
stdinEnded = false;
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Route one line of operator input at the retry prompt, which accepts a FILE
|
|
969
|
-
* PATH and nothing else. Exported and pure so every rule below is a table test
|
|
970
|
-
* rather than something only reachable by driving a live prompt end to end.
|
|
971
|
-
*
|
|
972
|
-
* WHY NO COMMANDS. The first destination can be a command because the
|
|
973
|
-
* operator's own shell parsed `-- <command…>` into argv. A typed line here has
|
|
974
|
-
* no such parser, and three consecutive review rounds each found splitting it on
|
|
975
|
-
* whitespace sending the key somewhere unintended: a one-word answer written as
|
|
976
|
-
* a file in the working directory; a `|` inside the argv becoming a file name;
|
|
977
|
-
* then `>`, `;`, `&&` and quotes each becoming literal argv words, so
|
|
978
|
-
* `tee key.pem > /dev/null` wrote 0644 copies of the key into the repository
|
|
979
|
-
* root and reported it stored. Each fix closed one spelling. Accepting a path
|
|
980
|
-
* only closes the class: an operator who wants a command writes the file, runs
|
|
981
|
-
* the command against it, and deletes the file.
|
|
982
|
-
*
|
|
983
|
-
* The rules, in order:
|
|
984
|
-
*
|
|
985
|
-
* - `null` (stdin has already ended — see `stdinEnded` above) gives up: there
|
|
986
|
-
* is nobody left who could answer.
|
|
987
|
-
* - A blank or whitespace-only line MUST NOT give up. Nothing reads stdin
|
|
988
|
-
* until a readline interface exists, so an Enter pressed during the long
|
|
989
|
-
* wait for GitHub's redirect — or while the FIRST destination's command was
|
|
990
|
-
* running — sits in the terminal's line buffer. When that first hand-off
|
|
991
|
-
* then fails, `askLine` reads the buffered blank line immediately, before
|
|
992
|
-
* the operator has even seen this prompt. Treating that as "give up"
|
|
993
|
-
* discarded the only copy of the key without anyone actually answering.
|
|
994
|
-
* Re-prompting instead costs nothing when the blank line really was
|
|
995
|
-
* intentional, because the very next prompt still offers "give up" as
|
|
996
|
-
* something the operator has to type.
|
|
997
|
-
* - The words "give up", case-insensitively and with whitespace normalised, is
|
|
998
|
-
* the one deliberate way to give up once stdin is live.
|
|
999
|
-
* - Any whitespace inside the answer, or a leading `|`, is re-prompted with
|
|
1000
|
-
* "commands are not accepted here" and the write-run-delete route. Every
|
|
1001
|
-
* command-shaped answer above was spelled with whitespace, and nothing about
|
|
1002
|
-
* a typed line tells a command apart from a path that contains spaces — so
|
|
1003
|
-
* the cost, stated in the reason, is that such a path cannot be entered here.
|
|
1004
|
-
* - A leading `~` is re-prompted: nothing here expands it, so `~/key.pem`
|
|
1005
|
-
* would name a directory literally called `~` under the working directory.
|
|
1006
|
-
* - A single token with NO `/` or `\` — `pbcopy`, `wl-copy`, any script on
|
|
1007
|
-
* PATH — is re-prompted, suggesting `./<token>`. A "one word is a path" rule
|
|
1008
|
-
* once let `writeFile('pbcopy', pem, { flag: 'wx' })` succeed in
|
|
1009
|
-
* `process.cwd()` — normally the git repository root — leaving a
|
|
1010
|
-
* `contents: write` private key sitting untracked on disk, while the
|
|
1011
|
-
* operator read "written to pbcopy (mode 0600)" and believed it had gone
|
|
1012
|
-
* into the command they meant.
|
|
1013
|
-
* - Anything else is a path, which `handOffKey` creates with mode 0600 and
|
|
514
|
+
* Route one line of operator input at the retry prompt, accepting a FILE
|
|
515
|
+
* PATH and nothing else (exported and pure, so every rule is table-tested
|
|
516
|
+
* directly). The first destination can be a command because the operator's
|
|
517
|
+
* shell parses `-- <command…>` into argv; a typed line has no such parser,
|
|
518
|
+
* and splitting on whitespace sends the key somewhere unintended (a stray
|
|
519
|
+
* `|`, `>`, `;`, `&&` or quote becomes a literal argv word). Accepting only a
|
|
520
|
+
* path closes that class — a command means write, run, then delete. Rules,
|
|
521
|
+
* in order:
|
|
522
|
+
* - `null` (stdin has already ended — see prompt.ts) gives up.
|
|
523
|
+
* - A blank or whitespace-only line MUST NOT give up — a stray Enter pressed
|
|
524
|
+
* earlier sits buffered and is read back before the operator has seen this
|
|
525
|
+
* prompt — but the words "give up", case-insensitively and
|
|
526
|
+
* whitespace-normalised, deliberately do once stdin is live.
|
|
527
|
+
* - Whitespace inside the answer, or a leading `|`, is re-prompted: nothing
|
|
528
|
+
* tells a command apart from a path containing spaces, so such paths
|
|
529
|
+
* aren't accepted either.
|
|
530
|
+
* - A leading `~` is re-prompted (unexpanded, `~/key.pem` would name a
|
|
531
|
+
* directory literally called `~`), as is a single token with no `/` or `\`
|
|
532
|
+
* (`pbcopy`, any PATH script): as a bare word it would write the key into
|
|
533
|
+
* the current directory while the operator believes it reached the command.
|
|
534
|
+
* - Anything else is a path: `handOffKey` creates it with mode 0600 and
|
|
1014
535
|
* refuses to write if something already exists there.
|
|
536
|
+
* @internal Exported for tests.
|
|
1015
537
|
*/
|
|
1016
538
|
export function parseKeyRetryAnswer(answer) {
|
|
1017
539
|
if (answer === null)
|
|
@@ -1047,21 +569,13 @@ export function parseKeyRetryAnswer(answer) {
|
|
|
1047
569
|
}
|
|
1048
570
|
/**
|
|
1049
571
|
* Hand the key over, and keep asking while the operator still has a chance.
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* a
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
* So a failure asks for a file path instead, while the key is still in memory —
|
|
1059
|
-
* a path only, never a command, for the reasons on `parseKeyRetryAnswer`.
|
|
1060
|
-
* `create` already requires a TTY, so there is someone there to answer. Only a
|
|
1061
|
-
* closed stdin (nobody left to answer) or the operator typing "give up" ends the
|
|
1062
|
-
* loop without a destination — a blank line re-prompts instead, since that is
|
|
1063
|
-
* exactly what a stray Enter pressed before this prompt existed leaves buffered.
|
|
1064
|
-
* See `parseKeyRetryAnswer` for the exact rules.
|
|
572
|
+
* The pre-flight can only refuse a MISSING destination — a bad path or a
|
|
573
|
+
* command not on PATH fails only here, and exiting on that would destroy the
|
|
574
|
+
* only copy of a private key for an App that already exists, over a typo. So
|
|
575
|
+
* a failure asks for a file path instead, while the key is still in memory —
|
|
576
|
+
* a path only, never a command, per `parseKeyRetryAnswer`'s rules. Only a
|
|
577
|
+
* closed stdin or "give up" ends the loop without a destination.
|
|
578
|
+
* @internal Exported for tests.
|
|
1065
579
|
*/
|
|
1066
580
|
export async function handOffWithRetry(pem, destination) {
|
|
1067
581
|
let attempt = destination;
|
|
@@ -1099,16 +613,11 @@ function describeDestination(destination) {
|
|
|
1099
613
|
: `\`${destination.argv.join(' ')}\``;
|
|
1100
614
|
}
|
|
1101
615
|
/**
|
|
1102
|
-
* How many accounts this App is installed on, or `null` when it cannot be
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
* this repository plus nine others. This is the check that actually observes
|
|
1108
|
-
* the invariant, and it costs one call with the JWT already in hand.
|
|
1109
|
-
*
|
|
1110
|
-
* `null` and a number are kept apart: "could not read the installation list" must
|
|
1111
|
-
* never be reported as "checked, there is exactly one".
|
|
616
|
+
* How many accounts this App is installed on, or `null` when it cannot be
|
|
617
|
+
* read — the one-App-per-site rule (see the file header) is only worth
|
|
618
|
+
* stating if something checks it, and `repository_selection: 'selected'`
|
|
619
|
+
* doesn't (equally true scoped here plus nine others). `null` and a number
|
|
620
|
+
* are kept apart: "could not read" must never report as "exactly one".
|
|
1112
621
|
*/
|
|
1113
622
|
async function installationCount(jwt) {
|
|
1114
623
|
const listed = await githubRequest('/app/installations?per_page=100', { jwt });
|
|
@@ -1118,28 +627,20 @@ async function installationCount(jwt) {
|
|
|
1118
627
|
}
|
|
1119
628
|
/**
|
|
1120
629
|
* Mint an installation token narrowed to this repository and the declared
|
|
1121
|
-
* permissions, then revoke it
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* surfaces nowhere else — **adding a permission to an App does not apply to
|
|
1128
|
-
* existing installations until an account owner ACCEPTS the new request**, so an
|
|
1129
|
-
* App whose settings page looks correct can hold a stale grant, and the mint
|
|
1130
|
-
* fails with a 422 that sounds like the App is misconfigured.
|
|
1131
|
-
*
|
|
1132
|
-
* The token is revoked immediately. Leaving a live `contents: write` credential
|
|
1133
|
-
* valid for an hour after a read-only check exits would be careless for no gain.
|
|
630
|
+
* permissions, then revoke it — proof the key signs, the App isn't
|
|
631
|
+
* suspended, and a token issues. Also catches a GitHub quirk found nowhere
|
|
632
|
+
* else: a new permission doesn't apply to existing installations until an
|
|
633
|
+
* owner ACCEPTS it, so a correct-looking App can still fail with a
|
|
634
|
+
* misleading 422. Revoked at once — no reason to leave `contents: write`
|
|
635
|
+
* live for an hour after a read-only check.
|
|
1134
636
|
*/
|
|
1135
637
|
async function proveTokenMint(jwt, installationId, repo) {
|
|
1136
638
|
const minted = await githubRequest(`/app/installations/${installationId}/access_tokens`, {
|
|
1137
639
|
jwt,
|
|
1138
640
|
method: 'POST',
|
|
1139
|
-
// Scoped by repository NAME
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
// answers a JWT with 401).
|
|
641
|
+
// Scoped by repository NAME: the endpoint applies the same narrowing as
|
|
642
|
+
// ids, and resolving a name to an id would need `GET /repos/{o}/{r}`,
|
|
643
|
+
// which an App JWT cannot reach (401).
|
|
1143
644
|
body: { repositories: [repo], permissions: { ...CANOPY_APP_PERMISSIONS } },
|
|
1144
645
|
});
|
|
1145
646
|
if (!minted.ok || !minted.body?.token) {
|
|
@@ -1152,32 +653,24 @@ async function proveTokenMint(jwt, installationId, repo) {
|
|
|
1152
653
|
const note = revoked.status === 204 ? 'revoked' : `NOT revoked (HTTP ${revoked.status})`;
|
|
1153
654
|
return { ok: true, detail: `minted and ${note}` };
|
|
1154
655
|
}
|
|
1155
|
-
/**
|
|
1156
|
-
* Read an installation back and report on it. Shared by `create` (right after
|
|
1157
|
-
* the operator installs the App) and `verify` (any time afterwards).
|
|
1158
|
-
*/
|
|
656
|
+
/** Read an installation back and report on it. Shared by `create` (after install) and `verify`. */
|
|
1159
657
|
async function readBackInstallation(appId, privateKey, target) {
|
|
1160
658
|
// Normalised the same way the worker normalises its own key
|
|
1161
|
-
// (`normalizeGitHubAppPrivateKey`, `worker/github-auth.ts`)
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
// sign a JWT" on a key the worker boots on fine. Called here rather than in
|
|
1168
|
-
// `cli.ts` so `create` goes through it too: GitHub's own freshly-minted PEM is
|
|
1169
|
-
// PKCS#1, so this re-exports it as PKCS#8 — the same key, differently encoded.
|
|
659
|
+
// (`normalizeGitHubAppPrivateKey`, `worker/github-auth.ts`) before it signs:
|
|
660
|
+
// trims it, unescapes a literal `\n`, unwraps a base64-wrapped PEM, and
|
|
661
|
+
// re-exports PKCS#8. `docs/deploying-to-aws.md` has operators pipe that
|
|
662
|
+
// exact secret, however it left Secrets Manager, into `verify --key-stdin`,
|
|
663
|
+
// which would otherwise fail to sign on a key the worker boots on fine.
|
|
664
|
+
// Called here too so `create` re-exports GitHub's PKCS#1 PEM as PKCS#8.
|
|
1170
665
|
let normalizedKey;
|
|
1171
666
|
try {
|
|
1172
667
|
normalizedKey = normalizeGitHubAppPrivateKey(privateKey);
|
|
1173
668
|
}
|
|
1174
669
|
catch (err) {
|
|
1175
670
|
// Distinct from the "could not sign a JWT" failure below: this key does not
|
|
1176
|
-
// even parse as a PEM once the common manglings are undone
|
|
1177
|
-
// never
|
|
1178
|
-
//
|
|
1179
|
-
// anyway, on the same belt-and-braces basis as every other error surfaced
|
|
1180
|
-
// in this file.
|
|
671
|
+
// even parse as a PEM once the common manglings are undone. `getErrorMessage`
|
|
672
|
+
// here names a PEM format problem, never key material, but is redacted
|
|
673
|
+
// anyway for consistency with every other error surfaced in this file.
|
|
1181
674
|
console.error(`\nThe private key could not be normalised: ${redactCredentials(getErrorMessage(err))}\n` +
|
|
1182
675
|
' If you supplied this key, check that it is the App private key (the downloaded\n' +
|
|
1183
676
|
' .pem), NOT the "client secret" listed a few sections above it on the App\'s\n' +
|
|
@@ -1204,12 +697,9 @@ async function readBackInstallation(appId, privateKey, target) {
|
|
|
1204
697
|
// Say WHICH of the two it is. Reporting "not installed" for an auth failure
|
|
1205
698
|
// sends the operator to check a page that is correct.
|
|
1206
699
|
if (found.status === 0) {
|
|
1207
|
-
// THREE cases, not two
|
|
1208
|
-
// status 0 with the
|
|
1209
|
-
//
|
|
1210
|
-
// a DNS failure, a proxy refusal or a timeout — with the actual error
|
|
1211
|
-
// discarded. Reading the body inside the try made this strictly more
|
|
1212
|
-
// reachable, since a reset mid-body now lands here instead of throwing.
|
|
700
|
+
// THREE cases, not two: `githubRequest` reports a transport failure as
|
|
701
|
+
// status 0 with the cause in `message`, which the else below would
|
|
702
|
+
// otherwise misreport as a confident "not installed".
|
|
1213
703
|
console.error(`\nCould not reach api.github.com: ${found.message}\n` +
|
|
1214
704
|
' This says nothing about the App or its installation. Check network access and\n' +
|
|
1215
705
|
' run `canopycms init-github-app verify` again.');
|
|
@@ -1241,16 +731,9 @@ async function readBackInstallation(appId, privateKey, target) {
|
|
|
1241
731
|
console.error(` ✗ ${finding.message}`);
|
|
1242
732
|
}
|
|
1243
733
|
}
|
|
1244
|
-
// The one-App-per-site invariant, observed
|
|
1245
|
-
// installation
|
|
1246
|
-
//
|
|
1247
|
-
// EXACTLY one is the pass, and everything else fails — including "could not
|
|
1248
|
-
// check". The looser first version printed a ✓ for a count of 0 (a state that
|
|
1249
|
-
// should not occur, but a tick for an unobserved fact is the failure these
|
|
1250
|
-
// comments exist to prevent) and let `null` leave the overall verdict at
|
|
1251
|
-
// "All checks passed", which is a check that did not run reported as one that
|
|
1252
|
-
// did. `repository_selection` is strict about `undefined` for the same
|
|
1253
|
-
// reason, and these two now agree.
|
|
734
|
+
// The one-App-per-site invariant, observed not asserted: EXACTLY one
|
|
735
|
+
// installation is the pass and everything else fails, including "could not
|
|
736
|
+
// check", matching how `repository_selection` treats `undefined`.
|
|
1254
737
|
let installationsOk = false;
|
|
1255
738
|
const installations = await installationCount(jwt);
|
|
1256
739
|
if (installations === null) {
|
|
@@ -1307,13 +790,10 @@ async function resolveTarget(options) {
|
|
|
1307
790
|
' Pass them explicitly: --owner <account> --repo <repository>');
|
|
1308
791
|
return null;
|
|
1309
792
|
}
|
|
1310
|
-
// Only `create` needs the account type,
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
// unauthenticated requests are rate-limited at 60/hour per IP, which a shared
|
|
1315
|
-
// egress address reaches without anyone doing anything wrong. `verify`
|
|
1316
|
-
// validates the owner far better anyway, by looking the installation up.
|
|
793
|
+
// Only `create` needs the account type, to pick the manifest form's URL.
|
|
794
|
+
// Making `verify` depend on this unauthenticated (60/hour per IP) lookup
|
|
795
|
+
// would give the read-only, non-interactive command a failure unrelated to
|
|
796
|
+
// the App — it validates the owner far better anyway, via the installation.
|
|
1317
797
|
if (options.mode !== 'create') {
|
|
1318
798
|
return { owner, repo, isOrganization: false };
|
|
1319
799
|
}
|
|
@@ -1339,9 +819,8 @@ async function createCommand(options) {
|
|
|
1339
819
|
'the only copy of its key and has nowhere to put it.');
|
|
1340
820
|
return 1;
|
|
1341
821
|
}
|
|
1342
|
-
// Refuse rather than hang
|
|
1343
|
-
//
|
|
1344
|
-
// only copy of its key about to die with the job.
|
|
822
|
+
// Refuse rather than hang: in CI this would block forever at "press Enter"
|
|
823
|
+
// with a live App already created and its only key about to die with the job.
|
|
1345
824
|
if (!process.stdin.isTTY) {
|
|
1346
825
|
console.error('`init-github-app create` needs an interactive terminal: it waits while you create the\n' +
|
|
1347
826
|
' App in a browser and then install it. Run it from a terminal, not from CI.\n\n' +
|
|
@@ -1372,11 +851,9 @@ async function createCommand(options) {
|
|
|
1372
851
|
if (available === null) {
|
|
1373
852
|
console.log(`! Could not check whether the name "${name}" is already taken, so this is unverified.\n`);
|
|
1374
853
|
}
|
|
1375
|
-
// PRINTED BEFORE THE BROWSER OPENS, not on failure
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
// Printing the recovery only when that happens would put it in a terminal that
|
|
1379
|
-
// may already be gone.
|
|
854
|
+
// PRINTED BEFORE THE BROWSER OPENS, not on failure: if the redirect never
|
|
855
|
+
// arrives, the App HAS still been created with no key held, and printing
|
|
856
|
+
// the recovery only then could target a terminal that is already gone.
|
|
1380
857
|
console.log('IF THIS GOES WRONG AFTER YOU CLICK CREATE, read this first:\n' +
|
|
1381
858
|
' The App will exist and this command will not have its private key. That is\n' +
|
|
1382
859
|
' recoverable. Either:\n' +
|
|
@@ -1392,9 +869,9 @@ async function createCommand(options) {
|
|
|
1392
869
|
callback = await startCallbackServer(state);
|
|
1393
870
|
}
|
|
1394
871
|
catch (err) {
|
|
1395
|
-
// Nothing has been created yet, so this is the cheap failure
|
|
1396
|
-
// rather than as a raw errno,
|
|
1397
|
-
//
|
|
872
|
+
// Nothing has been created yet, so this is the cheap failure — said plainly
|
|
873
|
+
// rather than as a raw errno, since a denied bind is usually a sandbox or
|
|
874
|
+
// host firewall, not GitHub.
|
|
1398
875
|
console.error(`Could not open the local callback server: ${getErrorMessage(err)}\n\n` +
|
|
1399
876
|
" This command needs to listen on 127.0.0.1 to receive GitHub's redirect, which is\n" +
|
|
1400
877
|
' how the private key is captured without ever touching disk. A sandbox or host\n' +
|
|
@@ -1406,13 +883,10 @@ async function createCommand(options) {
|
|
|
1406
883
|
const redirectUrl = `http://127.0.0.1:${callback.port}/callback`;
|
|
1407
884
|
const manifest = appManifest(target, redirectUrl, name);
|
|
1408
885
|
const postUrl = manifestPostUrl(target, state);
|
|
1409
|
-
// A private directory
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
// target, while `mode` only applies when it creates the file. The form is
|
|
1414
|
-
// not secret (manifest, state, port), but it is what the operator's browser
|
|
1415
|
-
// is about to POST to GitHub.
|
|
886
|
+
// A private directory, not a predictable name in a shared one: on Linux
|
|
887
|
+
// `tmpdir()` is `/tmp` for every user and the slug derives from a public
|
|
888
|
+
// repo name, so `create-<slug>.html` is guessable, and `writeFile`'s
|
|
889
|
+
// default `w` follows an existing symlink and truncates its target.
|
|
1416
890
|
formDir = await mkdtemp(path.join(tmpdir(), 'canopycms-app-'));
|
|
1417
891
|
const formFile = path.join(formDir, `create-${slug}.html`);
|
|
1418
892
|
await writeFile(formFile, creationForm(postUrl, manifest), { mode: 0o600, flag: 'wx' });
|
|
@@ -1458,24 +932,13 @@ async function createCommand(options) {
|
|
|
1458
932
|
console.log(` created App ${created.slug} (id ${created.id}) — private key captured; sending it to` +
|
|
1459
933
|
` ${describeDestination(destination)}`);
|
|
1460
934
|
// THE KEY IS HANDED OFF FIRST, before the install prompt and the readback,
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
// a private key for an App that already exists. Everything between the
|
|
1465
|
-
// conversion and the hand-off is therefore a window in which losing the
|
|
1466
|
-
// process loses the key: the install step waits on a human for an unbounded
|
|
1467
|
-
// time (one Ctrl-C and it is gone), and the readback makes two network
|
|
1468
|
-
// calls that can reject rather than return — a stalled response body or the
|
|
1469
|
-
// request timeout firing mid-read would unwind straight past the hand-off.
|
|
1470
|
-
//
|
|
1471
|
-
// Nothing in the hand-off depends on the readback, so there is no reason to
|
|
1472
|
-
// carry the key across either. Doing it here reduces the window to the
|
|
1473
|
-
// conversion call itself.
|
|
935
|
+
// because until it returns this process holds the ONLY copy of the key
|
|
936
|
+
// while the install step waits on a human indefinitely and the readback
|
|
937
|
+
// makes network calls that can reject instead of return.
|
|
1474
938
|
const stored = await handOffWithRetry(created.pem, destination);
|
|
1475
939
|
if (!stored) {
|
|
1476
|
-
// No point
|
|
1477
|
-
//
|
|
1478
|
-
// App id, which is the handle for generating a replacement key — and stop.
|
|
940
|
+
// No point installing an App whose key was just discarded. Say what IS
|
|
941
|
+
// still actionable — the App id, the handle for a replacement key.
|
|
1479
942
|
console.error(`\nStopping here: the App exists but its key was not stored, so installing it now\n` +
|
|
1480
943
|
' would achieve nothing. To recover, open the App and generate a private key from\n' +
|
|
1481
944
|
' its "Private keys" section, then run `verify`; or delete the App and start again.\n\n' +
|
|
@@ -1488,9 +951,9 @@ async function createCommand(options) {
|
|
|
1488
951
|
' That page shows an Install button first — the repository picker only appears after\n' +
|
|
1489
952
|
' you click it. Choose "Only select repositories" and pick that one repository.\n');
|
|
1490
953
|
await pressEnter(' Press Enter once it is installed.');
|
|
1491
|
-
// Wrapped
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
954
|
+
// Wrapped so a rejection here cannot change what was already reported about
|
|
955
|
+
// the key: by this point it is stored (or deliberately not), and a
|
|
956
|
+
// readback failure is only ever advisory.
|
|
1494
957
|
let readback;
|
|
1495
958
|
try {
|
|
1496
959
|
readback = await readBackInstallation(String(created.id), created.pem, target);
|