@digimuza/saas-core 0.0.2
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/CLAUDE.md +1165 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/dist/api-keys/index.d.ts +9 -0
- package/dist/api-keys/index.d.ts.map +1 -0
- package/dist/api-keys/index.js +37 -0
- package/dist/api-keys/index.js.map +1 -0
- package/dist/api-keys/keys.d.ts +55 -0
- package/dist/api-keys/keys.d.ts.map +1 -0
- package/dist/api-keys/keys.js +92 -0
- package/dist/api-keys/keys.js.map +1 -0
- package/dist/api-keys/service.d.ts +111 -0
- package/dist/api-keys/service.d.ts.map +1 -0
- package/dist/api-keys/service.js +210 -0
- package/dist/api-keys/service.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +44 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/service.d.ts +88 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +128 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/auth/client.d.ts +5 -0
- package/dist/auth/client.d.ts.map +1 -0
- package/dist/auth/client.js +26 -0
- package/dist/auth/client.js.map +1 -0
- package/dist/auth/cookies.d.ts +51 -0
- package/dist/auth/cookies.d.ts.map +1 -0
- package/dist/auth/cookies.js +58 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +46 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/service.d.ts +67 -0
- package/dist/auth/service.d.ts.map +1 -0
- package/dist/auth/service.js +275 -0
- package/dist/auth/service.js.map +1 -0
- package/dist/auth/types.d.ts +56 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/authorization/index.d.ts +4 -0
- package/dist/authorization/index.d.ts.map +1 -0
- package/dist/authorization/index.js +24 -0
- package/dist/authorization/index.js.map +1 -0
- package/dist/authorization/service.d.ts +92 -0
- package/dist/authorization/service.d.ts.map +1 -0
- package/dist/authorization/service.js +254 -0
- package/dist/authorization/service.js.map +1 -0
- package/dist/billing/index.d.ts +16 -0
- package/dist/billing/index.d.ts.map +1 -0
- package/dist/billing/index.js +47 -0
- package/dist/billing/index.js.map +1 -0
- package/dist/billing/provider.d.ts +114 -0
- package/dist/billing/provider.d.ts.map +1 -0
- package/dist/billing/provider.js +2 -0
- package/dist/billing/provider.js.map +1 -0
- package/dist/billing/revolut.d.ts +68 -0
- package/dist/billing/revolut.d.ts.map +1 -0
- package/dist/billing/revolut.js +181 -0
- package/dist/billing/revolut.js.map +1 -0
- package/dist/billing/service.d.ts +124 -0
- package/dist/billing/service.d.ts.map +1 -0
- package/dist/billing/service.js +470 -0
- package/dist/billing/service.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +43 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +212 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/project.d.ts +12 -0
- package/dist/cli/commands/project.d.ts.map +1 -0
- package/dist/cli/commands/project.js +49 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/docker.d.ts +50 -0
- package/dist/cli/docker.d.ts.map +1 -0
- package/dist/cli/docker.js +84 -0
- package/dist/cli/docker.js.map +1 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +198 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.d.ts +30 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +66 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/templates.d.ts +52 -0
- package/dist/cli/templates.d.ts.map +1 -0
- package/dist/cli/templates.js +225 -0
- package/dist/cli/templates.js.map +1 -0
- package/dist/config/client.d.ts +60 -0
- package/dist/config/client.d.ts.map +1 -0
- package/dist/config/client.js +94 -0
- package/dist/config/client.js.map +1 -0
- package/dist/config/config-loader.d.ts +2 -0
- package/dist/config/config-loader.d.ts.map +1 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/config-loader.js.map +1 -0
- package/dist/config/env.d.ts +24 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +40 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/errors.d.ts +17 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +26 -0
- package/dist/config/errors.js.map +1 -0
- package/dist/config/index.d.ts +72 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +106 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/runtime.d.ts +15 -0
- package/dist/config/runtime.d.ts.map +1 -0
- package/dist/config/runtime.js +22 -0
- package/dist/config/runtime.js.map +1 -0
- package/dist/config/sections.d.ts +226 -0
- package/dist/config/sections.d.ts.map +1 -0
- package/dist/config/sections.js +141 -0
- package/dist/config/sections.js.map +1 -0
- package/dist/db/client.d.ts +43 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +77 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/errors.d.ts +39 -0
- package/dist/db/errors.d.ts.map +1 -0
- package/dist/db/errors.js +67 -0
- package/dist/db/errors.js.map +1 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +21 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +28 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema/_shared.d.ts +30 -0
- package/dist/db/schema/_shared.d.ts.map +1 -0
- package/dist/db/schema/_shared.js +36 -0
- package/dist/db/schema/_shared.js.map +1 -0
- package/dist/db/schema/apikeys.d.ts +263 -0
- package/dist/db/schema/apikeys.d.ts.map +1 -0
- package/dist/db/schema/apikeys.js +70 -0
- package/dist/db/schema/apikeys.js.map +1 -0
- package/dist/db/schema/authorization.d.ts +465 -0
- package/dist/db/schema/authorization.d.ts.map +1 -0
- package/dist/db/schema/authorization.js +134 -0
- package/dist/db/schema/authorization.js.map +1 -0
- package/dist/db/schema/billing.d.ts +1100 -0
- package/dist/db/schema/billing.d.ts.map +1 -0
- package/dist/db/schema/billing.js +223 -0
- package/dist/db/schema/billing.js.map +1 -0
- package/dist/db/schema/events.d.ts +161 -0
- package/dist/db/schema/events.d.ts.map +1 -0
- package/dist/db/schema/events.js +66 -0
- package/dist/db/schema/events.js.map +1 -0
- package/dist/db/schema/features.d.ts +243 -0
- package/dist/db/schema/features.d.ts.map +1 -0
- package/dist/db/schema/features.js +87 -0
- package/dist/db/schema/features.js.map +1 -0
- package/dist/db/schema/files.d.ts +253 -0
- package/dist/db/schema/files.d.ts.map +1 -0
- package/dist/db/schema/files.js +74 -0
- package/dist/db/schema/files.js.map +1 -0
- package/dist/db/schema/index.d.ts +38 -0
- package/dist/db/schema/index.d.ts.map +1 -0
- package/dist/db/schema/index.js +38 -0
- package/dist/db/schema/index.js.map +1 -0
- package/dist/db/schema/invitations.d.ts +214 -0
- package/dist/db/schema/invitations.d.ts.map +1 -0
- package/dist/db/schema/invitations.js +80 -0
- package/dist/db/schema/invitations.js.map +1 -0
- package/dist/db/schema/messaging.d.ts +442 -0
- package/dist/db/schema/messaging.d.ts.map +1 -0
- package/dist/db/schema/messaging.js +120 -0
- package/dist/db/schema/messaging.js.map +1 -0
- package/dist/db/schema/users.d.ts +246 -0
- package/dist/db/schema/users.d.ts.map +1 -0
- package/dist/db/schema/users.js +60 -0
- package/dist/db/schema/users.js.map +1 -0
- package/dist/db/schema/webhooks.d.ts +693 -0
- package/dist/db/schema/webhooks.d.ts.map +1 -0
- package/dist/db/schema/webhooks.js +147 -0
- package/dist/db/schema/webhooks.js.map +1 -0
- package/dist/db/schema/workspaces.d.ts +196 -0
- package/dist/db/schema/workspaces.d.ts.map +1 -0
- package/dist/db/schema/workspaces.js +57 -0
- package/dist/db/schema/workspaces.js.map +1 -0
- package/dist/db/types.d.ts +18 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/email/adapters.d.ts +29 -0
- package/dist/email/adapters.d.ts.map +1 -0
- package/dist/email/adapters.js +60 -0
- package/dist/email/adapters.js.map +1 -0
- package/dist/email/index.d.ts +20 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +79 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/provider.d.ts +47 -0
- package/dist/email/provider.d.ts.map +1 -0
- package/dist/email/provider.js +7 -0
- package/dist/email/provider.js.map +1 -0
- package/dist/email/resend.d.ts +22 -0
- package/dist/email/resend.d.ts.map +1 -0
- package/dist/email/resend.js +51 -0
- package/dist/email/resend.js.map +1 -0
- package/dist/email/service.d.ts +55 -0
- package/dist/email/service.d.ts.map +1 -0
- package/dist/email/service.js +87 -0
- package/dist/email/service.js.map +1 -0
- package/dist/email/templates.d.ts +81 -0
- package/dist/email/templates.d.ts.map +1 -0
- package/dist/email/templates.js +84 -0
- package/dist/email/templates.js.map +1 -0
- package/dist/entitlements/index.d.ts +10 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +28 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/service.d.ts +83 -0
- package/dist/entitlements/service.d.ts.map +1 -0
- package/dist/entitlements/service.js +158 -0
- package/dist/entitlements/service.js.map +1 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +95 -0
- package/dist/errors.js.map +1 -0
- package/dist/events/core-events.d.ts +120 -0
- package/dist/events/core-events.d.ts.map +1 -0
- package/dist/events/core-events.js +109 -0
- package/dist/events/core-events.js.map +1 -0
- package/dist/events/index.d.ts +15 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +58 -0
- package/dist/events/index.js.map +1 -0
- package/dist/events/publisher.d.ts +46 -0
- package/dist/events/publisher.d.ts.map +1 -0
- package/dist/events/publisher.js +54 -0
- package/dist/events/publisher.js.map +1 -0
- package/dist/events/registry.d.ts +87 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +34 -0
- package/dist/events/registry.js.map +1 -0
- package/dist/events/service.d.ts +78 -0
- package/dist/events/service.d.ts.map +1 -0
- package/dist/events/service.js +182 -0
- package/dist/events/service.js.map +1 -0
- package/dist/features/bucket.d.ts +22 -0
- package/dist/features/bucket.d.ts.map +1 -0
- package/dist/features/bucket.js +42 -0
- package/dist/features/bucket.js.map +1 -0
- package/dist/features/index.d.ts +15 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +78 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/registry.d.ts +51 -0
- package/dist/features/registry.d.ts.map +1 -0
- package/dist/features/registry.js +52 -0
- package/dist/features/registry.js.map +1 -0
- package/dist/features/service.d.ts +78 -0
- package/dist/features/service.d.ts.map +1 -0
- package/dist/features/service.js +207 -0
- package/dist/features/service.js.map +1 -0
- package/dist/files/index.d.ts +6 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +64 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/service.d.ts +92 -0
- package/dist/files/service.d.ts.map +1 -0
- package/dist/files/service.js +303 -0
- package/dist/files/service.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/backend.d.ts +23 -0
- package/dist/jobs/backend.d.ts.map +1 -0
- package/dist/jobs/backend.js +23 -0
- package/dist/jobs/backend.js.map +1 -0
- package/dist/jobs/index.d.ts +29 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +114 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/service.d.ts +67 -0
- package/dist/jobs/service.d.ts.map +1 -0
- package/dist/jobs/service.js +139 -0
- package/dist/jobs/service.js.map +1 -0
- package/dist/jobs/types.d.ts +78 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +4 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/logger/default.d.ts +7 -0
- package/dist/logger/default.d.ts.map +1 -0
- package/dist/logger/default.js +28 -0
- package/dist/logger/default.js.map +1 -0
- package/dist/logger/index.d.ts +24 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +90 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/redact.d.ts +40 -0
- package/dist/logger/redact.d.ts.map +1 -0
- package/dist/logger/redact.js +178 -0
- package/dist/logger/redact.js.map +1 -0
- package/dist/logger/service.d.ts +14 -0
- package/dist/logger/service.d.ts.map +1 -0
- package/dist/logger/service.js +56 -0
- package/dist/logger/service.js.map +1 -0
- package/dist/logger/sinks.d.ts +37 -0
- package/dist/logger/sinks.d.ts.map +1 -0
- package/dist/logger/sinks.js +135 -0
- package/dist/logger/sinks.js.map +1 -0
- package/dist/logger/types.d.ts +47 -0
- package/dist/logger/types.d.ts.map +1 -0
- package/dist/logger/types.js +8 -0
- package/dist/logger/types.js.map +1 -0
- package/dist/notifications/index.d.ts +8 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +72 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/service.d.ts +79 -0
- package/dist/notifications/service.d.ts.map +1 -0
- package/dist/notifications/service.js +164 -0
- package/dist/notifications/service.js.map +1 -0
- package/dist/realtime/channels.d.ts +37 -0
- package/dist/realtime/channels.d.ts.map +1 -0
- package/dist/realtime/channels.js +58 -0
- package/dist/realtime/channels.js.map +1 -0
- package/dist/realtime/index.d.ts +19 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +118 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/postgres.d.ts +48 -0
- package/dist/realtime/postgres.d.ts.map +1 -0
- package/dist/realtime/postgres.js +151 -0
- package/dist/realtime/postgres.js.map +1 -0
- package/dist/realtime/service.d.ts +75 -0
- package/dist/realtime/service.d.ts.map +1 -0
- package/dist/realtime/service.js +103 -0
- package/dist/realtime/service.js.map +1 -0
- package/dist/realtime/sse.d.ts +55 -0
- package/dist/realtime/sse.d.ts.map +1 -0
- package/dist/realtime/sse.js +123 -0
- package/dist/realtime/sse.js.map +1 -0
- package/dist/realtime/transport.d.ts +52 -0
- package/dist/realtime/transport.d.ts.map +1 -0
- package/dist/realtime/transport.js +2 -0
- package/dist/realtime/transport.js.map +1 -0
- package/dist/storage/index.d.ts +18 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +89 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/keys.d.ts +50 -0
- package/dist/storage/keys.d.ts.map +1 -0
- package/dist/storage/keys.js +120 -0
- package/dist/storage/keys.js.map +1 -0
- package/dist/storage/memory.d.ts +32 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +84 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/provider.d.ts +93 -0
- package/dist/storage/provider.d.ts.map +1 -0
- package/dist/storage/provider.js +2 -0
- package/dist/storage/provider.js.map +1 -0
- package/dist/storage/s3.d.ts +34 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +175 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/service.d.ts +86 -0
- package/dist/storage/service.d.ts.map +1 -0
- package/dist/storage/service.js +107 -0
- package/dist/storage/service.js.map +1 -0
- package/dist/storage/sigv4.d.ts +58 -0
- package/dist/storage/sigv4.d.ts.map +1 -0
- package/dist/storage/sigv4.js +114 -0
- package/dist/storage/sigv4.js.map +1 -0
- package/dist/streams/client.d.ts +91 -0
- package/dist/streams/client.d.ts.map +1 -0
- package/dist/streams/client.js +128 -0
- package/dist/streams/client.js.map +1 -0
- package/dist/streams/events.d.ts +68 -0
- package/dist/streams/events.d.ts.map +1 -0
- package/dist/streams/events.js +81 -0
- package/dist/streams/events.js.map +1 -0
- package/dist/streams/index.d.ts +47 -0
- package/dist/streams/index.d.ts.map +1 -0
- package/dist/streams/index.js +47 -0
- package/dist/streams/index.js.map +1 -0
- package/dist/streams/realtime.d.ts +27 -0
- package/dist/streams/realtime.d.ts.map +1 -0
- package/dist/streams/realtime.js +50 -0
- package/dist/streams/realtime.js.map +1 -0
- package/dist/streams/state.d.ts +26 -0
- package/dist/streams/state.d.ts.map +1 -0
- package/dist/streams/state.js +34 -0
- package/dist/streams/state.js.map +1 -0
- package/dist/users/index.d.ts +9 -0
- package/dist/users/index.d.ts.map +1 -0
- package/dist/users/index.js +23 -0
- package/dist/users/index.js.map +1 -0
- package/dist/users/service.d.ts +43 -0
- package/dist/users/service.d.ts.map +1 -0
- package/dist/users/service.js +113 -0
- package/dist/users/service.js.map +1 -0
- package/dist/webhooks/inbound.d.ts +70 -0
- package/dist/webhooks/inbound.d.ts.map +1 -0
- package/dist/webhooks/inbound.js +146 -0
- package/dist/webhooks/inbound.js.map +1 -0
- package/dist/webhooks/index.d.ts +12 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +65 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/outgoing.d.ts +89 -0
- package/dist/webhooks/outgoing.d.ts.map +1 -0
- package/dist/webhooks/outgoing.js +375 -0
- package/dist/webhooks/outgoing.js.map +1 -0
- package/dist/webhooks/signature.d.ts +40 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +77 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/workspaces/context.d.ts +30 -0
- package/dist/workspaces/context.d.ts.map +1 -0
- package/dist/workspaces/context.js +12 -0
- package/dist/workspaces/context.js.map +1 -0
- package/dist/workspaces/index.d.ts +5 -0
- package/dist/workspaces/index.d.ts.map +1 -0
- package/dist/workspaces/index.js +21 -0
- package/dist/workspaces/index.js.map +1 -0
- package/dist/workspaces/service.d.ts +126 -0
- package/dist/workspaces/service.d.ts.map +1 -0
- package/dist/workspaces/service.js +495 -0
- package/dist/workspaces/service.js.map +1 -0
- package/docs/README.md +71 -0
- package/docs/api-keys.md +184 -0
- package/docs/audit.md +198 -0
- package/docs/auth.md +196 -0
- package/docs/authorization.md +225 -0
- package/docs/billing.md +293 -0
- package/docs/cli.md +238 -0
- package/docs/config.md +163 -0
- package/docs/db.md +219 -0
- package/docs/email.md +222 -0
- package/docs/entitlements.md +194 -0
- package/docs/errors.md +158 -0
- package/docs/events.md +279 -0
- package/docs/feature-flags.md +237 -0
- package/docs/files.md +229 -0
- package/docs/jobs.md +288 -0
- package/docs/logger.md +269 -0
- package/docs/notifications.md +179 -0
- package/docs/publishing.md +128 -0
- package/docs/realtime.md +360 -0
- package/docs/storage.md +279 -0
- package/docs/streams.md +296 -0
- package/docs/users.md +117 -0
- package/docs/webhooks.md +272 -0
- package/docs/workspaces.md +269 -0
- package/drizzle/0000_init_users.sql +21 -0
- package/drizzle/0001_workspaces_roles_permissions.sql +86 -0
- package/drizzle/0002_billing_entitlements.sql +97 -0
- package/drizzle/0003_events.sql +17 -0
- package/drizzle/0004_notifications_audit.sql +64 -0
- package/drizzle/0005_api_keys_webhooks.sql +77 -0
- package/drizzle/0006_feature_flags.sql +22 -0
- package/drizzle/0007_files.sql +23 -0
- package/drizzle/meta/0000_snapshot.json +160 -0
- package/drizzle/meta/0001_snapshot.json +837 -0
- package/drizzle/meta/0002_snapshot.json +1580 -0
- package/drizzle/meta/0003_snapshot.json +1751 -0
- package/drizzle/meta/0004_snapshot.json +2109 -0
- package/drizzle/meta/0005_snapshot.json +2719 -0
- package/drizzle/meta/0006_snapshot.json +2909 -0
- package/drizzle/meta/0007_snapshot.json +3113 -0
- package/drizzle/meta/_journal.json +62 -0
- package/package.json +180 -0
package/docs/streams.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# Streams (RxJS)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { fromEvents, ofType, inWorkspace, latestByKey } from "@digimuza/saas-core/streams";
|
|
5
|
+
import { fromEventStream } from "@digimuza/saas-core/streams/client";
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
> Orientation and recipes: [`src/streams/README.md`](../src/streams/README.md).
|
|
9
|
+
|
|
10
|
+
RxJS is a first-class dependency, used for **application-level reactive
|
|
11
|
+
behaviour** — and nothing else:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
event sources → application events → Observable → reactor → application/client
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`rxjs` is an **optional peer dependency**. Install it only if you use this module.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What this module deliberately is not
|
|
22
|
+
|
|
23
|
+
| Not | Because |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| the database | A one-shot read stays a promise. Wrapping it adds a lifecycle to manage for no benefit. |
|
|
26
|
+
| the durable job system | A crash loses a reaction, not an event. Durable work belongs in [jobs.md](./jobs.md). |
|
|
27
|
+
| the realtime transport | That is [realtime.md](./realtime.md); this consumes it. |
|
|
28
|
+
| persistence | State that must survive a restart belongs in PostgreSQL, never in an operator's accumulator. |
|
|
29
|
+
|
|
30
|
+
The rule throughout the package: **promises for one-time work, Observables for
|
|
31
|
+
sequences over time.**
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Server-side
|
|
36
|
+
|
|
37
|
+
### `fromEvents(events)`
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
function fromEvents<S extends EventSchemaMap>(events: EventsService<S>): Observable<SaasEvent<S>>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
An Observable of application events, from the in-process dispatcher.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const subscription = fromEvents(events)
|
|
47
|
+
.pipe(
|
|
48
|
+
inWorkspace(context.workspaceId),
|
|
49
|
+
ofType("workspace.member.created"),
|
|
50
|
+
debounceTime(500),
|
|
51
|
+
takeUntil(shutdown$),
|
|
52
|
+
)
|
|
53
|
+
.subscribe(reindexWorkspace);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Lifecycle.** The events handler is registered on subscribe and removed on
|
|
57
|
+
unsubscribe. Nothing exists before the first subscriber and nothing lingers after
|
|
58
|
+
the last — so forgetting `takeUntil` leaks one handler, not a growing set. Each
|
|
59
|
+
subscriber gets its own handler; one leaving does not silence another.
|
|
60
|
+
|
|
61
|
+
**It never completes on its own.** Bound it with `takeUntil` or `take(n)`.
|
|
62
|
+
|
|
63
|
+
### `fromRealtime(realtime, options)`
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
function fromRealtime(realtime: RealtimeService, options: {
|
|
67
|
+
userId: string;
|
|
68
|
+
channels: readonly string[];
|
|
69
|
+
}): Observable<RealtimeMessage>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Authorized realtime messages, for a *server* reacting to delivery. A browser
|
|
73
|
+
should use `fromEventStream` instead.
|
|
74
|
+
|
|
75
|
+
Authorization runs once, on subscribe. Because that check is asynchronous and the
|
|
76
|
+
Observable constructor is not, a subscriber that unsubscribes mid-check is
|
|
77
|
+
honoured — the transport subscription closes immediately rather than sitting open
|
|
78
|
+
with nobody listening.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Operators
|
|
83
|
+
|
|
84
|
+
Five helpers. Everything else is plain RxJS, because re-exporting existing
|
|
85
|
+
operators under new names adds vocabulary without adding behaviour.
|
|
86
|
+
|
|
87
|
+
### `ofType(...types)`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
function ofType<S, const K extends readonly EventTypeOf<S>[]>(
|
|
91
|
+
...types: K
|
|
92
|
+
): OperatorFunction<SaasEvent<S>, SaasEventOf<S, K[number]>>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Narrows in the **type system** as well as at runtime:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
fromEvents(events).pipe(
|
|
99
|
+
ofType("project.created", "project.deleted"),
|
|
100
|
+
map((event) => event.data.projectId), // narrowed to those two payloads
|
|
101
|
+
);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A plain `filter((e) => e.type === "project.created")` filters correctly but leaves
|
|
105
|
+
`data` as the union of every payload in the registry, so every downstream operator
|
|
106
|
+
needs a cast.
|
|
107
|
+
|
|
108
|
+
### `inWorkspace(workspaceId)` / `byActor(actorId)`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
function inWorkspace<T extends { workspaceId: string | null }>(id: string): MonoTypeOperatorFunction<T>
|
|
112
|
+
function byActor<T extends { actorId: string | null }>(id: string): MonoTypeOperatorFunction<T>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The in-process dispatcher is process-wide, so a server stream sees every
|
|
116
|
+
workspace's events. `inWorkspace` is what makes a per-tenant stream out of it.
|
|
117
|
+
|
|
118
|
+
**Not an authorization boundary.** The caller must already hold a verified
|
|
119
|
+
`WorkspaceContext` — see [workspaces.md](./workspaces.md#workspacecontext).
|
|
120
|
+
|
|
121
|
+
### `toData()`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
function toData<E extends { data: unknown }>(): OperatorFunction<E, E["data"]>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Projects each event to its payload. The type parameter is inferred from the
|
|
128
|
+
*source*; declaring it over the registry and event key would leave both unbound at
|
|
129
|
+
the call site and collapse `data` to `unknown`.
|
|
130
|
+
|
|
131
|
+
### `latestByKey(source, keyOf)`
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
function latestByKey<T>(source: Observable<T>, keyOf: (value: T) => string):
|
|
135
|
+
Observable<ReadonlyMap<string, T>>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
"Newest value per key" — reactive state.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const progress$ = latestByKey(jobEvents$, (event) => event.data.runId);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The one state shape worth wrapping, because the sharing is easy to miss: without
|
|
145
|
+
`shareReplay`, every new subscriber re-runs the scan from an empty map and sees
|
|
146
|
+
nothing until the next event.
|
|
147
|
+
|
|
148
|
+
Each emission is a **new** Map, so a subscriber holding an earlier value is not
|
|
149
|
+
mutated underneath it.
|
|
150
|
+
|
|
151
|
+
`refCount: true` discards the accumulated map when the last subscriber leaves.
|
|
152
|
+
Deliberate — a cache outliving its subscribers is a leak, and this is a *view* of
|
|
153
|
+
durable data.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Browser: `fromEventStream(options)`
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
function fromEventStream(options: {
|
|
161
|
+
url: string;
|
|
162
|
+
channels: readonly string[];
|
|
163
|
+
types: readonly string[];
|
|
164
|
+
createSource?: (url: string) => EventSourceLike;
|
|
165
|
+
initialRetryDelayMs?: number; // 1_000
|
|
166
|
+
maxRetryDelayMs?: number; // 30_000
|
|
167
|
+
onError?: (error: unknown) => void;
|
|
168
|
+
setTimeoutImpl?: (handler: () => void, ms: number) => unknown;
|
|
169
|
+
clearTimeoutImpl?: (handle: unknown) => void;
|
|
170
|
+
}): Observable<ClientRealtimeMessage>
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const events$ = fromEventStream({
|
|
175
|
+
url: "/api/realtime",
|
|
176
|
+
channels: [`workspace:${workspaceId}`],
|
|
177
|
+
types: ["job.progress", "project.created"],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const subscription = events$
|
|
181
|
+
.pipe(
|
|
182
|
+
filter((message) => message.type === "job.progress"),
|
|
183
|
+
map((message) => message.data as { percent?: number }),
|
|
184
|
+
distinctUntilChanged((a, b) => a.percent === b.percent),
|
|
185
|
+
)
|
|
186
|
+
.subscribe(render);
|
|
187
|
+
|
|
188
|
+
return () => subscription.unsubscribe(); // React effect cleanup
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**`types` is required.** SSE dispatches by event name and the server names each
|
|
192
|
+
frame after the event type, so listening for `message` alone receives nothing.
|
|
193
|
+
|
|
194
|
+
**One stream, shared.** Every component piping this beats each opening its own
|
|
195
|
+
`EventSource`: one connection, one reconnect policy, one ordering to reason about.
|
|
196
|
+
|
|
197
|
+
**No server imports.** This module pulls in nothing but RxJS — a test asserts it,
|
|
198
|
+
because a shared import would drag config and the database driver into the bundle.
|
|
199
|
+
|
|
200
|
+
### Reconnect behaviour
|
|
201
|
+
|
|
202
|
+
Errors do **not** terminate the stream. A dropped connection is expected, not
|
|
203
|
+
exceptional: it is reported through `onError` and retried, because surfacing it as
|
|
204
|
+
an RxJS error would tear down every downstream operator and lose the subscriber's
|
|
205
|
+
accumulated state on a transient blip.
|
|
206
|
+
|
|
207
|
+
Backoff doubles from `initialRetryDelayMs`, capped at `maxRetryDelayMs`
|
|
208
|
+
(1s → 2s → 4s → 8s → … → 30s), and **resets once a frame arrives** — a message
|
|
209
|
+
proves the connection is healthy. Unbounded retries would hammer a server that is
|
|
210
|
+
already struggling.
|
|
211
|
+
|
|
212
|
+
A pending retry is cancelled on unsubscribe, so a torn-down component cannot
|
|
213
|
+
reconnect behind the caller's back.
|
|
214
|
+
|
|
215
|
+
Only one failure is fatal: being unable to construct a source at all (no
|
|
216
|
+
`EventSource` and no `createSource`). That is a configuration fault, not a blip.
|
|
217
|
+
|
|
218
|
+
### Handle `truncated`
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
source.addEventListener("project.created", (message) => {
|
|
222
|
+
const { eventId, data, truncated } = JSON.parse(message.data);
|
|
223
|
+
if (truncated) return refetchEvent(eventId);
|
|
224
|
+
applyUpdate(data);
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`pg_notify` caps payloads at 8000 bytes; oversized `data` is dropped and `eventId`
|
|
229
|
+
retained so the client can re-read. See
|
|
230
|
+
[realtime.md](./realtime.md#the-default-transport-postgresql-listennotify).
|
|
231
|
+
|
|
232
|
+
Frames are validated, not cast — this crossed the network.
|
|
233
|
+
|
|
234
|
+
### `buildStreamUrl(url, channels)`
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
function buildStreamUrl(url: string, channels: readonly string[]): string
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
How `fromEventStream` builds its endpoint — each channel appended as a repeated
|
|
241
|
+
`channel` parameter. Exported because a caller sometimes needs the same URL: to
|
|
242
|
+
prefetch it, to pass it to a service worker, or to assert on it in a test.
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
buildStreamUrl("/api/realtime", ["workspace:abc", "user:def"]);
|
|
246
|
+
// "/api/realtime?channel=workspace%3Aabc&channel=user%3Adef"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A relative endpoint stays relative — browsers call relative endpoints, and forcing
|
|
250
|
+
an absolute URL would break that.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Errors: the gotcha worth reading twice
|
|
255
|
+
|
|
256
|
+
**A throwing subscriber is invisible to the events error sink.** RxJS catches an
|
|
257
|
+
exception thrown in a `next` callback and reports it to its *own* global unhandled
|
|
258
|
+
handler. It never propagates back to the events service, so `onHandlerError` never
|
|
259
|
+
sees it. There is a test asserting exactly this so it stays documented.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
// Wrong: the failure vanishes.
|
|
263
|
+
stream.subscribe((event) => { throw new Error("boom"); });
|
|
264
|
+
|
|
265
|
+
// Right: handle it, or let the chain raise it.
|
|
266
|
+
stream.subscribe({
|
|
267
|
+
next: apply,
|
|
268
|
+
error: (error) => logger.error({ error }),
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Publishing is never affected either way — the event is already committed.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Testing streams
|
|
277
|
+
|
|
278
|
+
Virtual time for anything time-based. Real timers make these tests slow and flaky,
|
|
279
|
+
and the point is the operator's semantics, not the clock's.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { TestScheduler } from "rxjs/testing";
|
|
283
|
+
|
|
284
|
+
new TestScheduler((actual, expected) => expect(actual).toEqual(expected)).run(
|
|
285
|
+
({ cold, expectObservable, expectSubscriptions }) => {
|
|
286
|
+
const source = cold("a-b-c---|");
|
|
287
|
+
expectObservable(source.pipe(debounceTime(3))).toBe("-------c|");
|
|
288
|
+
// Assert the source is actually released, not just that values stop.
|
|
289
|
+
expectSubscriptions(source.subscriptions).toBe("^-------!");
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For the client stream, inject `createSource`, `setTimeoutImpl` and
|
|
295
|
+
`clearTimeoutImpl` — the core's own tests drive reconnect and assert the exact
|
|
296
|
+
backoff schedule with no waiting.
|
package/docs/users.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Users & profiles
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { users } from "@digimuza/saas-core/users";
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
> Orientation and recipes: [`src/users/README.md`](../src/users/README.md).
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Identity and profile are separate tables but a single concern here, because callers
|
|
11
|
+
almost always want both.
|
|
12
|
+
|
|
13
|
+
- **`users`** — the link to WorkOS. No email, no credentials.
|
|
14
|
+
- **`user_profiles`** — email, name, avatar. Keyed by `user_id`.
|
|
15
|
+
|
|
16
|
+
Email lives on the profile because duplicating it on the identity row would create
|
|
17
|
+
two sources of truth that drift. Credentials are never stored: WorkOS owns
|
|
18
|
+
authentication.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## `users.getById(userId)`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
function getById(userId: string): Promise<UserWithProfile | undefined>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
interface UserWithProfile {
|
|
30
|
+
id: string;
|
|
31
|
+
workosUserId: string;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
updatedAt: Date;
|
|
34
|
+
profile: UserProfile | null;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The profile is nullable because the join is a left join — a user row can exist
|
|
39
|
+
without a profile if a sync was interrupted.
|
|
40
|
+
|
|
41
|
+
## `users.requireById(userId)`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
function requireById(userId: string): Promise<UserWithProfile>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Throws `NotFoundError`.
|
|
48
|
+
|
|
49
|
+
## `users.getByWorkosId(workosUserId)`
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
function getByWorkosId(workosUserId: string): Promise<UserWithProfile | undefined>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Used by the auth module to map a session back to a local user.
|
|
56
|
+
|
|
57
|
+
## `users.syncFromWorkos(attributes)`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
function syncFromWorkos(attributes: {
|
|
61
|
+
workosUserId: string;
|
|
62
|
+
email: string;
|
|
63
|
+
firstName?: string | null;
|
|
64
|
+
lastName?: string | null;
|
|
65
|
+
profilePictureUrl?: string | null;
|
|
66
|
+
}): Promise<UserWithProfile>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Creates or updates the local record for a WorkOS identity. Called on every
|
|
70
|
+
successful authentication, so it must be idempotent and concurrency-safe — and is:
|
|
71
|
+
|
|
72
|
+
- Uses `onConflictDoUpdate` rather than select-then-insert. Two concurrent
|
|
73
|
+
sign-ins for the same identity would otherwise race and one would fail on the
|
|
74
|
+
unique index. There is a test firing three concurrent syncs.
|
|
75
|
+
- Tracks email changes from the provider.
|
|
76
|
+
- **Never overwrites `display_name`.** The user may have set it themselves, and
|
|
77
|
+
the identity provider must not clobber it. Also tested.
|
|
78
|
+
|
|
79
|
+
Emits `user.created` once per identity, keyed on the WorkOS id — a re-login is not
|
|
80
|
+
a new user. See [events.md](./events.md).
|
|
81
|
+
|
|
82
|
+
## `users.updateProfile(userId, update)`
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
function updateProfile(userId: string, update: {
|
|
86
|
+
firstName?: string | null;
|
|
87
|
+
lastName?: string | null;
|
|
88
|
+
displayName?: string | null;
|
|
89
|
+
avatarUrl?: string | null;
|
|
90
|
+
}): Promise<UserProfile>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Throws `NotFoundError` if there is no profile row.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Email is indexed, not unique
|
|
98
|
+
|
|
99
|
+
`user_profiles.email` has a **case-insensitive, non-unique** index. WorkOS owns
|
|
100
|
+
identity uniqueness, and its account-linking flows can transiently present two
|
|
101
|
+
identities sharing an email. A unique constraint here would turn legitimate
|
|
102
|
+
provider state into a failed write inside the core.
|
|
103
|
+
|
|
104
|
+
If your product needs one email to mean one account, enforce it in WorkOS, or add
|
|
105
|
+
a unique index in your own migration having decided how to handle linking.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Custom construction
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { createUsersService } from "@digimuza/saas-core/users";
|
|
113
|
+
|
|
114
|
+
const users = createUsersService({ db, events }); // events optional
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Without `events`, nothing is emitted and behaviour is otherwise identical.
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Webhooks
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
webhooks, signWebhookPayload, verifyWebhookSignature,
|
|
6
|
+
} from "@digimuza/saas-core/webhooks";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> Orientation and recipes: [`src/webhooks/README.md`](../src/webhooks/README.md).
|
|
10
|
+
|
|
11
|
+
Both directions, sharing one signature scheme so there is a single implementation
|
|
12
|
+
to audit.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Signing
|
|
17
|
+
|
|
18
|
+
HMAC-SHA256 over `v1.{timestampMs}.{rawBody}`, presented as `v1=<hex>`. The same
|
|
19
|
+
scheme the core already verifies from Revolut — which means receivers can follow
|
|
20
|
+
instructions Stripe and Revolut users already know.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
function signWebhookPayload(input: { rawBody: string; secret: string; timestampMs: number }): string
|
|
24
|
+
|
|
25
|
+
function verifyWebhookSignature(input: {
|
|
26
|
+
rawBody: string;
|
|
27
|
+
signature: string;
|
|
28
|
+
timestamp: string | number;
|
|
29
|
+
secret: string | readonly string[]; // several during rotation
|
|
30
|
+
toleranceSeconds?: number; // 300
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}): boolean
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Give it the body exactly as received.** Parsing and re-serialising JSON changes
|
|
36
|
+
key order and whitespace, which invalidates the signature.
|
|
37
|
+
|
|
38
|
+
Stale **and** future timestamps are rejected: a signature is valid forever, so
|
|
39
|
+
without a window a captured request could be replayed indefinitely — and a clock
|
|
40
|
+
skewed forward would widen that window rather than narrow it.
|
|
41
|
+
|
|
42
|
+
Comparison is constant-time, and comma-separated signatures are accepted so a
|
|
43
|
+
sender can present both secrets while rotating.
|
|
44
|
+
|
|
45
|
+
Headers the core sends: `x-saas-core-signature`, `x-saas-core-timestamp`, plus
|
|
46
|
+
`X-Saas-Core-Event-Id`, `-Event-Type`, `-Delivery-Id` and `-Attempt`.
|
|
47
|
+
|
|
48
|
+
### `generateWebhookSecret()`
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
function generateWebhookSecret(): string // "whsec_<43 base64url chars>"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
256 bits, with a recognisable prefix so a leaked secret is identifiable in a log or
|
|
55
|
+
a paste. `endpoints.create` and `rotateSecret` call it for you; it is exported for
|
|
56
|
+
inbound endpoints you register with a sender yourself, where you need to generate
|
|
57
|
+
and store the shared secret on your own side.
|
|
58
|
+
|
|
59
|
+
`signingPayload(timestampMs, rawBody)` is exported too, if you need to reproduce
|
|
60
|
+
the exact signed string — for a receiver written in another language, say.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Outgoing
|
|
65
|
+
|
|
66
|
+
### Endpoints
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
webhooks.outgoing.endpoints.create(context, {
|
|
70
|
+
url: string;
|
|
71
|
+
description?: string;
|
|
72
|
+
eventTypes?: readonly string[]; // empty means every type
|
|
73
|
+
}): Promise<{ endpoint: WebhookEndpoint; secret: string }>
|
|
74
|
+
|
|
75
|
+
webhooks.outgoing.endpoints.list(context): Promise<WebhookEndpoint[]>
|
|
76
|
+
webhooks.outgoing.endpoints.get(context, id): Promise<{ endpoint; secret }>
|
|
77
|
+
webhooks.outgoing.endpoints.update(context, id, patch): Promise<WebhookEndpoint>
|
|
78
|
+
webhooks.outgoing.endpoints.rotateSecret(context, id): Promise<{ endpoint; secret }>
|
|
79
|
+
webhooks.outgoing.endpoints.remove(context, id): Promise<void>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The signing secret is **readable**, unlike an API key. It has to be: the core signs
|
|
83
|
+
with it and the receiver verifies with the same value, so a one-way hash would make
|
|
84
|
+
both impossible. Encryption at rest is the mitigation; `rotateSecret` handles a
|
|
85
|
+
leak without recreating the endpoint.
|
|
86
|
+
|
|
87
|
+
Re-enabling an endpoint clears its failure state — otherwise it would disable again
|
|
88
|
+
on the next failure regardless of what was fixed.
|
|
89
|
+
|
|
90
|
+
### URL safety
|
|
91
|
+
|
|
92
|
+
HTTPS only. A signed payload over plain HTTP still exposes its contents, and the
|
|
93
|
+
signature does not stop an interceptor reading it.
|
|
94
|
+
|
|
95
|
+
Private and loopback addresses are refused: loopback, RFC1918, link-local
|
|
96
|
+
(`169.254.*`), `.internal`, `.local`. A tenant registering
|
|
97
|
+
`https://169.254.169.254/latest/meta-data` would otherwise have your server fetch
|
|
98
|
+
its own cloud credentials and post them back.
|
|
99
|
+
|
|
100
|
+
**This is a first pass, not a complete defence.** DNS can resolve a public name to
|
|
101
|
+
a private address at connect time. A production deployment should route webhook
|
|
102
|
+
egress through a proxy that enforces this too — which is why `WebhookSender` is
|
|
103
|
+
injectable.
|
|
104
|
+
|
|
105
|
+
### `dispatch(event, tx?)`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
function dispatch(event: SaasEvent, tx?: CoreDatabase): Promise<WebhookDelivery[]>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Queues a delivery per active, subscribed endpoint. **Records rows; sends nothing.**
|
|
112
|
+
|
|
113
|
+
Idempotent per `(endpoint, event)`: a retried job or a replayed event cannot
|
|
114
|
+
produce a second delivery. The payload is frozen at dispatch, so a later change to
|
|
115
|
+
the event cannot alter what was already queued.
|
|
116
|
+
|
|
117
|
+
Platform-wide events (null workspace) are skipped — there are no endpoints that
|
|
118
|
+
should receive them, and picking a workspace would leak across tenants.
|
|
119
|
+
|
|
120
|
+
### Driving delivery
|
|
121
|
+
|
|
122
|
+
The core runs no loop. Retries must survive a deploy, so that is
|
|
123
|
+
[Trigger.dev's](./jobs.md) job:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
export const drainWebhooks = schedules.task({
|
|
127
|
+
id: "drain-webhooks",
|
|
128
|
+
cron: "* * * * *",
|
|
129
|
+
run: async () => {
|
|
130
|
+
for (const delivery of await webhooks.outgoing.claimDue({ limit: 100 })) {
|
|
131
|
+
await webhooks.outgoing.attempt(delivery);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
function claimDue(options?: { limit?: number }): Promise<WebhookDelivery[]>
|
|
139
|
+
function attempt(delivery: WebhookDelivery): Promise<DeliveryAttemptResult>
|
|
140
|
+
function listDeliveries(context, options?): Promise<WebhookDelivery[]>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Retries and failure
|
|
144
|
+
|
|
145
|
+
| Setting | Default |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `maxAttempts` | 6 |
|
|
148
|
+
| `initialRetryDelayMs` | 30s, doubling |
|
|
149
|
+
| `maxRetryDelayMs` | 6h |
|
|
150
|
+
| `failureThreshold` | 20 consecutive failures |
|
|
151
|
+
|
|
152
|
+
A sender that *throws* is a failed attempt, not a crashed worker. An exhausted
|
|
153
|
+
delivery has `next_attempt_at` nulled, so it is never re-claimed.
|
|
154
|
+
|
|
155
|
+
An endpoint that fails `failureThreshold` times consecutively is set inactive and
|
|
156
|
+
stops receiving dispatches — it should not keep consuming retry budget. Any success
|
|
157
|
+
resets the streak.
|
|
158
|
+
|
|
159
|
+
### `createFetchWebhookSender(options?)`
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
createFetchWebhookSender({ timeoutMs?: 10_000, fetchImpl?: typeof fetch })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`redirect: "manual"`, because a redirect could land somewhere the URL check
|
|
166
|
+
rejected. The abort signal matters: without a timeout one slow receiver holds a
|
|
167
|
+
worker slot indefinitely.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Inbound
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
function receive(input: {
|
|
175
|
+
source: string; // "github"
|
|
176
|
+
externalId: string; // the sender's event id
|
|
177
|
+
type: string;
|
|
178
|
+
payload: unknown;
|
|
179
|
+
workspaceId?: string | null;
|
|
180
|
+
}, tx?: CoreDatabase): Promise<ReceiveOutcome>
|
|
181
|
+
|
|
182
|
+
type ReceiveOutcome =
|
|
183
|
+
| { status: "claimed"; id: string }
|
|
184
|
+
| { status: "duplicate"; id: string | undefined };
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**The insert is the claim.** `(source, external_id)` is unique, so at-least-once
|
|
188
|
+
delivery and concurrent redeliveries are handled by PostgreSQL rather than by a
|
|
189
|
+
check-then-act two requests could both pass. A test fires three concurrent
|
|
190
|
+
receipts: one claims, two dedupe.
|
|
191
|
+
|
|
192
|
+
A retry re-claims an event whose previous attempt **failed**, but never one already
|
|
193
|
+
`processed` or currently `pending`. The raw payload is retained so a failed handler
|
|
194
|
+
can be replayed without asking the sender to redeliver.
|
|
195
|
+
|
|
196
|
+
`source` and `externalId` are required — without them there is nothing to
|
|
197
|
+
deduplicate on, and accepting the event would risk applying it repeatedly.
|
|
198
|
+
|
|
199
|
+
### `process(input, handler)`
|
|
200
|
+
|
|
201
|
+
The shape most callers want — claim, handle, record:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
await webhooks.inbound.process(
|
|
205
|
+
{ source: "github", externalId: eventId, type: eventType, payload },
|
|
206
|
+
async (webhook) => {
|
|
207
|
+
await handle(webhook);
|
|
208
|
+
// return "ignored" for something you deliberately do not handle
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Marks `processed`, or `ignored` if the handler says so, or `failed` **and rethrows**
|
|
214
|
+
so the sender sees a non-2xx and retries. The handler does not run for a duplicate.
|
|
215
|
+
|
|
216
|
+
### Status meanings
|
|
217
|
+
|
|
218
|
+
| Status | Means |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `pending` | claimed, being handled |
|
|
221
|
+
| `processed` | done |
|
|
222
|
+
| `ignored` | verified but not actionable — unknown tenant, unhandled type |
|
|
223
|
+
| `failed` | needs attention; re-claimable by a redelivery |
|
|
224
|
+
|
|
225
|
+
`ignored` and `failed` are distinct on purpose. Sharing one provider account across
|
|
226
|
+
environments makes unactionable events routine, and conflating them would bury real
|
|
227
|
+
failures.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
function markProcessed(id): Promise<void>
|
|
231
|
+
function markFailed(id, error): Promise<void>
|
|
232
|
+
function markIgnored(id): Promise<void>
|
|
233
|
+
function get(source, externalId): Promise<InboundWebhook | undefined>
|
|
234
|
+
function listFailed(options?): Promise<InboundWebhook[]>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Relationship to billing webhooks
|
|
240
|
+
|
|
241
|
+
[Billing](./billing.md) has its own ingestion against `billing_events`, predating
|
|
242
|
+
this module and using the same claim-on-insert mechanism. It stays separate because
|
|
243
|
+
it also applies subscription state — this module is the general case for everything
|
|
244
|
+
else.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Testing
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const outgoing = createOutgoingWebhooksService({
|
|
252
|
+
db,
|
|
253
|
+
sender: { async send(input) { calls.push(input); return { delivered: true, responseStatus: 200 }; } },
|
|
254
|
+
now: () => clock,
|
|
255
|
+
maxAttempts: 3,
|
|
256
|
+
initialRetryDelayMs: 1000,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const inbound = createInboundWebhooksService({ db, now: () => clock });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`WebhookSender` is one method, so retries, backoff and endpoint disabling are all
|
|
263
|
+
testable with no network. Inject `now` and both retry scheduling and signature
|
|
264
|
+
freshness become deterministic.
|
|
265
|
+
|
|
266
|
+
The default `webhooks` export composes the two, bound to the configured database
|
|
267
|
+
and a `fetch`-based sender:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
webhooks.outgoing; // OutgoingWebhooksService
|
|
271
|
+
webhooks.inbound; // InboundWebhooksService
|
|
272
|
+
```
|