@protobase/server 0.45.16 → 0.45.18

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.
Files changed (54) hide show
  1. package/dist/create-admin.js +10 -1
  2. package/dist/deps.d.ts +2 -0
  3. package/dist/index.d.ts +15 -3
  4. package/dist/index.js +12 -2
  5. package/dist/meta.d.ts +4 -0
  6. package/dist/meta.js +1 -0
  7. package/dist/notifications/caller.js +6 -6
  8. package/dist/notifications/device.js +7 -1
  9. package/dist/notifications/guarded-sender.js +61 -0
  10. package/dist/notifications/history-cursor.js +40 -0
  11. package/dist/notifications/history-request.js +40 -0
  12. package/dist/notifications/history-routes.d.ts +6 -0
  13. package/dist/notifications/history-routes.js +84 -0
  14. package/dist/notifications/history-sql.js +31 -0
  15. package/dist/notifications/history-store-of.d.ts +13 -0
  16. package/dist/notifications/history-store-of.js +13 -0
  17. package/dist/notifications/history-store.d.ts +29 -0
  18. package/dist/notifications/history-store.js +39 -0
  19. package/dist/notifications/history-url.js +18 -0
  20. package/dist/notifications/history.d.ts +34 -0
  21. package/dist/notifications/history.js +41 -0
  22. package/dist/notifications/json-value.d.ts +8 -0
  23. package/dist/notifications/json-value.js +54 -0
  24. package/dist/notifications/message.d.ts +52 -0
  25. package/dist/notifications/message.js +74 -0
  26. package/dist/notifications/options.d.ts +17 -0
  27. package/dist/notifications/options.js +34 -13
  28. package/dist/notifications/paths.d.ts +10 -0
  29. package/dist/notifications/paths.js +16 -1
  30. package/dist/notifications/plugin.d.ts +4 -1
  31. package/dist/notifications/plugin.js +4 -1
  32. package/dist/notifications/postgres-history-store.d.ts +15 -0
  33. package/dist/notifications/postgres-history-store.js +91 -0
  34. package/dist/notifications/routes.js +2 -2
  35. package/dist/notifications/schema.d.ts +4 -8
  36. package/dist/notifications/schema.js +34 -11
  37. package/dist/notifications/sender.d.ts +25 -0
  38. package/dist/notifications/sender.js +23 -0
  39. package/dist/notifications/senders-of.d.ts +10 -0
  40. package/dist/notifications/senders-of.js +19 -0
  41. package/dist/notifications/tap-url.js +15 -0
  42. package/dist/notifications/web-push/encrypt.js +45 -0
  43. package/dist/notifications/web-push/keys.d.ts +7 -0
  44. package/dist/notifications/web-push/keys.js +43 -0
  45. package/dist/notifications/web-push/payload.js +48 -0
  46. package/dist/notifications/web-push/post.d.ts +6 -0
  47. package/dist/notifications/web-push/post.js +47 -0
  48. package/dist/notifications/web-push/vapid-jwt.js +45 -0
  49. package/dist/notifications/web-push/vapid-settings.d.ts +22 -0
  50. package/dist/notifications/web-push/vapid-settings.js +51 -0
  51. package/dist/notifications/web-push/web-push-sender.d.ts +15 -0
  52. package/dist/notifications/web-push/web-push-sender.js +74 -0
  53. package/dist/package.js +1 -1
  54. package/package.json +4 -4
@@ -52,8 +52,11 @@ import { createWebhooks } from "./webhooks/webhooks.js";
52
52
  import { webhookTable } from "./webhooks/table/webhook-table.js";
53
53
  import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
54
54
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
55
- import { devicesPath } from "./notifications/paths.js";
55
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
56
+ import { devicesPath, historyPath } from "./notifications/paths.js";
57
+ import { historyRoutes } from "./notifications/history-routes.js";
56
58
  import { deviceRoutes } from "./notifications/routes.js";
59
+ import { notificationSenders, webPushKey } from "./notifications/senders-of.js";
57
60
  import { visitorsBrowse, withAnonymous } from "./anonymous.js";
58
61
  import { checkBottomBars, checkShell } from "./check-shell.js";
59
62
  import { checkOwnerFields } from "./check-owners.js";
@@ -152,6 +155,8 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
152
155
  const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
153
156
  const errorReporting = errorReportingSettings(options.errorReporting, globalThis.process?.env ?? {});
154
157
  const maps = mapSettings(options.maps, globalThis.process?.env ?? {});
158
+ const senders = notifications ? notificationSenders(notifications) : [];
159
+ const pushKey = webPushKey(senders);
155
160
  const locale = localeSetting(options.locale);
156
161
  const plugins = pluginOptions(options.plugins, globalThis.process?.env ?? {}, bundlePlugins);
157
162
  const sources = sourceOptions(options.sources, globalThis.process?.env ?? {});
@@ -204,6 +209,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
204
209
  ...runtime && { runtime },
205
210
  ...errorReporting && { errorReporting },
206
211
  ...maps && { maps },
212
+ ...pushKey && { webPushKey: pushKey },
207
213
  ...locale && { locale },
208
214
  plugins,
209
215
  sources,
@@ -368,6 +374,9 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
368
374
  const devices = deviceStore(db, notifications);
369
375
  if (auth) removeDevicesWithUsers(auth, devices);
370
376
  app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices))));
377
+ const history = historyStore(db, notifications);
378
+ if (auth) removeHistoryWithUsers(auth, history);
379
+ app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
371
380
  }
372
381
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
373
382
  const feed = tableFeed({
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,7 +20,12 @@ 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 { DeviceColumn, NotificationsOptions } from "./notifications/options.js";
23
+ import { JsonObject, JsonValue } from "./notifications/json-value.js";
24
+ import { HistoryPosition, NewNotification, StoredNotification, historyIdPattern, historyLimits } from "./notifications/history.js";
25
+ import { HistoryStore, maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
26
+ import { PushMessage, PushMessageError, Urgency, WebPushFields, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
27
+ import { PushAnswer, PushSender } from "./notifications/sender.js";
28
+ import { DeviceColumn, HistoryColumn, NotificationsOptions } from "./notifications/options.js";
24
29
  import { EventData, RunContext, SubscriptionDefinition, SubscriptionEvent, SubscriptionOptions, subscription } from "./webhooks/subscription.js";
25
30
  import { Sharp, renderVariant, thumbHash } from "./files/images.js";
26
31
  import { FileProvider, FileStore, StoredObject } from "./files/store.js";
@@ -93,8 +98,15 @@ import { notifications } from "./notifications/plugin.js";
93
98
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
94
99
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
95
100
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
96
- import { deviceIdParam, devicesPath } from "./notifications/paths.js";
101
+ import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
97
102
  import { maxDevicesPerUser } from "./notifications/routes.js";
103
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
104
+ import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
105
+ import { defaultHistoryPage } from "./notifications/history-routes.js";
106
+ import { notificationSenders } from "./notifications/senders-of.js";
107
+ import { VapidSettings, readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
108
+ import { WebPushOptions, webPushSender } from "./notifications/web-push/web-push-sender.js";
109
+ import { generateVapidKeys } from "./notifications/web-push/keys.js";
98
110
  import { MemberInput, NewOrganization, addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
99
111
  import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
100
112
  import { fileConversationStore } from "./assistant/file-conversation-store.js";
@@ -146,4 +158,4 @@ import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.
146
158
  import { MobileRelease, ReleaseFile, ServedReleaseFile, composeRelease, releaseManifest } from "./mobile-release/release.js";
147
159
  import { MobileReleaseAppInput, OfferedRelease, mobileReleaseApp } from "./mobile-release/release-app.js";
148
160
  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 };
161
+ 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 HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, 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 NewNotification, 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 StoredNotification, 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, defaultHistoryPage, 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, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, 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, maxHistoryPage, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, 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, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeHistoryWithUsers, 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,20 @@ 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 { maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
60
+ import { generateVapidKeys } from "./notifications/web-push/keys.js";
59
61
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
60
62
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
61
- import { deviceIdParam, devicesPath } from "./notifications/paths.js";
63
+ import { historyIdPattern, historyLimits } from "./notifications/history.js";
64
+ import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
65
+ import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
66
+ import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
67
+ import { defaultHistoryPage } from "./notifications/history-routes.js";
62
68
  import { maxDevicesPerUser } from "./notifications/routes.js";
69
+ import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
70
+ import { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
71
+ import { webPushSender } from "./notifications/web-push/web-push-sender.js";
72
+ import { notificationSenders } from "./notifications/senders-of.js";
63
73
  import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
64
74
  import { storageCleanupSchedule } from "./files/cleanup-schedule.js";
65
75
  import { limitedFileStore, storeUsage } from "./files/limits.js";
@@ -110,4 +120,4 @@ import { jobsDrainApp } from "./jobs/drain-route.js";
110
120
  import { composeRelease, releaseManifest } from "./mobile-release/release.js";
111
121
  import { mobileReleaseApp } from "./mobile-release/release-app.js";
112
122
  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 };
123
+ export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, 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, defaultHistoryPage, 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, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, 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, maxHistoryPage, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, 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, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readSigningKey, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, recordOf, releaseManifest, removeDevicesWithUsers, removeHistoryWithUsers, 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,16 +1,16 @@
1
1
  import { forbidden } from "../problem.js";
2
2
  //#region src/notifications/caller.ts
3
3
  /**
4
- * The id of the user whose devices a request may register and remove: only someone signed in as themselves. Refused:
5
- * visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not mark it as
6
- * the user's own sign-in (`Session.signIn`), staff signed in as the user included.
4
+ * The id of the user whose devices and notification history a request may reach: only someone signed in as themselves.
5
+ * Refused: visitors, outside apps acting for the user (an MCP client), and every session whose authenticator does not
6
+ * mark it as the user's own sign-in (`Session.signIn`), staff signed in as the user included.
7
7
  */
8
- const ownDeviceUser = (session) => {
9
- const refuse = () => forbidden("own-sign-in-only", "Only someone signed in as themselves can change their devices");
8
+ const ownSignInUser = (session) => {
9
+ const refuse = () => forbidden("own-sign-in-only", "Only someone signed in as themselves can reach their devices and notifications");
10
10
  if (session.anonymous || session.client || session.signIn?.staff !== false) throw refuse();
11
11
  const userId = String(session.user.id);
12
12
  if (userId === "" || session.user.id === void 0 || session.user.id === null) throw refuse();
13
13
  return userId;
14
14
  };
15
15
  //#endregion
16
- export { ownDeviceUser };
16
+ export { ownSignInUser };
@@ -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,40 @@
1
+ import { badRequest } from "../problem.js";
2
+ import { historyIdPattern } from "./history.js";
3
+ //#region src/notifications/history-cursor.ts
4
+ const base64url = /^[A-Za-z0-9_-]{1,200}$/;
5
+ /** The cursor of the page after `position`: its time and id as base64url JSON, nothing of the user or the page size. */
6
+ const historyCursor = (position) => Buffer.from(JSON.stringify({
7
+ t: position.createdAt.getTime(),
8
+ i: position.id
9
+ })).toString("base64url");
10
+ const refused = () => badRequest("invalid-cursor", "The cursor is not one a history page gave");
11
+ const decoded = (cursor) => {
12
+ if (!base64url.test(cursor)) return void 0;
13
+ const bytes = Buffer.from(cursor, "base64url");
14
+ if (bytes.toString("base64url") !== cursor) return void 0;
15
+ const text = bytes.toString("utf8");
16
+ if (!text.startsWith("{")) return void 0;
17
+ return JSON.parse(text);
18
+ };
19
+ /** The position a cursor holds, or a 400 that says nothing of what was wrong with it: exactly a time and an entry id. */
20
+ const historyPosition = (cursor) => {
21
+ let value;
22
+ try {
23
+ value = decoded(cursor);
24
+ } catch (error) {
25
+ if (error instanceof SyntaxError) throw refused();
26
+ throw error;
27
+ }
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw refused();
29
+ const keys = Object.keys(value).sort();
30
+ const { t, i } = value;
31
+ if (keys.join() !== "i,t" || !Number.isSafeInteger(t) || t < 0 || typeof i !== "string" || !historyIdPattern.test(i)) throw refused();
32
+ const createdAt = new Date(t);
33
+ if (Number.isNaN(createdAt.getTime())) throw refused();
34
+ return {
35
+ createdAt,
36
+ id: i
37
+ };
38
+ };
39
+ //#endregion
40
+ export { historyCursor, historyPosition };
@@ -0,0 +1,40 @@
1
+ import { HttpProblem, badRequest } from "../problem.js";
2
+ import { bodyLimit } from "hono/body-limit";
3
+ //#region src/notifications/history-request.ts
4
+ /** The largest body a history request takes, in bytes. */
5
+ const maxHistoryBodyBytes = 1024;
6
+ /** Refuses a body that is not `application/json`, so no plain form posted from another site reaches a mark. */
7
+ const jsonOnly = async (c, next) => {
8
+ if (c.req.header("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") throw new HttpProblem(415, "json-only", "Unsupported Media Type", "Send application/json");
9
+ await next();
10
+ };
11
+ const limitedBody = bodyLimit({
12
+ maxSize: maxHistoryBodyBytes,
13
+ onError: () => {
14
+ throw new HttpProblem(413, "too-large", "Payload Too Large", `The body is larger than ${maxHistoryBodyBytes} bytes`);
15
+ }
16
+ });
17
+ /** The request's JSON body, `undefined` when it is empty, or a 400 when it is no JSON. */
18
+ const jsonBody = async (c) => {
19
+ const text = await c.req.text();
20
+ if (text.trim() === "") return void 0;
21
+ try {
22
+ return JSON.parse(text);
23
+ } catch (error) {
24
+ if (error instanceof SyntaxError) throw badRequest("invalid-json", "The body is not JSON");
25
+ throw error;
26
+ }
27
+ };
28
+ /** The query's parameters, each at most once, or a 400 naming the first that is none of `allowed`. */
29
+ const queryOf = (c, allowed) => {
30
+ const given = c.req.queries();
31
+ const query = {};
32
+ for (const [key, values] of Object.entries(given)) {
33
+ if (!allowed.includes(key)) throw badRequest("unknown-parameter", `This endpoint takes no parameter "${key.slice(0, 64)}"`);
34
+ if (values.length !== 1) throw badRequest("repeated-parameter", `The parameter "${key}" is given more than once`);
35
+ query[key] = values[0];
36
+ }
37
+ return query;
38
+ };
39
+ //#endregion
40
+ export { jsonBody, jsonOnly, limitedBody, maxHistoryBodyBytes, queryOf };
@@ -0,0 +1,6 @@
1
+ import "../types.js";
2
+ import { Hono } from "hono";
3
+ //#region src/notifications/history-routes.d.ts
4
+ /** How many entries a page of the history holds when the request names no `limit`. */
5
+ export declare const defaultHistoryPage = 20;
6
+ //#endregion
@@ -0,0 +1,84 @@
1
+ import { badRequest } from "../problem.js";
2
+ import "./history-store.js";
3
+ import { historyIdPattern } from "./history.js";
4
+ import { ownSignInUser } from "./caller.js";
5
+ import { historyCursor, historyPosition } from "./history-cursor.js";
6
+ import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
7
+ import { historyUrl } from "./history-url.js";
8
+ import "./paths.js";
9
+ import { Hono } from "hono";
10
+ import { z } from "zod";
11
+ //#region src/notifications/history-routes.ts
12
+ /** How many entries a page of the history holds when the request names no `limit`. */
13
+ const defaultHistoryPage = 20;
14
+ const ownSignIn = async (c, next) => {
15
+ c.set("historyUser", ownSignInUser(c.get("session")));
16
+ await next();
17
+ };
18
+ const noStore = { "Cache-Control": "no-store" };
19
+ const pageLimit = (limit) => {
20
+ if (limit === void 0) return 20;
21
+ const value = /^[1-9][0-9]{0,2}$/.test(limit) ? Number(limit) : 0;
22
+ if (value < 1 || value > 50) throw badRequest("invalid-limit", `The limit is a whole number from 1 to 50`);
23
+ return value;
24
+ };
25
+ const markAll = z.strictObject({ upTo: z.iso.datetime({ offset: true }) });
26
+ /**
27
+ * The caller's own notification history, mounted at `historyPath` behind the API's authenticator: `GET /` a page of
28
+ * it, newest first, `GET /unread` how many are unread, `POST /read` marks all read up to the newest the caller was
29
+ * shown, `POST /<id>/read` one. No request names a user: each reaches the signed-in user's own entries alone. `origins`
30
+ * are the app's own, which an absolute url may lead to.
31
+ */
32
+ const historyRoutes = (store, origins) => {
33
+ const app = new Hono();
34
+ const answer = (entry) => ({
35
+ id: entry.id,
36
+ title: entry.title,
37
+ body: entry.body,
38
+ url: historyUrl(entry.url, origins),
39
+ data: entry.data,
40
+ createdAt: entry.createdAt.toISOString(),
41
+ readAt: entry.readAt === null ? null : entry.readAt.toISOString()
42
+ });
43
+ app.get("/", ownSignIn, async (c) => {
44
+ const userId = c.get("historyUser");
45
+ const query = queryOf(c, ["cursor", "limit"]);
46
+ const limit = pageLimit(query.limit);
47
+ const before = query.cursor === void 0 ? void 0 : historyPosition(query.cursor);
48
+ const entries = await store.list(userId, {
49
+ ...before && { before },
50
+ limit
51
+ });
52
+ if (entries.length > limit || entries.some((entry) => entry.userId !== userId || !historyIdPattern.test(entry.id))) throw new Error("The history store answered list with entries that are not the caller's");
53
+ const last = entries.at(-1);
54
+ const next = last && entries.length === limit ? historyCursor(last) : null;
55
+ return c.json({
56
+ items: entries.map(answer),
57
+ next
58
+ }, 200, noStore);
59
+ });
60
+ app.get("/unread", ownSignIn, async (c) => {
61
+ queryOf(c, []);
62
+ const count = await store.countUnread(c.get("historyUser"));
63
+ if (!Number.isSafeInteger(count) || count < 0 || count > 100) throw new Error("The history store answered countUnread with no count");
64
+ return c.json({ count }, 200, noStore);
65
+ });
66
+ app.post("/read", ownSignIn, jsonOnly, limitedBody, async (c) => {
67
+ queryOf(c, []);
68
+ const parsed = markAll.safeParse(await jsonBody(c));
69
+ if (!parsed.success) throw badRequest("invalid-mark", "Send { \"upTo\": <createdAt of the newest notification shown> }");
70
+ await store.markAllRead(c.get("historyUser"), new Date(parsed.data.upTo));
71
+ return c.body(null, 204);
72
+ });
73
+ app.post(`/:id/read`, ownSignIn, jsonOnly, limitedBody, async (c) => {
74
+ queryOf(c, []);
75
+ const body = await jsonBody(c);
76
+ if (body !== void 0 && (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length > 0)) throw badRequest("invalid-mark", "Marking one read takes no body");
77
+ const id = c.req.param("id");
78
+ if (historyIdPattern.test(id)) await store.markRead(c.get("historyUser"), id);
79
+ return c.body(null, 204);
80
+ });
81
+ return app;
82
+ };
83
+ //#endregion
84
+ export { defaultHistoryPage, historyRoutes };
@@ -0,0 +1,31 @@
1
+ import { quoteIdent } from "../changes/capture-sql.js";
2
+ import { qualified } from "./schema-sql.js";
3
+ //#region src/notifications/history-sql.ts
4
+ /**
5
+ * The history table: with auth, its user column points at auth's users table and goes with the user. `read_at` is NULL
6
+ * while unread and nothing has a default. One index serves a user's newest-first pages, another their unread ones.
7
+ */
8
+ const historyTableSql = ({ schema, table, columns }, user) => {
9
+ const c = Object.fromEntries(Object.entries(columns).map(([name, value]) => [name, quoteIdent(value)]));
10
+ const history = qualified({
11
+ schema,
12
+ table
13
+ });
14
+ return [
15
+ `create schema if not exists ${quoteIdent(schema)}`,
16
+ `create table ${history} (
17
+ ${c.id} uuid primary key,
18
+ ${c.user_id} ${user.type} not null${user.references ? ` references ${user.references} on delete cascade` : ""},
19
+ ${c.title} text not null,
20
+ ${c.body} text,
21
+ ${c.url} text,
22
+ ${c.data} jsonb,
23
+ ${c.created_at} timestamptz not null,
24
+ ${c.read_at} timestamptz
25
+ )`,
26
+ `create index on ${history} (${c.user_id}, ${c.created_at} desc, ${c.id} desc)`,
27
+ `create index on ${history} (${c.user_id}, ${c.created_at}) where ${c.read_at} is null`
28
+ ];
29
+ };
30
+ //#endregion
31
+ export { historyTableSql };
@@ -0,0 +1,13 @@
1
+ import { AdminAuth } from "../better-auth/create-auth.js";
2
+ import { HistoryStore } from "./history-store.js";
3
+ import { NotificationsOptions } from "./options.js";
4
+ import { Db } from "@protobase/query";
5
+ //#region src/notifications/history-store-of.d.ts
6
+ /**
7
+ * The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
8
+ * that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
9
+ */
10
+ export declare const historyStore: (db: Db, options?: NotificationsOptions) => HistoryStore;
11
+ /** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
12
+ export declare const removeHistoryWithUsers: (auth: AdminAuth, store: HistoryStore) => void;
13
+ //#endregion
@@ -0,0 +1,13 @@
1
+ import { postgresHistoryStore } from "./postgres-history-store.js";
2
+ //#region src/notifications/history-store-of.ts
3
+ /**
4
+ * The history's store `notifications()` configures: the app's own `historyStore`, or the history table in `db`. An app
5
+ * that deletes users without Protobase's auth calls its `removeAllForUser` as it deletes one.
6
+ */
7
+ const historyStore = (db, options = {}) => options.historyStore ?? postgresHistoryStore(db, options);
8
+ /** Has `auth` remove a user's history from `store` when it deletes the user, before and once more after. */
9
+ const removeHistoryWithUsers = (auth, store) => {
10
+ auth.userDeletion.set("notifications-history", (userId) => store.removeAllForUser(userId));
11
+ };
12
+ //#endregion
13
+ export { historyStore, removeHistoryWithUsers };
@@ -0,0 +1,29 @@
1
+ import { HistoryPosition, NewNotification, StoredNotification } from "./history.js";
2
+ //#region src/notifications/history-store.d.ts
3
+ /** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
4
+ export declare const maxUnreadCount = 100;
5
+ /**
6
+ * Where each user's inbox notifications are kept: the Postgres default (`postgresHistoryStore`), or a store of the
7
+ * app's own passed as `notifications({ historyStore })`. Plain data in and out, so any store can implement it. Every
8
+ * method takes the user and touches that user's entries alone; each throws on an empty user id or id.
9
+ */
10
+ export type HistoryStore = {
11
+ /** Keeps the notification for `userId`, unread, created now, under a new id matching `historyIdPattern`. */
12
+ record: (userId: string, notification: NewNotification) => Promise<StoredNotification>;
13
+ /** At most `limit` of the user's entries, newest first by creation time then id, starting after `before`. */
14
+ list: (userId: string, page: {
15
+ before?: HistoryPosition;
16
+ limit: number;
17
+ }) => Promise<StoredNotification[]>;
18
+ /** How many of the user's entries are unread, counting up to `maxUnreadCount` at most. */
19
+ countUnread: (userId: string) => Promise<number>;
20
+ /** Marks the user's entry with this id read now, unless it was read already; false when the user has none such. */
21
+ markRead: (userId: string, id: string) => Promise<boolean>;
22
+ /** Marks the user's unread entries created at or before `upTo` read now; entries read already keep their time. */
23
+ markAllRead: (userId: string, upTo: Date) => Promise<void>;
24
+ /** Removes every entry of the user, as when the user is deleted. */
25
+ removeAllForUser: (userId: string) => Promise<void>;
26
+ };
27
+ /** The most entries `list` gives at once. */
28
+ export declare const maxHistoryPage = 50;
29
+ //#endregion
@@ -0,0 +1,39 @@
1
+ //#region src/notifications/history-store.ts
2
+ /** The most unread notifications `countUnread` counts: a bell shows "99+" past it. */
3
+ const maxUnreadCount = 100;
4
+ const methods = [
5
+ "record",
6
+ "list",
7
+ "countUnread",
8
+ "markRead",
9
+ "markAllRead",
10
+ "removeAllForUser"
11
+ ];
12
+ /** The store, checked to have every method; a missing one is an error naming it. */
13
+ const checkHistoryStore = (store) => {
14
+ const missing = methods.find((method) => typeof store[method] !== "function");
15
+ if (missing !== void 0) throw new Error(`notifications({ historyStore }): the history store has no ${missing} method`);
16
+ return store;
17
+ };
18
+ /** The most entries `list` gives at once. */
19
+ const maxHistoryPage = 50;
20
+ /** The value, refused when empty: no store call may be read as "every user". */
21
+ const requiredText = (name, value) => {
22
+ if (typeof value !== "string" || value === "") throw new Error(`The history store needs a ${name}`);
23
+ return value;
24
+ };
25
+ /** The time, refused when it is no valid `Date`. */
26
+ const requiredTime = (name, value) => {
27
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) throw new Error(`The history store needs a ${name}`);
28
+ return value;
29
+ };
30
+ /** The page asked for, refused unless its limit is a whole number from 1 to `maxHistoryPage` and its position whole. */
31
+ const requiredPage = (page) => {
32
+ if (typeof page !== "object" || page === null || !Number.isInteger(page.limit) || page.limit < 1 || page.limit > 50) throw new Error(`The history store needs a page limit from 1 to 50`);
33
+ if (page.before === void 0) return page;
34
+ requiredTime("position time", page.before.createdAt);
35
+ requiredText("position id", page.before.id);
36
+ return page;
37
+ };
38
+ //#endregion
39
+ export { checkHistoryStore, maxHistoryPage, maxUnreadCount, requiredPage, requiredText, requiredTime };
@@ -0,0 +1,18 @@
1
+ //#region src/notifications/history-url.ts
2
+ const ownPath = /^\/(?![/\\])[^\s\u0000-\u001f\u007f-\u009f]*$/;
3
+ const absolute = /^https?:\/\//i;
4
+ /**
5
+ * The url an entry of the history may lead to, as an answer shows it, or `null`: a path on the app, or an absolute
6
+ * `http` or `https` URL without user info whose origin is exactly one of `origins`, the app's own, written as the URL
7
+ * parser writes it. Anything else, another site, `javascript:`, `data:` or a scheme-relative URL, is `null`.
8
+ */
9
+ const historyUrl = (url, origins) => {
10
+ if (url === null || url.length > 2048) return null;
11
+ if (ownPath.test(url)) return url;
12
+ if (!absolute.test(url) || !URL.canParse(url) || /[\s\u0000-\u001f\u007f-\u009f]/.test(url)) return null;
13
+ const parsed = new URL(url);
14
+ if (parsed.username !== "" || parsed.password !== "") return null;
15
+ return origins.some((origin) => URL.canParse(origin) && new URL(origin).origin === parsed.origin) ? parsed.href : null;
16
+ };
17
+ //#endregion
18
+ export { historyUrl };