@protobase/server 0.62.4 → 0.63.0

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 (61) hide show
  1. package/dist/better-auth/auth-settings.d.ts +4 -1
  2. package/dist/better-auth/create-auth.d.ts +14 -2
  3. package/dist/better-auth/create-auth.js +8 -0
  4. package/dist/better-auth/organizations/organization-role.js +13 -0
  5. package/dist/better-auth/status-route.js +8 -1
  6. package/dist/calls/call-hub.js +28 -7
  7. package/dist/calls/call-member.d.ts +5 -1
  8. package/dist/calls/call-reads.d.ts +7 -2
  9. package/dist/calls/call-reads.js +6 -4
  10. package/dist/calls/call-store.d.ts +7 -0
  11. package/dist/calls/call-store.js +35 -24
  12. package/dist/calls/call-sweep.js +8 -6
  13. package/dist/calls/calls-access.js +7 -2
  14. package/dist/calls/calls-runtime.d.ts +26 -5
  15. package/dist/calls/calls-runtime.js +84 -19
  16. package/dist/calls/calls-service.js +4 -2
  17. package/dist/calls/calls-sql.d.ts +1 -1
  18. package/dist/calls/calls-sql.js +56 -5
  19. package/dist/calls/calls-store.js +2 -1
  20. package/dist/calls/client-message.js +27 -2
  21. package/dist/calls/guest-checks-sql.d.ts +14 -0
  22. package/dist/calls/guest-checks-sql.js +43 -0
  23. package/dist/calls/guest-limits.js +49 -0
  24. package/dist/calls/guest-links-sql.d.ts +16 -0
  25. package/dist/calls/guest-links-sql.js +78 -0
  26. package/dist/calls/guest-lobby-sql.d.ts +17 -0
  27. package/dist/calls/guest-lobby-sql.js +81 -0
  28. package/dist/calls/guest-name.js +10 -0
  29. package/dist/calls/guest-routes.js +97 -0
  30. package/dist/calls/guest-socket.js +58 -0
  31. package/dist/calls/guest-store.d.ts +48 -0
  32. package/dist/calls/guest-store.js +20 -0
  33. package/dist/calls/guest-tokens.js +17 -0
  34. package/dist/calls/member-relay.js +63 -5
  35. package/dist/calls/organization-guests-route.js +38 -0
  36. package/dist/calls/record-organization.js +24 -0
  37. package/dist/create-admin.js +23 -2
  38. package/dist/functions/define-function.d.ts +10 -0
  39. package/dist/functions/own-mailbox.js +9 -0
  40. package/dist/functions/routes.js +5 -2
  41. package/dist/functions/upgrade-caller.js +1 -1
  42. package/dist/index.d.ts +4 -2
  43. package/dist/index.js +3 -2
  44. package/dist/jobs/job-types.d.ts +1 -1
  45. package/dist/jobs/runtime.d.ts +1 -1
  46. package/dist/mail/composer-message.js +27 -0
  47. package/dist/mail/define-email.d.ts +7 -4
  48. package/dist/mail/define-email.js +3 -2
  49. package/dist/mail/{smtp-mailer.d.ts → mail-message.d.ts} +9 -1
  50. package/dist/mail/smtp-mailer.js +2 -8
  51. package/dist/package.js +1 -1
  52. package/dist/personal-mail/gmail.js +43 -0
  53. package/dist/personal-mail/linked-accounts.js +36 -0
  54. package/dist/personal-mail/personal-mail-error.d.ts +22 -0
  55. package/dist/personal-mail/personal-mail-error.js +25 -0
  56. package/dist/personal-mail/personal-mail-provider.d.ts +25 -0
  57. package/dist/personal-mail/personal-mail-providers.d.ts +8 -0
  58. package/dist/personal-mail/personal-mail-providers.js +16 -0
  59. package/dist/personal-mail/personal-mailer.js +26 -0
  60. package/dist/personal-mail/rfc822-message.js +15 -0
  61. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import { supabasePath } from "./supabase-paths.js";
33
33
  import { goTruePath } from "./gotrue/keys.js";
34
34
  import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
35
35
  import { parseRoles } from "./better-auth/parse-roles.js";
36
+ import { PersonalMailError } from "./personal-mail/personal-mail-error.js";
36
37
  import { passwordLength } from "./better-auth/new-account-password.js";
37
38
  import { readOperatorSettings } from "./better-auth/operator-provider.js";
38
39
  import { readPlatformSignIn } from "./better-auth/platform-sign-in.js";
@@ -67,6 +68,7 @@ import { historyIdPattern, historyLimits } from "./notifications/history.js";
67
68
  import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
68
69
  import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
69
70
  import { sendNotification } from "./notifications/send-notification.js";
71
+ import { capSocketMessages, honorSocketMessageCaps, socketMessageCapOf } from "./functions/socket-message-cap.js";
70
72
  import { maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
71
73
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
72
74
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
@@ -88,7 +90,6 @@ import { localFileStore } from "./files/local-files.js";
88
90
  import { s3FileStore } from "./files/s3-files.js";
89
91
  import { storageProvider } from "./files/storage-provider.js";
90
92
  import { isBackgroundWork, runInBackground, wantsLongQueries } from "./background-work.js";
91
- import { capSocketMessages, honorSocketMessageCaps, socketMessageCapOf } from "./functions/socket-message-cap.js";
92
93
  import { mapSettings, mapStyleUrl, mapStyleVariable } from "./maps/map-settings.js";
93
94
  import { beforeSignInPluginsVariable, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable } from "./plugins/plugin-settings.js";
94
95
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
@@ -132,4 +133,4 @@ import { enqueueJob } from "./jobs/declared-job.js";
132
133
  import { defineEmail } from "./mail/define-email.js";
133
134
  import { startJobs } from "./jobs/runtime.js";
134
135
  import { jobsDrainApp } from "./jobs/drain-route.js";
135
- export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, HttpProblem, MissingCredentials, ModelError, NotificationSendError, PushMessageError, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, callsMigration, capSocketMessages, changesMigration, channelSendsMigration, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkStatusSchema, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, 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, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, onDoNotDisturb, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, sqlSends, startErrorReporting, startJobs, statusMigration, statusNames, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
136
+ export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, HttpProblem, MissingCredentials, ModelError, NotificationSendError, PersonalMailError, PushMessageError, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, callsMigration, capSocketMessages, changesMigration, channelSendsMigration, checkAi, checkAuthSchema, checkCalls, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkStatusSchema, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, 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, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, onDoNotDisturb, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, sqlSends, startErrorReporting, startJobs, statusMigration, statusNames, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
@@ -1,6 +1,6 @@
1
1
  import { DecryptField } from "../encryption/decrypt-field-types.js";
2
2
  import { StandardSchema } from "../functions/read-json.js";
3
- import { Mailer } from "../mail/smtp-mailer.js";
3
+ import { Mailer } from "../mail/mail-message.js";
4
4
  import { SendNotification } from "../notifications/send-input.js";
5
5
  import { Db } from "@protobase/query";
6
6
  //#region src/jobs/job-types.d.ts
@@ -1,5 +1,5 @@
1
1
  import { FieldDecrypter } from "../encryption/decrypt-field-types.js";
2
- import { Mailer } from "../mail/smtp-mailer.js";
2
+ import { Mailer } from "../mail/mail-message.js";
3
3
  import { JobDefinition } from "./job-types.js";
4
4
  import { JobsEnqueuer } from "./current.js";
5
5
  import { Db } from "@protobase/query";
@@ -0,0 +1,27 @@
1
+ //#region src/mail/composer-message.ts
2
+ /**
3
+ * `message` as Nodemailer composes it: its header fields checked, and its attachments as their bytes alone, with
4
+ * Nodemailer's reads of a file path or URL turned off, so no attachment can name a file on the server or a URL to fetch.
5
+ */
6
+ const composerMessage = ({ attachments, ...message }) => {
7
+ checkHeaders(message.headers);
8
+ if (!attachments?.length) return message;
9
+ return {
10
+ ...message,
11
+ attachments: attachments.map(({ filename, content, contentType }) => ({
12
+ filename,
13
+ content: Buffer.from(content),
14
+ ...contentType && { contentType }
15
+ })),
16
+ disableFileAccess: true,
17
+ disableUrlAccess: true
18
+ };
19
+ };
20
+ const checkHeaders = (headers = {}) => {
21
+ for (const [name, value] of Object.entries(headers)) {
22
+ if (!/^[!-9;-~]+$/.test(name)) throw new Error(`Mail header name "${name}" is not a valid header field name`);
23
+ if (/[\r\n]/.test(value)) throw new Error(`Mail header ${name} has a line break; a header value is one line`);
24
+ }
25
+ };
26
+ //#endregion
27
+ export { composerMessage };
@@ -1,4 +1,4 @@
1
- import { Mailer } from "./smtp-mailer.js";
1
+ import { MailAttachment, Mailer } from "./mail-message.js";
2
2
  //#region src/mail/define-email.d.ts
3
3
  export type EmailOptions<Data extends object> = {
4
4
  /** Names the email in errors, such as `welcome` or `listing-alert`. */
@@ -32,10 +32,13 @@ export type EmailTemplate<Data extends object> = {
32
32
  render(data: Data): Promise<RenderedEmail>;
33
33
  /**
34
34
  * Renders the email for `data` and sends it to `to`: through `mailer` when given, such as a job's `context.mail`,
35
- * otherwise through the app's SMTP server (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`). Rejects when mail is off
36
- * or the server refuses the message.
35
+ * or a function's `context.personalMail`, the caller's own mailbox, otherwise through the app's SMTP server
36
+ * (`PROTOBASE_SMTP_URL`, `PROTOBASE_MAIL_FROM`), with the `attachments` given. Rejects when mail is off or the server
37
+ * refuses the message.
37
38
  */
38
- send(to: string, data: Data, mailer?: Mailer): Promise<void>;
39
+ send(to: string, data: Data, mailer?: Mailer, extra?: {
40
+ attachments?: MailAttachment[];
41
+ }): Promise<void>;
39
42
  };
40
43
  /**
41
44
  * An email the app sends, with typed data: its subject and a React component for its body. Protobase renders it to
@@ -27,11 +27,12 @@ const defineEmail = (options) => {
27
27
  ...headers && { headers }
28
28
  };
29
29
  };
30
- const send = async (to, data, mailer = platformMailer()) => {
30
+ const send = async (to, data, mailer = platformMailer(), { attachments } = {}) => {
31
31
  if (!mailer) throw new Error(`Email "${options.name}" cannot be sent: mail is off; set ${smtpUrlVariable} and ${mailFromVariable}`);
32
32
  await mailer.send({
33
33
  to,
34
- ...await render(data)
34
+ ...await render(data),
35
+ ...attachments && attachments.length > 0 && { attachments }
35
36
  });
36
37
  };
37
38
  return {
@@ -1,4 +1,4 @@
1
- //#region src/mail/smtp-mailer.d.ts
1
+ //#region src/mail/mail-message.d.ts
2
2
  export type MailMessage = {
3
3
  to: string;
4
4
  subject: string;
@@ -11,6 +11,14 @@ export type MailMessage = {
11
11
  * of bulk mail. A value is one line.
12
12
  */
13
13
  headers?: Record<string, string>;
14
+ /** Files sent with the message, such as an invoice's PDF. */
15
+ attachments?: MailAttachment[];
16
+ };
17
+ /** A file sent with a message: its name as the recipient sees it, its bytes, and its media type, else one its name implies. */
18
+ export type MailAttachment = {
19
+ filename: string;
20
+ content: Uint8Array;
21
+ contentType?: string;
14
22
  };
15
23
  /** Sends one message; rejects when the server refuses it. */
16
24
  export type Mailer = {
@@ -1,3 +1,4 @@
1
+ import { composerMessage } from "./composer-message.js";
1
2
  //#region src/mail/smtp-mailer.ts
2
3
  /**
3
4
  * Mail over SMTP with Nodemailer: the most used Node SMTP client, maintained, with no dependencies of its
@@ -10,14 +11,13 @@
10
11
  const smtpMailer = (settings) => {
11
12
  let transport;
12
13
  return { send: async (message) => {
13
- checkHeaders(message.headers);
14
14
  transport ??= import("nodemailer").then(({ createTransport }) => createTransport({
15
15
  url: settings.smtpUrl,
16
16
  name: senderDomain(settings.from)
17
17
  }));
18
18
  await (await transport).sendMail({
19
19
  from: sender(settings),
20
- ...message
20
+ ...composerMessage(message)
21
21
  });
22
22
  } };
23
23
  };
@@ -27,11 +27,5 @@ const sender = ({ from, name }) => name ? {
27
27
  } : from;
28
28
  /** The domain of a sender address `readMailSettings` accepted, so always a dotted hostname. */
29
29
  const senderDomain = (from) => from.slice(from.lastIndexOf("@") + 1);
30
- const checkHeaders = (headers = {}) => {
31
- for (const [name, value] of Object.entries(headers)) {
32
- if (!/^[!-9;-~]+$/.test(name)) throw new Error(`Mail header name "${name}" is not a valid header field name`);
33
- if (/[\r\n]/.test(value)) throw new Error(`Mail header ${name} has a line break; a header value is one line`);
34
- }
35
- };
36
30
  //#endregion
37
31
  export { smtpMailer };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.62.4";
2
+ var version = "0.63.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,43 @@
1
+ import { PersonalMailError } from "./personal-mail-error.js";
2
+ import { rfc822Message } from "./rfc822-message.js";
3
+ //#region src/personal-mail/gmail.ts
4
+ /** The Gmail API's scope that sends mail and nothing else: no reading of the mailbox. */
5
+ const gmailSendScope = "https://www.googleapis.com/auth/gmail.send";
6
+ /** Its upload endpoint takes the message as it is, up to 35 MB, where the JSON one takes it base64-encoded. */
7
+ const gmailSendUrl = "https://gmail.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media";
8
+ /**
9
+ * Gmail, linked through Google sign-in: offline access, so Google gives a refresh token, asked with `prompt=consent`
10
+ * so it gives one again on a second link. Gmail sets the From header to the linked address.
11
+ */
12
+ const gmail = (fetcher = (...args) => fetch(...args)) => ({
13
+ id: "gmail",
14
+ name: "Gmail",
15
+ signInProvider: "google",
16
+ scopes: [gmailSendScope],
17
+ linkParams: {
18
+ access_type: "offline",
19
+ prompt: "consent"
20
+ },
21
+ send: async (message, accessToken) => {
22
+ const body = await rfc822Message(message);
23
+ const response = await fetcher(gmailSendUrl, {
24
+ method: "POST",
25
+ headers: {
26
+ authorization: `Bearer ${accessToken}`,
27
+ "content-type": "message/rfc822"
28
+ },
29
+ body: new Uint8Array(body)
30
+ });
31
+ if (response.ok) return;
32
+ if (response.status === 401 || response.status === 403 && await gmailReason(response) === "insufficientPermissions") throw new PersonalMailError("link_expired", "gmail");
33
+ throw new Error(`Gmail refused the message: ${response.status} ${response.statusText}`);
34
+ }
35
+ });
36
+ const gmailReason = async (response) => {
37
+ if (!response.headers.get("content-type")?.includes("json")) return;
38
+ const body = await response.json();
39
+ const reasons = [...body.error?.errors ?? [], ...body.error?.details ?? []].map((entry) => entry.reason);
40
+ return reasons.includes("insufficientPermissions") || reasons.includes("ACCESS_TOKEN_SCOPE_INSUFFICIENT") ? "insufficientPermissions" : reasons[0];
41
+ };
42
+ //#endregion
43
+ export { gmail, gmailSendScope, gmailSendUrl };
@@ -0,0 +1,36 @@
1
+ import { PersonalMailError } from "./personal-mail-error.js";
2
+ import { APIError } from "better-auth/api";
3
+ //#region src/personal-mail/linked-accounts.ts
4
+ const storedScopes = (scope) => (scope ?? "").split(/[\s,]+/).filter(Boolean);
5
+ /**
6
+ * The accounts Better Auth keeps, with the tokens it stores, decrypted when `encryptOAuthTokens` is on. Its
7
+ * `getAccessToken` refreshes a token within five seconds of expiry with the provider's refresh token and stores the new
8
+ * one; when the provider refuses the refresh, as for a revoked grant, that is `link_expired`.
9
+ */
10
+ const betterAuthLinkedAccounts = (auth) => ({
11
+ accounts: async (userId) => {
12
+ return (await (await auth.$context).internalAdapter.findAccounts(userId)).map((account) => ({
13
+ id: account.id,
14
+ providerId: account.providerId,
15
+ scopes: storedScopes(account.scope)
16
+ }));
17
+ },
18
+ accessToken: async (userId, account, provider) => {
19
+ const accessToken = await validAccessToken(auth, userId, account.id, provider);
20
+ if (!accessToken) throw new PersonalMailError("link_expired", provider);
21
+ return accessToken;
22
+ }
23
+ });
24
+ const validAccessToken = async (auth, userId, accountId, provider) => {
25
+ try {
26
+ return (await auth.api.getAccessToken({ body: {
27
+ accountId,
28
+ userId
29
+ } })).accessToken;
30
+ } catch (error) {
31
+ if (error instanceof APIError && error.body?.code === "FAILED_TO_GET_ACCESS_TOKEN") throw new PersonalMailError("link_expired", provider);
32
+ throw error;
33
+ }
34
+ };
35
+ //#endregion
36
+ export { betterAuthLinkedAccounts };
@@ -0,0 +1,22 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ //#region src/personal-mail/personal-mail-error.d.ts
3
+ /**
4
+ * Why a message cannot go out from someone's own mailbox: `not_linked`, they linked no mail provider of the app's, or
5
+ * linked the sign-in account without allowing it to send mail; `link_expired`, the provider no longer honours the
6
+ * link, as when they revoked it or its refresh token expired, so they link it again.
7
+ */
8
+ export type PersonalMailErrorCode = 'not_linked' | 'link_expired';
9
+ /**
10
+ * Thrown when a message cannot go out from someone's own mailbox, and nothing was sent. Out of a function it answers as
11
+ * a 409 problem whose `code` and `provider` say which, so the app can offer to link the mailbox; in code,
12
+ * `error instanceof PersonalMailError` and its `code` tell.
13
+ */
14
+ export declare class PersonalMailError extends HttpProblem {
15
+ readonly code: PersonalMailErrorCode;
16
+ /** The mail provider at fault, such as `gmail`; none when nothing is linked. */
17
+ readonly provider?: string | undefined;
18
+ constructor(code: PersonalMailErrorCode,
19
+ /** The mail provider at fault, such as `gmail`; none when nothing is linked. */
20
+ provider?: string | undefined);
21
+ }
22
+ //#endregion
@@ -0,0 +1,25 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ //#region src/personal-mail/personal-mail-error.ts
3
+ const details = {
4
+ not_linked: "Link a mailbox to send mail from it",
5
+ link_expired: "The link to your mailbox has expired: link it again to send mail from it"
6
+ };
7
+ /**
8
+ * Thrown when a message cannot go out from someone's own mailbox, and nothing was sent. Out of a function it answers as
9
+ * a 409 problem whose `code` and `provider` say which, so the app can offer to link the mailbox; in code,
10
+ * `error instanceof PersonalMailError` and its `code` tell.
11
+ */
12
+ var PersonalMailError = class extends HttpProblem {
13
+ code;
14
+ provider;
15
+ constructor(code, provider) {
16
+ super(409, `personal-mail-${code.replace("_", "-")}`, "Conflict", details[code], {
17
+ code,
18
+ ...provider && { provider }
19
+ });
20
+ this.code = code;
21
+ this.provider = provider;
22
+ }
23
+ };
24
+ //#endregion
25
+ export { PersonalMailError };
@@ -0,0 +1,25 @@
1
+ import { MailMessage } from "../mail/mail-message.js";
2
+ //#region src/personal-mail/personal-mail-provider.d.ts
3
+ /**
4
+ * A mail provider people link their own mailbox with, through the app's sign-in provider of the same company: linking
5
+ * asks the sign-in provider for `scopes` with `linkParams`, the sign-in account Better Auth keeps holds the tokens, and
6
+ * `send` sends a message with its access token.
7
+ */
8
+ export type PersonalMailProvider = {
9
+ /** Its id, such as `gmail`, as `auth.personalMail` names it. */
10
+ id: string;
11
+ /** The name the account page shows, such as `Gmail`. */
12
+ name: string;
13
+ /** The sign-in provider whose account holds the tokens, such as `google`: one of the app's `socialProviders`. */
14
+ signInProvider: string;
15
+ /** The scopes linking asks for, which the account must hold to send. */
16
+ scopes: string[];
17
+ /** More authorization parameters of the link, such as those that ask for a refresh token. */
18
+ linkParams: Record<string, string>;
19
+ /**
20
+ * Sends `message` from the mailbox. Rejects with a `PersonalMailError` of `link_expired` when the provider refuses the
21
+ * token, and with an error naming the status for anything else it refuses.
22
+ */
23
+ send: (message: MailMessage, accessToken: string) => Promise<void>;
24
+ };
25
+ //#endregion
@@ -0,0 +1,8 @@
1
+ import { PersonalMailProvider } from "./personal-mail-provider.js";
2
+ //#region src/personal-mail/personal-mail-providers.d.ts
3
+ /** The mail providers Protobase links mailboxes with, by the id `auth.personalMail` names them with. */
4
+ export declare const personalMailProviders: {
5
+ gmail: (fetcher?: typeof fetch) => PersonalMailProvider;
6
+ };
7
+ export type PersonalMailProviderId = keyof typeof personalMailProviders;
8
+ //#endregion
@@ -0,0 +1,16 @@
1
+ import { gmail } from "./gmail.js";
2
+ //#region src/personal-mail/personal-mail-providers.ts
3
+ /** The mail providers Protobase links mailboxes with, by the id `auth.personalMail` names them with. */
4
+ const personalMailProviders = { gmail };
5
+ /**
6
+ * The providers of `ids`, each checked against the app's sign-in providers: linking one goes through its sign-in
7
+ * provider, so an app without it is a configuration error at startup.
8
+ */
9
+ const resolvePersonalMail = (ids, signInProviders) => ids.map((id) => {
10
+ if (!Object.hasOwn(personalMailProviders, id)) throw new Error(`auth.personalMail: "${id}" is not a mail provider; use one of ${Object.keys(personalMailProviders).join(", ")}`);
11
+ const provider = personalMailProviders[id]();
12
+ if (!signInProviders.includes(provider.signInProvider)) throw new Error(`auth.personalMail: ${provider.name} links through the sign-in provider "${provider.signInProvider}"; add it to auth.socialProviders`);
13
+ return provider;
14
+ });
15
+ //#endregion
16
+ export { personalMailProviders, resolvePersonalMail };
@@ -0,0 +1,26 @@
1
+ import { PersonalMailError } from "./personal-mail-error.js";
2
+ //#region src/personal-mail/personal-mailer.ts
3
+ /** The mail provider `userId` linked, of the app's `providers` in their order, with the account that holds its tokens. */
4
+ const linkedProvider = async (accounts, providers, userId) => {
5
+ const linked = await accounts.accounts(userId);
6
+ for (const provider of providers) {
7
+ const account = linked.find((candidate) => candidate.providerId === provider.signInProvider && provider.scopes.every((scope) => candidate.scopes.includes(scope)));
8
+ if (account) return {
9
+ provider,
10
+ account
11
+ };
12
+ }
13
+ };
14
+ /**
15
+ * Mail from `userId`'s own mailbox: each message goes out through the mail provider they linked, with an access token
16
+ * refreshed when it expired. Rejects with a `PersonalMailError` and sends nothing when they linked none (`not_linked`)
17
+ * or the provider no longer honours the link (`link_expired`).
18
+ */
19
+ const personalMailer = (accounts, providers, userId) => ({ send: async (message) => {
20
+ const linked = await linkedProvider(accounts, providers, userId);
21
+ if (!linked) throw new PersonalMailError("not_linked");
22
+ const accessToken = await accounts.accessToken(userId, linked.account, linked.provider.id);
23
+ await linked.provider.send(message, accessToken);
24
+ } });
25
+ //#endregion
26
+ export { linkedProvider, personalMailer };
@@ -0,0 +1,15 @@
1
+ import { composerMessage } from "../mail/composer-message.js";
2
+ //#region src/personal-mail/rfc822-message.ts
3
+ /**
4
+ * `message` as an RFC 5322 message, attachments and all, the way the SMTP mailer composes it, with Nodemailer's
5
+ * composer. Without `from`, the provider fills in the From header with the mailbox it sends from.
6
+ */
7
+ const rfc822Message = async (message, from) => {
8
+ const { default: MailComposer } = await import("nodemailer/lib/mail-composer");
9
+ return new MailComposer({
10
+ ...from && { from },
11
+ ...composerMessage(message)
12
+ }).compile().build();
13
+ };
14
+ //#endregion
15
+ export { rfc822Message };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.62.4",
3
+ "version": "0.63.0",
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": {
@@ -42,9 +42,9 @@
42
42
  "@better-auth/sso": "^1.7.12",
43
43
  "@livekit/protocol": "1.53.1",
44
44
  "@modelcontextprotocol/server": "^2.3.1",
45
- "@protobase/layout": "0.62.4",
46
- "@protobase/query": "0.62.4",
47
- "@protobase/schema": "0.62.4",
45
+ "@protobase/layout": "0.63.0",
46
+ "@protobase/query": "0.63.0",
47
+ "@protobase/schema": "0.63.0",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",