@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/realtime.md
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Realtime
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { realtime, createEventStream, workspaceChannel } from "@digimuza/saas-core/realtime";
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
> Orientation and recipes: [`src/realtime/README.md`](../src/realtime/README.md).
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Realtime **delivers** events to connected clients. It is not the source of truth —
|
|
11
|
+
the durable record is the `events` table, and a client that missed a message
|
|
12
|
+
re-reads from it:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
PostgreSQL → application events → realtime transport → browser
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That ordering is the whole design, and it is why a failed delivery never fails the
|
|
19
|
+
operation that published it.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Wiring
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// once at startup
|
|
27
|
+
import { events } from "@digimuza/saas-core/events";
|
|
28
|
+
import { realtime } from "@digimuza/saas-core/realtime";
|
|
29
|
+
|
|
30
|
+
realtime.attach(events);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`attach` uses the events service's existing `onAny` hook, so realtime is an
|
|
34
|
+
optional *consumer* of events and never a dependency of publishing. It returns an
|
|
35
|
+
unsubscribe function.
|
|
36
|
+
|
|
37
|
+
## Serving a stream
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// app/api/realtime/route.ts
|
|
41
|
+
import { createEventStream } from "@digimuza/saas-core/realtime";
|
|
42
|
+
import { realtime } from "@digimuza/saas-core/realtime";
|
|
43
|
+
import { auth } from "@/lib/auth";
|
|
44
|
+
|
|
45
|
+
export async function GET(request: Request) {
|
|
46
|
+
const user = await auth.requireUser();
|
|
47
|
+
|
|
48
|
+
const stream = await createEventStream(realtime, {
|
|
49
|
+
userId: user.id, // from the session
|
|
50
|
+
channels: new URL(request.url).searchParams.getAll("channel"), // untrusted
|
|
51
|
+
signal: request.signal,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return new Response(stream.body, { headers: stream.headers });
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Consuming it
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const source = new EventSource(
|
|
62
|
+
`/api/realtime?channel=workspace:${workspaceId}`,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
source.addEventListener("project.created", (message) => {
|
|
66
|
+
const { eventId, data, truncated } = JSON.parse(message.data);
|
|
67
|
+
if (truncated) {
|
|
68
|
+
// Payload was too large for the transport; re-read it by id.
|
|
69
|
+
return refetchEvent(eventId);
|
|
70
|
+
}
|
|
71
|
+
applyUpdate(data);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Channels
|
|
78
|
+
|
|
79
|
+
Two kinds, both scoped to something the server can verify:
|
|
80
|
+
|
|
81
|
+
| Channel | Granted when |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `workspace:<uuid>` | the caller is a member of that workspace |
|
|
84
|
+
| `user:<uuid>` | the uuid is the caller's own id |
|
|
85
|
+
|
|
86
|
+
**There is no global channel and no wildcard.** A client that could subscribe to
|
|
87
|
+
`*` would receive every tenant's traffic, and no amount of downstream filtering
|
|
88
|
+
makes that safe.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
workspaceChannel(workspaceId); // "workspace:<uuid>"
|
|
92
|
+
userChannel(userId); // "user:<uuid>"
|
|
93
|
+
parseChannel(value); // Channel | undefined
|
|
94
|
+
requireChannel(value); // throws InvalidInputError
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Channel names are untrusted input
|
|
98
|
+
|
|
99
|
+
A client names the channels it wants, so every one is parsed before it is checked.
|
|
100
|
+
`parseChannel` rejects wildcards, path traversal, wrong-case prefixes, surrounding
|
|
101
|
+
whitespace, embedded newlines, the nil UUID, and anything longer than a
|
|
102
|
+
well-formed channel. There are 21 tests on that function alone.
|
|
103
|
+
|
|
104
|
+
It returns `undefined` rather than throwing: a subscriber sending a batch should
|
|
105
|
+
have the bad ones denied, not the whole request rejected — a client on a stale
|
|
106
|
+
deploy would otherwise be unable to connect at all.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## `realtime.authorizeChannels(input)`
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
function authorizeChannels(input: {
|
|
114
|
+
userId: string; // from the session
|
|
115
|
+
channels: readonly string[]; // from the client
|
|
116
|
+
}): Promise<{ granted: readonly string[]; denied: readonly string[] }>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The **one** place channel access is decided. Transports do not filter — a test
|
|
120
|
+
enforces that they cannot even reach `resolveContext` — so there is exactly one
|
|
121
|
+
rule to audit.
|
|
122
|
+
|
|
123
|
+
Workspace channels are checked against `workspace_members`; user channels against
|
|
124
|
+
the session's own id. Revoking a membership stops granting immediately, which is
|
|
125
|
+
tested.
|
|
126
|
+
|
|
127
|
+
`denied` deliberately does not say *why*. "Not a member" and "no such workspace"
|
|
128
|
+
are the same answer, or a client could enumerate tenants by probing channels.
|
|
129
|
+
|
|
130
|
+
At most 32 channels per subscription, de-duplicated, so a client cannot multiply
|
|
131
|
+
the membership lookups a single request triggers.
|
|
132
|
+
|
|
133
|
+
## `realtime.subscribe(input)`
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
function subscribe(input: {
|
|
137
|
+
userId: string;
|
|
138
|
+
channels: readonly string[];
|
|
139
|
+
onMessage: (message: RealtimeMessage) => void;
|
|
140
|
+
}): Promise<RealtimeSubscription & { granted: readonly string[] }>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Authorizes, then subscribes. Returns a subscription even when nothing was granted —
|
|
144
|
+
so a caller has one cleanup path — and in that case opens nothing against the
|
|
145
|
+
transport at all.
|
|
146
|
+
|
|
147
|
+
## `realtime.publish(event, options?)`
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
function publish(event: SaasEvent, options?: { channels?: readonly string[] }): Promise<void>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Projects an event onto its channels. By default that is the workspace channel
|
|
154
|
+
derived from `event.workspaceId`; pass `channels` to add more — a user channel for
|
|
155
|
+
something addressed to one person, for instance.
|
|
156
|
+
|
|
157
|
+
### What gets broadcast
|
|
158
|
+
|
|
159
|
+
The default filter is **workspace-scoped events only**. Platform-wide events
|
|
160
|
+
(`user.created`, with a null workspace) have no tenant channel to go to, and
|
|
161
|
+
broadcasting them would mean choosing an audience the event does not describe.
|
|
162
|
+
|
|
163
|
+
Narrow or widen it explicitly:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
createRealtimeService({
|
|
167
|
+
transport, workspaces,
|
|
168
|
+
broadcast: (event) => event.type.startsWith("job."),
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Worth doing deliberately. Event payloads carry identifiers rather than snapshots
|
|
173
|
+
(see [events.md](./events.md#payload-conventions)), so they are usually safe — but
|
|
174
|
+
your own event types are yours to audit.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## `RealtimeMessage`
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
interface RealtimeMessage {
|
|
182
|
+
channel: string;
|
|
183
|
+
eventId: string; // the events.id this projects
|
|
184
|
+
type: string;
|
|
185
|
+
occurredAt: string; // ISO 8601
|
|
186
|
+
data?: unknown; // absent when truncated
|
|
187
|
+
truncated?: boolean;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`eventId` is what makes realtime recoverable: a client that missed or received a
|
|
192
|
+
truncated message re-reads the event rather than guessing.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## The default transport: PostgreSQL LISTEN/NOTIFY
|
|
197
|
+
|
|
198
|
+
Chosen because it adds **no infrastructure** — the database is already there, and
|
|
199
|
+
the spec is explicit about preferring PostgreSQL where PostgreSQL is enough. No
|
|
200
|
+
Redis, no broker, no extra service to operate.
|
|
201
|
+
|
|
202
|
+
**Browsers never touch `LISTEN`/`NOTIFY`.** The server holds the listening
|
|
203
|
+
connection and re-emits only what a given subscriber was granted. PostgreSQL is
|
|
204
|
+
used *internally* as part of the pipeline, which is what the architecture allows.
|
|
205
|
+
|
|
206
|
+
### Design details worth knowing
|
|
207
|
+
|
|
208
|
+
**One PG channel, not one per workspace.** `LISTEN` is per-connection and
|
|
209
|
+
per-name, so a channel-per-workspace design would mean thousands of `LISTEN`
|
|
210
|
+
statements and constant `LISTEN`/`UNLISTEN` churn as clients come and go. Instead
|
|
211
|
+
every server sees every message and filters to the channels it has authorized
|
|
212
|
+
subscribers for.
|
|
213
|
+
|
|
214
|
+
The trade-off is real: fan-out is O(servers), so at large scale every node decodes
|
|
215
|
+
traffic it discards. That is the point at which to swap in a broker — which is why
|
|
216
|
+
`RealtimeTransport` exists.
|
|
217
|
+
|
|
218
|
+
**One database listener shared across all local subscribers.** A `LISTEN` per HTTP
|
|
219
|
+
connection would exhaust the pool at a few hundred concurrent clients.
|
|
220
|
+
|
|
221
|
+
**Oversized payloads are truncated, not dropped.** `pg_notify` rejects payloads
|
|
222
|
+
over 8000 bytes. Rather than failing the notification, `data` is removed and
|
|
223
|
+
`truncated: true` set, so the client still learns *that* something happened and can
|
|
224
|
+
re-read it.
|
|
225
|
+
|
|
226
|
+
**Notifications are validated, not cast.** The payload arrives through the
|
|
227
|
+
database, so anything with `NOTIFY` privileges could have written it. A malformed
|
|
228
|
+
or hostile notification is discarded rather than reaching subscribers as if it were
|
|
229
|
+
a real event.
|
|
230
|
+
|
|
231
|
+
**Delivery failures are swallowed and reported.** The event is already committed;
|
|
232
|
+
a failed notification costs a client its live update, not the record — and must not
|
|
233
|
+
fail the operation that published it.
|
|
234
|
+
|
|
235
|
+
### Constructing it explicitly
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import {
|
|
239
|
+
createPostgresTransport,
|
|
240
|
+
postgresJsNotifyClient,
|
|
241
|
+
PG_NOTIFY_CHANNEL,
|
|
242
|
+
} from "@digimuza/saas-core/realtime";
|
|
243
|
+
import { getDatabaseHandle } from "@digimuza/saas-core/db";
|
|
244
|
+
|
|
245
|
+
const transport = createPostgresTransport({
|
|
246
|
+
client: postgresJsNotifyClient(getDatabaseHandle().client),
|
|
247
|
+
// Separate environments sharing one database, so a staging event cannot
|
|
248
|
+
// surface in a production browser.
|
|
249
|
+
notifyChannel: `${PG_NOTIFY_CHANNEL}_staging`,
|
|
250
|
+
onError: (error) => logger.error({ error }, "realtime transport"),
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`NotifyCapableClient` is a three-method interface (`listen`, `unlisten`, `notify`),
|
|
255
|
+
so any driver that can do those works — the core's tests drive it with PGlite.
|
|
256
|
+
|
|
257
|
+
`encodeMessage` and `decodeMessage` are exported too, if you are writing a
|
|
258
|
+
transport that needs the same size-limit and validation behaviour.
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Server-sent events, not WebSockets
|
|
263
|
+
|
|
264
|
+
`createEventStream` returns bytes plus headers, deliberately **not** a `Response`:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
interface EventStream {
|
|
268
|
+
body: ReadableStream<Uint8Array>;
|
|
269
|
+
headers: Record<string, string>;
|
|
270
|
+
granted: readonly string[];
|
|
271
|
+
close(): Promise<void>;
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Returning a `Response` would tie the module to the fetch types and to one
|
|
276
|
+
framework; a stream and a header map compose with anything, including plain
|
|
277
|
+
`node:http`.
|
|
278
|
+
|
|
279
|
+
SSE because it is one-directional — server to browser — which is exactly what
|
|
280
|
+
event delivery needs. It reconnects automatically, survives proxies, and needs no
|
|
281
|
+
protocol upgrade. If you need client-to-server messaging, that is an HTTP request,
|
|
282
|
+
not a realtime concern.
|
|
283
|
+
|
|
284
|
+
Details the implementation handles:
|
|
285
|
+
|
|
286
|
+
- **`retry:` hint** on open, so the browser knows how long to wait before
|
|
287
|
+
reconnecting.
|
|
288
|
+
- **An immediate `: connected` comment**, which flushes headers so the connection
|
|
289
|
+
opens now rather than on the first real event.
|
|
290
|
+
- **Heartbeat comments every 25s** by default — under the 30-second idle timeout
|
|
291
|
+
most proxies and load balancers impose.
|
|
292
|
+
- **`X-Accel-Buffering: no`**, without which nginx buffers the response and SSE
|
|
293
|
+
never arrives at all.
|
|
294
|
+
- **Abort handling.** Pass `request.signal`; without it a dropped connection leaks
|
|
295
|
+
a subscription and a listener slot for the process's lifetime.
|
|
296
|
+
- **Messages arriving during setup are buffered.** `subscribe` must run before the
|
|
297
|
+
stream is constructed (`granted` is part of the result), and anything delivered
|
|
298
|
+
in that window would otherwise be silently dropped.
|
|
299
|
+
|
|
300
|
+
### Serverless caveat
|
|
301
|
+
|
|
302
|
+
An SSE connection is a long-lived request. Platforms that cap function duration
|
|
303
|
+
will cut it — on Vercel that is the function `maxDuration`. The browser reconnects
|
|
304
|
+
automatically, so it degrades to polling at the cap rather than breaking, but a
|
|
305
|
+
long-lived host (a container, a persistent Node process) is where this transport
|
|
306
|
+
belongs.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Replacing the transport
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
interface RealtimeTransport {
|
|
314
|
+
readonly name: string;
|
|
315
|
+
publish(messages: readonly RealtimeMessage[]): Promise<void>;
|
|
316
|
+
subscribe(
|
|
317
|
+
channels: readonly string[],
|
|
318
|
+
onMessage: (message: RealtimeMessage) => void,
|
|
319
|
+
): Promise<RealtimeSubscription>;
|
|
320
|
+
close?(): Promise<void>;
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Two obligations:
|
|
325
|
+
|
|
326
|
+
1. **Do not authorize.** `channels` is already authorized; deliver to nothing
|
|
327
|
+
outside the set. Access is decided in the service, and a transport that also
|
|
328
|
+
filtered would create a second place for the rule to drift.
|
|
329
|
+
2. **Do not throw for an unknown channel.** Publishing is best-effort.
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { createRealtimeService, setDefaultRealtimeTransport } from "@digimuza/saas-core/realtime";
|
|
333
|
+
|
|
334
|
+
// Either build your own service...
|
|
335
|
+
const realtime = createRealtimeService({ transport: myTransport, workspaces });
|
|
336
|
+
|
|
337
|
+
// ...or replace what the default export uses, before first use.
|
|
338
|
+
setDefaultRealtimeTransport(myTransport);
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`REALTIME_URL` and `REALTIME_API_KEY` exist in the `realtime` config section for
|
|
342
|
+
exactly this — a deployment that outgrows LISTEN/NOTIFY and points at Centrifugo,
|
|
343
|
+
Soketi or a hosted service. The PostgreSQL transport ignores them;
|
|
344
|
+
`hasExternalRealtimeConfig()` reports whether they are set.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Testing
|
|
349
|
+
|
|
350
|
+
The core's own tests run the full pipeline against real PostgreSQL — PGlite
|
|
351
|
+
supports `LISTEN`/`NOTIFY` — so cross-tenant isolation is verified through actual
|
|
352
|
+
notification delivery, not a stand-in that would deliver anything asked of it.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const aliceSaw: RealtimeMessage[] = [];
|
|
356
|
+
const bobSaw: RealtimeMessage[] = [];
|
|
357
|
+
// ...subscribe both, publish to each workspace...
|
|
358
|
+
expect(aliceSaw.map((m) => m.channel)).toEqual([workspaceChannel(acme.id)]);
|
|
359
|
+
expect(bobSaw.map((m) => m.channel)).toEqual([workspaceChannel(globex.id)]);
|
|
360
|
+
```
|
package/docs/storage.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Storage
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { storage, createStorageService, createMemoryStorageProvider } from "@digimuza/saas-core/storage";
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
> Orientation and recipes: [`src/storage/README.md`](../src/storage/README.md).
|
|
8
|
+
|
|
9
|
+
Object storage behind an adapter, with tenant scoping that cannot be bypassed.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## No database table
|
|
14
|
+
|
|
15
|
+
An object is referenced by key from whichever product table owns it. A core `files`
|
|
16
|
+
table would have to guess at metadata, ownership and lifecycle only the product
|
|
17
|
+
knows — and the spec's core-owned table list does not include one. Storage adds no
|
|
18
|
+
migration.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## `storage.forWorkspace(context)`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
function forWorkspace(context: WorkspaceContext): ScopedStorage
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The only way to address an object. Takes a verified
|
|
29
|
+
[`WorkspaceContext`](./workspaces.md#workspacecontext), and the returned API takes
|
|
30
|
+
**paths** rather than keys — so `workspaces/<id>/` is applied in exactly one place.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
interface ScopedStorage {
|
|
34
|
+
readonly workspaceId: string;
|
|
35
|
+
|
|
36
|
+
keyFor(path: string): string;
|
|
37
|
+
|
|
38
|
+
upload(input: UploadInput): Promise<StoredObject>;
|
|
39
|
+
delete(path: string): Promise<void>;
|
|
40
|
+
exists(path: string): Promise<boolean>;
|
|
41
|
+
|
|
42
|
+
getUrl(path: string, options?: UrlOptions): Promise<string>;
|
|
43
|
+
createUploadUrl(path: string, options?: UploadUrlOptions): Promise<string>;
|
|
44
|
+
|
|
45
|
+
urlForKey(key: string, options?: UrlOptions): Promise<string>;
|
|
46
|
+
deleteKey(key: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `upload(input)`
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await files.upload({
|
|
54
|
+
path: "avatars/me.png",
|
|
55
|
+
body, // Uint8Array | string
|
|
56
|
+
contentType: "image/png",
|
|
57
|
+
cacheControl: "public, max-age=31536000, immutable",
|
|
58
|
+
metadata: { uploadedBy: user.id },
|
|
59
|
+
maxBytes: 5 * 1024 * 1024, // overrides the service default
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Size is checked **before** the provider call, so an oversized body is never sent,
|
|
64
|
+
and measured in **bytes** rather than characters. Default limit 25 MB.
|
|
65
|
+
|
|
66
|
+
Never put a secret in `metadata` — it is stored alongside the object and returned
|
|
67
|
+
by a `HEAD`.
|
|
68
|
+
|
|
69
|
+
### `keyFor(path)` / `urlForKey(key)` / `deleteKey(key)`
|
|
70
|
+
|
|
71
|
+
Store `keyFor(path)` in your own table, and pass it back through `urlForKey` or
|
|
72
|
+
`deleteKey`. Both **re-assert ownership**:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
await files.urlForKey(row.storageKey); // InvalidInputError if not this workspace
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
That check matters because a key coming back out of your own database is untrusted
|
|
79
|
+
input — it may have been written before a bug was fixed, or by a different tenant.
|
|
80
|
+
The error deliberately does not distinguish "wrong tenant" from "not a scoped key".
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Object keys are the attack surface
|
|
85
|
+
|
|
86
|
+
A caller who can put `../` in a key reads out of its own prefix and into another
|
|
87
|
+
tenant's: a cross-tenant read with no database involved and nothing in an audit
|
|
88
|
+
log.
|
|
89
|
+
|
|
90
|
+
So keys are validated **before** being concatenated with a prefix, and validation
|
|
91
|
+
**rejects rather than sanitises** — silently rewriting `../secret` to `secret` would
|
|
92
|
+
store the object somewhere the caller did not ask for.
|
|
93
|
+
|
|
94
|
+
| Rejected | Why |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `..`, `.` segments | Traversal out of the tenant prefix |
|
|
97
|
+
| Leading `/` | Absolute key defeats prefixing entirely |
|
|
98
|
+
| `\` | A separator on some receivers, data on others |
|
|
99
|
+
| Control chars, incl. NUL | A NUL can truncate a key in a C-based backend, so `a<NUL>../b` may be stored as `a` — traversal past a naive check |
|
|
100
|
+
| `a//b`, trailing `/` | Empty segment |
|
|
101
|
+
| ` a.png`, `a.png ` | Legal in S3, invisible in every UI |
|
|
102
|
+
| over 1024 bytes | S3's key limit |
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
workspaceObjectKey(workspaceId, path): string // validates both, then prefixes
|
|
106
|
+
parseWorkspaceObjectKey(key): { workspaceId, path } | undefined
|
|
107
|
+
assertKeyBelongsTo(workspaceId, key): void
|
|
108
|
+
assertObjectPath(path): void // throws with a reason
|
|
109
|
+
isValidObjectPath(path): boolean
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`workspaceObjectKey` validates the **workspace id as a UUID** too — it is the only
|
|
113
|
+
thing separating one tenant's objects from another's, and a malformed one could
|
|
114
|
+
create a colliding prefix.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## URLs
|
|
119
|
+
|
|
120
|
+
### `getUrl(path, options?)`
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
interface UrlOptions {
|
|
124
|
+
expiresInSeconds?: number; // default 900, max 604800 (7 days)
|
|
125
|
+
downloadFilename?: string;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Returns the **public URL** when the store has one (`STORAGE_PUBLIC_URL`), otherwise
|
|
130
|
+
a time-limited signed URL. Only a deployment with a public CDN origin can answer
|
|
131
|
+
that; a private bucket is the correct default for user uploads.
|
|
132
|
+
|
|
133
|
+
**Pass `downloadFilename` for anything a user uploaded.** It forces
|
|
134
|
+
`Content-Disposition: attachment`, which stops a browser rendering an uploaded HTML
|
|
135
|
+
or SVG file in your origin — that is stored XSS.
|
|
136
|
+
|
|
137
|
+
Requesting it also forces a *signed* URL even when a public one exists, because
|
|
138
|
+
handing back a bare CDN link would silently drop the protection the caller asked
|
|
139
|
+
for.
|
|
140
|
+
|
|
141
|
+
Quotes and backslashes are stripped from the filename, so it cannot break out of
|
|
142
|
+
the header value.
|
|
143
|
+
|
|
144
|
+
### `createUploadUrl(path, options?)`
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const url = await files.createUploadUrl("imports/data.csv", {
|
|
148
|
+
contentType: "text/csv",
|
|
149
|
+
expiresInSeconds: 600,
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A URL the browser can `PUT` to directly. The reason the interface has this shape:
|
|
154
|
+
proxying uploads through the application means a request that holds a server for
|
|
155
|
+
the whole transfer, and a body limit on every platform in the path.
|
|
156
|
+
|
|
157
|
+
`contentType` is **signed into the URL**, so the client must send exactly that type
|
|
158
|
+
or be rejected. Without it, a client uploads anything and declares it anything.
|
|
159
|
+
|
|
160
|
+
### The 7-day cap
|
|
161
|
+
|
|
162
|
+
`MAX_SIGNED_URL_SECONDS` is S3's own hard limit for a SigV4 presigned URL — beyond
|
|
163
|
+
it the signature is invalid. The service throws rather than clamping, because
|
|
164
|
+
clamping would hand back a URL that expires sooner than the caller was told.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Providers
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
interface StorageProvider {
|
|
172
|
+
readonly name: string;
|
|
173
|
+
put(input: PutObjectInput): Promise<StoredObject>;
|
|
174
|
+
delete(key: string): Promise<void>;
|
|
175
|
+
exists(key: string): Promise<boolean>;
|
|
176
|
+
publicUrl(key: string): string | undefined;
|
|
177
|
+
signedDownloadUrl(input: SignedDownloadInput): Promise<string>;
|
|
178
|
+
signedUploadUrl(input: SignedUploadInput): Promise<string>;
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Implementations do **no** key validation or tenant scoping. Both happen once, in
|
|
183
|
+
the service; a provider that also checked would give the rule two homes and one
|
|
184
|
+
would drift.
|
|
185
|
+
|
|
186
|
+
| Provider | Use |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| `createS3Provider(options)` | AWS S3, Cloudflare R2, MinIO, Backblaze, Supabase |
|
|
189
|
+
| `createMemoryStorageProvider(options?)` | Tests, and a local loop with no credentials |
|
|
190
|
+
|
|
191
|
+
`createConfiguredStorageProvider()` builds the S3 one from the `storage` config
|
|
192
|
+
section. The default `storage` export uses it, resolving configuration on first use.
|
|
193
|
+
|
|
194
|
+
### Path style vs virtual-hosted
|
|
195
|
+
|
|
196
|
+
`forcePathStyle` defaults to **true when an endpoint is given**, false for AWS.
|
|
197
|
+
MinIO and most self-hosted stores cannot do wildcard subdomains, so
|
|
198
|
+
`https://minio.internal:9000/my-bucket/key` is required rather than
|
|
199
|
+
`https://my-bucket.minio.internal:9000/key`.
|
|
200
|
+
|
|
201
|
+
### Errors never include the response body
|
|
202
|
+
|
|
203
|
+
S3 error documents echo the key and can echo request parameters, and the message
|
|
204
|
+
may reach a log.
|
|
205
|
+
|
|
206
|
+
A `404` on delete is treated as success — deleting something already gone is not an
|
|
207
|
+
error. A `403` on `HEAD` is treated as "not found", because that is what S3 returns
|
|
208
|
+
for a missing key on a bucket without `ListBucket`.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## SigV4, implemented rather than imported
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
presign(input: PresignInput): PresignResult
|
|
216
|
+
signingKey(input): Buffer
|
|
217
|
+
uriEncode(value): string
|
|
218
|
+
encodeObjectPath(key): string
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Verified against AWS's published worked example** — the test asserts both the
|
|
222
|
+
signature `aeeed9bb…` and the exact canonical request. So unlike the Revolut and
|
|
223
|
+
Resend adapters, this signing is known correct rather than plausible.
|
|
224
|
+
|
|
225
|
+
Why not `@aws-sdk/client-s3` plus the presigner: it is a large dependency tree for
|
|
226
|
+
a package many applications install, and a deployment on R2 or MinIO has no reason
|
|
227
|
+
to carry the AWS SDK. SigV4 is fully specified, which is precisely what makes
|
|
228
|
+
implementing it defensible — there are vectors to check against.
|
|
229
|
+
|
|
230
|
+
`PresignResult` exposes `canonicalRequest` and `stringToSign`, which is what makes
|
|
231
|
+
a signature mismatch debuggable rather than a shrug.
|
|
232
|
+
|
|
233
|
+
### `objectDigest(body)`
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
function objectDigest(body: ObjectBody): string // sha256 hex
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Content hash, for naming an object by its bytes — which gives you deduplication and
|
|
240
|
+
immutable caching for free:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const digest = objectDigest(body);
|
|
244
|
+
await files.upload({
|
|
245
|
+
path: `uploads/${digest}${extension}`,
|
|
246
|
+
body,
|
|
247
|
+
cacheControl: "public, max-age=31536000, immutable",
|
|
248
|
+
});
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Re-uploading identical bytes then overwrites the same key rather than growing the
|
|
252
|
+
bucket, and the URL can be cached forever because the content cannot change.
|
|
253
|
+
|
|
254
|
+
Details that are easy to get wrong and are covered:
|
|
255
|
+
|
|
256
|
+
- `uriEncode` escapes `!'()*`, which `encodeURIComponent` leaves alone.
|
|
257
|
+
- `encodeObjectPath` preserves `/` — separators in a key, not data.
|
|
258
|
+
- The canonical query string is encoded **then** sorted. Sorting first gives a
|
|
259
|
+
different order for some inputs and the signature will not match.
|
|
260
|
+
- The signing key is four chained HMACs, each narrowing scope, so a leaked one is
|
|
261
|
+
good for one date, region and service rather than the account.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Testing
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const provider = createMemoryStorageProvider();
|
|
269
|
+
const storage = createStorageService({ provider });
|
|
270
|
+
|
|
271
|
+
await storage.forWorkspace(context).upload({ path: "a.png", body: "bytes" });
|
|
272
|
+
|
|
273
|
+
expect(provider.keysUnder("workspaces/")).toEqual([`workspaces/${context.workspaceId}/a.png`]);
|
|
274
|
+
expect(provider.text(`workspaces/${context.workspaceId}/a.png`)).toBe("bytes");
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`keysUnder` exists specifically for asserting tenant scoping. Signed URLs from the
|
|
278
|
+
memory provider are **synthesised** — they carry an expiry and a signature-shaped
|
|
279
|
+
token so a test can assert on them, but nothing verifies them. It is not a store.
|