@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/email.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Email
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
createEmailService, createResendProvider, coreEmailTemplates,
|
|
6
|
+
defineEmailTemplates, emailTemplate, mergeEmailTemplates,
|
|
7
|
+
} from "@digimuza/saas-core/email";
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
> Orientation and recipes: [`src/email/README.md`](../src/email/README.md).
|
|
11
|
+
|
|
12
|
+
Provider-independent sending with typed templates. Everything above
|
|
13
|
+
`EmailProvider` is generic, so adopting another provider means writing a ~20-line
|
|
14
|
+
adapter.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// lib/email.ts
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { config } from "@digimuza/saas-core/config";
|
|
24
|
+
|
|
25
|
+
const templates = mergeEmailTemplates(
|
|
26
|
+
coreEmailTemplates,
|
|
27
|
+
defineEmailTemplates({
|
|
28
|
+
"project.shared": emailTemplate({
|
|
29
|
+
schema: z.object({ projectName: z.string().min(1), sharedBy: z.string().min(1) }),
|
|
30
|
+
subject: (data) => `${data.sharedBy} shared "${data.projectName}" with you`,
|
|
31
|
+
text: (data) => `${data.sharedBy} shared ${data.projectName}.`,
|
|
32
|
+
html: (data) => `<p>${data.sharedBy} shared ${data.projectName}.</p>`,
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export const email = createEmailService({
|
|
38
|
+
provider: createResendProvider({ apiKey: config.email.apiKey }),
|
|
39
|
+
templates,
|
|
40
|
+
from: { address: config.email.fromAddress, name: config.email.fromName },
|
|
41
|
+
replyTo: { address: "support@acme.com" },
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`createConfiguredEmailProvider()` and `configuredSender()` build both from the
|
|
46
|
+
`email` config section, choosing the console logger in development.
|
|
47
|
+
|
|
48
|
+
### `emailTemplate()` is load-bearing
|
|
49
|
+
|
|
50
|
+
TypeScript cannot infer a callback's parameter type from a **sibling** property of
|
|
51
|
+
the same object literal. Writing a template inline leaves `data` as `unknown` in
|
|
52
|
+
`subject` and `text`; binding the schema generic inside `emailTemplate()` is what
|
|
53
|
+
types them.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## `email.sendTemplate(input)`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
function sendTemplate(input: {
|
|
61
|
+
to: string | EmailAddress | readonly (string | EmailAddress)[];
|
|
62
|
+
template: K; // a key of the registry
|
|
63
|
+
data: z.input<T[K]["schema"]>; // narrowed to that template
|
|
64
|
+
from?: EmailAddress;
|
|
65
|
+
replyTo?: string | EmailAddress;
|
|
66
|
+
idempotencyKey?: string;
|
|
67
|
+
headers?: Record<string, string>;
|
|
68
|
+
}): Promise<SentEmail>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`data` is validated **before** a message is built, so a missing field throws
|
|
72
|
+
`InvalidInputError` naming it rather than emailing a customer the word
|
|
73
|
+
"undefined".
|
|
74
|
+
|
|
75
|
+
## `email.send(input)`
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
function send(input: {
|
|
79
|
+
to: string | EmailAddress | readonly (string | EmailAddress)[];
|
|
80
|
+
subject: string;
|
|
81
|
+
text: string; // required
|
|
82
|
+
html?: string; // optional
|
|
83
|
+
from?: EmailAddress;
|
|
84
|
+
replyTo?: string | EmailAddress;
|
|
85
|
+
cc?: readonly (string | EmailAddress)[];
|
|
86
|
+
bcc?: readonly (string | EmailAddress)[];
|
|
87
|
+
idempotencyKey?: string;
|
|
88
|
+
headers?: Record<string, string>;
|
|
89
|
+
}): Promise<SentEmail>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**A text part is mandatory; HTML is not.** Some clients render only text, and a
|
|
93
|
+
text-less message scores worse with spam filters.
|
|
94
|
+
|
|
95
|
+
Recipients accept a string, an object, or a list of either.
|
|
96
|
+
|
|
97
|
+
## `email.render(template, data)`
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
function render<K>(template: K, data: z.input<T[K]["schema"]>):
|
|
101
|
+
{ subject: string; text: string; html?: string }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Renders without sending — for previews, and for asserting copy in tests.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Idempotency
|
|
109
|
+
|
|
110
|
+
**Sending is not transactional with the database.** A retried request can send
|
|
111
|
+
twice, and `idempotencyKey` is the only defence. Pass one for anything a customer
|
|
112
|
+
would notice receiving twice.
|
|
113
|
+
|
|
114
|
+
Resend honours it for 24 hours, via an `Idempotency-Key` header. A provider that
|
|
115
|
+
ignores it will simply deliver twice — the interface cannot fix that.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Templates
|
|
120
|
+
|
|
121
|
+
### `defineEmailTemplates` / `mergeEmailTemplates` / `overrideEmailTemplates`
|
|
122
|
+
|
|
123
|
+
`mergeEmailTemplates` **throws** on a duplicate key. `overrideEmailTemplates`
|
|
124
|
+
replaces. Two functions, so shadowing a core template is a visible decision rather
|
|
125
|
+
than silent:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const templates = overrideEmailTemplates(
|
|
129
|
+
coreEmailTemplates,
|
|
130
|
+
defineEmailTemplates({
|
|
131
|
+
"workspace.invitation": emailTemplate({ /* your branding */ }),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### What the core ships
|
|
137
|
+
|
|
138
|
+
**One template: `workspace.invitation`.** It has to exist — the core creates
|
|
139
|
+
invitations, and the single-use token cannot be delivered without an email.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
{
|
|
143
|
+
workspaceName: string;
|
|
144
|
+
inviterName?: string;
|
|
145
|
+
acceptUrl: string; // absolute, carries the token
|
|
146
|
+
expiresAt: Date;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Its copy is intentionally plain. Override it for anything customer-facing.
|
|
151
|
+
|
|
152
|
+
Welcome, security and billing emails are **not** shipped. They are product copy;
|
|
153
|
+
a default would mean shipping branding decisions every application overrides
|
|
154
|
+
anyway.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Providers
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
interface EmailProvider {
|
|
162
|
+
readonly name: string;
|
|
163
|
+
send(message: OutgoingEmail): Promise<SentEmail>;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
One method, because sending is all the core needs. Bounce handling, suppression
|
|
168
|
+
lists and analytics live in the provider's dashboard — wrapping them would mean
|
|
169
|
+
maintaining a lowest common denominator of every provider's feature set.
|
|
170
|
+
|
|
171
|
+
| Provider | Use |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `createResendProvider({ apiKey })` | Production. **HTTP call unverified** against live credentials. |
|
|
174
|
+
| `createConsoleEmailProvider()` | Development. Refuses to run in production. |
|
|
175
|
+
| `createMemoryEmailProvider()` | Tests. Collects messages, dedupes on `idempotencyKey`. |
|
|
176
|
+
|
|
177
|
+
### Writing your own
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const provider: EmailProvider = {
|
|
181
|
+
name: "postmark",
|
|
182
|
+
async send(message) {
|
|
183
|
+
const response = await fetch("https://api.postmarkapp.com/email", {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "X-Postmark-Server-Token": token, "Content-Type": "application/json" },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
From: formatAddress(message.from),
|
|
188
|
+
To: message.to.map(formatAddress).join(","),
|
|
189
|
+
Subject: message.subject,
|
|
190
|
+
TextBody: message.text,
|
|
191
|
+
HtmlBody: message.html,
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Do not include the response body: provider errors echo request contents,
|
|
196
|
+
// and a bounced address is personal data.
|
|
197
|
+
if (!response.ok) throw new Error(`Postmark rejected: ${response.status}`);
|
|
198
|
+
|
|
199
|
+
const body = (await response.json()) as { MessageID: string };
|
|
200
|
+
return { id: body.MessageID, provider: "postmark" };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `formatAddress(address)`
|
|
206
|
+
|
|
207
|
+
Quotes the display name and strips embedded quotes, so a name containing a comma
|
|
208
|
+
stays in one field and one containing a quote cannot split the header.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Testing
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const provider = createMemoryEmailProvider();
|
|
216
|
+
const email = createEmailService({ provider, templates, from: { address: "a@b.test" } });
|
|
217
|
+
|
|
218
|
+
await email.sendTemplate({ to: "user@example.test", template: "project.shared", data });
|
|
219
|
+
|
|
220
|
+
expect(provider.to("user@example.test")).toHaveLength(1);
|
|
221
|
+
expect(provider.sent[0]?.subject).toContain("shared");
|
|
222
|
+
```
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Entitlements
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { entitlements, createEntitlementsService } from "@digimuza/saas-core/entitlements";
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
> Orientation and recipes: [`src/entitlements/README.md`](../src/entitlements/README.md).
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
What a workspace has *paid for*, as opposed to what a user is *allowed* to do.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Subscription → Plan → Entitlements → features and limits
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Product code must never inspect a plan name.** Ask about the entitlement.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Two kinds of entitlement
|
|
21
|
+
|
|
22
|
+
- **`feature`** — on or off. `ai_generation`, `api_access`, `custom_domains`.
|
|
23
|
+
- **`limit`** — a quota. `ai_generations`, `team_members`, `storage_bytes`.
|
|
24
|
+
|
|
25
|
+
Defined per plan; see [billing.md](./billing.md#plans).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## `entitlements.has(context, key)`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
function has(context: WorkspaceContext, key: string): Promise<boolean>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Whether the workspace's plan includes this key at all.
|
|
36
|
+
|
|
37
|
+
## `entitlements.require(context, key)`
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
function require(context: WorkspaceContext, key: string): Promise<void>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Throws `EntitlementRequiredError` (code `payment_required`, HTTP 402) with
|
|
44
|
+
`.entitlement` set. Deliberately distinct from `ForbiddenError`: a client shows an
|
|
45
|
+
upsell for this and an access error for that.
|
|
46
|
+
|
|
47
|
+
## `entitlements.getLimit(context, key)`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
function getLimit(context: WorkspaceContext, key: string): Promise<number | null>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Three states, all distinct:
|
|
54
|
+
|
|
55
|
+
| Return | Meaning |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `null` | Unlimited |
|
|
58
|
+
| `0` | Explicitly none |
|
|
59
|
+
| `0` | Key absent from the plan — **fails closed** |
|
|
60
|
+
|
|
61
|
+
A limit the plan does not mention is zero, not unlimited. A typo in a plan
|
|
62
|
+
definition must not hand out infinite quota. Asking for the limit of a *feature*
|
|
63
|
+
also returns `0`, because it is a meaningless question.
|
|
64
|
+
|
|
65
|
+
## `entitlements.checkLimit(context, key, quantity?)`
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
function checkLimit(context: WorkspaceContext, key: string, quantity = 1): Promise<LimitCheck>
|
|
69
|
+
|
|
70
|
+
interface LimitCheck {
|
|
71
|
+
allowed: boolean;
|
|
72
|
+
limit: number | null; // null = unlimited
|
|
73
|
+
used: number;
|
|
74
|
+
remaining: number | null; // null = unlimited
|
|
75
|
+
window: { start: Date; end: Date };
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`remaining` never goes negative. `requireLimit` is the throwing variant
|
|
80
|
+
(`LimitExceededError`, carrying `.limit` and `.used`).
|
|
81
|
+
|
|
82
|
+
## `entitlements.getUsage(context, key)` / `recordUsage(...)`
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
function getUsage(context: WorkspaceContext, key: string): Promise<number>
|
|
86
|
+
|
|
87
|
+
function recordUsage(context: WorkspaceContext, key: string, options?: {
|
|
88
|
+
quantity?: number; // 1
|
|
89
|
+
idempotencyKey?: string;
|
|
90
|
+
occurredAt?: Date;
|
|
91
|
+
}): Promise<void>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`recordUsage` does not itself enforce the limit. A reused `idempotencyKey` is
|
|
95
|
+
counted **once**, so a network retry does not consume a customer's quota twice —
|
|
96
|
+
scoped per workspace, since two tenants may legitimately use the same request id.
|
|
97
|
+
|
|
98
|
+
## `entitlements.consume(context, key, options?)`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
function consume(context: WorkspaceContext, key: string, options?: RecordUsageOptions): Promise<LimitCheck>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Check and record together. Throws `LimitExceededError` **without recording
|
|
105
|
+
anything**.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
await entitlements.consume(context, "ai_generations", {
|
|
109
|
+
quantity: 1,
|
|
110
|
+
idempotencyKey: requestId,
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### It is not transactional, on purpose
|
|
115
|
+
|
|
116
|
+
The check and the insert are separate statements, so concurrent consumers can
|
|
117
|
+
together overshoot the quota by their in-flight quantity. That is a deliberate
|
|
118
|
+
trade: serialising every metered call behind a lock would make the hot path a
|
|
119
|
+
contention point, and a small overshoot on a usage quota is cheaper than that.
|
|
120
|
+
|
|
121
|
+
If you need a hard cap, wrap `consume` in your own transaction with
|
|
122
|
+
`SELECT ... FOR UPDATE` on a per-workspace row.
|
|
123
|
+
|
|
124
|
+
## `entitlements.resolve(context)` / `resolvePlan(context)`
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
function resolve(context: WorkspaceContext): Promise<{
|
|
128
|
+
plan: Plan | undefined;
|
|
129
|
+
byKey: ReadonlyMap<string, Entitlement>;
|
|
130
|
+
}>
|
|
131
|
+
function resolvePlan(context: WorkspaceContext): Promise<Plan | undefined>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Everything at once — for rendering a plan-comparison or usage page without a query
|
|
135
|
+
per key.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Which subscriptions entitle
|
|
140
|
+
|
|
141
|
+
`trialing`, `active` and `past_due` all entitle. **`past_due` is a dunning state,
|
|
142
|
+
not a revocation** — cutting access the moment a card fails is usually the wrong
|
|
143
|
+
product decision, and the provider is still retrying.
|
|
144
|
+
|
|
145
|
+
`paused`, `canceled` and `incomplete` do not entitle.
|
|
146
|
+
|
|
147
|
+
Cancelling at period end keeps entitlements until the paid period ends; cancelling
|
|
148
|
+
immediately ends them now. See [billing.md](./billing.md).
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## The usage window
|
|
153
|
+
|
|
154
|
+
Consumption is summed over a window, never all-time:
|
|
155
|
+
|
|
156
|
+
1. The subscription's billing period, when it has one — that is what the customer
|
|
157
|
+
is paying for.
|
|
158
|
+
2. Otherwise the current **UTC calendar month**.
|
|
159
|
+
|
|
160
|
+
The fallback matters: a workspace on a free plan has no billing period, and an
|
|
161
|
+
all-time total would leave a monthly quota permanently exhausted.
|
|
162
|
+
|
|
163
|
+
Usage is append-only. A running counter would lose the ability to attribute usage
|
|
164
|
+
to a period, and would make concurrent increments a contention point.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Free tiers
|
|
169
|
+
|
|
170
|
+
The default `entitlements` export configures **no fallback plan**, so a workspace
|
|
171
|
+
without a live subscription has no entitlements at all. That is the safe default
|
|
172
|
+
but leaves no room for a free tier. Build your own:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import { createEntitlementsService } from "@digimuza/saas-core/entitlements";
|
|
176
|
+
import { getDatabase } from "@digimuza/saas-core/db";
|
|
177
|
+
|
|
178
|
+
export const entitlements = createEntitlementsService({
|
|
179
|
+
db: getDatabase(),
|
|
180
|
+
fallbackPlanKey: "free",
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A live subscription always wins over the fallback.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Testing
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
createEntitlementsService({ db, now: () => new Date("2026-06-15T12:00:00Z") });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Inject `now` to make calendar-month windows deterministic.
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
SaasCoreError, UnauthorizedError, ForbiddenError, NotFoundError,
|
|
6
|
+
ConflictError, InvalidInputError, EntitlementRequiredError, LimitExceededError,
|
|
7
|
+
} from "@digimuza/saas-core";
|
|
8
|
+
|
|
9
|
+
// Identical, if you prefer the explicit subpath:
|
|
10
|
+
import { NotFoundError } from "@digimuza/saas-core/errors";
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Both entry points export the same classes. The root re-exports them because route
|
|
14
|
+
handlers map errors to status codes and would otherwise need a second import just
|
|
15
|
+
for the error types.
|
|
16
|
+
|
|
17
|
+
Every core error carries a stable machine-readable `code`, so handlers map to
|
|
18
|
+
status codes without matching on message text.
|
|
19
|
+
|
|
20
|
+
| Error | `code` | Status | Meaning |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| `UnauthorizedError` | `unauthorized` | 401 | No authenticated user |
|
|
23
|
+
| `ForbiddenError` | `forbidden` | 403 | Authenticated, lacks the permission |
|
|
24
|
+
| `NotFoundError` | `not_found` | 404 | Absent, or the caller may not know it exists |
|
|
25
|
+
| `ConflictError` | `conflict` | 409 | Conflicts with existing state |
|
|
26
|
+
| `InvalidInputError` | `invalid_input` | 400 | Structurally invalid input |
|
|
27
|
+
| `EntitlementRequiredError` | `payment_required` | 402 | Plan does not include the feature |
|
|
28
|
+
| `LimitExceededError` | `limit_exceeded` | 429 | Metered quota exhausted |
|
|
29
|
+
|
|
30
|
+
All extend `SaasCoreError`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Mapping to HTTP
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { SaasCoreError } from "@digimuza/saas-core";
|
|
38
|
+
|
|
39
|
+
const STATUS: Record<string, number> = {
|
|
40
|
+
unauthorized: 401,
|
|
41
|
+
forbidden: 403,
|
|
42
|
+
not_found: 404,
|
|
43
|
+
conflict: 409,
|
|
44
|
+
invalid_input: 400,
|
|
45
|
+
payment_required: 402,
|
|
46
|
+
limit_exceeded: 429,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function toResponse(error: unknown): Response {
|
|
50
|
+
if (error instanceof SaasCoreError) {
|
|
51
|
+
return Response.json(
|
|
52
|
+
{ error: error.code, message: error.message },
|
|
53
|
+
{ status: STATUS[error.code] ?? 500 },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
throw error; // let genuine faults become a 500 and reach your tracker
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Errors that carry detail
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
class ForbiddenError extends SaasCoreError {
|
|
66
|
+
readonly permission?: string; // the permission that was required
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class EntitlementRequiredError extends SaasCoreError {
|
|
70
|
+
readonly entitlement: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class LimitExceededError extends SaasCoreError {
|
|
74
|
+
readonly entitlement: string;
|
|
75
|
+
readonly limit: number;
|
|
76
|
+
readonly used: number;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Enough to drive UI without parsing a message:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
try {
|
|
84
|
+
await entitlements.consume(context, "ai_generations");
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof LimitExceededError) {
|
|
87
|
+
return upsell({ used: error.used, limit: error.limit });
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Not found vs forbidden
|
|
96
|
+
|
|
97
|
+
Tenant isolation reports **`NotFoundError`** for a workspace the caller is not a
|
|
98
|
+
member of, not `ForbiddenError`.
|
|
99
|
+
|
|
100
|
+
A 403 confirms that a given workspace id or slug *exists*, which lets an attacker
|
|
101
|
+
enumerate other tenants by iterating ids. A 404 reveals nothing.
|
|
102
|
+
|
|
103
|
+
`ForbiddenError` is for callers already known to belong to the workspace who lack a
|
|
104
|
+
specific permission — inside the tenant boundary, where existence is not a secret.
|
|
105
|
+
|
|
106
|
+
The same reasoning makes missing, expired, revoked and already-accepted invitation
|
|
107
|
+
tokens all raise one indistinguishable `NotFoundError`: telling them apart would
|
|
108
|
+
tell a token-guessing attacker which guesses hit.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Forbidden vs payment required
|
|
113
|
+
|
|
114
|
+
`ForbiddenError` means "you may not". `EntitlementRequiredError` means "your plan
|
|
115
|
+
does not include this". Clients render an access error for the first and an upsell
|
|
116
|
+
for the second, so conflating them produces the wrong UI.
|
|
117
|
+
|
|
118
|
+
Both checks usually apply:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
await authorization.require(context, "projects.create"); // 403 if not permitted
|
|
122
|
+
await entitlements.require(context, "ai_generation"); // 402 if not paid for
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Errors outside this hierarchy
|
|
128
|
+
|
|
129
|
+
Some deliberately do not extend `SaasCoreError`:
|
|
130
|
+
|
|
131
|
+
- **`ConfigError`** (`@digimuza/saas-core/config`) — a startup fault, not a request
|
|
132
|
+
outcome. Names the offending variables and never interpolates values, so a
|
|
133
|
+
malformed secret cannot leak into a log or error tracker.
|
|
134
|
+
- **`InvalidWebhookSignatureError`** (`@digimuza/saas-core/billing`) — map it to
|
|
135
|
+
401 yourself. Its message is deliberately uninformative: a caller who cannot
|
|
136
|
+
produce a valid signature gets no hint about which part failed.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Database constraint failures
|
|
141
|
+
|
|
142
|
+
Services translate the constraint violations that represent domain outcomes:
|
|
143
|
+
|
|
144
|
+
- duplicate workspace slug → `ConflictError`
|
|
145
|
+
- outstanding invitation for that email → `ConflictError`
|
|
146
|
+
- cross-workspace role assignment → `NotFoundError`
|
|
147
|
+
|
|
148
|
+
Anything else propagates. That is intentional — swallowing an unrecognised
|
|
149
|
+
constraint failure hides a real bug. If you catch these yourself, always match on
|
|
150
|
+
the **constraint name**, not just the SQLSTATE code:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { isUniqueViolation } from "@digimuza/saas-core/db";
|
|
154
|
+
|
|
155
|
+
if (isUniqueViolation(error, "projects_workspace_id_name_unique")) { /* ... */ }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
See [db.md](./db.md#constraint-error-introspection).
|