@hexclave/shared 1.0.3 → 1.0.6
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/dist/ai/unified-prompts/reminders.js +4 -3
- package/dist/ai/unified-prompts/reminders.js.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts +3 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +216 -29
- package/dist/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-index.d.ts.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-index.js +1 -0
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-index.js.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.d.ts +18 -6
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.js +23 -8
- package/dist/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.js.map +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/openapi-specs.js +1 -1
- package/dist/ai/unified-prompts/skill-site-prompt-parts/openapi-specs.js.map +1 -1
- package/dist/config/schema.d.ts +1 -1
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +5 -5
- package/dist/config/schema.js.map +1 -1
- package/dist/config-authoring.d.ts +4 -4
- package/dist/config-authoring.d.ts.map +1 -1
- package/dist/config-authoring.js +2 -2
- package/dist/config-authoring.js.map +1 -1
- package/dist/config-rendering.d.ts +4 -4
- package/dist/config-rendering.d.ts.map +1 -1
- package/dist/config-rendering.js +19 -18
- package/dist/config-rendering.js.map +1 -1
- package/dist/esm/ai/unified-prompts/reminders.js +4 -3
- package/dist/esm/ai/unified-prompts/reminders.js.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts +3 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.d.ts.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js +215 -30
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.js.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-index.d.ts.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-index.js +1 -0
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-index.js.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.d.ts +18 -6
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.js +23 -8
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.js.map +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/openapi-specs.js +1 -1
- package/dist/esm/ai/unified-prompts/skill-site-prompt-parts/openapi-specs.js.map +1 -1
- package/dist/esm/config/schema.d.ts +1 -1
- package/dist/esm/config/schema.d.ts.map +1 -1
- package/dist/esm/config/schema.js +5 -5
- package/dist/esm/config/schema.js.map +1 -1
- package/dist/esm/config-authoring.d.ts +4 -4
- package/dist/esm/config-authoring.d.ts.map +1 -1
- package/dist/esm/config-authoring.js +2 -2
- package/dist/esm/config-authoring.js.map +1 -1
- package/dist/esm/config-rendering.d.ts +4 -4
- package/dist/esm/config-rendering.d.ts.map +1 -1
- package/dist/esm/config-rendering.js +15 -14
- package/dist/esm/config-rendering.js.map +1 -1
- package/dist/esm/helpers/vault/client-side.d.ts.map +1 -1
- package/dist/esm/helpers/vault/client-side.js.map +1 -1
- package/dist/esm/helpers/vault/server-side.d.ts.map +1 -1
- package/dist/esm/helpers/vault/server-side.js.map +1 -1
- package/dist/esm/hexclave-config-file.d.ts +15 -0
- package/dist/esm/hexclave-config-file.d.ts.map +1 -0
- package/dist/esm/{stack-config-file.js → hexclave-config-file.js} +7 -7
- package/dist/esm/hexclave-config-file.js.map +1 -0
- package/dist/esm/interface/admin-metrics.d.ts +2 -2
- package/dist/esm/interface/admin-metrics.js.map +1 -1
- package/dist/esm/interface/client-interface.js +2 -2
- package/dist/esm/interface/client-interface.js.map +1 -1
- package/dist/esm/interface/conversations.d.ts +32 -32
- package/dist/esm/interface/crud/current-user.d.ts +7 -7
- package/dist/esm/interface/crud/email-outbox.d.ts +42 -42
- package/dist/esm/interface/crud/project-permissions.js +5 -5
- package/dist/esm/interface/crud/project-permissions.js.map +1 -1
- package/dist/esm/interface/crud/team-member-profiles.d.ts +18 -18
- package/dist/esm/interface/crud/team-permissions.js +5 -5
- package/dist/esm/interface/crud/team-permissions.js.map +1 -1
- package/dist/esm/interface/crud/users.d.ts +10 -10
- package/dist/esm/interface/page-component-versions.js +107 -107
- package/dist/esm/interface/page-component-versions.js.map +1 -1
- package/dist/esm/interface/webhooks.d.ts +2 -2
- package/dist/esm/known-errors.d.ts +1 -1
- package/dist/esm/known-errors.d.ts.map +1 -1
- package/dist/esm/known-errors.js +9 -5
- package/dist/esm/known-errors.js.map +1 -1
- package/dist/esm/schema-fields.d.ts +15 -5
- package/dist/esm/schema-fields.d.ts.map +1 -1
- package/dist/esm/schema-fields.js +26 -16
- package/dist/esm/schema-fields.js.map +1 -1
- package/dist/esm/utils/crypto.d.ts.map +1 -1
- package/dist/esm/utils/crypto.js.map +1 -1
- package/dist/esm/utils/currency-constants.d.ts +6 -0
- package/dist/esm/utils/currency-constants.d.ts.map +1 -1
- package/dist/esm/utils/currency-constants.js.map +1 -1
- package/dist/esm/utils/dev-env-state-path.d.ts +3 -3
- package/dist/esm/utils/dev-env-state-path.d.ts.map +1 -1
- package/dist/esm/utils/dev-env-state-path.js +4 -4
- package/dist/esm/utils/dev-env-state-path.js.map +1 -1
- package/dist/esm/utils/errors.js +2 -2
- package/dist/esm/utils/errors.js.map +1 -1
- package/dist/esm/utils/esbuild.d.ts.map +1 -1
- package/dist/esm/utils/esbuild.js +0 -1
- package/dist/esm/utils/esbuild.js.map +1 -1
- package/dist/esm/utils/featurebase.d.ts +3 -3
- package/dist/esm/utils/featurebase.d.ts.map +1 -1
- package/dist/esm/utils/featurebase.js +15 -15
- package/dist/esm/utils/featurebase.js.map +1 -1
- package/dist/esm/utils/fs.js +6 -6
- package/dist/esm/utils/fs.js.map +1 -1
- package/dist/esm/utils/globals.d.ts.map +1 -1
- package/dist/esm/utils/globals.js +10 -10
- package/dist/esm/utils/globals.js.map +1 -1
- package/dist/esm/utils/jwt.d.ts.map +1 -1
- package/dist/esm/utils/jwt.js +3 -3
- package/dist/esm/utils/jwt.js.map +1 -1
- package/dist/esm/utils/redirect-urls.d.ts +3 -3
- package/dist/esm/utils/redirect-urls.d.ts.map +1 -1
- package/dist/esm/utils/redirect-urls.js +5 -5
- package/dist/esm/utils/redirect-urls.js.map +1 -1
- package/dist/esm/utils/urls.js.map +1 -1
- package/dist/helpers/vault/client-side.d.ts.map +1 -1
- package/dist/helpers/vault/client-side.js.map +1 -1
- package/dist/helpers/vault/server-side.d.ts.map +1 -1
- package/dist/helpers/vault/server-side.js.map +1 -1
- package/dist/hexclave-config-file.d.ts +15 -0
- package/dist/hexclave-config-file.d.ts.map +1 -0
- package/dist/{stack-config-file.js → hexclave-config-file.js} +8 -8
- package/dist/hexclave-config-file.js.map +1 -0
- package/dist/interface/admin-metrics.d.ts +12 -12
- package/dist/interface/admin-metrics.js.map +1 -1
- package/dist/interface/client-interface.js +2 -2
- package/dist/interface/client-interface.js.map +1 -1
- package/dist/interface/conversations.d.ts +32 -32
- package/dist/interface/crud/current-user.d.ts +7 -7
- package/dist/interface/crud/email-outbox.d.ts +42 -42
- package/dist/interface/crud/project-permissions.js +5 -5
- package/dist/interface/crud/project-permissions.js.map +1 -1
- package/dist/interface/crud/team-member-profiles.d.ts +18 -18
- package/dist/interface/crud/team-permissions.js +5 -5
- package/dist/interface/crud/team-permissions.js.map +1 -1
- package/dist/interface/crud/users.d.ts +10 -10
- package/dist/interface/page-component-versions.js +107 -107
- package/dist/interface/page-component-versions.js.map +1 -1
- package/dist/interface/webhooks.d.ts +2 -2
- package/dist/known-errors.d.ts +1 -1
- package/dist/known-errors.d.ts.map +1 -1
- package/dist/known-errors.js +9 -5
- package/dist/known-errors.js.map +1 -1
- package/dist/schema-fields.d.ts +15 -5
- package/dist/schema-fields.d.ts.map +1 -1
- package/dist/schema-fields.js +26 -16
- package/dist/schema-fields.js.map +1 -1
- package/dist/utils/crypto.d.ts.map +1 -1
- package/dist/utils/crypto.js.map +1 -1
- package/dist/utils/currency-constants.d.ts +6 -0
- package/dist/utils/currency-constants.d.ts.map +1 -1
- package/dist/utils/currency-constants.js.map +1 -1
- package/dist/utils/dev-env-state-path.d.ts +3 -3
- package/dist/utils/dev-env-state-path.d.ts.map +1 -1
- package/dist/utils/dev-env-state-path.js +5 -5
- package/dist/utils/dev-env-state-path.js.map +1 -1
- package/dist/utils/errors.js +2 -2
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/esbuild.d.ts.map +1 -1
- package/dist/utils/esbuild.js +0 -1
- package/dist/utils/esbuild.js.map +1 -1
- package/dist/utils/featurebase.d.ts +3 -3
- package/dist/utils/featurebase.d.ts.map +1 -1
- package/dist/utils/featurebase.js +15 -15
- package/dist/utils/featurebase.js.map +1 -1
- package/dist/utils/fs.js.map +1 -1
- package/dist/utils/globals.d.ts.map +1 -1
- package/dist/utils/globals.js +10 -10
- package/dist/utils/globals.js.map +1 -1
- package/dist/utils/jwt.d.ts.map +1 -1
- package/dist/utils/jwt.js +3 -3
- package/dist/utils/jwt.js.map +1 -1
- package/dist/utils/redirect-urls.d.ts +3 -3
- package/dist/utils/redirect-urls.d.ts.map +1 -1
- package/dist/utils/redirect-urls.js +5 -5
- package/dist/utils/redirect-urls.js.map +1 -1
- package/dist/utils/urls.js.map +1 -1
- package/package.json +2 -1
- package/src/ai/image-limits.ts +62 -0
- package/src/ai/llms/llms.ts +36 -0
- package/src/ai/unified-prompts/reminders.ts +27 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt.ts +1047 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/brain.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/cli-help.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/config-docs.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/custom-components-instructions.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/dashboard-instructions.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/docs-index.ts +80 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/docs-json.generated.ts +322 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/openapi-specs.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt-parts/sdk-interface-source.ts +5 -0
- package/src/ai/unified-prompts/skill-site-prompt.ts +72 -0
- package/src/apps/apps-config.ts +177 -0
- package/src/apps/apps-ui.tsx +112 -0
- package/src/config/README.md +154 -0
- package/src/config/db-sync-mappings.ts +2293 -0
- package/src/config/format.ts +388 -0
- package/src/config/migrate-catalogs-to-product-lines.ts +475 -0
- package/src/config/schema-fuzzer.test.ts +377 -0
- package/src/config/schema.ts +1507 -0
- package/src/config-authoring.test.ts +52 -0
- package/src/config-authoring.ts +37 -0
- package/src/config-rendering.ts +142 -0
- package/src/crud.tsx +275 -0
- package/src/global.d.ts +1 -0
- package/src/helpers/emails.ts +250 -0
- package/src/helpers/init-prompt.ts +39 -0
- package/src/helpers/password.ts +16 -0
- package/src/helpers/production-mode.ts +58 -0
- package/src/helpers/support-sla.test.ts +124 -0
- package/src/helpers/support-sla.ts +93 -0
- package/src/helpers/vault/client-side.ts +92 -0
- package/src/helpers/vault/server-side.ts +153 -0
- package/src/hexclave-config-file.ts +120 -0
- package/src/hooks/use-async-callback.tsx +43 -0
- package/src/hooks/use-async-external-store.tsx +24 -0
- package/src/hooks/use-hash.tsx +14 -0
- package/src/hooks/use-hover.tsx +88 -0
- package/src/hooks/use-strict-memo.tsx +157 -0
- package/src/index.ts +17 -0
- package/src/interface/admin-interface.ts +1043 -0
- package/src/interface/admin-metrics.ts +214 -0
- package/src/interface/client-interface.test.ts +765 -0
- package/src/interface/client-interface.ts +2586 -0
- package/src/interface/conversations.ts +109 -0
- package/src/interface/crud/analytics.ts +11 -0
- package/src/interface/crud/connected-accounts.ts +52 -0
- package/src/interface/crud/contact-channels.ts +74 -0
- package/src/interface/crud/current-user.ts +72 -0
- package/src/interface/crud/email-outbox.ts +263 -0
- package/src/interface/crud/email-templates.ts +50 -0
- package/src/interface/crud/emails.ts +22 -0
- package/src/interface/crud/internal-api-keys.ts +69 -0
- package/src/interface/crud/invoices.ts +36 -0
- package/src/interface/crud/items.ts +24 -0
- package/src/interface/crud/notification-preferences.ts +33 -0
- package/src/interface/crud/oauth-providers.ts +91 -0
- package/src/interface/crud/products.ts +42 -0
- package/src/interface/crud/project-api-keys.ts +100 -0
- package/src/interface/crud/project-permissions.ts +118 -0
- package/src/interface/crud/projects.ts +229 -0
- package/src/interface/crud/session-replays.ts +85 -0
- package/src/interface/crud/sessions.ts +66 -0
- package/src/interface/crud/sign-up-rules.ts +12 -0
- package/src/interface/crud/svix-token.ts +20 -0
- package/src/interface/crud/team-invitation-details.ts +22 -0
- package/src/interface/crud/team-invitation.ts +35 -0
- package/src/interface/crud/team-member-profiles.ts +61 -0
- package/src/interface/crud/team-memberships.ts +56 -0
- package/src/interface/crud/team-permissions.ts +119 -0
- package/src/interface/crud/teams.ts +142 -0
- package/src/interface/crud/transactions.ts +131 -0
- package/src/interface/crud/users.ts +185 -0
- package/src/interface/handler-urls.ts +74 -0
- package/src/interface/page-component-versions.ts +1702 -0
- package/src/interface/server-interface.ts +1146 -0
- package/src/interface/webhooks.ts +28 -0
- package/src/known-errors.tsx +2034 -0
- package/src/local-emulator.ts +5 -0
- package/src/payments/payment-methods.ts +167 -0
- package/src/payments/stripe-limits.ts +18 -0
- package/src/plans.ts +107 -0
- package/src/schema-fields.ts +944 -0
- package/src/sessions.ts +310 -0
- package/src/utils/api-keys.tsx +122 -0
- package/src/utils/arrays.tsx +248 -0
- package/src/utils/auth-methods.ts +8 -0
- package/src/utils/base64.tsx +24 -0
- package/src/utils/booleans.tsx +34 -0
- package/src/utils/browser-compat.tsx +17 -0
- package/src/utils/bytes.tsx +280 -0
- package/src/utils/caches.tsx +280 -0
- package/src/utils/cel-fields.ts +92 -0
- package/src/utils/cloud-hosts.tsx +92 -0
- package/src/utils/compile-time.tsx +11 -0
- package/src/utils/country-codes.ts +39 -0
- package/src/utils/crypto.tsx +174 -0
- package/src/utils/currencies.tsx +53 -0
- package/src/utils/currency-constants.tsx +51 -0
- package/src/utils/dates.tsx +246 -0
- package/src/utils/dev-env-state-path.ts +14 -0
- package/src/utils/dom.tsx +7 -0
- package/src/utils/env.tsx +117 -0
- package/src/utils/errors.tsx +284 -0
- package/src/utils/esbuild.tsx +235 -0
- package/src/utils/featurebase.tsx +247 -0
- package/src/utils/fs.tsx +33 -0
- package/src/utils/functions.tsx +22 -0
- package/src/utils/geo.tsx +16 -0
- package/src/utils/globals.tsx +48 -0
- package/src/utils/hashes.tsx +52 -0
- package/src/utils/html.tsx +60 -0
- package/src/utils/http.tsx +91 -0
- package/src/utils/ips.tsx +47 -0
- package/src/utils/json.tsx +200 -0
- package/src/utils/jsx-editable-transpiler.test.ts +156 -0
- package/src/utils/jsx-editable-transpiler.tsx +401 -0
- package/src/utils/jwt.tsx +170 -0
- package/src/utils/locks.tsx +71 -0
- package/src/utils/maps.tsx +407 -0
- package/src/utils/math.tsx +18 -0
- package/src/utils/monkey-patch.tsx +29 -0
- package/src/utils/node-http.tsx +47 -0
- package/src/utils/numbers.tsx +82 -0
- package/src/utils/oauth.tsx +9 -0
- package/src/utils/objects.tsx +581 -0
- package/src/utils/paginated-lists.test.ts +1039 -0
- package/src/utils/paginated-lists.tsx +479 -0
- package/src/utils/passkey.tsx +1 -0
- package/src/utils/promises.tsx +540 -0
- package/src/utils/proxies.tsx +212 -0
- package/src/utils/react.tsx +308 -0
- package/src/utils/redirect-urls.tsx +299 -0
- package/src/utils/regex.tsx +10 -0
- package/src/utils/results.tsx +411 -0
- package/src/utils/sentry.tsx +20 -0
- package/src/utils/stores.tsx +238 -0
- package/src/utils/strings.nicify.test.ts +253 -0
- package/src/utils/strings.tsx +723 -0
- package/src/utils/telemetry.tsx +37 -0
- package/src/utils/turnstile-browser.ts +106 -0
- package/src/utils/turnstile-flow.test.ts +101 -0
- package/src/utils/turnstile-flow.ts +266 -0
- package/src/utils/turnstile.ts +34 -0
- package/src/utils/typed-arrays.tsx +17 -0
- package/src/utils/types.tsx +208 -0
- package/src/utils/unicode.tsx +24 -0
- package/src/utils/urls.tsx +411 -0
- package/src/utils/uuids.tsx +47 -0
- package/dist/esm/stack-config-file.d.ts +0 -15
- package/dist/esm/stack-config-file.d.ts.map +0 -1
- package/dist/esm/stack-config-file.js.map +0 -1
- package/dist/stack-config-file.d.ts +0 -15
- package/dist/stack-config-file.d.ts.map +0 -1
- package/dist/stack-config-file.js.map +0 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { decodeBase64, encodeBase64 } from "../../utils/bytes";
|
|
2
|
+
import { decrypt, encrypt, hash, iteratedHash } from "../../utils/crypto";
|
|
3
|
+
|
|
4
|
+
// NOTE (Hexclave rebrand): do NOT rename these "stack-*" purpose tags. They are mixed into the
|
|
5
|
+
// vault's key derivation and value encryption; renaming any of them would make every
|
|
6
|
+
// previously-stored vault value undecryptable. Internal constants, never user-visible.
|
|
7
|
+
const hashPurpose = "stack-data-vault-client-side-encryption-key-hash";
|
|
8
|
+
const encryptionSecretPurpose = "stack-data-vault-client-side-encryption-value-encryption-key-hash";
|
|
9
|
+
const encryptionValuePurpose = "stack-data-vault-client-side-encryption-value-encryption-value-encryption";
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async function getDerivedKey(secret: string, key: string) {
|
|
13
|
+
return await iteratedHash({
|
|
14
|
+
purpose: encryptionSecretPurpose,
|
|
15
|
+
extra: secret,
|
|
16
|
+
value: key,
|
|
17
|
+
iterations: 100_000,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Use to hash the key so the server cannot infer it.
|
|
23
|
+
*/
|
|
24
|
+
export async function hashKey(secret: string, key: string) {
|
|
25
|
+
return encodeBase64(await hash({
|
|
26
|
+
purpose: hashPurpose,
|
|
27
|
+
extra: secret,
|
|
28
|
+
value: await getDerivedKey(secret, key),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Use to encrypt the value so that the server cannot read the value without knowing the key.
|
|
34
|
+
*/
|
|
35
|
+
export async function encryptValue(secret: string, key: string, value: string) {
|
|
36
|
+
const valueEncryptionDerivedKey = await getDerivedKey(secret, key);
|
|
37
|
+
|
|
38
|
+
const bytes = await encrypt({
|
|
39
|
+
purpose: encryptionValuePurpose,
|
|
40
|
+
secret: valueEncryptionDerivedKey,
|
|
41
|
+
value: new TextEncoder().encode(value)
|
|
42
|
+
});
|
|
43
|
+
return encodeBase64(bytes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Use to decrypt the value. See encryptValue.
|
|
48
|
+
*/
|
|
49
|
+
export async function decryptValue(secret: string, key: string, encryptedValue: string) {
|
|
50
|
+
const valueEncryptionDerivedKey = await getDerivedKey(secret, key);
|
|
51
|
+
|
|
52
|
+
const bytesResult = await decrypt({
|
|
53
|
+
purpose: encryptionValuePurpose,
|
|
54
|
+
secret: valueEncryptionDerivedKey,
|
|
55
|
+
cipher: decodeBase64(encryptedValue),
|
|
56
|
+
});
|
|
57
|
+
if (bytesResult.status === "error") throw new Error("Data vault client-side decryption failed. Are you sure you're using the correct secret?", { cause: bytesResult.error });
|
|
58
|
+
return new TextDecoder().decode(bytesResult.data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
import.meta.vitest?.describe("encryptValue & decryptValue", () => {
|
|
63
|
+
import.meta.vitest?.it("should encrypt and decrypt a value", async ({ expect }) => {
|
|
64
|
+
const secret = "test-secret";
|
|
65
|
+
const value = "test-value";
|
|
66
|
+
const encrypted = await encryptValue(secret, "key", value);
|
|
67
|
+
const decrypted = await decryptValue(secret, "key", encrypted);
|
|
68
|
+
expect(decrypted).toEqual(value);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
import.meta.vitest?.it("should not decrypt a value with a different secret", async ({ expect }) => {
|
|
72
|
+
const secret = "test-secret";
|
|
73
|
+
const value = "test-value";
|
|
74
|
+
const encrypted = await encryptValue(secret, "key", value);
|
|
75
|
+
await expect(decryptValue("different-secret", "key", encrypted)).rejects.toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
import.meta.vitest?.it("should not decrypt a value with a different key", async ({ expect }) => {
|
|
79
|
+
const secret = "test-secret";
|
|
80
|
+
const value = "test-value";
|
|
81
|
+
const encrypted = await encryptValue(secret, "key", value);
|
|
82
|
+
await expect(decryptValue(secret, "different-key", encrypted)).rejects.toThrow();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
import.meta.vitest?.it("should not decrypt a value if the cipher was tampered with", async ({ expect }) => {
|
|
86
|
+
const secret = "test-secret";
|
|
87
|
+
const value = "test-value";
|
|
88
|
+
const encrypted = await encryptValue(secret, "key", value);
|
|
89
|
+
const tampered = encrypted + "7";
|
|
90
|
+
await expect(decryptValue(secret, "key", tampered)).rejects.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CreateAliasCommand,
|
|
3
|
+
CreateKeyCommand,
|
|
4
|
+
DecryptCommand,
|
|
5
|
+
DescribeKeyCommand,
|
|
6
|
+
GenerateDataKeyCommand,
|
|
7
|
+
KMSClient
|
|
8
|
+
} from "@aws-sdk/client-kms";
|
|
9
|
+
import { decodeBase64, encodeBase64 } from "../../utils/bytes";
|
|
10
|
+
import { decrypt, encrypt } from "../../utils/crypto";
|
|
11
|
+
import { getEnvVariable } from "../../utils/env";
|
|
12
|
+
import { Result } from "../../utils/results";
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async function getAwsCredentials() {
|
|
16
|
+
// 1. Vercel OIDC: Vercel injects an OIDC token that can be exchanged for AWS credentials
|
|
17
|
+
const vercelRoleArn = getEnvVariable("STACK_AWS_VERCEL_OIDC_ROLE_ARN", "");
|
|
18
|
+
if (vercelRoleArn) {
|
|
19
|
+
const { awsCredentialsProvider } = await import("@vercel/functions/oidc");
|
|
20
|
+
return awsCredentialsProvider({ roleArn: vercelRoleArn });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 2. GCP Workload Identity Federation: Cloud Run gets a GCP ID token from the metadata server,
|
|
24
|
+
// then exchanges it for temporary AWS credentials via STS AssumeRoleWithWebIdentity.
|
|
25
|
+
// Requires:
|
|
26
|
+
// - An OIDC identity provider in AWS IAM (issuer: https://accounts.google.com)
|
|
27
|
+
// - An IAM role with a trust policy allowing the GCP service account
|
|
28
|
+
// - STACK_AWS_GCP_WIF_ROLE_ARN set to that role's ARN
|
|
29
|
+
// - STACK_AWS_GCP_WIF_AUDIENCE set to the audience configured in the AWS OIDC provider
|
|
30
|
+
const gcpWifRoleArn = getEnvVariable("STACK_AWS_GCP_WIF_ROLE_ARN", "");
|
|
31
|
+
if (gcpWifRoleArn) {
|
|
32
|
+
const { fromWebToken } = await import("@aws-sdk/credential-provider-web-identity");
|
|
33
|
+
const audience = getEnvVariable("STACK_AWS_GCP_WIF_AUDIENCE", "sts.amazonaws.com");
|
|
34
|
+
// Return a provider that fetches a fresh GCP ID token on each invocation.
|
|
35
|
+
// GCP metadata tokens expire after ~1h, so we can't bake a single token into the closure.
|
|
36
|
+
return async () => {
|
|
37
|
+
return await fromWebToken({
|
|
38
|
+
roleArn: gcpWifRoleArn,
|
|
39
|
+
roleSessionName: "stack-backend-cloudrun",
|
|
40
|
+
webIdentityToken: await fetchGcpIdToken(audience),
|
|
41
|
+
})();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. Static credentials: fallback for self-hosted / local development
|
|
46
|
+
return {
|
|
47
|
+
accessKeyId: getEnvVariable("STACK_AWS_ACCESS_KEY_ID"),
|
|
48
|
+
secretAccessKey: getEnvVariable("STACK_AWS_SECRET_ACCESS_KEY"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetches a GCP ID token from the metadata server (available on Cloud Run, GCE, GKE).
|
|
54
|
+
* The token is a Google-signed JWT with the specified audience, suitable for
|
|
55
|
+
* AWS STS AssumeRoleWithWebIdentity.
|
|
56
|
+
*/
|
|
57
|
+
async function fetchGcpIdToken(audience: string): Promise<string> {
|
|
58
|
+
const metadataUrl = `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${encodeURIComponent(audience)}`;
|
|
59
|
+
const response = await fetch(metadataUrl, {
|
|
60
|
+
headers: { "Metadata-Flavor": "Google" },
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Failed to fetch GCP ID token: ${response.status} ${await response.text()}`);
|
|
64
|
+
}
|
|
65
|
+
return await response.text();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let kmsClientCache: KMSClient | undefined;
|
|
69
|
+
|
|
70
|
+
async function getKmsClient() {
|
|
71
|
+
if (!kmsClientCache) {
|
|
72
|
+
kmsClientCache = new KMSClient({
|
|
73
|
+
region: getEnvVariable("STACK_AWS_REGION"),
|
|
74
|
+
endpoint: getEnvVariable("STACK_AWS_KMS_ENDPOINT"),
|
|
75
|
+
credentials: await getAwsCredentials(),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return kmsClientCache;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function getOrCreateKekId(): Promise<string> {
|
|
82
|
+
// NOTE (Hexclave rebrand): do NOT rename this "stack-*" alias. It is the AWS KMS key alias for
|
|
83
|
+
// the existing key-encryption key; renaming it would point at a non-existent key and break
|
|
84
|
+
// wrap/unwrap of all stored data keys. Internal infrastructure constant, never user-visible.
|
|
85
|
+
const id = "alias/stack-data-vault-server-side-kek";
|
|
86
|
+
const kms = await getKmsClient();
|
|
87
|
+
try {
|
|
88
|
+
const describeResult = await kms.send(new DescribeKeyCommand({ KeyId: id }));
|
|
89
|
+
if (describeResult.KeyMetadata?.KeyId) return describeResult.KeyMetadata.KeyId;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e instanceof Error && e.name !== "NotFoundException") {
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const { KeyMetadata } = await kms.send(new CreateKeyCommand({
|
|
96
|
+
KeyUsage: "ENCRYPT_DECRYPT",
|
|
97
|
+
Description: "DataVault KEK"
|
|
98
|
+
}));
|
|
99
|
+
await kms.send(new CreateAliasCommand({ AliasName: id, TargetKeyId: KeyMetadata!.KeyId! }));
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function genDEK() {
|
|
104
|
+
const kekId = await getOrCreateKekId();
|
|
105
|
+
const kms = await getKmsClient();
|
|
106
|
+
const out = await kms.send(new GenerateDataKeyCommand({ KeyId: kekId, KeySpec: "AES_256" }));
|
|
107
|
+
if (!out.Plaintext || !out.CiphertextBlob) throw new Error("GenerateDataKey failed");
|
|
108
|
+
return {
|
|
109
|
+
dekBytes: out.Plaintext,
|
|
110
|
+
edkBytes: out.CiphertextBlob,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function unwrapDEK(edk_b64: string) {
|
|
115
|
+
const edkBytes = decodeBase64(edk_b64);
|
|
116
|
+
const kms = await getKmsClient();
|
|
117
|
+
const out = await kms.send(new DecryptCommand({ CiphertextBlob: edkBytes }));
|
|
118
|
+
if (!out.Plaintext) throw new Error("KMS Decrypt failed");
|
|
119
|
+
return {
|
|
120
|
+
dekBytes: out.Plaintext,
|
|
121
|
+
edkBytes,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function encryptWithKms(value: string) {
|
|
126
|
+
const { dekBytes, edkBytes } = await genDEK();
|
|
127
|
+
try {
|
|
128
|
+
const ciphertext = await encrypt({
|
|
129
|
+
// NOTE (Hexclave rebrand): do NOT rename this "stack-*" purpose tag — it must match the one
|
|
130
|
+
// used in decryptWithKms below, or already-encrypted values become undecryptable.
|
|
131
|
+
purpose: "stack-data-vault-server-side-encryption",
|
|
132
|
+
secret: dekBytes,
|
|
133
|
+
value: new TextEncoder().encode(value),
|
|
134
|
+
});
|
|
135
|
+
return { edkBase64: encodeBase64(edkBytes), ciphertextBase64: encodeBase64(ciphertext) };
|
|
136
|
+
} finally {
|
|
137
|
+
dekBytes.fill(0);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function decryptWithKms(encrypted: Awaited<ReturnType<typeof encryptWithKms>>) {
|
|
142
|
+
const { dekBytes } = await unwrapDEK(encrypted.edkBase64);
|
|
143
|
+
try {
|
|
144
|
+
const value = Result.orThrow(await decrypt({
|
|
145
|
+
purpose: "stack-data-vault-server-side-encryption",
|
|
146
|
+
secret: dekBytes,
|
|
147
|
+
cipher: decodeBase64(encrypted.ciphertextBase64),
|
|
148
|
+
}));
|
|
149
|
+
return new TextDecoder().decode(value);
|
|
150
|
+
} finally {
|
|
151
|
+
dekBytes.fill(0);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as parser from "@babel/parser";
|
|
2
|
+
import * as t from "@babel/types";
|
|
3
|
+
import { isValidConfig, normalize } from "./config/format";
|
|
4
|
+
|
|
5
|
+
export const showOnboardingHexclaveConfigValue = "show-onboarding";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG_IMPORT_PACKAGE = "@hexclave/js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders a config object into the source text of a `stack.config.ts` file.
|
|
11
|
+
*
|
|
12
|
+
* Browser-safe: kept here (next to `parseHexclaveConfigFileContent`) instead of in
|
|
13
|
+
* `config-rendering.ts` so dashboard client code can render config files
|
|
14
|
+
* without pulling in `fs` / `path`.
|
|
15
|
+
*/
|
|
16
|
+
export function renderConfigFileContent(config: unknown, importPackage?: string): string {
|
|
17
|
+
if (!isValidConfig(config)) {
|
|
18
|
+
throw new Error("Invalid config: expected a plain object.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const droppedKeys: string[] = [];
|
|
22
|
+
const normalizedConfig = normalize(config, {
|
|
23
|
+
onDotIntoNonObject: "ignore",
|
|
24
|
+
onDotIntoNull: "empty-object",
|
|
25
|
+
droppedKeys,
|
|
26
|
+
});
|
|
27
|
+
if (droppedKeys.length > 0) {
|
|
28
|
+
throw new Error(`Config has conflicting keys that would be dropped during normalization: ${droppedKeys.map(k => JSON.stringify(k)).join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
const pkg = importPackage ?? DEFAULT_CONFIG_IMPORT_PACKAGE;
|
|
31
|
+
const importLine = `import type { HexclaveConfig } from "${pkg}";`;
|
|
32
|
+
return `${importLine}\n\nexport const config: HexclaveConfig = ${JSON.stringify(normalizedConfig, null, 2)};\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ParsedStackConfig = Record<string, unknown> | typeof showOnboardingHexclaveConfigValue;
|
|
36
|
+
|
|
37
|
+
function unwrapStaticConfigExpression(expression: t.Expression): t.Expression {
|
|
38
|
+
if (
|
|
39
|
+
t.isTSAsExpression(expression)
|
|
40
|
+
|| t.isTSSatisfiesExpression(expression)
|
|
41
|
+
|| t.isTSTypeAssertion(expression)
|
|
42
|
+
|| t.isTSNonNullExpression(expression)
|
|
43
|
+
) {
|
|
44
|
+
return unwrapStaticConfigExpression(expression.expression);
|
|
45
|
+
}
|
|
46
|
+
return expression;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function evaluateStaticConfigExpression(expression: t.Expression): unknown {
|
|
50
|
+
const unwrapped = unwrapStaticConfigExpression(expression);
|
|
51
|
+
if (t.isStringLiteral(unwrapped)) return unwrapped.value;
|
|
52
|
+
if (t.isBooleanLiteral(unwrapped)) return unwrapped.value;
|
|
53
|
+
if (t.isNumericLiteral(unwrapped)) return unwrapped.value;
|
|
54
|
+
if (t.isNullLiteral(unwrapped)) return null;
|
|
55
|
+
if (t.isIdentifier(unwrapped) && unwrapped.name === "undefined") return undefined;
|
|
56
|
+
if (t.isUnaryExpression(unwrapped) && unwrapped.operator === "-" && t.isNumericLiteral(unwrapped.argument)) {
|
|
57
|
+
return -unwrapped.argument.value;
|
|
58
|
+
}
|
|
59
|
+
if (t.isArrayExpression(unwrapped)) {
|
|
60
|
+
return unwrapped.elements.map((element) => {
|
|
61
|
+
if (element == null || t.isSpreadElement(element)) {
|
|
62
|
+
throw new Error("Config arrays cannot contain holes or spreads.");
|
|
63
|
+
}
|
|
64
|
+
return evaluateStaticConfigExpression(element);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (t.isObjectExpression(unwrapped)) {
|
|
68
|
+
const result: Record<string, unknown> = {};
|
|
69
|
+
for (const property of unwrapped.properties) {
|
|
70
|
+
if (t.isSpreadElement(property)) {
|
|
71
|
+
throw new Error("Config objects cannot contain spreads.");
|
|
72
|
+
}
|
|
73
|
+
if (property.computed) {
|
|
74
|
+
throw new Error("Config object keys cannot be computed.");
|
|
75
|
+
}
|
|
76
|
+
const key = t.isIdentifier(property.key)
|
|
77
|
+
? property.key.name
|
|
78
|
+
: t.isStringLiteral(property.key) || t.isNumericLiteral(property.key)
|
|
79
|
+
? String(property.key.value)
|
|
80
|
+
: null;
|
|
81
|
+
if (key == null) {
|
|
82
|
+
throw new Error("Unsupported config object key.");
|
|
83
|
+
}
|
|
84
|
+
if (t.isObjectMethod(property)) {
|
|
85
|
+
throw new Error("Config objects cannot contain methods.");
|
|
86
|
+
}
|
|
87
|
+
if (!t.isExpression(property.value)) {
|
|
88
|
+
throw new Error("Unsupported config object value.");
|
|
89
|
+
}
|
|
90
|
+
result[key] = evaluateStaticConfigExpression(property.value);
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`Unsupported config expression: ${unwrapped.type}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseHexclaveConfigFileContent(content: string, filePath: string): ParsedStackConfig {
|
|
98
|
+
if (content.trim() === "") return {};
|
|
99
|
+
const ast = parser.parse(content, {
|
|
100
|
+
sourceType: "module",
|
|
101
|
+
plugins: ["typescript"],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
for (const statement of ast.program.body) {
|
|
105
|
+
if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const declaration of statement.declaration.declarations) {
|
|
109
|
+
if (!t.isIdentifier(declaration.id) || declaration.id.name !== "config") {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (declaration.init == null || !t.isExpression(declaration.init)) {
|
|
113
|
+
throw new Error(`Config export in ${filePath} must have an initializer.`);
|
|
114
|
+
}
|
|
115
|
+
return evaluateStaticConfigExpression(declaration.init) as ParsedStackConfig;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`);
|
|
120
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { captureError } from "../utils/errors";
|
|
3
|
+
|
|
4
|
+
export function useAsyncCallback<A extends any[], R>(
|
|
5
|
+
callback: (...args: A) => Promise<R>,
|
|
6
|
+
deps: React.DependencyList
|
|
7
|
+
): [cb: (...args: A) => Promise<R>, loading: boolean, error: unknown | undefined] {
|
|
8
|
+
const [error, setError] = React.useState<unknown | undefined>(undefined);
|
|
9
|
+
const [loadingCount, setLoadingCount] = React.useState(0);
|
|
10
|
+
|
|
11
|
+
const cb = React.useCallback(
|
|
12
|
+
async (...args: A) => {
|
|
13
|
+
setLoadingCount((c) => c + 1);
|
|
14
|
+
try {
|
|
15
|
+
return await callback(...args);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
setError(e);
|
|
18
|
+
throw e;
|
|
19
|
+
} finally {
|
|
20
|
+
setLoadingCount((c) => c - 1);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
deps,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return [cb, loadingCount > 0, error];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useAsyncCallbackWithLoggedError<A extends any[], R>(
|
|
30
|
+
callback: (...args: A) => Promise<R>,
|
|
31
|
+
deps: React.DependencyList
|
|
32
|
+
): [cb: (...args: A) => Promise<R>, loading: boolean] {
|
|
33
|
+
const [newCallback, loading] = useAsyncCallback<A, R>(async (...args) => {
|
|
34
|
+
try {
|
|
35
|
+
return await callback(...args);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
captureError("async-callback", e);
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}, deps);
|
|
41
|
+
|
|
42
|
+
return [newCallback, loading];
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { AsyncResult } from "../utils/results";
|
|
3
|
+
|
|
4
|
+
export function useAsyncExternalStore<T>(
|
|
5
|
+
subscribe: (callback: (t: T) => void) => (() => void),
|
|
6
|
+
): AsyncResult<T, never> & { status: "ok" | "pending" } {
|
|
7
|
+
// sure, the "sync" in useSyncExternalStore refers to "synchronize a store" and not "sync/async", but it's too good of a name to pass up on
|
|
8
|
+
|
|
9
|
+
const [isAvailable, setIsAvailable] = useState(false);
|
|
10
|
+
const [value, setValue] = useState<T>();
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const unsubscribe = subscribe((value: T) => {
|
|
13
|
+
setValue(() => value);
|
|
14
|
+
setIsAvailable(() => true);
|
|
15
|
+
});
|
|
16
|
+
return unsubscribe;
|
|
17
|
+
}, [subscribe]);
|
|
18
|
+
|
|
19
|
+
if (isAvailable) {
|
|
20
|
+
return AsyncResult.ok(value as T);
|
|
21
|
+
} else {
|
|
22
|
+
return AsyncResult.pending();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { suspendIfSsr } from "../utils/react";
|
|
3
|
+
|
|
4
|
+
export const useHash = () => {
|
|
5
|
+
suspendIfSsr("useHash");
|
|
6
|
+
return useSyncExternalStore(
|
|
7
|
+
(onChange) => {
|
|
8
|
+
const interval = setInterval(() => onChange(), 10);
|
|
9
|
+
return () => clearInterval(interval);
|
|
10
|
+
},
|
|
11
|
+
() => window.location.hash.substring(1)
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useLayoutEffect } from "react";
|
|
2
|
+
import { useRefState } from "../utils/react";
|
|
3
|
+
|
|
4
|
+
export function useHover<T extends HTMLElement | null>(
|
|
5
|
+
ref: React.RefObject<T>,
|
|
6
|
+
options: {
|
|
7
|
+
onMouseEnter?: () => void,
|
|
8
|
+
onMouseLeave?: () => void,
|
|
9
|
+
} = {},
|
|
10
|
+
): boolean {
|
|
11
|
+
// Internal counter: mouseenter++ / mouseleave-- (isHovering = counter > 0)
|
|
12
|
+
const counter = useRefState(0);
|
|
13
|
+
|
|
14
|
+
useLayoutEffect(() => {
|
|
15
|
+
const el = ref.current;
|
|
16
|
+
if (!el) return;
|
|
17
|
+
let incr = 0;
|
|
18
|
+
let prevInside = false;
|
|
19
|
+
|
|
20
|
+
const contains = (r: DOMRect, x: number, y: number) =>
|
|
21
|
+
x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
22
|
+
|
|
23
|
+
const enter = () => {
|
|
24
|
+
incr++;
|
|
25
|
+
counter.set(c => c + 1);
|
|
26
|
+
if (counter.current === 1) {
|
|
27
|
+
options.onMouseEnter?.();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const leave = () => {
|
|
32
|
+
incr--;
|
|
33
|
+
requestAnimationFrame(() => {
|
|
34
|
+
requestAnimationFrame(() => {
|
|
35
|
+
counter.set(c => c - 1);
|
|
36
|
+
if (counter.current === 0) {
|
|
37
|
+
options.onMouseLeave?.();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const topMatchesTarget = (x: number, y: number) => {
|
|
44
|
+
const top = document.elementFromPoint(x, y);
|
|
45
|
+
return !!(top && (top === el || el.contains(top)));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const processPoint = (x: number, y: number) => {
|
|
49
|
+
const rect = el.getBoundingClientRect();
|
|
50
|
+
|
|
51
|
+
// True “hoverability”: inside rect AND not occluded by others
|
|
52
|
+
const inside = contains(rect, x, y) && topMatchesTarget(x, y);
|
|
53
|
+
if (inside && !prevInside) {
|
|
54
|
+
enter();
|
|
55
|
+
} else if (!inside && prevInside) {
|
|
56
|
+
leave();
|
|
57
|
+
}
|
|
58
|
+
prevInside = inside;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onMove = (e: PointerEvent) => {
|
|
62
|
+
if (e.pointerType !== "mouse") return; // keep it hover-only
|
|
63
|
+
// Use coalesced points when available
|
|
64
|
+
const batch = e.getCoalescedEvents();
|
|
65
|
+
if (batch.length) {
|
|
66
|
+
for (let eventIndex = 0; eventIndex < batch.length - 1; eventIndex++) {
|
|
67
|
+
const e1 = batch[eventIndex];
|
|
68
|
+
const e2 = batch[eventIndex + 1];
|
|
69
|
+
const steps = 10;
|
|
70
|
+
for (let i = 0; i <= steps; i++) {
|
|
71
|
+
processPoint(e1.clientX + (e2.clientX - e1.clientX) * i / steps, e1.clientY + (e2.clientY - e1.clientY) * i / steps);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
processPoint(e.clientX, e.clientY);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
window.removeEventListener("pointermove", onMove);
|
|
83
|
+
counter.set(c => c - incr);
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
return counter.current > 0;
|
|
88
|
+
}
|