@protobase/server 0.45.15 → 0.45.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-admin.js +4 -0
- package/dist/deps.d.ts +2 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +6 -1
- package/dist/meta.d.ts +4 -0
- package/dist/meta.js +1 -0
- package/dist/notifications/device.js +7 -1
- package/dist/notifications/guarded-sender.js +61 -0
- package/dist/notifications/json-value.d.ts +8 -0
- package/dist/notifications/json-value.js +54 -0
- package/dist/notifications/message.d.ts +52 -0
- package/dist/notifications/message.js +74 -0
- package/dist/notifications/options.d.ts +6 -0
- package/dist/notifications/options.js +3 -1
- package/dist/notifications/plugin.d.ts +2 -1
- package/dist/notifications/plugin.js +2 -1
- package/dist/notifications/sender.d.ts +25 -0
- package/dist/notifications/sender.js +23 -0
- package/dist/notifications/senders-of.d.ts +10 -0
- package/dist/notifications/senders-of.js +19 -0
- package/dist/notifications/tap-url.js +15 -0
- package/dist/notifications/web-push/encrypt.js +45 -0
- package/dist/notifications/web-push/keys.d.ts +7 -0
- package/dist/notifications/web-push/keys.js +43 -0
- package/dist/notifications/web-push/payload.js +48 -0
- package/dist/notifications/web-push/post.d.ts +6 -0
- package/dist/notifications/web-push/post.js +47 -0
- package/dist/notifications/web-push/vapid-jwt.js +45 -0
- package/dist/notifications/web-push/vapid-settings.d.ts +22 -0
- package/dist/notifications/web-push/vapid-settings.js +51 -0
- package/dist/notifications/web-push/web-push-sender.d.ts +15 -0
- package/dist/notifications/web-push/web-push-sender.js +74 -0
- package/dist/package.js +1 -1
- package/package.json +4 -4
package/dist/create-admin.js
CHANGED
|
@@ -54,6 +54,7 @@ import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/v
|
|
|
54
54
|
import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
|
|
55
55
|
import { devicesPath } from "./notifications/paths.js";
|
|
56
56
|
import { deviceRoutes } from "./notifications/routes.js";
|
|
57
|
+
import { notificationSenders, webPushKey } from "./notifications/senders-of.js";
|
|
57
58
|
import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
58
59
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
59
60
|
import { checkOwnerFields } from "./check-owners.js";
|
|
@@ -152,6 +153,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
152
153
|
const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
|
|
153
154
|
const errorReporting = errorReportingSettings(options.errorReporting, globalThis.process?.env ?? {});
|
|
154
155
|
const maps = mapSettings(options.maps, globalThis.process?.env ?? {});
|
|
156
|
+
const senders = notifications ? notificationSenders(notifications) : [];
|
|
157
|
+
const pushKey = webPushKey(senders);
|
|
155
158
|
const locale = localeSetting(options.locale);
|
|
156
159
|
const plugins = pluginOptions(options.plugins, globalThis.process?.env ?? {}, bundlePlugins);
|
|
157
160
|
const sources = sourceOptions(options.sources, globalThis.process?.env ?? {});
|
|
@@ -204,6 +207,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
204
207
|
...runtime && { runtime },
|
|
205
208
|
...errorReporting && { errorReporting },
|
|
206
209
|
...maps && { maps },
|
|
210
|
+
...pushKey && { webPushKey: pushKey },
|
|
207
211
|
...locale && { locale },
|
|
208
212
|
plugins,
|
|
209
213
|
sources,
|
package/dist/deps.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type Deps = {
|
|
|
47
47
|
errorReporting?: ErrorReportingSettings;
|
|
48
48
|
/** The map style the admin app draws maps with and the layers they may switch to, which `/meta` names to everyone. */
|
|
49
49
|
maps?: MapSettings;
|
|
50
|
+
/** The VAPID public key browsers subscribe for Web Push with, when notifications have a web sender, which `/meta` names to everyone. */
|
|
51
|
+
webPushKey?: string;
|
|
50
52
|
/** The language the admin app's screens are in for everyone, which `/meta` names to everyone. */
|
|
51
53
|
locale?: Locale;
|
|
52
54
|
/** The plugins `/meta` names to the callers they are for. */
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { HistoryOptions } from "./history/options.js";
|
|
|
20
20
|
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
21
21
|
import { ApnsEnvironment, Device, DeviceKind, NewDevice } from "./notifications/device.js";
|
|
22
22
|
import { DeviceStore } from "./notifications/device-store.js";
|
|
23
|
+
import { JsonObject, JsonValue } from "./notifications/json-value.js";
|
|
24
|
+
import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
|
|
25
|
+
import { PushAnswer, PushSender } from "./notifications/sender.js";
|
|
23
26
|
import { DeviceColumn, NotificationsOptions } from "./notifications/options.js";
|
|
24
27
|
import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
|
|
25
28
|
import { Sharp, renderVariant, thumbHash } from "./files/images.js";
|
|
@@ -95,6 +98,10 @@ import { deviceStore, removeDevicesWithUsers } from "./notifications/device-stor
|
|
|
95
98
|
import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
|
|
96
99
|
import { deviceIdParam, devicesPath } from "./notifications/paths.js";
|
|
97
100
|
import { maxDevicesPerUser } from "./notifications/routes.js";
|
|
101
|
+
import { notificationSenders } from "./notifications/senders-of.js";
|
|
102
|
+
import { VapidSettings, readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
|
|
103
|
+
import { WebPushOptions, webPushSender } from "./notifications/web-push/web-push-sender.js";
|
|
104
|
+
import { generateVapidKeys } from "./notifications/web-push/keys.js";
|
|
98
105
|
import { MemberInput, NewOrganization, addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
|
|
99
106
|
import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
|
|
100
107
|
import { fileConversationStore } from "./assistant/file-conversation-store.js";
|
|
@@ -146,4 +153,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
|
|
|
146
153
|
import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
|
|
147
154
|
import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
|
|
148
155
|
import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
|
|
149
|
-
export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewDevice, type NewOrganization, type NewUser, type NotificationsOptions, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type User, type UserCreatedHook, type ViewSource, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
|
156
|
+
export { type Admin, type AdminAuth, type AdminOptions, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type Caller, type CallerOptions, type CapturedRow, type CascadingKey, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryOptions, HttpProblem, type IssueTokenOptions, type JobContext, type JobDefinition, type JobOptions, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, MissingCredentials, type MobileConfig, type MobileRelease, type MobileReleaseAppInput, ModelError, type ModelSettings, type NewDevice, type NewOrganization, type NewUser, type NotificationsOptions, type OfferedRelease, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProjectConfig, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordKeyInput, type RecordPage, type ReleaseFile, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type ServedReleaseFile, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
package/dist/index.js
CHANGED
|
@@ -56,10 +56,15 @@ import { recordOf, resourceChange } from "./changes/resource-change.js";
|
|
|
56
56
|
import { defineFunction } from "./functions/define-function.js";
|
|
57
57
|
import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
|
|
58
58
|
import { subscription } from "./webhooks/subscription.js";
|
|
59
|
+
import { generateVapidKeys } from "./notifications/web-push/keys.js";
|
|
59
60
|
import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
|
|
60
61
|
import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
|
|
61
62
|
import { deviceIdParam, devicesPath } from "./notifications/paths.js";
|
|
62
63
|
import { maxDevicesPerUser } from "./notifications/routes.js";
|
|
64
|
+
import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
|
|
65
|
+
import { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
|
|
66
|
+
import { webPushSender } from "./notifications/web-push/web-push-sender.js";
|
|
67
|
+
import { notificationSenders } from "./notifications/senders-of.js";
|
|
63
68
|
import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
|
|
64
69
|
import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
|
|
65
70
|
import { limitedFileStore, storeUsage } from "./files/limits.js";
|
|
@@ -110,4 +115,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
|
|
|
110
115
|
import { composeRelease, releaseManifest } from "./mobile-release/release.js";
|
|
111
116
|
import { mobileReleaseApp } from "./mobile-release/release-app.js";
|
|
112
117
|
import { mobileSigningKeyVariable, readSigningKey } from "./mobile-release/signing-key.js";
|
|
113
|
-
export { ConnectionsBusy, DeviceStoreError, FatalJobError, HttpProblem, MissingCredentials, ModelError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
|
118
|
+
export { ConnectionsBusy, DeviceStoreError, FatalJobError, HttpProblem, MissingCredentials, ModelError, PushMessageError, addAppRoleToOwners, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appPool, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthAuthenticator, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentsMigration, composeRelease, computedMigration, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultSignInPolicy, defineConfig, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, deviceIdParam, deviceStore, devicesPath, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, localFiles, longQueryPool, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxTokenTtlSeconds, maxTtlSeconds, maxWidgetPropsBytes, mcpGate, mcpHandler, mcpRoutes, mcpServer, mcpToolContext, mcpTools, mergeConfig, migrationCommand, mobileReleaseApp, mobileSigningKeyVariable, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notifications, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, s3Files, seesAssistant, seesRuntime, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooks, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
|
package/dist/meta.d.ts
CHANGED
|
@@ -49,6 +49,10 @@ export type CallerMeta = {
|
|
|
49
49
|
errorReporting?: ErrorReportingSettings;
|
|
50
50
|
/** The map style the admin app draws its maps with, and the layers they may switch to, when the app has one: public, as the tiles are. */
|
|
51
51
|
maps?: MapSettings;
|
|
52
|
+
/** The VAPID public key browsers subscribe for Web Push with, when the app's notifications deliver to browsers: public, as any VAPID key is. */
|
|
53
|
+
notifications?: {
|
|
54
|
+
webPushKey: string;
|
|
55
|
+
};
|
|
52
56
|
/** The language the admin app's screens are in, when the app sets one for everyone. */
|
|
53
57
|
locale?: Locale;
|
|
54
58
|
/** The hosts besides the app's own whose images the admin app draws with the page's referrer, when there are any. */
|
package/dist/meta.js
CHANGED
|
@@ -90,6 +90,7 @@ const createMeta = (deps, appName, appIcon) => {
|
|
|
90
90
|
...deps.runtime && seesRuntime(session.user.roles) && { runtime: { url: deps.runtime } },
|
|
91
91
|
...deps.errorReporting && { errorReporting: deps.errorReporting },
|
|
92
92
|
...deps.maps && { maps: deps.maps },
|
|
93
|
+
...deps.webPushKey && { notifications: { webPushKey: deps.webPushKey } },
|
|
93
94
|
...deps.locale && { locale: deps.locale },
|
|
94
95
|
...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
|
|
95
96
|
...plugins.length > 0 && { plugins },
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
//#region src/notifications/device.ts
|
|
2
|
+
/** How a device receives push: a browser's Web Push subscription, an iOS app's APNs token or an Android app's FCM token. */
|
|
3
|
+
const deviceKinds = [
|
|
4
|
+
"web",
|
|
5
|
+
"apns",
|
|
6
|
+
"fcm"
|
|
7
|
+
];
|
|
2
8
|
/** The APNs environment an iOS build's token belongs to. */
|
|
3
9
|
const apnsEnvironments = ["sandbox", "production"];
|
|
4
10
|
//#endregion
|
|
5
|
-
export { apnsEnvironments };
|
|
11
|
+
export { apnsEnvironments, deviceKinds };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PushMessageError, pushMessage } from "./message.js";
|
|
2
|
+
//#region src/notifications/guarded-sender.ts
|
|
3
|
+
const results = /* @__PURE__ */ new Set([
|
|
4
|
+
"ok",
|
|
5
|
+
"gone",
|
|
6
|
+
"retry"
|
|
7
|
+
]);
|
|
8
|
+
const deepFrozen = (value) => {
|
|
9
|
+
if (typeof value !== "object" || value === null) return value;
|
|
10
|
+
for (const item of Object.values(value)) deepFrozen(item);
|
|
11
|
+
return Object.freeze(value);
|
|
12
|
+
};
|
|
13
|
+
const validAnswer = (answer) => {
|
|
14
|
+
if (typeof answer !== "object" || answer === null) return false;
|
|
15
|
+
const { id, result, retryAfterSeconds } = answer;
|
|
16
|
+
if (typeof id !== "string" || typeof result !== "string" || !results.has(result)) return false;
|
|
17
|
+
return retryAfterSeconds === void 0 || Number.isInteger(retryAfterSeconds) && retryAfterSeconds >= 0;
|
|
18
|
+
};
|
|
19
|
+
const answersFor = (batch, given) => {
|
|
20
|
+
const byId = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const answer of Array.isArray(given) ? given : []) {
|
|
22
|
+
if (!validAnswer(answer) || byId.has(answer.id)) continue;
|
|
23
|
+
byId.set(answer.id, {
|
|
24
|
+
id: answer.id,
|
|
25
|
+
result: answer.result,
|
|
26
|
+
...answer.retryAfterSeconds !== void 0 && { retryAfterSeconds: answer.retryAfterSeconds }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return batch.map((device) => byId.get(device.id) ?? {
|
|
30
|
+
id: device.id,
|
|
31
|
+
result: "retry"
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* `sender` as the code that sends calls it, whether built in or the app's own: the message is checked as `pushMessage`
|
|
36
|
+
* checks it, and refused before the sender sees it; the sender gets a frozen copy of it and of the devices of its kinds
|
|
37
|
+
* only, and answers each of those once, as above. A sender that throws throws an error of its own naming its index,
|
|
38
|
+
* never the sender's, which may carry an endpoint or a key, but for a `PushMessageError`, which names a field only.
|
|
39
|
+
*/
|
|
40
|
+
const guardedSender = (sender, index) => {
|
|
41
|
+
const send = async (given, devices) => {
|
|
42
|
+
const message = deepFrozen(pushMessage(given));
|
|
43
|
+
const batch = deepFrozen(devices.filter((device) => sender.kinds.includes(device.kind)).map((device) => structuredClone(device)));
|
|
44
|
+
if (batch.length === 0) return [];
|
|
45
|
+
let answers;
|
|
46
|
+
try {
|
|
47
|
+
answers = await sender.send(message, batch);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof PushMessageError) throw error;
|
|
50
|
+
throw new Error(`notifications sender ${index} failed`);
|
|
51
|
+
}
|
|
52
|
+
return answersFor(batch, answers);
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
kinds: [...sender.kinds],
|
|
56
|
+
...sender.publicKey !== void 0 && { publicKey: sender.publicKey },
|
|
57
|
+
send
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { guardedSender };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/notifications/json-value.ts
|
|
2
|
+
const forbiddenKeys = /* @__PURE__ */ new Set([
|
|
3
|
+
"__proto__",
|
|
4
|
+
"constructor",
|
|
5
|
+
"prototype"
|
|
6
|
+
]);
|
|
7
|
+
const maxDepth = 16;
|
|
8
|
+
const isPlainObject = (value) => {
|
|
9
|
+
const prototype = Object.getPrototypeOf(value);
|
|
10
|
+
return prototype === Object.prototype || prototype === null;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The path of the first part of `value` that is not plain JSON, or `undefined` when all of it is: plain objects,
|
|
14
|
+
* arrays, strings, finite numbers, booleans and null, at most 16 levels deep, without a `__proto__`, `constructor` or
|
|
15
|
+
* `prototype` key at any depth.
|
|
16
|
+
*/
|
|
17
|
+
const jsonProblem = (value, path, depth = 0) => {
|
|
18
|
+
if (depth > maxDepth) return path;
|
|
19
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
|
|
20
|
+
if (typeof value === "number") return Number.isFinite(value) ? void 0 : path;
|
|
21
|
+
if (typeof value !== "object") return path;
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
for (const [index, item] of value.entries()) {
|
|
24
|
+
const problem = jsonProblem(item, `${path}.${index}`, depth + 1);
|
|
25
|
+
if (problem !== void 0) return problem;
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!isPlainObject(value)) return path;
|
|
30
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
31
|
+
if (typeof key !== "string" || forbiddenKeys.has(key)) return `${path}.${String(key)}`;
|
|
32
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
33
|
+
if (!("value" in descriptor)) return `${path}.${key}`;
|
|
34
|
+
const problem = jsonProblem(descriptor.value, `${path}.${key}`, depth + 1);
|
|
35
|
+
if (problem !== void 0) return problem;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** Whether `value` is a plain JSON object, as `jsonProblem` checks it. */
|
|
39
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && isPlainObject(value);
|
|
40
|
+
/**
|
|
41
|
+
* `over` merged into `base`, objects key by key and anything else replaced, as new objects: neither is changed and no
|
|
42
|
+
* key is assigned onto an object with a prototype. Both are plain JSON, as `jsonProblem` checks.
|
|
43
|
+
*/
|
|
44
|
+
const mergeJson = (base, over) => {
|
|
45
|
+
const merged = Object.create(null);
|
|
46
|
+
for (const [key, value] of Object.entries(base)) merged[key] = value;
|
|
47
|
+
for (const [key, value] of Object.entries(over)) {
|
|
48
|
+
const under = merged[key];
|
|
49
|
+
merged[key] = isJsonObject(under) && isJsonObject(value) ? mergeJson(under, value) : value;
|
|
50
|
+
}
|
|
51
|
+
return structuredClone({ ...merged });
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { isJsonObject, jsonProblem, mergeJson };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { JsonObject } from "./json-value.js";
|
|
2
|
+
//#region src/notifications/message.d.ts
|
|
3
|
+
/** How soon a push service delivers a message, as Web Push names it (RFC 8030); the other senders map it to theirs. */
|
|
4
|
+
export declare const urgencies: readonly ["very-low", "low", "normal", "high"];
|
|
5
|
+
export type Urgency = (typeof urgencies)[number];
|
|
6
|
+
/** The longest a push service keeps an undelivered message: 28 days, the most any of them keeps one. */
|
|
7
|
+
export declare const maxTtlSeconds = 2419200;
|
|
8
|
+
/**
|
|
9
|
+
* What `platform.web` may set beside the common message: the Web Push headers (RFC 8030's TTL, Urgency and Topic) and
|
|
10
|
+
* fields merged into the JSON payload the browser's service worker reads. Nothing else, such as the endpoint or a
|
|
11
|
+
* header of the sender's own.
|
|
12
|
+
*/
|
|
13
|
+
export type WebPushFields = {
|
|
14
|
+
ttl?: number;
|
|
15
|
+
urgency?: Urgency;
|
|
16
|
+
topic?: string;
|
|
17
|
+
payload?: JsonObject;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A notification as every sender takes it, plain data: `url` is opened on tap (an absolute `http` or `https` URL),
|
|
21
|
+
* `tag` replaces an earlier notification with the same tag, `ttl` is how long in seconds a push service keeps it for a
|
|
22
|
+
* device that is offline, and `platform` holds each protocol's own fields, merged into its request.
|
|
23
|
+
*/
|
|
24
|
+
export type PushMessage = {
|
|
25
|
+
title: string;
|
|
26
|
+
body?: string;
|
|
27
|
+
url?: string;
|
|
28
|
+
data?: JsonObject;
|
|
29
|
+
badge?: number;
|
|
30
|
+
sound?: string;
|
|
31
|
+
tag?: string;
|
|
32
|
+
ttl?: number;
|
|
33
|
+
urgency?: Urgency;
|
|
34
|
+
platform?: {
|
|
35
|
+
web?: WebPushFields;
|
|
36
|
+
apns?: JsonObject;
|
|
37
|
+
fcm?: JsonObject;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** A message that is not one `pushMessage` accepts: `field` names the part at fault, never its value. */
|
|
41
|
+
export declare class PushMessageError extends Error {
|
|
42
|
+
readonly field: string;
|
|
43
|
+
constructor(field: string);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The message, checked and as a new object, or a `PushMessageError` naming the first field at fault: every field
|
|
47
|
+
* within its format, only plain JSON in the free-form ones, and a `url` that is an absolute `http` or `https` URL,
|
|
48
|
+
* written as the WHATWG parser writes it. Senders check what they are given the same way, since a message is plain
|
|
49
|
+
* data and need not come from here.
|
|
50
|
+
*/
|
|
51
|
+
export declare const pushMessage: (input: PushMessage) => PushMessage;
|
|
52
|
+
//#endregion
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsonProblem } from "./json-value.js";
|
|
2
|
+
import { tapUrl } from "./tap-url.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/notifications/message.ts
|
|
5
|
+
/** How soon a push service delivers a message, as Web Push names it (RFC 8030); the other senders map it to theirs. */
|
|
6
|
+
const urgencies = [
|
|
7
|
+
"very-low",
|
|
8
|
+
"low",
|
|
9
|
+
"normal",
|
|
10
|
+
"high"
|
|
11
|
+
];
|
|
12
|
+
/** The longest a push service keeps an undelivered message: 28 days, the most any of them keeps one. */
|
|
13
|
+
const maxTtlSeconds = 2419200;
|
|
14
|
+
/** A message that is not one `pushMessage` accepts: `field` names the part at fault, never its value. */
|
|
15
|
+
var PushMessageError = class extends Error {
|
|
16
|
+
field;
|
|
17
|
+
constructor(field) {
|
|
18
|
+
super(`The notification is invalid: ${field}`);
|
|
19
|
+
this.field = field;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const noControls = /^[^\u0000-\u001f\u007f]*$/;
|
|
23
|
+
const noControlsButLines = /^[^\u0000-\u0008\u000b-\u001f\u007f]*$/;
|
|
24
|
+
const topic = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
25
|
+
const ttl = z.number().int().min(0).max(maxTtlSeconds);
|
|
26
|
+
const urgency = z.enum(urgencies);
|
|
27
|
+
const json = z.custom((value) => typeof value === "object" && value !== null && !Array.isArray(value));
|
|
28
|
+
const web = z.strictObject({
|
|
29
|
+
ttl: ttl.optional(),
|
|
30
|
+
urgency: urgency.optional(),
|
|
31
|
+
topic: topic.optional(),
|
|
32
|
+
payload: json.optional()
|
|
33
|
+
});
|
|
34
|
+
const message = z.strictObject({
|
|
35
|
+
title: z.string().min(1).max(256).regex(noControls),
|
|
36
|
+
body: z.string().max(2048).regex(noControlsButLines).optional(),
|
|
37
|
+
url: z.string().optional(),
|
|
38
|
+
data: json.optional(),
|
|
39
|
+
badge: z.number().int().min(0).max(1e6).optional(),
|
|
40
|
+
sound: z.string().min(1).max(256).regex(noControls).optional(),
|
|
41
|
+
tag: topic.optional(),
|
|
42
|
+
ttl: ttl.optional(),
|
|
43
|
+
urgency: urgency.optional(),
|
|
44
|
+
platform: z.strictObject({
|
|
45
|
+
web: web.optional(),
|
|
46
|
+
apns: json.optional(),
|
|
47
|
+
fcm: json.optional()
|
|
48
|
+
}).optional()
|
|
49
|
+
});
|
|
50
|
+
const issueField = (issue) => {
|
|
51
|
+
return (issue.code === "unrecognized_keys" ? [...issue.path, issue.keys[0]] : issue.path).join(".") || "message";
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The message, checked and as a new object, or a `PushMessageError` naming the first field at fault: every field
|
|
55
|
+
* within its format, only plain JSON in the free-form ones, and a `url` that is an absolute `http` or `https` URL,
|
|
56
|
+
* written as the WHATWG parser writes it. Senders check what they are given the same way, since a message is plain
|
|
57
|
+
* data and need not come from here.
|
|
58
|
+
*/
|
|
59
|
+
const pushMessage = (input) => {
|
|
60
|
+
const problem = jsonProblem(input, "");
|
|
61
|
+
if (problem !== void 0) throw new PushMessageError(problem.slice(1) || "message");
|
|
62
|
+
const parsed = message.safeParse(input);
|
|
63
|
+
if (!parsed.success) throw new PushMessageError(issueField(parsed.error.issues[0]));
|
|
64
|
+
const checked = structuredClone(parsed.data);
|
|
65
|
+
if (checked.url === void 0) return checked;
|
|
66
|
+
const url = tapUrl(checked.url);
|
|
67
|
+
if (url === void 0) throw new PushMessageError("url");
|
|
68
|
+
return {
|
|
69
|
+
...checked,
|
|
70
|
+
url
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
export { PushMessageError, maxTtlSeconds, pushMessage, urgencies };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DeviceStore } from "./device-store.js";
|
|
2
|
+
import { PushSender } from "./sender.js";
|
|
2
3
|
//#region src/notifications/options.d.ts
|
|
3
4
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
4
5
|
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
@@ -16,5 +17,10 @@ export type NotificationsOptions = {
|
|
|
16
17
|
};
|
|
17
18
|
/** Keeps the devices somewhere else than the devices table, which is then not needed. */
|
|
18
19
|
store?: DeviceStore;
|
|
20
|
+
/**
|
|
21
|
+
* Deliver notifications in place of the built-in senders, such as senders from a package; at most one for each kind
|
|
22
|
+
* of device. By default the built-in ones whose environment is set: Web Push with the `PROTOBASE_VAPID_*` variables.
|
|
23
|
+
*/
|
|
24
|
+
senders?: PushSender[];
|
|
19
25
|
};
|
|
20
26
|
//#endregion
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkDeviceStore } from "./device-store.js";
|
|
2
|
+
import { checkSenders } from "./sender.js";
|
|
2
3
|
//#region src/notifications/options.ts
|
|
3
4
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
4
5
|
const deviceColumns = [
|
|
@@ -32,11 +33,12 @@ const notificationsNames = (options = {}) => {
|
|
|
32
33
|
columns
|
|
33
34
|
};
|
|
34
35
|
};
|
|
35
|
-
/** The options, checked as `notifications()` takes them: the names,
|
|
36
|
+
/** The options, checked as `notifications()` takes them: the names, a given store's methods and the senders given. */
|
|
36
37
|
const checkNotificationsOptions = (options) => {
|
|
37
38
|
if (options.ui !== void 0 && typeof options.ui !== "boolean") throw new Error("notifications ui is true or false");
|
|
38
39
|
notificationsNames(options);
|
|
39
40
|
if (options.store !== void 0) checkDeviceStore(options.store);
|
|
41
|
+
if (options.senders !== void 0) checkSenders(options.senders);
|
|
40
42
|
return options;
|
|
41
43
|
};
|
|
42
44
|
//#endregion
|
|
@@ -4,7 +4,8 @@ import { ProjectConfig } from "../project-config.js";
|
|
|
4
4
|
/**
|
|
5
5
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
6
6
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
7
|
-
* through which a signed-in user registers and removes their own (`devicesPath`).
|
|
7
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It delivers through `senders`, by
|
|
8
|
+
* default the built-in ones whose environment is set (see `notificationSenders`).
|
|
8
9
|
*/
|
|
9
10
|
export declare const notifications: (options?: NotificationsOptions) => ProjectConfig;
|
|
10
11
|
//#endregion
|
|
@@ -3,7 +3,8 @@ import { checkNotificationsOptions } from "./options.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Notifications: `extends: [notifications()]` in `protobase.config.ts`. It keeps each user's devices, in the
|
|
5
5
|
* `notifications.devices` table that `protobase migration` writes or in the `store` given, and serves the endpoints
|
|
6
|
-
* through which a signed-in user registers and removes their own (`devicesPath`).
|
|
6
|
+
* through which a signed-in user registers and removes their own (`devicesPath`). It delivers through `senders`, by
|
|
7
|
+
* default the built-in ones whose environment is set (see `notificationSenders`).
|
|
7
8
|
*/
|
|
8
9
|
const notifications = (options = {}) => ({
|
|
9
10
|
name: "protobase-notifications",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Device, DeviceKind } from "./device.js";
|
|
2
|
+
import { PushMessage } from "./message.js";
|
|
3
|
+
//#region src/notifications/sender.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a sender answers for one device: `ok` when the push service took the message, `gone` when the device no longer
|
|
6
|
+
* exists there and should be removed, `retry` when it may take the message later, after `retryAfterSeconds` when the
|
|
7
|
+
* push service says.
|
|
8
|
+
*/
|
|
9
|
+
export type PushAnswer = {
|
|
10
|
+
id: string;
|
|
11
|
+
result: 'ok' | 'gone' | 'retry';
|
|
12
|
+
retryAfterSeconds?: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Delivers notifications to the devices of its `kinds`, through their push service: the built-in Web Push sender, or
|
|
16
|
+
* one of the app's own passed as `notifications({ senders })`, typically from a package. `send` takes a message and a
|
|
17
|
+
* batch of devices of those kinds only, and answers each device by its id. A sender for `web` devices names the VAPID
|
|
18
|
+
* `publicKey` browsers subscribe with, which every client may read.
|
|
19
|
+
*/
|
|
20
|
+
export type PushSender = {
|
|
21
|
+
kinds: readonly DeviceKind[];
|
|
22
|
+
publicKey?: string;
|
|
23
|
+
send: (message: PushMessage, devices: readonly Device[]) => Promise<PushAnswer[]>;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { deviceKinds } from "./device.js";
|
|
2
|
+
import { isPublicKey } from "./web-push/keys.js";
|
|
3
|
+
//#region src/notifications/sender.ts
|
|
4
|
+
const checkSender = (sender, index, claimed) => {
|
|
5
|
+
const name = `notifications({ senders }): sender ${index}`;
|
|
6
|
+
if (typeof sender !== "object" || sender === null || typeof sender.send !== "function") throw new Error(`${name} has no send method`);
|
|
7
|
+
if (!Array.isArray(sender.kinds) || sender.kinds.length === 0) throw new Error(`${name} names no kinds: ${deviceKinds.join(", ")}`);
|
|
8
|
+
for (const kind of sender.kinds) {
|
|
9
|
+
if (!deviceKinds.includes(kind)) throw new Error(`${name} names a kind that is none of ${deviceKinds.join(", ")}`);
|
|
10
|
+
if (claimed.has(kind)) throw new Error(`${name} delivers to ${kind} devices, as an earlier sender does`);
|
|
11
|
+
claimed.add(kind);
|
|
12
|
+
}
|
|
13
|
+
if (sender.publicKey !== void 0 && !isPublicKey(sender.publicKey)) throw new Error(`${name} has a publicKey that is not a P-256 public key in base64url`);
|
|
14
|
+
};
|
|
15
|
+
/** The senders, checked: each with a send method and kinds, no kind delivered by two, and a public key when one is named. */
|
|
16
|
+
const checkSenders = (senders) => {
|
|
17
|
+
if (!Array.isArray(senders)) throw new Error("notifications({ senders }) is a list of senders");
|
|
18
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
19
|
+
senders.forEach((sender, index) => checkSender(sender, index, claimed));
|
|
20
|
+
return senders;
|
|
21
|
+
};
|
|
22
|
+
//#endregion
|
|
23
|
+
export { checkSenders };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PushSender } from "./sender.js";
|
|
2
|
+
import { NotificationsOptions } from "./options.js";
|
|
3
|
+
//#region src/notifications/senders-of.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The senders `notifications()` delivers with: the app's own `senders`, which replace the built-in ones, or the
|
|
6
|
+
* built-in ones whose environment is set. Each comes guarded (see `guardedSender`): the code that sends calls them only
|
|
7
|
+
* through what this returns. A VAPID variable that is wrong stops startup.
|
|
8
|
+
*/
|
|
9
|
+
export declare const notificationSenders: (options: NotificationsOptions, env?: Record<string, string | undefined>) => PushSender[];
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { checkSenders } from "./sender.js";
|
|
2
|
+
import { guardedSender } from "./guarded-sender.js";
|
|
3
|
+
import { readVapidSettings } from "./web-push/vapid-settings.js";
|
|
4
|
+
import { webPushSender } from "./web-push/web-push-sender.js";
|
|
5
|
+
//#region src/notifications/senders-of.ts
|
|
6
|
+
const builtInSenders = (env) => {
|
|
7
|
+
const vapid = readVapidSettings(env);
|
|
8
|
+
return vapid ? [webPushSender(vapid)] : [];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The senders `notifications()` delivers with: the app's own `senders`, which replace the built-in ones, or the
|
|
12
|
+
* built-in ones whose environment is set. Each comes guarded (see `guardedSender`): the code that sends calls them only
|
|
13
|
+
* through what this returns. A VAPID variable that is wrong stops startup.
|
|
14
|
+
*/
|
|
15
|
+
const notificationSenders = (options, env = globalThis.process?.env ?? {}) => checkSenders(options.senders ?? builtInSenders(env)).map(guardedSender);
|
|
16
|
+
/** The VAPID public key browsers subscribe with: the web sender's, when there is one. */
|
|
17
|
+
const webPushKey = (senders) => senders.find((sender) => sender.kinds.includes("web"))?.publicKey;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { notificationSenders, webPushKey };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/notifications/tap-url.ts
|
|
2
|
+
const plain = /^[^\s\u0000-\u001f\u007f-\u009f]+$/;
|
|
3
|
+
/**
|
|
4
|
+
* The URL a notification opens on tap, as the WHATWG parser writes it, or `undefined` when it is no absolute `http` or
|
|
5
|
+
* `https` URL: a relative or scheme-relative URL, `javascript:`, `data:`, `file:`, `blob:` and every other scheme are
|
|
6
|
+
* refused, since they would run or show content on tap. Any origin is allowed.
|
|
7
|
+
*/
|
|
8
|
+
const tapUrl = (value) => {
|
|
9
|
+
if (typeof value !== "string" || value.length > 2048 || !plain.test(value) || !URL.canParse(value)) return void 0;
|
|
10
|
+
const url = new URL(value);
|
|
11
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
12
|
+
return url.href;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { tapUrl };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createCipheriv, createECDH, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/encrypt.ts
|
|
3
|
+
/** The record size the body is one record of. */
|
|
4
|
+
const recordSize = 4096;
|
|
5
|
+
/** The most plaintext one message carries: push services take a body of at most 4096 bytes, of which the 86-byte header, the 16-byte tag and the padding delimiter are not plaintext. */
|
|
6
|
+
const maxPlaintextBytes = 3993;
|
|
7
|
+
const derived = (ikm, salt, info, length) => Buffer.from(hkdfSync("sha256", ikm, salt, info, length));
|
|
8
|
+
/**
|
|
9
|
+
* `plaintext` encrypted for one subscription as Web Push's aes128gcm (RFC 8291, RFC 8188): a fresh ephemeral key and a
|
|
10
|
+
* random 16-byte salt for every call, one record with its padding delimiter. `fixed` replaces both, for test vectors
|
|
11
|
+
* only. Throws for a plaintext over `maxPlaintextBytes`.
|
|
12
|
+
*/
|
|
13
|
+
const encryptPayload = (plaintext, keys, fixed) => {
|
|
14
|
+
if (plaintext.length > 3993) throw new Error(`A Web Push payload carries at most ${maxPlaintextBytes} bytes`);
|
|
15
|
+
const salt = fixed?.salt ?? randomBytes(16);
|
|
16
|
+
const ecdh = fixed?.ecdh ?? createECDH("prime256v1");
|
|
17
|
+
const serverPublic = fixed ? ecdh.getPublicKey() : ecdh.generateKeys();
|
|
18
|
+
const secret = ecdh.computeSecret(keys.p256dh);
|
|
19
|
+
const keyInfo = Buffer.concat([
|
|
20
|
+
Buffer.from("WebPush: info\0"),
|
|
21
|
+
keys.p256dh,
|
|
22
|
+
serverPublic
|
|
23
|
+
]);
|
|
24
|
+
const ikm = derived(secret, keys.auth, keyInfo, 32);
|
|
25
|
+
const contentKey = derived(ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16);
|
|
26
|
+
const nonce = derived(ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12);
|
|
27
|
+
const cipher = createCipheriv("aes-128-gcm", contentKey, nonce);
|
|
28
|
+
const ciphertext = Buffer.concat([
|
|
29
|
+
cipher.update(plaintext),
|
|
30
|
+
cipher.update(Buffer.from([2])),
|
|
31
|
+
cipher.final(),
|
|
32
|
+
cipher.getAuthTag()
|
|
33
|
+
]);
|
|
34
|
+
const size = Buffer.alloc(4);
|
|
35
|
+
size.writeUInt32BE(recordSize);
|
|
36
|
+
return Buffer.concat([
|
|
37
|
+
salt,
|
|
38
|
+
size,
|
|
39
|
+
Buffer.from([serverPublic.length]),
|
|
40
|
+
serverPublic,
|
|
41
|
+
ciphertext
|
|
42
|
+
]);
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { encryptPayload, maxPlaintextBytes };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/notifications/web-push/keys.d.ts
|
|
2
|
+
/** A fresh VAPID key pair, both in base64url, for `PROTOBASE_VAPID_PUBLIC_KEY` and `PROTOBASE_VAPID_PRIVATE_KEY`. */
|
|
3
|
+
export declare const generateVapidKeys: () => {
|
|
4
|
+
publicKey: string;
|
|
5
|
+
privateKey: string;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ECDH, createECDH } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/keys.ts
|
|
3
|
+
const curve = "prime256v1";
|
|
4
|
+
const base64url = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
/** The bytes of unpadded base64url text, or `undefined` for anything else. */
|
|
6
|
+
const fromBase64url = (text) => typeof text === "string" && base64url.test(text) ? Buffer.from(text, "base64url") : void 0;
|
|
7
|
+
/** Whether `bytes` is a P-256 public key, an uncompressed point on the curve, as browsers and VAPID give it. */
|
|
8
|
+
const isPublicPoint = (bytes) => {
|
|
9
|
+
if (bytes?.length !== 65 || bytes[0] !== 4) return false;
|
|
10
|
+
try {
|
|
11
|
+
ECDH.convertKey(bytes, curve);
|
|
12
|
+
return true;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof Error && "code" in error && error.code === "ERR_CRYPTO_OPERATION_FAILED") return false;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Whether `text` is a P-256 public key in base64url. */
|
|
19
|
+
const isPublicKey = (text) => isPublicPoint(fromBase64url(text));
|
|
20
|
+
/** The public key of a P-256 private key, or `undefined` when the bytes are none. */
|
|
21
|
+
const publicKeyOf = (privateKey) => {
|
|
22
|
+
if (privateKey.length !== 32) return void 0;
|
|
23
|
+
const ecdh = createECDH(curve);
|
|
24
|
+
try {
|
|
25
|
+
ecdh.setPrivateKey(privateKey);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof Error && "code" in error && error.code === "ERR_CRYPTO_INVALID_KEYTYPE") return void 0;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return ecdh.getPublicKey();
|
|
31
|
+
};
|
|
32
|
+
/** A private key as 32 bytes: the runtime gives a key with leading zero bytes shorter. */
|
|
33
|
+
const padPrivateKey = (privateKey) => Buffer.concat([Buffer.alloc(32 - privateKey.length), privateKey]);
|
|
34
|
+
/** A fresh VAPID key pair, both in base64url, for `PROTOBASE_VAPID_PUBLIC_KEY` and `PROTOBASE_VAPID_PRIVATE_KEY`. */
|
|
35
|
+
const generateVapidKeys = () => {
|
|
36
|
+
const ecdh = createECDH(curve);
|
|
37
|
+
return {
|
|
38
|
+
publicKey: ecdh.generateKeys().toString("base64url"),
|
|
39
|
+
privateKey: padPrivateKey(ecdh.getPrivateKey()).toString("base64url")
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { fromBase64url, generateVapidKeys, isPublicKey, isPublicPoint, padPrivateKey, publicKeyOf };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isJsonObject, mergeJson } from "../json-value.js";
|
|
2
|
+
import { tapUrl } from "../tap-url.js";
|
|
3
|
+
import { PushMessageError } from "../message.js";
|
|
4
|
+
//#region src/notifications/web-push/payload.ts
|
|
5
|
+
const checkedUrl = (value, field) => {
|
|
6
|
+
const url = tapUrl(value);
|
|
7
|
+
if (url === void 0) throw new PushMessageError(field);
|
|
8
|
+
return url;
|
|
9
|
+
};
|
|
10
|
+
const withCheckedUrls = (payload) => {
|
|
11
|
+
const checked = { ...payload };
|
|
12
|
+
if (checked.url !== void 0) checked.url = checkedUrl(checked.url, "platform.web.payload.url");
|
|
13
|
+
if (checked.notification === void 0) return checked;
|
|
14
|
+
if (!isJsonObject(checked.notification)) throw new PushMessageError("platform.web.payload.notification");
|
|
15
|
+
const notification = { ...checked.notification };
|
|
16
|
+
if (notification.navigate !== void 0) notification.navigate = checkedUrl(notification.navigate, "platform.web.payload.notification.navigate");
|
|
17
|
+
if (notification.actions !== void 0) {
|
|
18
|
+
if (!Array.isArray(notification.actions)) throw new PushMessageError("platform.web.payload.notification.actions");
|
|
19
|
+
notification.actions = notification.actions.map((action, index) => {
|
|
20
|
+
const field = `platform.web.payload.notification.actions.${index}`;
|
|
21
|
+
if (!isJsonObject(action)) throw new PushMessageError(field);
|
|
22
|
+
return action.navigate === void 0 ? action : {
|
|
23
|
+
...action,
|
|
24
|
+
navigate: checkedUrl(action.navigate, `${field}.navigate`)
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...checked,
|
|
30
|
+
notification
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The JSON a browser's service worker gets, from a checked message: its title, body, url, data, badge and tag, with
|
|
35
|
+
* `platform.web.payload` merged over them. Every url in it is checked again after the merge, so the payload never
|
|
36
|
+
* carries one that is no absolute `http` or `https` URL.
|
|
37
|
+
*/
|
|
38
|
+
const webPushPayload = (message) => {
|
|
39
|
+
const common = { title: message.title };
|
|
40
|
+
if (message.body !== void 0) common.body = message.body;
|
|
41
|
+
if (message.url !== void 0) common.url = message.url;
|
|
42
|
+
if (message.data !== void 0) common.data = message.data;
|
|
43
|
+
if (message.badge !== void 0) common.badge = message.badge;
|
|
44
|
+
if (message.tag !== void 0) common.tag = message.tag;
|
|
45
|
+
return withCheckedUrls(mergeJson(common, message.platform?.web?.payload ?? {}));
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { webPushPayload };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/notifications/web-push/post.ts
|
|
2
|
+
/** How long a push service has to answer before the device is answered `retry`. */
|
|
3
|
+
const defaultPushTimeoutMs = 1e4;
|
|
4
|
+
const retryAfter = (response) => {
|
|
5
|
+
const seconds = response.headers.get("retry-after")?.trim();
|
|
6
|
+
return seconds !== void 0 && /^\d{1,7}$/.test(seconds) ? {
|
|
7
|
+
result: "retry",
|
|
8
|
+
retryAfterSeconds: Number(seconds)
|
|
9
|
+
} : { result: "retry" };
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* What a push service's status means for the device: any 2xx took it; 404 and 410 say the subscription is gone; every
|
|
13
|
+
* other status, a redirect, a 429 or 5xx, and a 4xx that may be a configuration mistake (a wrong key), asks to try
|
|
14
|
+
* again, so no mistake of ours removes devices.
|
|
15
|
+
*/
|
|
16
|
+
const pushResult = (response) => {
|
|
17
|
+
if (response.status >= 200 && response.status < 300) return { result: "ok" };
|
|
18
|
+
if (response.status === 404 || response.status === 410) return { result: "gone" };
|
|
19
|
+
return retryAfter(response);
|
|
20
|
+
};
|
|
21
|
+
const isTransient = (error) => error instanceof TypeError || error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
22
|
+
/**
|
|
23
|
+
* Posts one Web Push request and answers what it means for the device. Redirects are never followed, so the request
|
|
24
|
+
* and its token reach only `url`, and an answer that does not come within the timeout is `retry`. Fetch's own errors
|
|
25
|
+
* are never passed on, since they can name the URL: one that is not a missing answer becomes an error of its own.
|
|
26
|
+
*/
|
|
27
|
+
const postPush = async (url, headers, body, options = {}) => {
|
|
28
|
+
const request = new Request(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers,
|
|
31
|
+
body,
|
|
32
|
+
redirect: "manual"
|
|
33
|
+
});
|
|
34
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 1e4);
|
|
35
|
+
const send = options.fetch ?? fetch;
|
|
36
|
+
let response;
|
|
37
|
+
try {
|
|
38
|
+
response = await send(request, { signal });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isTransient(error)) return { result: "retry" };
|
|
41
|
+
throw new Error("A Web Push request failed unexpectedly");
|
|
42
|
+
}
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
return pushResult(response);
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { defaultPushTimeoutMs, postPush, pushResult };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sign } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/vapid-jwt.ts
|
|
3
|
+
/** How long a token is valid: RFC 8292 allows at most 24 hours. */
|
|
4
|
+
const lifetimeSeconds = 43200;
|
|
5
|
+
/** A token is signed again once less than this is left of it, so none expires on its way. */
|
|
6
|
+
const refreshSeconds = 3600;
|
|
7
|
+
/** The push services' origins a sender keeps a token for: a handful, as there are a handful of push services. */
|
|
8
|
+
const maxAudiences = 16;
|
|
9
|
+
const encoded = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
10
|
+
const signed = (settings, audience, exp) => {
|
|
11
|
+
const input = `${encoded({
|
|
12
|
+
typ: "JWT",
|
|
13
|
+
alg: "ES256"
|
|
14
|
+
})}.${encoded({
|
|
15
|
+
aud: audience,
|
|
16
|
+
exp,
|
|
17
|
+
sub: settings.subject
|
|
18
|
+
})}`;
|
|
19
|
+
return `${input}.${sign("sha256", Buffer.from(input), {
|
|
20
|
+
key: settings.privateKey,
|
|
21
|
+
dsaEncoding: "ieee-p1363"
|
|
22
|
+
}).toString("base64url")}`;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The `Authorization` header of a Web Push request to a push service's origin (RFC 8292): `vapid t=<ES256 JWT>,
|
|
26
|
+
* k=<public key>`. A token is reused for its origin until an hour before it expires; at most 16 origins are kept, the
|
|
27
|
+
* least recently used dropped first. Each sender makes its own, so no two key pairs share a token.
|
|
28
|
+
*/
|
|
29
|
+
const vapidAuthorizer = (settings, now = Date.now) => {
|
|
30
|
+
const tokens = /* @__PURE__ */ new Map();
|
|
31
|
+
return (audience) => {
|
|
32
|
+
const seconds = Math.floor(now() / 1e3);
|
|
33
|
+
const kept = tokens.get(audience);
|
|
34
|
+
tokens.delete(audience);
|
|
35
|
+
const entry = kept && kept.exp - seconds > refreshSeconds ? kept : {
|
|
36
|
+
token: signed(settings, audience, seconds + lifetimeSeconds),
|
|
37
|
+
exp: seconds + lifetimeSeconds
|
|
38
|
+
};
|
|
39
|
+
tokens.set(audience, entry);
|
|
40
|
+
if (tokens.size > maxAudiences) tokens.delete(tokens.keys().next().value);
|
|
41
|
+
return `vapid t=${entry.token}, k=${settings.publicKey}`;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { vapidAuthorizer };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { KeyObject } from "node:crypto";
|
|
2
|
+
//#region src/notifications/web-push/vapid-settings.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The VAPID key pair and contact (RFC 8292) the Web Push sender signs with. The private key is a `KeyObject`, which
|
|
5
|
+
* prints no key material when logged or serialized.
|
|
6
|
+
*/
|
|
7
|
+
export type VapidSettings = {
|
|
8
|
+
publicKey: string;
|
|
9
|
+
privateKey: KeyObject;
|
|
10
|
+
subject: string;
|
|
11
|
+
};
|
|
12
|
+
export declare const vapidPublicKeyVariable = "PROTOBASE_VAPID_PUBLIC_KEY";
|
|
13
|
+
export declare const vapidPrivateKeyVariable = "PROTOBASE_VAPID_PRIVATE_KEY";
|
|
14
|
+
export declare const vapidSubjectVariable = "PROTOBASE_VAPID_SUBJECT";
|
|
15
|
+
/**
|
|
16
|
+
* The VAPID settings from `PROTOBASE_VAPID_PUBLIC_KEY`, `PROTOBASE_VAPID_PRIVATE_KEY` and `PROTOBASE_VAPID_SUBJECT`;
|
|
17
|
+
* `undefined` when none is set, which turns Web Push off. A blank variable counts as unset. Some set but not all, a key
|
|
18
|
+
* that is not one, a public key that is not the private key's, or a subject that is no `mailto:` or `https:` URL is a
|
|
19
|
+
* configuration error naming the variable, never its value, so the server stops at startup.
|
|
20
|
+
*/
|
|
21
|
+
export declare const readVapidSettings: (env: Record<string, string | undefined>) => VapidSettings | undefined;
|
|
22
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { fromBase64url, isPublicPoint, publicKeyOf } from "./keys.js";
|
|
2
|
+
import { createPrivateKey } from "node:crypto";
|
|
3
|
+
//#region src/notifications/web-push/vapid-settings.ts
|
|
4
|
+
const vapidPublicKeyVariable = "PROTOBASE_VAPID_PUBLIC_KEY";
|
|
5
|
+
const vapidPrivateKeyVariable = "PROTOBASE_VAPID_PRIVATE_KEY";
|
|
6
|
+
const vapidSubjectVariable = "PROTOBASE_VAPID_SUBJECT";
|
|
7
|
+
const variables = [
|
|
8
|
+
vapidPublicKeyVariable,
|
|
9
|
+
vapidPrivateKeyVariable,
|
|
10
|
+
vapidSubjectVariable
|
|
11
|
+
];
|
|
12
|
+
const isSubject = (subject) => {
|
|
13
|
+
if (/^mailto:[^\s@]+@[^\s@]+$/.test(subject)) return true;
|
|
14
|
+
return URL.canParse(subject) && new URL(subject).protocol === "https:" && !/\s/.test(subject);
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The VAPID settings from `PROTOBASE_VAPID_PUBLIC_KEY`, `PROTOBASE_VAPID_PRIVATE_KEY` and `PROTOBASE_VAPID_SUBJECT`;
|
|
18
|
+
* `undefined` when none is set, which turns Web Push off. A blank variable counts as unset. Some set but not all, a key
|
|
19
|
+
* that is not one, a public key that is not the private key's, or a subject that is no `mailto:` or `https:` URL is a
|
|
20
|
+
* configuration error naming the variable, never its value, so the server stops at startup.
|
|
21
|
+
*/
|
|
22
|
+
const readVapidSettings = (env) => {
|
|
23
|
+
const [publicText, privateText, subject] = variables.map((name) => env[name]?.trim() || void 0);
|
|
24
|
+
if (!publicText && !privateText && !subject) return void 0;
|
|
25
|
+
const missing = variables.filter((name) => !env[name]?.trim());
|
|
26
|
+
if (missing.length > 0) throw new Error(`${missing.join(" and ")} must be set for Web Push, with ${variables.filter((name) => !missing.includes(name)).join(" and ")}; set all three, or none to turn Web Push off`);
|
|
27
|
+
const publicKey = fromBase64url(publicText);
|
|
28
|
+
if (!isPublicPoint(publicKey)) throw new Error(`${vapidPublicKeyVariable} is not a P-256 public key in base64url; generate a pair with protobase notifications vapid-keys`);
|
|
29
|
+
const privateKey = fromBase64url(privateText);
|
|
30
|
+
const derived = privateKey && publicKeyOf(privateKey);
|
|
31
|
+
if (!derived) throw new Error(`${vapidPrivateKeyVariable} is not a P-256 private key in base64url; generate a pair with protobase notifications vapid-keys`);
|
|
32
|
+
if (!derived.equals(publicKey)) throw new Error(`${vapidPublicKeyVariable} is not the public key of ${vapidPrivateKeyVariable}; set both from one pair`);
|
|
33
|
+
if (!isSubject(subject)) throw new Error(`${vapidSubjectVariable} must be a mailto: address or an https: URL, such as mailto:admin@example.com`);
|
|
34
|
+
const jwk = {
|
|
35
|
+
kty: "EC",
|
|
36
|
+
crv: "P-256",
|
|
37
|
+
d: privateKey.toString("base64url"),
|
|
38
|
+
x: publicKey.subarray(1, 33).toString("base64url"),
|
|
39
|
+
y: publicKey.subarray(33).toString("base64url")
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
publicKey: publicKey.toString("base64url"),
|
|
43
|
+
privateKey: createPrivateKey({
|
|
44
|
+
key: jwk,
|
|
45
|
+
format: "jwk"
|
|
46
|
+
}),
|
|
47
|
+
subject
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { PushSender } from "../sender.js";
|
|
2
|
+
import { PostOptions } from "./post.js";
|
|
3
|
+
import { VapidSettings } from "./vapid-settings.js";
|
|
4
|
+
//#region src/notifications/web-push/web-push-sender.d.ts
|
|
5
|
+
export type WebPushOptions = PostOptions & {
|
|
6
|
+
now?: () => number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
10
|
+
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
11
|
+
* is not one. A device whose endpoint is not on a browser's push service, or whose keys are not a subscription's, is
|
|
12
|
+
* answered `gone` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
13
|
+
*/
|
|
14
|
+
export declare const webPushSender: (settings: VapidSettings, options?: WebPushOptions) => PushSender;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fromBase64url, isPublicPoint } from "./keys.js";
|
|
2
|
+
import { isPushEndpoint } from "../push-hosts.js";
|
|
3
|
+
import { PushMessageError, pushMessage } from "../message.js";
|
|
4
|
+
import { encryptPayload, maxPlaintextBytes } from "./encrypt.js";
|
|
5
|
+
import { webPushPayload } from "./payload.js";
|
|
6
|
+
import { postPush } from "./post.js";
|
|
7
|
+
import { vapidAuthorizer } from "./vapid-jwt.js";
|
|
8
|
+
//#region src/notifications/web-push/web-push-sender.ts
|
|
9
|
+
/** How many requests one batch has open at once. */
|
|
10
|
+
const concurrency = 8;
|
|
11
|
+
const endpointOf = (device) => isPushEndpoint(device.token) ? new URL(device.token) : void 0;
|
|
12
|
+
const keysOf = (device) => {
|
|
13
|
+
const p256dh = fromBase64url(device.p256dh);
|
|
14
|
+
const auth = fromBase64url(device.auth);
|
|
15
|
+
return isPublicPoint(p256dh) && auth?.length === 16 ? {
|
|
16
|
+
p256dh,
|
|
17
|
+
auth
|
|
18
|
+
} : void 0;
|
|
19
|
+
};
|
|
20
|
+
const headersOf = (message) => {
|
|
21
|
+
const web = message.platform?.web;
|
|
22
|
+
const urgency = web?.urgency ?? message.urgency;
|
|
23
|
+
const topic = web?.topic ?? message.tag;
|
|
24
|
+
return {
|
|
25
|
+
TTL: String(web?.ttl ?? message.ttl ?? 2419200),
|
|
26
|
+
...urgency !== void 0 && { Urgency: urgency },
|
|
27
|
+
...topic !== void 0 && { Topic: topic },
|
|
28
|
+
"Content-Type": "application/octet-stream",
|
|
29
|
+
"Content-Encoding": "aes128gcm"
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
34
|
+
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
35
|
+
* is not one. A device whose endpoint is not on a browser's push service, or whose keys are not a subscription's, is
|
|
36
|
+
* answered `gone` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
37
|
+
*/
|
|
38
|
+
const webPushSender = (settings, options = {}) => {
|
|
39
|
+
const authorization = vapidAuthorizer(settings, options.now);
|
|
40
|
+
const sendOne = async (device, headers, plaintext) => {
|
|
41
|
+
const url = endpointOf(device);
|
|
42
|
+
const keys = keysOf(device);
|
|
43
|
+
if (!url || !keys) return {
|
|
44
|
+
id: device.id,
|
|
45
|
+
result: "gone"
|
|
46
|
+
};
|
|
47
|
+
const body = encryptPayload(plaintext, keys);
|
|
48
|
+
const answer = await postPush(url, {
|
|
49
|
+
...headers,
|
|
50
|
+
Authorization: authorization(url.origin)
|
|
51
|
+
}, body, options);
|
|
52
|
+
return {
|
|
53
|
+
id: device.id,
|
|
54
|
+
...answer
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const send = async (given, devices) => {
|
|
58
|
+
const message = pushMessage(given);
|
|
59
|
+
if (devices.some((device) => device.kind !== "web")) throw new Error("The Web Push sender was given a device that is not a web device");
|
|
60
|
+
const headers = headersOf(message);
|
|
61
|
+
const plaintext = Buffer.from(JSON.stringify(webPushPayload(message)));
|
|
62
|
+
if (plaintext.length > 3993) throw new PushMessageError(`message: its Web Push payload is over ${maxPlaintextBytes} bytes`);
|
|
63
|
+
const answers = [];
|
|
64
|
+
for (let start = 0; start < devices.length; start += concurrency) answers.push(...await Promise.all(devices.slice(start, start + concurrency).map((device) => sendOne(device, headers, plaintext))));
|
|
65
|
+
return answers;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
kinds: ["web"],
|
|
69
|
+
publicKey: settings.publicKey,
|
|
70
|
+
send
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
export { webPushSender };
|
package/dist/package.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.17",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"@better-auth/passkey": "^1.7.12",
|
|
37
37
|
"@better-auth/sso": "^1.7.12",
|
|
38
38
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
39
|
-
"@protobase/layout": "0.45.
|
|
40
|
-
"@protobase/query": "0.45.
|
|
41
|
-
"@protobase/schema": "0.45.
|
|
39
|
+
"@protobase/layout": "0.45.17",
|
|
40
|
+
"@protobase/query": "0.45.17",
|
|
41
|
+
"@protobase/schema": "0.45.17",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|