@graph8/sdk 0.370.0 → 0.374.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/blocks/_foundation/README.md +61 -0
- package/blocks/_foundation/block.json +57 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
- package/blocks/_foundation/components/empty-state.tsx +5 -2
- package/blocks/_foundation/components/list-select.tsx +112 -0
- package/blocks/_foundation/components/paging-notices.tsx +55 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
- package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/split-view.tsx +1 -1
- package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
- package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
- package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
- package/blocks/_foundation/lib/paging.ts +57 -0
- package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/agents/README.md +81 -4
- package/blocks/agents/block.json +311 -2
- package/blocks/agents/components/agent-activity.tsx +112 -0
- package/blocks/agents/components/agent-approvals.tsx +135 -0
- package/blocks/agents/components/agent-builder.tsx +17 -3
- package/blocks/agents/components/agent-conversations.tsx +143 -0
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
- package/blocks/agents/components/agent-operators.tsx +164 -0
- package/blocks/agents/components/agent-run-detail.tsx +20 -3
- package/blocks/agents/components/agent-runs.tsx +3 -3
- package/blocks/agents/components/agent-skills-panel.tsx +7 -7
- package/blocks/agents/components/agent-test-history.tsx +121 -0
- package/blocks/agents/components/agents-screen.tsx +53 -11
- package/blocks/agents/components/assistant-sessions.tsx +150 -0
- package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
- package/blocks/agents/components/from-template-button.tsx +65 -0
- package/blocks/agents/components/knowledge-browser.tsx +107 -0
- package/blocks/agents/components/knowledge-detail.tsx +156 -0
- package/blocks/agents/components/knowledge-form.tsx +69 -0
- package/blocks/agents/components/name-dialog.tsx +32 -0
- package/blocks/agents/components/receptionist-button.tsx +46 -0
- package/blocks/agents/components/save-as-template-button.tsx +32 -0
- package/blocks/agents/components/skill-editor.tsx +147 -0
- package/blocks/agents/components/skill-library.tsx +111 -0
- package/blocks/agents/lib/agent-types.ts +7 -1
- package/blocks/agents/lib/facts.tsx +13 -0
- package/blocks/agents/lib/knowledge-utils.ts +49 -0
- package/blocks/agents/lib/load-more.tsx +141 -0
- package/blocks/agents/lib/skill-utils.ts +91 -0
- package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
- package/blocks/analytics/components/analytics-screen.tsx +16 -3
- package/blocks/analytics/components/kpi-tiles.tsx +1 -1
- package/blocks/analytics/components/report-builder.tsx +15 -2
- package/blocks/appointments/README.md +6 -0
- package/blocks/appointments/block.json +27 -0
- package/blocks/appointments/components/booking-page.tsx +2 -2
- package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
- package/blocks/appointments/components/meeting-detail.tsx +2 -2
- package/blocks/appointments/components/meeting-list.tsx +29 -13
- package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-assets.tsx +1 -1
- package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
- package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
- package/blocks/campaigns/components/icp-detail.tsx +11 -2
- package/blocks/campaigns/components/icp-editor.tsx +8 -3
- package/blocks/campaigns/components/persona-detail.tsx +11 -2
- package/blocks/campaigns/components/persona-editor.tsx +6 -1
- package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
- package/blocks/coach/components/call-scorecard.tsx +7 -7
- package/blocks/company-intelligence/README.md +132 -0
- package/blocks/company-intelligence/block.json +318 -0
- package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
- package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
- package/blocks/company-intelligence/components/company-profile.tsx +68 -0
- package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
- package/blocks/company-intelligence/components/report-detail.tsx +189 -0
- package/blocks/company-intelligence/components/research-reports.tsx +136 -0
- package/blocks/company-intelligence/components/research-run.tsx +165 -0
- package/blocks/company-intelligence/components/source-detail.tsx +117 -0
- package/blocks/company-intelligence/components/source-view.tsx +274 -0
- package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
- package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
- package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
- package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
- package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
- package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
- package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
- package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
- package/blocks/copilot/components/copilot-memory.tsx +6 -2
- package/blocks/copilot/components/copilot-panel.tsx +1 -1
- package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
- package/blocks/crm/README.md +25 -0
- package/blocks/crm/block.json +257 -9
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/companies-screen.tsx +23 -9
- package/blocks/crm/components/company-detail.tsx +2 -2
- package/blocks/crm/components/contact-detail.tsx +4 -4
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +85 -0
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +77 -18
- package/blocks/crm/components/dedupe-review.tsx +6 -1
- package/blocks/crm/components/import-wizard.tsx +3 -7
- package/blocks/crm/components/list-select.tsx +20 -0
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-engagement-tab.tsx +103 -0
- package/blocks/crm/components/record-form.tsx +1 -1
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/tasks-screen.tsx +35 -15
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-all-lists.ts +18 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/hooks/use-page-query.ts +3 -1
- package/blocks/crm/lib/crm-types.ts +14 -0
- package/blocks/crm/lib/read-lists.ts +16 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +25 -6
- package/blocks/deals/block.json +168 -2
- package/blocks/deals/components/close-deal-dialog.tsx +169 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
- package/blocks/deals/components/deal-detail.tsx +7 -5
- package/blocks/deals/components/deal-filters.tsx +65 -0
- package/blocks/deals/components/deal-line-items.tsx +120 -0
- package/blocks/deals/components/deal-notes-tab.tsx +97 -0
- package/blocks/deals/components/deal-related.tsx +30 -9
- package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
- package/blocks/deals/components/deals-screen.tsx +57 -22
- package/blocks/deals/components/forecast-view.tsx +4 -4
- package/blocks/deals/components/pipeline-board.tsx +8 -6
- package/blocks/deals/components/pipeline-editor.tsx +132 -0
- package/blocks/deals/components/record-deals-tab.tsx +62 -0
- package/blocks/deals/components/stage-editor.tsx +1 -1
- package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
- package/blocks/deals/lib/deal-extras.ts +156 -0
- package/blocks/dialer/README.md +14 -5
- package/blocks/dialer/block.json +17 -2
- package/blocks/dialer/components/call-contact-button.tsx +26 -9
- package/blocks/dialer/components/call-log-screen.tsx +10 -2
- package/blocks/dialer/components/call-log.tsx +16 -4
- package/blocks/dialer/components/call-queue.tsx +3 -3
- package/blocks/dialer/components/dialer-screen.tsx +69 -27
- package/blocks/dialer/components/disposition-panel.tsx +1 -1
- package/blocks/dialer/components/record-calls-tab.tsx +101 -0
- package/blocks/dialer/components/session-start-form.tsx +58 -11
- package/blocks/dialer/components/softphone.tsx +1 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +38 -10
- package/blocks/enrichment/block.json +164 -12
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +79 -30
- package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
- package/blocks/enrichment/components/enrich-button.tsx +1 -1
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +103 -19
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/components/waterfall-config.tsx +27 -7
- package/blocks/enrichment/lib/enrichment-types.ts +51 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
- package/blocks/forms/README.md +10 -0
- package/blocks/forms/block.json +107 -0
- package/blocks/forms/components/form-activity.tsx +71 -0
- package/blocks/forms/components/form-backfill.tsx +108 -0
- package/blocks/forms/components/form-builder.tsx +1 -1
- package/blocks/forms/components/form-embed.tsx +1 -1
- package/blocks/forms/components/form-groups.tsx +151 -0
- package/blocks/forms/components/form-mapping-editor.tsx +6 -9
- package/blocks/forms/components/form-route-history.tsx +72 -0
- package/blocks/forms/components/form-saved-views.tsx +81 -0
- package/blocks/forms/components/forms-screen.tsx +57 -20
- package/blocks/forms/components/more-rows.tsx +30 -0
- package/blocks/forms/components/submissions-export.tsx +67 -0
- package/blocks/forms/components/submissions-table.tsx +15 -6
- package/blocks/forms/lib/forms-extra.ts +67 -0
- package/blocks/inbox/README.md +45 -4
- package/blocks/inbox/block.json +145 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-badge.tsx +2 -2
- package/blocks/inbox/components/thread-view.tsx +3 -1
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/integrations/README.md +54 -1
- package/blocks/integrations/block.json +189 -15
- package/blocks/integrations/components/ad-accounts.tsx +82 -0
- package/blocks/integrations/components/connection-health.tsx +59 -0
- package/blocks/integrations/components/crm-detail.tsx +20 -1
- package/blocks/integrations/components/crm-records.tsx +103 -0
- package/blocks/integrations/components/field-mappings.tsx +61 -0
- package/blocks/integrations/components/integration-list.tsx +4 -4
- package/blocks/integrations/components/integrations-screen.tsx +20 -3
- package/blocks/integrations/components/migration-panel.tsx +133 -0
- package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
- package/blocks/integrations/components/sync-status.tsx +6 -6
- package/blocks/integrations/components/sync-triggers.tsx +134 -0
- package/blocks/integrations/components/webhook-detail.tsx +26 -7
- package/blocks/integrations/components/webhook-manager.tsx +30 -17
- package/blocks/integrations/components/workspace-tools.tsx +127 -0
- package/blocks/integrations/lib/crm-utils.ts +39 -0
- package/blocks/integrations/lib/facts.tsx +13 -0
- package/blocks/integrations/lib/integrations-utils.ts +5 -0
- package/blocks/integrations/lib/load-more.tsx +141 -0
- package/blocks/intent/components/account-intent-score.tsx +5 -5
- package/blocks/intent/components/intent-feed.tsx +8 -8
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
- package/blocks/intent/lib/intent-utils.ts +4 -4
- package/blocks/lifecycle/components/account-related.tsx +8 -1
- package/blocks/lifecycle/components/churn-list.tsx +5 -2
- package/blocks/lifecycle/components/customer-table.tsx +1 -1
- package/blocks/lists/README.md +7 -1
- package/blocks/lists/block.json +31 -3
- package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
- package/blocks/lists/components/add-to-list-button.tsx +17 -7
- package/blocks/lists/components/list-picker.tsx +13 -3
- package/blocks/lists/components/list-table.tsx +1 -1
- package/blocks/lists/components/lists-screen.tsx +4 -2
- package/blocks/lists/components/record-lists-tab.tsx +99 -0
- package/blocks/lists/components/suppression-manager.tsx +27 -6
- package/blocks/lists/components/suppressions-screen.tsx +21 -12
- package/blocks/mailboxes/block.json +4 -0
- package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
- package/blocks/mailboxes/components/domain-detail.tsx +10 -4
- package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
- package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
- package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
- package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
- package/blocks/mailboxes/components/warmup-health.tsx +20 -20
- package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
- package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
- package/blocks/newsletter/README.md +12 -0
- package/blocks/newsletter/block.json +117 -0
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
- package/blocks/newsletter/components/issue-versions.tsx +93 -0
- package/blocks/newsletter/components/more-rows.tsx +30 -0
- package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
- package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +38 -13
- package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
- package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
- package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
- package/blocks/newsletter/components/subscriber-table.tsx +5 -7
- package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
- package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
- package/blocks/nurture/components/nurture-builder.tsx +10 -2
- package/blocks/nurture/components/nurture-enroll.tsx +6 -7
- package/blocks/nurture/components/nurture-form.tsx +5 -8
- package/blocks/nurture/components/nurture-screen.tsx +16 -5
- package/blocks/nurture/components/nurture-stats.tsx +5 -4
- package/blocks/objects/README.md +1 -1
- package/blocks/objects/block.json +13 -5
- package/blocks/objects/components/attribute-field-input.tsx +14 -1
- package/blocks/objects/components/object-record-detail.tsx +8 -1
- package/blocks/objects/components/object-schema-editor.tsx +1 -1
- package/blocks/objects/components/record-roles-panel.tsx +15 -5
- package/blocks/pages/README.md +14 -0
- package/blocks/pages/block.json +123 -2
- package/blocks/pages/components/more-rows.tsx +30 -0
- package/blocks/pages/components/page-ai-tools.tsx +196 -0
- package/blocks/pages/components/page-analytics.tsx +176 -0
- package/blocks/pages/components/page-clone.tsx +99 -0
- package/blocks/pages/components/page-domains.tsx +167 -0
- package/blocks/pages/components/page-editor.tsx +1 -1
- package/blocks/pages/components/page-generation-status.tsx +48 -0
- package/blocks/pages/components/page-tracking.tsx +137 -0
- package/blocks/pages/components/pages-screen.tsx +57 -16
- package/blocks/pages/components/seo-report.tsx +6 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/pages/lib/studio.ts +7 -0
- package/blocks/phone-numbers/README.md +74 -0
- package/blocks/phone-numbers/block.json +262 -0
- package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
- package/blocks/phone-numbers/components/assign-number.tsx +104 -0
- package/blocks/phone-numbers/components/number-search.tsx +239 -0
- package/blocks/phone-numbers/components/number-settings.tsx +134 -0
- package/blocks/phone-numbers/components/number-usage.tsx +97 -0
- package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
- package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
- package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
- package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
- package/blocks/phone-numbers/components/release-number.tsx +126 -0
- package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
- package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
- package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
- package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
- package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
- package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
- package/blocks/pickers/README.md +23 -0
- package/blocks/pickers/block.json +103 -0
- package/blocks/pickers/components/company-picker.tsx +90 -0
- package/blocks/pickers/components/contact-picker.tsx +131 -0
- package/blocks/pickers/components/member-picker.tsx +99 -0
- package/blocks/pickers/components/search-picker.tsx +98 -0
- package/blocks/pickers/hooks/use-current-member.ts +104 -0
- package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
- package/blocks/pickers/lib/use-debounced.ts +13 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +38 -1
- package/blocks/quotes/block.json +170 -6
- package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-builder.tsx +1 -1
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
- package/blocks/quotes/components/quote-list.tsx +28 -8
- package/blocks/quotes/components/quote-related.tsx +3 -2
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +23 -5
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/radar/components/competitor-battle-card.tsx +1 -1
- package/blocks/radar/components/radar-overview.tsx +4 -4
- package/blocks/radar/components/radar-screen.tsx +2 -2
- package/blocks/sequencer/README.md +61 -0
- package/blocks/sequencer/block.json +200 -6
- package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
- package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
- package/blocks/sequencer/components/sequence-builder.tsx +32 -23
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +10 -2
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +5 -2
- package/blocks/sequencer/components/sequence-list.tsx +1 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-stats.tsx +8 -8
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +39 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +21 -12
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +9 -5
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/social-ads/README.md +8 -0
- package/blocks/social-ads/block.json +137 -4
- package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
- package/blocks/social-ads/components/audience-sync.tsx +6 -2
- package/blocks/social-ads/components/campaign-ads.tsx +166 -0
- package/blocks/social-ads/components/campaign-detail.tsx +6 -1
- package/blocks/social-ads/components/campaign-editor.tsx +134 -0
- package/blocks/social-ads/components/creative-concepts.tsx +74 -0
- package/blocks/social-ads/components/creative-detail.tsx +6 -1
- package/blocks/social-ads/components/creative-gallery.tsx +20 -2
- package/blocks/social-ads/components/creative-revisions.tsx +57 -0
- package/blocks/social-ads/components/creative-tools.tsx +176 -0
- package/blocks/social-ads/components/post-composer.tsx +4 -2
- package/blocks/social-ads/components/post-detail.tsx +3 -0
- package/blocks/social-ads/components/social-ideas.tsx +64 -0
- package/blocks/social-ads/components/social-performance.tsx +80 -0
- package/blocks/social-ads/components/social-screen.tsx +33 -6
- package/blocks/social-ads/lib/ads-extra.ts +67 -0
- package/blocks/surveys/README.md +91 -0
- package/blocks/surveys/block.json +299 -0
- package/blocks/surveys/components/account-nps.tsx +69 -0
- package/blocks/surveys/components/contact-survey-history.tsx +52 -0
- package/blocks/surveys/components/org-summary.tsx +82 -0
- package/blocks/surveys/components/sending-domains.tsx +113 -0
- package/blocks/surveys/components/survey-form.tsx +126 -0
- package/blocks/surveys/components/survey-responses.tsx +145 -0
- package/blocks/surveys/components/survey-results.tsx +72 -0
- package/blocks/surveys/components/survey-send-history.tsx +58 -0
- package/blocks/surveys/components/survey-send.tsx +162 -0
- package/blocks/surveys/components/survey-triggers.tsx +166 -0
- package/blocks/surveys/components/surveys-screen.tsx +234 -0
- package/blocks/surveys/lib/surveys-types.ts +228 -0
- package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
- package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
- package/blocks/surveys/recipes/survey-score-report.ts +27 -0
- package/blocks/visitors/components/snippet-install.tsx +18 -9
- package/blocks/visitors/components/traffic-chart.tsx +7 -6
- package/blocks/visitors/components/visitor-company-card.tsx +24 -14
- package/blocks/visitors/components/visitor-feed.tsx +5 -5
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/webchat/components/chat-widget-config.tsx +1 -1
- package/blocks/webchat/components/conversation-list.tsx +1 -1
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
- package/blocks/workbench/components/run-progress.tsx +1 -1
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +71 -8
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +65 -22
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-list.tsx +14 -2
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/blocks/workflows/components/workflow-editor.tsx +1 -1
- package/dist/cli.js +744 -394
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/brand.d.ts +11 -2
- package/dist/types/blocks/create.d.ts +11 -4
- package/dist/types/blocks/doctor.d.ts +1 -0
- package/dist/types/blocks/flows.d.ts +7 -1
- package/dist/types/blocks/orgBrand.d.ts +34 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/desk.d.ts +2 -2
- package/docs/api/desk.md +3 -3
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
- package/stacks/6sense/pages/contacts.tsx.tmpl +21 -0
- package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
- package/stacks/6sense.json +37 -4
- package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
- package/stacks/apollo/pages/contacts.tsx.tmpl +35 -95
- package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/apollo/pages/lists.tsx.tmpl +24 -0
- package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
- package/stacks/apollo/pages/sequences.tsx.tmpl +18 -0
- package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
- package/stacks/apollo.json +68 -9
- package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
- package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
- package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
- package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
- package/stacks/calendly.json +8 -4
- package/stacks/clay/pages/companies.tsx.tmpl +17 -1
- package/stacks/clay/pages/contacts.tsx.tmpl +14 -0
- package/stacks/clay/pages/lists.tsx.tmpl +24 -0
- package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
- package/stacks/clay.json +16 -3
- package/stacks/gong/pages/calls.tsx.tmpl +23 -0
- package/stacks/gong/pages/companies.tsx.tmpl +22 -0
- package/stacks/gong/pages/contacts.tsx.tmpl +34 -0
- package/stacks/gong/pages/lists.tsx.tmpl +12 -0
- package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
- package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
- package/stacks/gong.json +23 -6
- package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +30 -0
- package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
- package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
- package/stacks/hootsuite.json +19 -4
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +16 -35
- package/stacks/hubspot/pages/contacts.tsx.tmpl +35 -95
- package/stacks/hubspot/pages/deals.tsx.tmpl +14 -1
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
- package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
- package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
- package/stacks/hubspot/pages/sequences.tsx.tmpl +18 -0
- package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +66 -7
- package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
- package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
- package/stacks/intercom.json +18 -4
- package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +30 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
- package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
- package/stacks/mailchimp.json +22 -6
- package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +29 -66
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
- package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
- package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
- package/stacks/outreach/pages/sequences.tsx.tmpl +18 -0
- package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
- package/stacks/outreach.json +38 -9
- package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +17 -35
- package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
- package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
- package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
- package/stacks/pipedrive.json +14 -2
- package/stacks/salesforce/pages/companies.tsx.tmpl +16 -35
- package/stacks/salesforce/pages/contacts.tsx.tmpl +26 -35
- package/stacks/salesforce/pages/deals.tsx.tmpl +14 -1
- package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
- package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
- package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
- package/stacks/salesforce.json +17 -3
- package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +16 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +24 -0
- package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +36 -3
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
- package/templates/next-app/app/globals.css.tmpl +349 -64
- package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-made `authorize` hooks for the proxy route (./proxy.ts), so a generated app can go live with sign-in
|
|
3
|
+
* instead of "bring your own auth". Every preset is OPTIONAL: `createG8Proxy({ authorize })` takes any function,
|
|
4
|
+
* and without one the proxy behaves exactly as before (local preview in `next dev`, 500 in a production build).
|
|
5
|
+
*
|
|
6
|
+
* g8.auth.ts export const appAuth = sharedPassword(); (presets come from this file)
|
|
7
|
+
* app/api/g8/[...path]/route.ts createG8Proxy({ ..., authorize: appAuth.authorize })
|
|
8
|
+
* app/api/g8-auth/route.ts export const { GET, POST, DELETE } = createG8AuthRoute(appAuth); (sign-in screen)
|
|
9
|
+
*
|
|
10
|
+
* Presets (all read their settings from the SERVER environment; nothing here is sent to the browser):
|
|
11
|
+
* - sharedPassword(): one password for the whole team. POST /api/g8-auth signs in and sets an HttpOnly,
|
|
12
|
+
* SameSite=Lax session cookie signed with HMAC-SHA-256. Env: G8_APP_PASSWORD (12+ characters) and
|
|
13
|
+
* G8_SESSION_SECRET (32+ characters). Changing either signs everyone out.
|
|
14
|
+
* - basicAuth(): HTTP Basic auth from G8_BASIC_AUTH ("user:password", comma-separated for several users).
|
|
15
|
+
* Pair it with middleware.ts so the browser asks once for the whole site.
|
|
16
|
+
* - jwtAuth(): verify a JWT from your identity provider (OIDC: Auth0, Okta, Cognito, Keycloak, Clerk, Supabase,
|
|
17
|
+
* ...) with its JWKS (RS256/PS256/ES256 family), or a shared HS256 secret. Reads the Bearer header and/or a
|
|
18
|
+
* cookie; checks exp/nbf, issuer and audience.
|
|
19
|
+
* - sessionAuth(fn): adapter for any session library: Clerk `auth()`, NextAuth `auth()`, Supabase
|
|
20
|
+
* `getUser()`, iron-session... Return a user (anything truthy) to allow, null/undefined to refuse.
|
|
21
|
+
* - allowAnonymous(): an explicit "anyone may use this app's key" choice (a public demo). Logs a loud warning
|
|
22
|
+
* and `npx @graph8/sdk doctor` flags it.
|
|
23
|
+
*
|
|
24
|
+
* Security model: secrets and passwords are compared in constant time (HMAC both sides, then compare the
|
|
25
|
+
* digests); cookie-based presets refuse cross-site writes (CSRF) by checking Sec-Fetch-Site and Origin; tokens
|
|
26
|
+
* with alg "none", an unexpected alg, a key of the wrong type, `crit` headers, or no `exp` are refused. Sign-in
|
|
27
|
+
* attempts are rate limited per client in memory (best effort on serverless: use a long password). Every
|
|
28
|
+
* refusal is a JSON 401/403/429; a missing setting is a JSON 500 that names the variable, never its value.
|
|
29
|
+
*
|
|
30
|
+
* Settings: sharedPassword and basicAuth read the environment on every request; jwtAuth and sessionAuth read it
|
|
31
|
+
* once, when the preset is created (module load in Next). Restart the server after changing them.
|
|
32
|
+
*
|
|
33
|
+
* Plain Web APIs (fetch, crypto.subtle, TextEncoder), no dependencies: runs on the Node and Edge runtimes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type AuthKind = "password" | "basic" | "jwt" | "session" | "anonymous";
|
|
37
|
+
|
|
38
|
+
/** What the sign-in screen (components/auth-gate.tsx) needs to know. Never contains a secret. */
|
|
39
|
+
export interface AuthStatus {
|
|
40
|
+
kind: AuthKind;
|
|
41
|
+
signedIn: boolean;
|
|
42
|
+
/** Where to send the user to sign in (jwt and session presets; G8_SIGN_IN_URL). */
|
|
43
|
+
signInUrl?: string;
|
|
44
|
+
/** Where to send the user to sign out (G8_SIGN_OUT_URL). */
|
|
45
|
+
signOutUrl?: string;
|
|
46
|
+
/** Set when the server is missing a setting: what to set, never a value. */
|
|
47
|
+
problem?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface G8AppAuth {
|
|
51
|
+
kind: AuthKind;
|
|
52
|
+
/** Pass to `createG8Proxy({ authorize })`. A plain function: safe to pass unbound. */
|
|
53
|
+
authorize: (request: Request) => Promise<Response | void>;
|
|
54
|
+
/** For the sign-in screen. */
|
|
55
|
+
status: (request: Request) => Promise<AuthStatus>;
|
|
56
|
+
/** POST /api/g8-auth (sharedPassword only). */
|
|
57
|
+
signIn?: (request: Request) => Promise<Response>;
|
|
58
|
+
/** DELETE /api/g8-auth (sharedPassword only). */
|
|
59
|
+
signOut?: (request: Request) => Promise<Response>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Env = Record<string, string | undefined>;
|
|
63
|
+
const readEnv = (): Env => (typeof process !== "undefined" && process.env ? process.env : {});
|
|
64
|
+
const isProduction = (): boolean => readEnv().NODE_ENV === "production";
|
|
65
|
+
|
|
66
|
+
function json(status: number, body: Record<string, unknown>, headers: Record<string, string> = {}): Response {
|
|
67
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const unauthenticated = (message = "Sign in to use this app.") => json(401, { error: "unauthenticated", message });
|
|
71
|
+
const misconfigured = (message: string) => json(500, { error: "g8_auth_misconfigured", message });
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
74
|
+
// Bytes, base64url, constant time
|
|
75
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const enc = new TextEncoder();
|
|
78
|
+
|
|
79
|
+
function b64urlEncode(bytes: Uint8Array): string {
|
|
80
|
+
let s = "";
|
|
81
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
82
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Null on anything that is not strict base64url. */
|
|
86
|
+
function b64urlDecode(text: string): Uint8Array | null {
|
|
87
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
88
|
+
try {
|
|
89
|
+
const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4));
|
|
90
|
+
const out = new Uint8Array(bin.length);
|
|
91
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
92
|
+
return out;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `as BufferSource` below: TypeScript 5.7+ types a plain Uint8Array as possibly SharedArrayBuffer-backed, which
|
|
100
|
+
* WebCrypto's signatures reject at compile time; every array here is a fresh, non-shared buffer.
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/** Constant time for equal lengths; a length mismatch is false (lengths are not secret here: digests). */
|
|
104
|
+
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
105
|
+
if (a.length !== b.length) return false;
|
|
106
|
+
let diff = 0;
|
|
107
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
108
|
+
return diff === 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function hmacKey(secret: string | Uint8Array, hash = "SHA-256"): Promise<CryptoKey> {
|
|
112
|
+
const raw = typeof secret === "string" ? enc.encode(secret) : secret;
|
|
113
|
+
return crypto.subtle.importKey("raw", raw as BufferSource, { name: "HMAC", hash }, false, ["sign", "verify"]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function hmac(key: CryptoKey, data: string | Uint8Array): Promise<Uint8Array> {
|
|
117
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, (typeof data === "string" ? enc.encode(data) : data) as BufferSource));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A per-process random key: comparing HMACs of two strings hides their length and content from timing. */
|
|
121
|
+
let compareKey: Promise<CryptoKey> | null = null;
|
|
122
|
+
function getCompareKey(): Promise<CryptoKey> {
|
|
123
|
+
compareKey ??= hmacKey(crypto.getRandomValues(new Uint8Array(32)));
|
|
124
|
+
return compareKey;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Constant-time string equality (any lengths). */
|
|
128
|
+
export async function safeEqual(a: string, b: string): Promise<boolean> {
|
|
129
|
+
const key = await getCompareKey();
|
|
130
|
+
const [x, y] = await Promise.all([hmac(key, a), hmac(key, b)]);
|
|
131
|
+
return timingSafeEqual(x, y);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
135
|
+
// Request helpers: cookies, CSRF, client id, rate limit
|
|
136
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export function readCookie(request: Request, name: string): string | undefined {
|
|
139
|
+
const header = request.headers.get("cookie");
|
|
140
|
+
if (!header) return undefined;
|
|
141
|
+
for (const part of header.split(";")) {
|
|
142
|
+
const i = part.indexOf("=");
|
|
143
|
+
if (i < 0) continue;
|
|
144
|
+
if (part.slice(0, i).trim() === name) {
|
|
145
|
+
const v = part.slice(i + 1).trim();
|
|
146
|
+
try {
|
|
147
|
+
return decodeURIComponent(v);
|
|
148
|
+
} catch {
|
|
149
|
+
return v;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* True when a request may come from another site: Sec-Fetch-Site says cross-site/same-site, or Origin is
|
|
160
|
+
* present and is not this request's own origin. Cookie-based presets refuse such writes (CSRF).
|
|
161
|
+
*/
|
|
162
|
+
export function isCrossSite(request: Request): boolean {
|
|
163
|
+
const site = request.headers.get("sec-fetch-site");
|
|
164
|
+
if (site && site !== "same-origin" && site !== "none") return true;
|
|
165
|
+
const origin = request.headers.get("origin");
|
|
166
|
+
if (!origin) return false;
|
|
167
|
+
if (origin === "null") return true;
|
|
168
|
+
const host = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() || request.headers.get("host") || new URL(request.url).host;
|
|
169
|
+
try {
|
|
170
|
+
return new URL(origin).host.toLowerCase() !== host.toLowerCase();
|
|
171
|
+
} catch {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const crossSite = () => json(403, { error: "cross_site_request", message: "Refused a request from another site." });
|
|
177
|
+
|
|
178
|
+
function clientId(request: Request): string {
|
|
179
|
+
const fwd = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
180
|
+
return fwd || request.headers.get("x-real-ip") || "local";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isHttps(request: Request): boolean {
|
|
184
|
+
const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
185
|
+
return proto === "https" || new URL(request.url).protocol === "https:" || isProduction();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Fixed-window attempt counter per client, bounded in size. Best effort: one process only. */
|
|
189
|
+
export function createRateLimiter(limit: number, windowMs: number, maxKeys = 10_000) {
|
|
190
|
+
const hits = new Map<string, { count: number; reset: number }>();
|
|
191
|
+
return {
|
|
192
|
+
/** Seconds to wait, or 0 when this attempt is allowed. */
|
|
193
|
+
take(key: string, now = Date.now()): number {
|
|
194
|
+
let h = hits.get(key);
|
|
195
|
+
if (!h || h.reset <= now) {
|
|
196
|
+
if (hits.size >= maxKeys) hits.delete(hits.keys().next().value as string);
|
|
197
|
+
h = { count: 0, reset: now + windowMs };
|
|
198
|
+
hits.set(key, h);
|
|
199
|
+
}
|
|
200
|
+
h.count += 1;
|
|
201
|
+
return h.count > limit ? Math.ceil((h.reset - now) / 1000) : 0;
|
|
202
|
+
},
|
|
203
|
+
/** Seconds to wait if the limit is already used up, without counting an attempt. */
|
|
204
|
+
peek(key: string, now = Date.now()): number {
|
|
205
|
+
const h = hits.get(key);
|
|
206
|
+
return h && h.reset > now && h.count >= limit ? Math.ceil((h.reset - now) / 1000) : 0;
|
|
207
|
+
},
|
|
208
|
+
clear(key: string): void {
|
|
209
|
+
hits.delete(key);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const MAX_SIGN_IN_BODY = 4096;
|
|
215
|
+
|
|
216
|
+
/** The body as text, or null when it is larger than `max` bytes (stops reading as soon as it passes). */
|
|
217
|
+
async function readTextCapped(request: Request, max: number): Promise<string | null> {
|
|
218
|
+
const declared = Number(request.headers.get("content-length"));
|
|
219
|
+
if (Number.isFinite(declared) && declared > max) return null;
|
|
220
|
+
if (!request.body) return "";
|
|
221
|
+
const reader = request.body.getReader();
|
|
222
|
+
const chunks: Uint8Array[] = [];
|
|
223
|
+
let total = 0;
|
|
224
|
+
for (;;) {
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done) break;
|
|
227
|
+
total += value.byteLength;
|
|
228
|
+
if (total > max) {
|
|
229
|
+
await reader.cancel().catch(() => undefined);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
chunks.push(value);
|
|
233
|
+
}
|
|
234
|
+
const all = new Uint8Array(total);
|
|
235
|
+
let at = 0;
|
|
236
|
+
for (const c of chunks) {
|
|
237
|
+
all.set(c, at);
|
|
238
|
+
at += c.byteLength;
|
|
239
|
+
}
|
|
240
|
+
return new TextDecoder().decode(all);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function readJsonOrForm(request: Request): Promise<Record<string, unknown>> {
|
|
244
|
+
const type = request.headers.get("content-type") ?? "";
|
|
245
|
+
const text = await readTextCapped(request, MAX_SIGN_IN_BODY).catch(() => null);
|
|
246
|
+
if (text === null) return {};
|
|
247
|
+
if (type.includes("application/x-www-form-urlencoded")) return Object.fromEntries(new URLSearchParams(text));
|
|
248
|
+
try {
|
|
249
|
+
const v = JSON.parse(text) as unknown;
|
|
250
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
251
|
+
} catch {
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function urlSetting(value: string | undefined): string | undefined {
|
|
257
|
+
const v = value?.trim();
|
|
258
|
+
if (!v) return undefined;
|
|
259
|
+
if (v.startsWith("/") && !v.startsWith("//")) return v;
|
|
260
|
+
return /^https?:\/\/[^\s]+$/i.test(v) ? v : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
264
|
+
// sharedPassword
|
|
265
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
export interface SharedPasswordOptions {
|
|
268
|
+
/** Default: G8_APP_PASSWORD. At least 12 characters. */
|
|
269
|
+
password?: string;
|
|
270
|
+
/** Signs the session cookie. Default: G8_SESSION_SECRET. At least 32 characters. */
|
|
271
|
+
secret?: string;
|
|
272
|
+
/** Session length in seconds. Default 7 days. */
|
|
273
|
+
maxAgeSeconds?: number;
|
|
274
|
+
/** Default "g8_session". */
|
|
275
|
+
cookieName?: string;
|
|
276
|
+
/** Sign-in attempts per client per 15 minutes. Default 10. */
|
|
277
|
+
maxAttempts?: number;
|
|
278
|
+
/**
|
|
279
|
+
* WRONG passwords from all clients together per 15 minutes before NEW sign-ins pause for everyone. Default 100.
|
|
280
|
+
* The per-client limit keys on X-Forwarded-For, which a client can rotate; this one cannot be dodged. Trade-off:
|
|
281
|
+
* anyone who can reach the sign-in route can keep new sign-ins paused by sending wrong passwords (existing
|
|
282
|
+
* sessions keep working). With a long random password (create generates 144 bits) you can raise it, or set
|
|
283
|
+
* `Infinity` to turn it off.
|
|
284
|
+
*/
|
|
285
|
+
maxFailuresGlobal?: number;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const MIN_PASSWORD = 12;
|
|
289
|
+
export const MIN_SECRET = 32;
|
|
290
|
+
const SESSION_VERSION = "v1";
|
|
291
|
+
|
|
292
|
+
function passwordSettings(o: SharedPasswordOptions): { password: string; secret: string } | string {
|
|
293
|
+
const env = readEnv();
|
|
294
|
+
const password = o.password ?? env.G8_APP_PASSWORD ?? "";
|
|
295
|
+
const secret = o.secret ?? env.G8_SESSION_SECRET ?? "";
|
|
296
|
+
const missing: string[] = [];
|
|
297
|
+
if (password.length < MIN_PASSWORD) missing.push(`G8_APP_PASSWORD (${MIN_PASSWORD}+ characters)`);
|
|
298
|
+
if (secret.length < MIN_SECRET) missing.push(`G8_SESSION_SECRET (${MIN_SECRET}+ random characters, e.g. \`openssl rand -base64 48\`)`);
|
|
299
|
+
return missing.length ? `Set ${missing.join(" and ")} in the server environment, then restart.` : { password, secret };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The signing key depends on the password too, so changing the password ends every session. */
|
|
303
|
+
async function sessionKey(secret: string, password: string): Promise<CryptoKey> {
|
|
304
|
+
const material = await hmac(await hmacKey(secret), `g8-session-key\u0000${password}`);
|
|
305
|
+
return hmacKey(material);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function createSessionToken(secret: string, password: string, maxAgeSeconds: number, now = Date.now()): Promise<string> {
|
|
309
|
+
const exp = Math.floor(now / 1000) + maxAgeSeconds;
|
|
310
|
+
const nonce = b64urlEncode(crypto.getRandomValues(new Uint8Array(16)));
|
|
311
|
+
const body = `${SESSION_VERSION}.${exp}.${nonce}`;
|
|
312
|
+
const sig = b64urlEncode(await hmac(await sessionKey(secret, password), body));
|
|
313
|
+
return `${body}.${sig}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function verifySessionToken(token: string | undefined, secret: string, password: string, now = Date.now()): Promise<boolean> {
|
|
317
|
+
if (!token || token.length > 512) return false;
|
|
318
|
+
const parts = token.split(".");
|
|
319
|
+
if (parts.length !== 4 || parts[0] !== SESSION_VERSION || !/^\d{1,12}$/.test(parts[1])) return false;
|
|
320
|
+
const sig = b64urlDecode(parts[3]);
|
|
321
|
+
if (!sig) return false;
|
|
322
|
+
const expected = await hmac(await sessionKey(secret, password), parts.slice(0, 3).join("."));
|
|
323
|
+
if (!timingSafeEqual(sig, expected)) return false;
|
|
324
|
+
return Number(parts[1]) > Math.floor(now / 1000);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sessionCookie(name: string, value: string, maxAge: number, request: Request): string {
|
|
328
|
+
const attrs = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`];
|
|
329
|
+
if (isHttps(request)) attrs.push("Secure");
|
|
330
|
+
return attrs.join("; ");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** One shared password for the whole team, with a signed session cookie and a sign-in screen. */
|
|
334
|
+
export function sharedPassword(options: SharedPasswordOptions = {}): G8AppAuth {
|
|
335
|
+
const cookieName = options.cookieName ?? "g8_session";
|
|
336
|
+
const maxAge = options.maxAgeSeconds ?? 7 * 24 * 3600;
|
|
337
|
+
const limiter = createRateLimiter(options.maxAttempts ?? 10, 15 * 60 * 1000);
|
|
338
|
+
const failures = createRateLimiter(options.maxFailuresGlobal ?? 100, 15 * 60 * 1000);
|
|
339
|
+
const GLOBAL = "*";
|
|
340
|
+
|
|
341
|
+
const signedIn = async (request: Request, s: { password: string; secret: string }) =>
|
|
342
|
+
verifySessionToken(readCookie(request, cookieName), s.secret, s.password);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
kind: "password",
|
|
346
|
+
async authorize(request) {
|
|
347
|
+
const s = passwordSettings(options);
|
|
348
|
+
if (typeof s === "string") return misconfigured(s);
|
|
349
|
+
if (!(await signedIn(request, s))) return unauthenticated("Your session ended. Sign in again.");
|
|
350
|
+
if (!SAFE_METHODS.has(request.method.toUpperCase()) && isCrossSite(request)) return crossSite();
|
|
351
|
+
},
|
|
352
|
+
async status(request) {
|
|
353
|
+
const s = passwordSettings(options);
|
|
354
|
+
if (typeof s === "string") return { kind: "password", signedIn: false, problem: s };
|
|
355
|
+
return { kind: "password", signedIn: await signedIn(request, s) };
|
|
356
|
+
},
|
|
357
|
+
async signIn(request) {
|
|
358
|
+
if (isCrossSite(request)) return crossSite();
|
|
359
|
+
const s = passwordSettings(options);
|
|
360
|
+
if (typeof s === "string") return misconfigured(s);
|
|
361
|
+
const id = clientId(request);
|
|
362
|
+
const wait = Math.max(limiter.take(id), failures.peek(GLOBAL));
|
|
363
|
+
if (wait > 0) return json(429, { error: "too_many_attempts", message: `Too many attempts. Try again in ${Math.ceil(wait / 60)} min.`, retryAfter: wait }, { "retry-after": String(wait) });
|
|
364
|
+
const body = await readJsonOrForm(request);
|
|
365
|
+
const given = typeof body.password === "string" ? body.password : "";
|
|
366
|
+
if (!given || !(await safeEqual(given, s.password))) {
|
|
367
|
+
failures.take(GLOBAL);
|
|
368
|
+
return json(401, { error: "wrong_password", message: "That password is not right." });
|
|
369
|
+
}
|
|
370
|
+
limiter.clear(id);
|
|
371
|
+
const token = await createSessionToken(s.secret, s.password, maxAge);
|
|
372
|
+
return json(200, { kind: "password", signedIn: true }, { "set-cookie": sessionCookie(cookieName, token, maxAge, request) });
|
|
373
|
+
},
|
|
374
|
+
async signOut(request) {
|
|
375
|
+
if (isCrossSite(request)) return crossSite();
|
|
376
|
+
return json(200, { kind: "password", signedIn: false }, { "set-cookie": sessionCookie(cookieName, "", 0, request) });
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
382
|
+
// basicAuth
|
|
383
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
export interface BasicAuthOptions {
|
|
386
|
+
/**
|
|
387
|
+
* "user:password" pairs, comma-separated (so a password cannot contain a comma; such an entry is ignored, as is
|
|
388
|
+
* one with a password under 12 characters). Default: G8_BASIC_AUTH, read on every request.
|
|
389
|
+
*/
|
|
390
|
+
credentials?: string;
|
|
391
|
+
/** Shown by the browser's prompt. Default "app". */
|
|
392
|
+
realm?: string;
|
|
393
|
+
/** WRONG credentials per client per 15 minutes before that client gets 429. Default 20. */
|
|
394
|
+
maxFailures?: number;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parseCredentials(text: string): { user: string; pass: string }[] {
|
|
398
|
+
return text
|
|
399
|
+
.split(",")
|
|
400
|
+
.map((pair) => pair.trim())
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.map((pair) => ({ user: pair.slice(0, pair.indexOf(":")), pass: pair.slice(pair.indexOf(":") + 1) }))
|
|
403
|
+
.filter((c) => c.user && c.pass && c.pass.length >= MIN_PASSWORD);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** HTTP Basic auth: the browser prompts once (use with middleware.ts so pages ask too). */
|
|
407
|
+
export function basicAuth(options: BasicAuthOptions = {}): G8AppAuth {
|
|
408
|
+
const realm = (options.realm ?? "app").replace(/["\\]/g, "");
|
|
409
|
+
const settings = () => parseCredentials(options.credentials ?? readEnv().G8_BASIC_AUTH ?? "");
|
|
410
|
+
const problem = `Set G8_BASIC_AUTH to user:password (${MIN_PASSWORD}+ character passwords without commas; comma-separated for several users) in the server environment.`;
|
|
411
|
+
const failures = createRateLimiter(options.maxFailures ?? 20, 15 * 60 * 1000);
|
|
412
|
+
/** "none" when no Basic header was sent (the browser has not asked yet), else whether it matches. */
|
|
413
|
+
const check = async (request: Request): Promise<"none" | "ok" | "bad"> => {
|
|
414
|
+
const header = request.headers.get("authorization") ?? "";
|
|
415
|
+
if (!/^Basic\s/i.test(header.trim())) return "none";
|
|
416
|
+
const m = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(header.trim());
|
|
417
|
+
if (!m) return "bad";
|
|
418
|
+
let decoded: string;
|
|
419
|
+
try {
|
|
420
|
+
decoded = new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0)));
|
|
421
|
+
} catch {
|
|
422
|
+
return "bad";
|
|
423
|
+
}
|
|
424
|
+
const i = decoded.indexOf(":");
|
|
425
|
+
if (i <= 0) return "bad";
|
|
426
|
+
const user = decoded.slice(0, i);
|
|
427
|
+
const pass = decoded.slice(i + 1);
|
|
428
|
+
// Compare against EVERY entry, no early exit, so timing does not reveal which user exists.
|
|
429
|
+
let ok = false;
|
|
430
|
+
for (const c of settings()) {
|
|
431
|
+
const [u, p] = await Promise.all([safeEqual(user, c.user), safeEqual(pass, c.pass)]);
|
|
432
|
+
ok = (u && p) || ok;
|
|
433
|
+
}
|
|
434
|
+
return ok ? "ok" : "bad";
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
kind: "basic",
|
|
438
|
+
async authorize(request) {
|
|
439
|
+
if (!settings().length) return misconfigured(problem);
|
|
440
|
+
const id = clientId(request);
|
|
441
|
+
const wait = failures.peek(id);
|
|
442
|
+
if (wait > 0) return json(429, { error: "too_many_attempts", message: `Too many attempts. Try again in ${Math.ceil(wait / 60)} min.`, retryAfter: wait }, { "retry-after": String(wait) });
|
|
443
|
+
const result = await check(request);
|
|
444
|
+
if (result === "bad") failures.take(id);
|
|
445
|
+
if (result !== "ok") {
|
|
446
|
+
return new Response(JSON.stringify({ error: "unauthenticated", message: "Sign in to use this app." }), {
|
|
447
|
+
status: 401,
|
|
448
|
+
headers: { "content-type": "application/json", "www-authenticate": `Basic realm="${realm}", charset="UTF-8"`, "cache-control": "no-store" },
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
// Browsers attach cached Basic credentials to cross-site form posts too (ambient, like a cookie): CSRF.
|
|
452
|
+
if (!SAFE_METHODS.has(request.method.toUpperCase()) && isCrossSite(request)) return crossSite();
|
|
453
|
+
},
|
|
454
|
+
async status(request) {
|
|
455
|
+
if (!settings().length) return { kind: "basic", signedIn: false, problem };
|
|
456
|
+
return { kind: "basic", signedIn: (await check(request)) === "ok" };
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
462
|
+
// jwtAuth
|
|
463
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
export interface JwtAuthOptions {
|
|
466
|
+
/** HS256/384/512 shared secret. Default: G8_JWT_SECRET. Use this OR jwksUrl/issuer discovery. */
|
|
467
|
+
secret?: string;
|
|
468
|
+
/** The provider's JWKS. Default: G8_JWT_JWKS_URL, else discovered from `${issuer}/.well-known/openid-configuration`. */
|
|
469
|
+
jwksUrl?: string;
|
|
470
|
+
/** Expected `iss`. Default: G8_JWT_ISSUER. Required with a JWKS. */
|
|
471
|
+
issuer?: string;
|
|
472
|
+
/** Expected `aud` (one of). Default: G8_JWT_AUDIENCE (comma-separated). Required when the issuer is shared by many apps (Google). */
|
|
473
|
+
audience?: string | string[];
|
|
474
|
+
/** Cookie holding the token (e.g. "__session" for Clerk). Default: G8_JWT_COOKIE. */
|
|
475
|
+
cookie?: string;
|
|
476
|
+
/** Also accept `Authorization: Bearer <token>`. Default true. */
|
|
477
|
+
bearer?: boolean;
|
|
478
|
+
/** Allowed algorithms. Default: HS256 with a secret; RS256, PS256 and ES256 with a JWKS. */
|
|
479
|
+
algorithms?: string[];
|
|
480
|
+
/** Seconds of clock skew allowed on exp/nbf. Default 30. */
|
|
481
|
+
clockToleranceSeconds?: number;
|
|
482
|
+
/** Extra check on the verified claims (a role, an email domain). Return false to refuse. */
|
|
483
|
+
claims?: (claims: Record<string, unknown>) => boolean | Promise<boolean>;
|
|
484
|
+
/** Default: G8_SIGN_IN_URL. */
|
|
485
|
+
signInUrl?: string;
|
|
486
|
+
/** Default: G8_SIGN_OUT_URL. */
|
|
487
|
+
signOutUrl?: string;
|
|
488
|
+
/** Replace fetch (tests). */
|
|
489
|
+
fetch?: typeof fetch;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type Jwk = JsonWebKey & { kid?: string; use?: string; alg?: string };
|
|
493
|
+
|
|
494
|
+
const ALGS: Record<string, { family: "HS" | "RS" | "PS" | "ES"; hash: string; crv?: string; saltLength?: number }> = {
|
|
495
|
+
HS256: { family: "HS", hash: "SHA-256" },
|
|
496
|
+
HS384: { family: "HS", hash: "SHA-384" },
|
|
497
|
+
HS512: { family: "HS", hash: "SHA-512" },
|
|
498
|
+
RS256: { family: "RS", hash: "SHA-256" },
|
|
499
|
+
RS384: { family: "RS", hash: "SHA-384" },
|
|
500
|
+
RS512: { family: "RS", hash: "SHA-512" },
|
|
501
|
+
PS256: { family: "PS", hash: "SHA-256", saltLength: 32 },
|
|
502
|
+
PS384: { family: "PS", hash: "SHA-384", saltLength: 48 },
|
|
503
|
+
PS512: { family: "PS", hash: "SHA-512", saltLength: 64 },
|
|
504
|
+
ES256: { family: "ES", hash: "SHA-256", crv: "P-256" },
|
|
505
|
+
ES384: { family: "ES", hash: "SHA-384", crv: "P-384" },
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const MAX_TOKEN = 16 * 1024;
|
|
509
|
+
const JWKS_TTL = 10 * 60 * 1000;
|
|
510
|
+
const JWKS_MIN_REFRESH = 30 * 1000;
|
|
511
|
+
|
|
512
|
+
function remoteUrlOk(url: string): boolean {
|
|
513
|
+
try {
|
|
514
|
+
const u = new URL(url);
|
|
515
|
+
return u.protocol === "https:" || (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname));
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function fetchJson(doFetch: typeof fetch, url: string): Promise<Record<string, unknown>> {
|
|
522
|
+
const ctrl = new AbortController();
|
|
523
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
524
|
+
try {
|
|
525
|
+
const res = await doFetch(url, { headers: { accept: "application/json" }, redirect: "error", signal: ctrl.signal, cache: "no-store" });
|
|
526
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
527
|
+
return (await res.json()) as Record<string, unknown>;
|
|
528
|
+
} finally {
|
|
529
|
+
clearTimeout(timer);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function decodeSegment(seg: string): Record<string, unknown> | null {
|
|
534
|
+
const bytes = b64urlDecode(seg);
|
|
535
|
+
if (!bytes) return null;
|
|
536
|
+
try {
|
|
537
|
+
const v = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
538
|
+
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function keyMatches(jwk: Jwk, alg: string): boolean {
|
|
545
|
+
const spec = ALGS[alg];
|
|
546
|
+
if (jwk.use && jwk.use !== "sig") return false;
|
|
547
|
+
if (jwk.alg && jwk.alg !== alg) return false;
|
|
548
|
+
if (spec.family === "ES") return jwk.kty === "EC" && jwk.crv === spec.crv;
|
|
549
|
+
return jwk.kty === "RSA";
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function importParams(alg: string): { importAlg: RsaHashedImportParams | EcKeyImportParams; verifyAlg: AlgorithmIdentifier | RsaPssParams | EcdsaParams } {
|
|
553
|
+
const spec = ALGS[alg];
|
|
554
|
+
if (spec.family === "RS") return { importAlg: { name: "RSASSA-PKCS1-v1_5", hash: spec.hash }, verifyAlg: { name: "RSASSA-PKCS1-v1_5" } };
|
|
555
|
+
if (spec.family === "PS") return { importAlg: { name: "RSA-PSS", hash: spec.hash }, verifyAlg: { name: "RSA-PSS", saltLength: spec.saltLength as number } };
|
|
556
|
+
return { importAlg: { name: "ECDSA", namedCurve: spec.crv as string }, verifyAlg: { name: "ECDSA", hash: spec.hash } };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Verify a JWT: alg allow-list, signature, exp/nbf, iss, aud. Returns the claims or null. Exported for tests. */
|
|
560
|
+
export function createJwtVerifier(options: JwtAuthOptions = {}) {
|
|
561
|
+
const env = readEnv();
|
|
562
|
+
const secret = options.secret ?? env.G8_JWT_SECRET;
|
|
563
|
+
const issuer = (options.issuer ?? env.G8_JWT_ISSUER)?.trim() || undefined;
|
|
564
|
+
const audRaw = options.audience ?? env.G8_JWT_AUDIENCE;
|
|
565
|
+
const audience = (Array.isArray(audRaw) ? audRaw : (audRaw ?? "").split(",")).map((a) => a.trim()).filter(Boolean);
|
|
566
|
+
const configuredJwks = (options.jwksUrl ?? env.G8_JWT_JWKS_URL)?.trim() || undefined;
|
|
567
|
+
const tolerance = options.clockToleranceSeconds ?? 30;
|
|
568
|
+
const doFetch = options.fetch ?? ((...a: Parameters<typeof fetch>) => globalThis.fetch(...a));
|
|
569
|
+
|
|
570
|
+
let problem: string | null = null;
|
|
571
|
+
if (secret && configuredJwks) problem = "Set G8_JWT_SECRET or G8_JWT_JWKS_URL, not both.";
|
|
572
|
+
else if (!secret && !configuredJwks && !issuer) problem = "Set G8_JWT_JWKS_URL and G8_JWT_ISSUER (your identity provider), or G8_JWT_SECRET (HS256), in the server environment.";
|
|
573
|
+
else if (!secret && !issuer) problem = "Set G8_JWT_ISSUER too: a JWKS alone would accept tokens your provider issued for any app.";
|
|
574
|
+
else if (secret && secret.length < MIN_SECRET) problem = `G8_JWT_SECRET must be at least ${MIN_SECRET} characters.`;
|
|
575
|
+
else if (configuredJwks && !remoteUrlOk(configuredJwks)) problem = "G8_JWT_JWKS_URL must be an https:// URL.";
|
|
576
|
+
else if (!secret && !configuredJwks && issuer && !remoteUrlOk(issuer)) problem = "G8_JWT_ISSUER must be an https:// URL to discover its keys (or set G8_JWT_JWKS_URL).";
|
|
577
|
+
|
|
578
|
+
// Issuer alone accepts any token that issuer minted, for ANY app on a shared tenant (Auth0, Cognito, Okta).
|
|
579
|
+
if (!problem && !secret && !audience.length && !options.claims) {
|
|
580
|
+
console.warn(
|
|
581
|
+
"[graph8] jwtAuth: no audience (G8_JWT_AUDIENCE) and no claims check. Tokens the issuer minted for other apps on the same tenant will be accepted. Set G8_JWT_AUDIENCE, or pass jwtAuth({ claims }) (e.g. Clerk: check azp).",
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const algorithms = (options.algorithms ?? (secret ? ["HS256"] : ["RS256", "PS256", "ES256"])).filter((a) => a in ALGS);
|
|
586
|
+
// A secret allows HS* only; a JWKS allows asymmetric only. Never both: that is the alg-confusion attack.
|
|
587
|
+
const allowed = new Set(algorithms.filter((a) => (secret ? ALGS[a].family === "HS" : ALGS[a].family !== "HS")));
|
|
588
|
+
if (!problem && !allowed.size) problem = secret ? "algorithms must include an HS* algorithm when G8_JWT_SECRET is used." : "algorithms must include an RS*, PS* or ES* algorithm with a JWKS.";
|
|
589
|
+
|
|
590
|
+
let jwks: { keys: Jwk[]; at: number } | null = null;
|
|
591
|
+
let jwksUrl: string | undefined = configuredJwks;
|
|
592
|
+
let inflight: Promise<Jwk[]> | null = null;
|
|
593
|
+
|
|
594
|
+
async function loadKeys(force: boolean): Promise<Jwk[]> {
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
if (jwks && !force && now - jwks.at < JWKS_TTL) return jwks.keys;
|
|
597
|
+
if (jwks && force && now - jwks.at < JWKS_MIN_REFRESH) return jwks.keys;
|
|
598
|
+
inflight ??= (async () => {
|
|
599
|
+
try {
|
|
600
|
+
if (!jwksUrl) {
|
|
601
|
+
const conf = await fetchJson(doFetch, `${(issuer as string).replace(/\/+$/, "")}/.well-known/openid-configuration`);
|
|
602
|
+
if (typeof conf.issuer !== "string" || conf.issuer.replace(/\/+$/, "") !== (issuer as string).replace(/\/+$/, "")) throw new Error("issuer mismatch in discovery");
|
|
603
|
+
if (typeof conf.jwks_uri !== "string" || !remoteUrlOk(conf.jwks_uri)) throw new Error("bad jwks_uri");
|
|
604
|
+
jwksUrl = conf.jwks_uri;
|
|
605
|
+
}
|
|
606
|
+
const body = await fetchJson(doFetch, jwksUrl);
|
|
607
|
+
const keys = Array.isArray(body.keys) ? (body.keys as Jwk[]).filter((k) => k && typeof k === "object") : [];
|
|
608
|
+
jwks = { keys, at: Date.now() };
|
|
609
|
+
return keys;
|
|
610
|
+
} finally {
|
|
611
|
+
inflight = null;
|
|
612
|
+
}
|
|
613
|
+
})();
|
|
614
|
+
return inflight;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function verifyKey(alg: string, kid: string | undefined): Promise<CryptoKey | null> {
|
|
618
|
+
const spec = ALGS[alg];
|
|
619
|
+
if (spec.family === "HS") return hmacKey(secret as string, spec.hash);
|
|
620
|
+
const pick = (keys: Jwk[]) => {
|
|
621
|
+
const fits = keys.filter((k) => keyMatches(k, alg));
|
|
622
|
+
return kid ? fits.find((k) => k.kid === kid) : fits.length === 1 ? fits[0] : undefined;
|
|
623
|
+
};
|
|
624
|
+
let jwk = pick(await loadKeys(false));
|
|
625
|
+
if (!jwk && kid) jwk = pick(await loadKeys(true));
|
|
626
|
+
if (!jwk) return null;
|
|
627
|
+
const { kid: _k, use: _u, alg: _a, key_ops: _o, ...material } = jwk as Jwk & { key_ops?: unknown };
|
|
628
|
+
return crypto.subtle.importKey("jwk", { ...material, ext: true }, importParams(alg).importAlg, false, ["verify"]);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function verify(token: string, now = Date.now()): Promise<Record<string, unknown> | null> {
|
|
632
|
+
if (problem || !token || token.length > MAX_TOKEN) return null;
|
|
633
|
+
const parts = token.split(".");
|
|
634
|
+
if (parts.length !== 3) return null;
|
|
635
|
+
const header = decodeSegment(parts[0]);
|
|
636
|
+
const claims = decodeSegment(parts[1]);
|
|
637
|
+
const sig = b64urlDecode(parts[2]);
|
|
638
|
+
if (!header || !claims || !sig || !sig.length) return null;
|
|
639
|
+
const alg = header.alg;
|
|
640
|
+
if (typeof alg !== "string" || !allowed.has(alg) || header.crit !== undefined) return null;
|
|
641
|
+
if (header.typ !== undefined && (typeof header.typ !== "string" || !/^(at\+)?jwt$/i.test(header.typ.replace(/^application\//i, "")))) return null;
|
|
642
|
+
let key: CryptoKey | null;
|
|
643
|
+
try {
|
|
644
|
+
key = await verifyKey(alg, typeof header.kid === "string" ? header.kid : undefined);
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
if (!key) return null;
|
|
649
|
+
const data = enc.encode(`${parts[0]}.${parts[1]}`);
|
|
650
|
+
let ok: boolean;
|
|
651
|
+
try {
|
|
652
|
+
ok = ALGS[alg].family === "HS" ? timingSafeEqual(await hmac(key, data), sig) : await crypto.subtle.verify(importParams(alg).verifyAlg, key, sig as BufferSource, data as BufferSource);
|
|
653
|
+
} catch {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
if (!ok) return null;
|
|
657
|
+
const t = Math.floor(now / 1000);
|
|
658
|
+
if (typeof claims.exp !== "number" || claims.exp + tolerance <= t) return null;
|
|
659
|
+
if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || claims.nbf - tolerance > t)) return null;
|
|
660
|
+
if (issuer && (typeof claims.iss !== "string" || claims.iss.replace(/\/+$/, "") !== issuer.replace(/\/+$/, ""))) return null;
|
|
661
|
+
if (audience.length) {
|
|
662
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
663
|
+
if (!aud.some((a) => typeof a === "string" && audience.includes(a))) return null;
|
|
664
|
+
}
|
|
665
|
+
return claims;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { verify, problem: () => problem };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function bearerToken(request: Request): string | undefined {
|
|
672
|
+
const m = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization")?.trim() ?? "");
|
|
673
|
+
return m?.[1];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Verify a JWT from your identity provider (OIDC JWKS or an HS256 secret). */
|
|
677
|
+
export function jwtAuth(options: JwtAuthOptions = {}): G8AppAuth {
|
|
678
|
+
const verifier = createJwtVerifier(options);
|
|
679
|
+
const env = readEnv();
|
|
680
|
+
const cookie = (options.cookie ?? env.G8_JWT_COOKIE)?.trim() || undefined;
|
|
681
|
+
const bearer = options.bearer ?? true;
|
|
682
|
+
const signInUrl = urlSetting(options.signInUrl ?? env.G8_SIGN_IN_URL);
|
|
683
|
+
const signOutUrl = urlSetting(options.signOutUrl ?? env.G8_SIGN_OUT_URL);
|
|
684
|
+
|
|
685
|
+
async function check(request: Request): Promise<{ ok: boolean; viaCookie: boolean }> {
|
|
686
|
+
const fromHeader = bearer ? bearerToken(request) : undefined;
|
|
687
|
+
const token = fromHeader ?? (cookie ? readCookie(request, cookie) : undefined);
|
|
688
|
+
if (!token) return { ok: false, viaCookie: false };
|
|
689
|
+
const claims = await verifier.verify(token);
|
|
690
|
+
if (!claims) return { ok: false, viaCookie: !fromHeader };
|
|
691
|
+
const extra = options.claims ? await Promise.resolve(options.claims(claims)).catch(() => false) : true;
|
|
692
|
+
return { ok: extra === true, viaCookie: !fromHeader };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
kind: "jwt",
|
|
697
|
+
async authorize(request) {
|
|
698
|
+
const p = verifier.problem();
|
|
699
|
+
if (p) return misconfigured(p);
|
|
700
|
+
const { ok, viaCookie } = await check(request);
|
|
701
|
+
if (!ok) return unauthenticated();
|
|
702
|
+
// A cookie rides along on cross-site requests; a Bearer header cannot be forged by another site.
|
|
703
|
+
if (viaCookie && !SAFE_METHODS.has(request.method.toUpperCase()) && isCrossSite(request)) return crossSite();
|
|
704
|
+
},
|
|
705
|
+
async status(request) {
|
|
706
|
+
const p = verifier.problem();
|
|
707
|
+
const base = { kind: "jwt" as const, ...(signInUrl ? { signInUrl } : {}), ...(signOutUrl ? { signOutUrl } : {}) };
|
|
708
|
+
if (p) return { ...base, signedIn: false, problem: p };
|
|
709
|
+
return { ...base, signedIn: (await check(request)).ok };
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
715
|
+
// sessionAuth
|
|
716
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
export interface SessionAuthOptions {
|
|
719
|
+
/** Default: G8_SIGN_IN_URL. */
|
|
720
|
+
signInUrl?: string;
|
|
721
|
+
/** Default: G8_SIGN_OUT_URL. */
|
|
722
|
+
signOutUrl?: string;
|
|
723
|
+
/**
|
|
724
|
+
* Refuse cross-site writes (CSRF), for cookie sessions. Default true; turn off only if your session check
|
|
725
|
+
* already does it.
|
|
726
|
+
*/
|
|
727
|
+
sameOriginWrites?: boolean;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Adapter for any session library. Return the user (anything truthy) to allow, null/undefined/false to refuse.
|
|
732
|
+
* A throw refuses too (fail closed) and is logged without details.
|
|
733
|
+
*
|
|
734
|
+
* Clerk: sessionAuth(async () => (await auth()).userId) // import { auth } from "@clerk/nextjs/server"
|
|
735
|
+
* NextAuth: sessionAuth(() => auth()) // import { auth } from "@/auth"
|
|
736
|
+
* Supabase: sessionAuth(async () => (await (await createClient()).auth.getUser()).data.user)
|
|
737
|
+
*/
|
|
738
|
+
export function sessionAuth(getUser: (request: Request) => unknown | Promise<unknown>, options: SessionAuthOptions = {}): G8AppAuth {
|
|
739
|
+
const env = readEnv();
|
|
740
|
+
const signInUrl = urlSetting(options.signInUrl ?? env.G8_SIGN_IN_URL);
|
|
741
|
+
const signOutUrl = urlSetting(options.signOutUrl ?? env.G8_SIGN_OUT_URL);
|
|
742
|
+
const sameOrigin = options.sameOriginWrites ?? true;
|
|
743
|
+
const check = async (request: Request): Promise<boolean> => {
|
|
744
|
+
try {
|
|
745
|
+
return Boolean(await getUser(request));
|
|
746
|
+
} catch {
|
|
747
|
+
console.error("[graph8] sessionAuth: the session check threw; refusing the request.");
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
return {
|
|
752
|
+
kind: "session",
|
|
753
|
+
async authorize(request) {
|
|
754
|
+
if (!(await check(request))) return unauthenticated();
|
|
755
|
+
if (sameOrigin && !SAFE_METHODS.has(request.method.toUpperCase()) && isCrossSite(request)) return crossSite();
|
|
756
|
+
},
|
|
757
|
+
async status(request) {
|
|
758
|
+
return { kind: "session", signedIn: await check(request), ...(signInUrl ? { signInUrl } : {}), ...(signOutUrl ? { signOutUrl } : {}) };
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
764
|
+
// allowAnonymous
|
|
765
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
let anonymousWarned = false;
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* NO sign-in: anyone who can reach this app can use its graph8 API key through /api/g8, within allowedPrefixes.
|
|
771
|
+
* For a public demo or an app behind your own network gate. Logs a loud warning once per server start;
|
|
772
|
+
* `npx @graph8/sdk doctor` flags it.
|
|
773
|
+
*/
|
|
774
|
+
export function allowAnonymous(): G8AppAuth {
|
|
775
|
+
const warn = () => {
|
|
776
|
+
if (anonymousWarned) return;
|
|
777
|
+
anonymousWarned = true;
|
|
778
|
+
console.warn(
|
|
779
|
+
"[graph8] WARNING: allowAnonymous() is on. Anyone who can reach this app can use its graph8 API key through /api/g8 (within allowedPrefixes). Use a narrowly scoped key with a spend cap, or switch to a sign-in preset in g8.auth.ts.",
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
return {
|
|
783
|
+
kind: "anonymous",
|
|
784
|
+
async authorize() {
|
|
785
|
+
warn();
|
|
786
|
+
},
|
|
787
|
+
async status() {
|
|
788
|
+
return { kind: "anonymous", signedIn: true };
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
794
|
+
// The sign-in route
|
|
795
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* `app/api/g8-auth/route.ts`: GET the session status (for the sign-in screen), POST to sign in and DELETE to
|
|
799
|
+
* sign out (sharedPassword only; other presets answer 405 there, sign-in happens at your provider).
|
|
800
|
+
*/
|
|
801
|
+
export function createG8AuthRoute(auth: G8AppAuth) {
|
|
802
|
+
return {
|
|
803
|
+
async GET(request: Request): Promise<Response> {
|
|
804
|
+
return json(200, { ...(await auth.status(request)) });
|
|
805
|
+
},
|
|
806
|
+
async POST(request: Request): Promise<Response> {
|
|
807
|
+
if (!auth.signIn) return json(405, { error: "method_not_allowed", message: "Sign in with your identity provider." });
|
|
808
|
+
return auth.signIn(request);
|
|
809
|
+
},
|
|
810
|
+
async DELETE(request: Request): Promise<Response> {
|
|
811
|
+
if (!auth.signOut) return json(405, { error: "method_not_allowed", message: "Sign out with your identity provider." });
|
|
812
|
+
return auth.signOut(request);
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
}
|