@protobase/server 0.45.19 → 0.45.21
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/better-auth/create-auth.d.ts +9 -20
- package/dist/better-auth/create-auth.js +9 -1
- package/dist/better-auth/end-replaced-session.d.ts +17 -0
- package/dist/better-auth/end-replaced-session.js +15 -0
- package/dist/better-auth/live-sessions.js +24 -0
- package/dist/create-admin.js +4 -2
- package/dist/deps.d.ts +2 -0
- package/dist/meta.d.ts +6 -2
- package/dist/meta.js +4 -1
- package/dist/notifications/caller.js +15 -1
- package/dist/notifications/deliver.js +16 -2
- package/dist/notifications/device-input.js +17 -6
- package/dist/notifications/device-store.d.ts +5 -3
- package/dist/notifications/device.d.ts +3 -1
- package/dist/notifications/history-routes.d.ts +1 -0
- package/dist/notifications/history-routes.js +3 -3
- package/dist/notifications/history.d.ts +3 -3
- package/dist/notifications/history.js +1 -2
- package/dist/notifications/options.d.ts +1 -1
- package/dist/notifications/options.js +1 -0
- package/dist/notifications/paths.d.ts +2 -16
- package/dist/notifications/paths.js +1 -21
- package/dist/notifications/postgres-store.js +4 -0
- package/dist/notifications/push-job.js +6 -4
- package/dist/notifications/routes.js +22 -10
- package/dist/notifications/schema-sql.js +7 -1
- package/dist/notifications/schema.d.ts +2 -1
- package/dist/notifications/schema.js +19 -9
- package/dist/notifications/send-input.d.ts +1 -0
- package/dist/notifications/web-push/web-push-sender.d.ts +1 -1
- package/dist/notifications/web-push/web-push-sender.js +8 -4
- package/dist/package.js +1 -1
- package/package.json +4 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { StaffSignIn } from "./staff-log.js";
|
|
2
2
|
import { RoleDefinitions, RolesInput } from "./organizations/role-definitions.js";
|
|
3
3
|
import { ConnectedApp } from "./mcp/connected-apps.js";
|
|
4
|
+
import { ReplacingContext } from "./end-replaced-session.js";
|
|
4
5
|
import { UserDeletionListener } from "./user-deletion.js";
|
|
5
6
|
import "./auth-base-path.js";
|
|
6
7
|
import { Mailer } from "../mail/smtp-mailer.js";
|
|
@@ -3752,7 +3753,7 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
3752
3753
|
userId: string;
|
|
3753
3754
|
expiresAt: Date;
|
|
3754
3755
|
token: string;
|
|
3755
|
-
ipAddress
|
|
3756
|
+
ipAddress?: string | null | undefined;
|
|
3756
3757
|
userAgent?: string | null | undefined;
|
|
3757
3758
|
};
|
|
3758
3759
|
user: Record<string, any> & {
|
|
@@ -8590,25 +8591,6 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
8590
8591
|
})[];
|
|
8591
8592
|
disabledPaths: string[];
|
|
8592
8593
|
databaseHooks: {
|
|
8593
|
-
session?: {
|
|
8594
|
-
create: {
|
|
8595
|
-
before: (session: {
|
|
8596
|
-
userId: string;
|
|
8597
|
-
activeOrganizationId?: string | null;
|
|
8598
|
-
} & Record<string, unknown>) => Promise<{
|
|
8599
|
-
data: {
|
|
8600
|
-
activeOrganizationId: string;
|
|
8601
|
-
userId: string;
|
|
8602
|
-
};
|
|
8603
|
-
} | undefined>;
|
|
8604
|
-
};
|
|
8605
|
-
update: {
|
|
8606
|
-
after: (session: {
|
|
8607
|
-
userId: string;
|
|
8608
|
-
activeOrganizationId?: string | null;
|
|
8609
|
-
}) => Promise<void>;
|
|
8610
|
-
};
|
|
8611
|
-
} | undefined;
|
|
8612
8594
|
user: {
|
|
8613
8595
|
create: {
|
|
8614
8596
|
after?: ((user: import("better-auth").User & Record<string, unknown>, context: import("better-auth").GenericEndpointContext | null) => Promise<void>) | undefined;
|
|
@@ -8654,6 +8636,13 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
|
|
|
8654
8636
|
}>;
|
|
8655
8637
|
};
|
|
8656
8638
|
};
|
|
8639
|
+
session: {
|
|
8640
|
+
create: {
|
|
8641
|
+
after: (session: {
|
|
8642
|
+
token: string;
|
|
8643
|
+
}, ctx: ReplacingContext | null) => Promise<void>;
|
|
8644
|
+
};
|
|
8645
|
+
};
|
|
8657
8646
|
};
|
|
8658
8647
|
rateLimit: {
|
|
8659
8648
|
enabled: true;
|
|
@@ -5,6 +5,7 @@ import { defaultSignInPolicy } from "./sign-in-policy.js";
|
|
|
5
5
|
import { roleDefinitions } from "./organizations/role-definitions.js";
|
|
6
6
|
import { signInPolicyPlugin } from "./policy-plugin.js";
|
|
7
7
|
import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
|
|
8
|
+
import { endReplacedSession } from "./end-replaced-session.js";
|
|
8
9
|
import { authBasePath } from "./auth-base-path.js";
|
|
9
10
|
import { mobileRequests } from "./mobile-requests.js";
|
|
10
11
|
import { passwordLength } from "./new-account-password.js";
|
|
@@ -43,6 +44,13 @@ import { betterAuth } from "better-auth";
|
|
|
43
44
|
import { admin, emailOTP, genericOAuth, jwt } from "better-auth/plugins";
|
|
44
45
|
import { adminAc, userAc } from "better-auth/plugins/admin/access";
|
|
45
46
|
//#region src/better-auth/create-auth.ts
|
|
47
|
+
const withEndedReplaced = (hooks) => ({
|
|
48
|
+
...hooks,
|
|
49
|
+
create: {
|
|
50
|
+
...hooks.create,
|
|
51
|
+
after: endReplacedSession
|
|
52
|
+
}
|
|
53
|
+
});
|
|
46
54
|
const platformOperator = () => readOperatorSettings(globalThis.process?.env ?? {});
|
|
47
55
|
const platformSignInSettings = () => readPlatformSignIn(globalThis.process?.env ?? {});
|
|
48
56
|
const platformOrigins = () => readOrigins(globalThis.process?.env ?? {});
|
|
@@ -244,7 +252,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
|
|
|
244
252
|
},
|
|
245
253
|
update: { before: async (user) => ({ data: confirmedOnUpdate(user, /* @__PURE__ */ new Date()) }) }
|
|
246
254
|
},
|
|
247
|
-
|
|
255
|
+
session: withEndedReplaced(organizations ? sessionOrganizationHooks(context) : {})
|
|
248
256
|
},
|
|
249
257
|
rateLimit: {
|
|
250
258
|
enabled: true,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/better-auth/end-replaced-session.d.ts
|
|
2
|
+
/** The part of Better Auth's endpoint context that ending a replaced session reads. */
|
|
3
|
+
export type ReplacingContext = {
|
|
4
|
+
getSignedCookie: (name: string, secret: string) => Promise<string | null | false>;
|
|
5
|
+
context: {
|
|
6
|
+
authCookies: {
|
|
7
|
+
sessionToken: {
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
secret: string;
|
|
12
|
+
internalAdapter: {
|
|
13
|
+
deleteSession: (token: string) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/better-auth/end-replaced-session.ts
|
|
2
|
+
/**
|
|
3
|
+
* Better Auth's `databaseHooks.session.create.after`: a sign-in in a browser ends the session that browser's cookie
|
|
4
|
+
* held, whoever it was for, as the new session's cookie replaces it. Without this the old session would stand until it
|
|
5
|
+
* expired, and with it the devices registered in it, which then kept getting their user's push in someone else's
|
|
6
|
+
* browser. A staff sign-in as someone ends it too: nothing restores the earlier session when it stops.
|
|
7
|
+
*/
|
|
8
|
+
const endReplacedSession = async (session, ctx) => {
|
|
9
|
+
if (!ctx) return;
|
|
10
|
+
const held = await ctx.getSignedCookie(ctx.context.authCookies.sessionToken.name, ctx.context.secret);
|
|
11
|
+
if (!held || held === session.token) return;
|
|
12
|
+
await ctx.context.internalAdapter.deleteSession(held);
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { endReplacedSession };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/better-auth/live-sessions.ts
|
|
2
|
+
/**
|
|
3
|
+
* Which of the sessions with these ids still stand, read from the store: each live one's id with its user's id. A
|
|
4
|
+
* session signed out, revoked, ended by a ban or a newer sign-in in the same browser, or expired is not among them.
|
|
5
|
+
* Reads by the session's id, never its token.
|
|
6
|
+
*/
|
|
7
|
+
const liveSessionsIn = async (context, ids, now = /* @__PURE__ */ new Date()) => {
|
|
8
|
+
const live = /* @__PURE__ */ new Map();
|
|
9
|
+
if (ids.length === 0) return live;
|
|
10
|
+
const rows = await context.adapter.findMany({
|
|
11
|
+
model: "session",
|
|
12
|
+
where: [{
|
|
13
|
+
field: "id",
|
|
14
|
+
operator: "in",
|
|
15
|
+
value: [...ids]
|
|
16
|
+
}]
|
|
17
|
+
});
|
|
18
|
+
for (const row of rows) if (ids.includes(row.id) && new Date(row.expiresAt) > now) live.set(row.id, String(row.userId));
|
|
19
|
+
return live;
|
|
20
|
+
};
|
|
21
|
+
/** `liveSessionsIn` on the store of this Better Auth instance. */
|
|
22
|
+
const liveSessions = (auth) => async (ids) => liveSessionsIn(await auth.$context, ids);
|
|
23
|
+
//#endregion
|
|
24
|
+
export { liveSessions, liveSessionsIn };
|
package/dist/create-admin.js
CHANGED
|
@@ -58,6 +58,7 @@ import { historyRoutes } from "./notifications/history-routes.js";
|
|
|
58
58
|
import { deviceRoutes } from "./notifications/routes.js";
|
|
59
59
|
import { notificationSenders, webPushKey } from "./notifications/senders-of.js";
|
|
60
60
|
import { pushJob } from "./notifications/push-job.js";
|
|
61
|
+
import { liveSessions } from "./better-auth/live-sessions.js";
|
|
61
62
|
import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
62
63
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
63
64
|
import { checkOwnerFields } from "./check-owners.js";
|
|
@@ -210,6 +211,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
210
211
|
...runtime && { runtime },
|
|
211
212
|
...errorReporting && { errorReporting },
|
|
212
213
|
...maps && { maps },
|
|
214
|
+
...notifications && notifications.ui !== false && { bell: true },
|
|
213
215
|
...pushKey && { webPushKey: pushKey },
|
|
214
216
|
...locale && { locale },
|
|
215
217
|
plugins,
|
|
@@ -374,7 +376,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
374
376
|
if (notifications) {
|
|
375
377
|
const devices = deviceStore(db, notifications);
|
|
376
378
|
if (auth) removeDevicesWithUsers(auth, devices);
|
|
377
|
-
app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices))));
|
|
379
|
+
app.route(devicesPath(systemPath), guarded((routes) => void routes.route("/", deviceRoutes(devices, { sessionRequired: Boolean(auth) }))));
|
|
378
380
|
const history = historyStore(db, notifications);
|
|
379
381
|
if (auth) removeHistoryWithUsers(auth, history);
|
|
380
382
|
app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
|
|
@@ -437,7 +439,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
437
439
|
...withHistory && days !== void 0 ? [pruneHistoryJob(changes, days)] : [],
|
|
438
440
|
...mentions ? [mentions] : [],
|
|
439
441
|
...fill ? [fill] : [],
|
|
440
|
-
...notifications ? [pushJob(notifications, senders)] : []
|
|
442
|
+
...notifications ? [pushJob(notifications, senders, auth && liveSessions(auth))] : []
|
|
441
443
|
] });
|
|
442
444
|
};
|
|
443
445
|
//#endregion
|
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
|
+
/** Whether the shell draws the notification bell: notifications are on and their `ui` is not false. */
|
|
51
|
+
bell?: boolean;
|
|
50
52
|
/** The VAPID public key browsers subscribe for Web Push with, when notifications have a web sender, which `/meta` names to everyone. */
|
|
51
53
|
webPushKey?: string;
|
|
52
54
|
/** The language the admin app's screens are in for everyone, which `/meta` names to everyone. */
|
package/dist/meta.d.ts
CHANGED
|
@@ -49,9 +49,13 @@ 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
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* With notifications: `bell` when the shell draws the bell, which `ui: false` turns off, and the VAPID public key
|
|
54
|
+
* browsers subscribe for Web Push with, when the app's notifications deliver to browsers: public, as any VAPID key is.
|
|
55
|
+
*/
|
|
53
56
|
notifications?: {
|
|
54
|
-
|
|
57
|
+
bell?: true;
|
|
58
|
+
webPushKey?: string;
|
|
55
59
|
};
|
|
56
60
|
/** The language the admin app's screens are in, when the app sets one for everyone. */
|
|
57
61
|
locale?: Locale;
|
package/dist/meta.js
CHANGED
|
@@ -90,7 +90,10 @@ 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: {
|
|
93
|
+
...(deps.bell || deps.webPushKey) && { notifications: {
|
|
94
|
+
...deps.bell && { bell: true },
|
|
95
|
+
...deps.webPushKey && { webPushKey: deps.webPushKey }
|
|
96
|
+
} },
|
|
94
97
|
...deps.locale && { locale: deps.locale },
|
|
95
98
|
...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
|
|
96
99
|
...plugins.length > 0 && { plugins },
|
|
@@ -12,5 +12,19 @@ const ownSignInUser = (session) => {
|
|
|
12
12
|
if (userId === "" || session.user.id === void 0 || session.user.id === null) throw refuse();
|
|
13
13
|
return userId;
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The caller of a device request: `ownSignInUser`'s user, and the id of the session they signed in with, when their
|
|
17
|
+
* authenticator names one. With `sessionRequired`, as with Protobase's auth, whose devices get push only while their
|
|
18
|
+
* session stands, a caller without one is refused too.
|
|
19
|
+
*/
|
|
20
|
+
const ownSignInDevice = (session, sessionRequired) => {
|
|
21
|
+
const userId = ownSignInUser(session);
|
|
22
|
+
const sessionId = session.signIn?.session;
|
|
23
|
+
if (sessionRequired && (typeof sessionId !== "string" || sessionId === "")) throw forbidden("own-sign-in-only", "Only someone signed in as themselves can reach their devices and notifications");
|
|
24
|
+
return {
|
|
25
|
+
userId,
|
|
26
|
+
sessionId: typeof sessionId === "string" && sessionId !== "" ? sessionId : null
|
|
27
|
+
};
|
|
28
|
+
};
|
|
15
29
|
//#endregion
|
|
16
|
-
export { ownSignInUser };
|
|
30
|
+
export { ownSignInDevice, ownSignInUser };
|
|
@@ -15,7 +15,8 @@ const listed = (counts) => [...counts].map(([method, count]) => `${count} in ${m
|
|
|
15
15
|
/**
|
|
16
16
|
* One try of a send, resuming from `given`: for a user send, each user's history entry when the send is for the inbox
|
|
17
17
|
* (once, as `record` marks it), then the user's devices as the store lists them now; for a device send, the devices
|
|
18
|
-
* with those ids now. A device the store gives that is not the user's, or not one asked for, gets nothing.
|
|
18
|
+
* with those ids now. A device the store gives that is not the user's, or not one asked for, gets nothing. With
|
|
19
|
+
* `liveSessions`, a device whose session no longer stands is removed instead of pushed to. Each kind's
|
|
19
20
|
* devices go to its sender in one batch; a kind without a sender is skipped. An answer of `gone` removes the device,
|
|
20
21
|
* `retry` leaves it to the next try once and gives up on it the second time, and any other is done. A sender that
|
|
21
22
|
* throws leaves its batch to the next try. The try then throws, counting what is left, so the job retries it, or
|
|
@@ -57,7 +58,20 @@ const deliver = async ({ to, message }, given, steps) => {
|
|
|
57
58
|
progress.retried.push(device.id);
|
|
58
59
|
left.retried += 1;
|
|
59
60
|
};
|
|
60
|
-
const
|
|
61
|
+
const standing = async (devices) => {
|
|
62
|
+
const { liveSessions } = steps;
|
|
63
|
+
if (!liveSessions) return devices;
|
|
64
|
+
const pending = devices.filter((device) => !done.has(device.id));
|
|
65
|
+
const live = await liveSessions([...new Set(pending.flatMap((device) => device.sessionId === null ? [] : [device.sessionId]))]);
|
|
66
|
+
const stands = (device) => device.sessionId !== null && live.get(device.sessionId) === device.userId;
|
|
67
|
+
for (const device of pending.filter((device) => !stands(device))) {
|
|
68
|
+
await storeCall("remove", () => steps.devices.remove(device.userId, device.id));
|
|
69
|
+
markDone(device.id);
|
|
70
|
+
}
|
|
71
|
+
return pending.filter(stands);
|
|
72
|
+
};
|
|
73
|
+
const push = async (given) => {
|
|
74
|
+
const devices = await standing(given);
|
|
61
75
|
for (const device of devices) if (!steps.senders.some((sender) => sender.kinds.includes(device.kind))) markDone(device.id);
|
|
62
76
|
for (const sender of steps.senders) {
|
|
63
77
|
const batch = devices.filter((device) => sender.kinds.includes(device.kind) && !done.has(device.id));
|
|
@@ -7,6 +7,7 @@ const text = (max) => z.string().min(1).max(max).regex(/^[^\u0000-\u001f\u007f]*
|
|
|
7
7
|
const base64url = (bytes) => z.string().max(200).regex(/^[A-Za-z0-9_-]+$/).refine((value) => Buffer.from(value, "base64url").length === bytes);
|
|
8
8
|
const appId = z.string().regex(/^[A-Za-z0-9._-]{1,155}$/);
|
|
9
9
|
const userAgent = text(512).optional();
|
|
10
|
+
const userId = text(255).optional();
|
|
10
11
|
const web = z.strictObject({
|
|
11
12
|
kind: z.literal("web"),
|
|
12
13
|
endpoint: text(2048).refine(isPushEndpoint),
|
|
@@ -14,20 +15,23 @@ const web = z.strictObject({
|
|
|
14
15
|
p256dh: base64url(65),
|
|
15
16
|
auth: base64url(16)
|
|
16
17
|
}),
|
|
17
|
-
userAgent
|
|
18
|
+
userAgent,
|
|
19
|
+
userId
|
|
18
20
|
});
|
|
19
21
|
const apns = z.strictObject({
|
|
20
22
|
kind: z.literal("apns"),
|
|
21
23
|
token: z.string().regex(/^[0-9a-f]{64,200}$/),
|
|
22
24
|
environment: z.enum(apnsEnvironments),
|
|
23
25
|
appId,
|
|
24
|
-
userAgent
|
|
26
|
+
userAgent,
|
|
27
|
+
userId
|
|
25
28
|
});
|
|
26
29
|
const fcm = z.strictObject({
|
|
27
30
|
kind: z.literal("fcm"),
|
|
28
31
|
token: z.string().regex(/^[A-Za-z0-9_:-]{1,4096}$/),
|
|
29
32
|
appId: appId.optional(),
|
|
30
|
-
userAgent
|
|
33
|
+
userAgent,
|
|
34
|
+
userId
|
|
31
35
|
});
|
|
32
36
|
const registration = z.discriminatedUnion("kind", [
|
|
33
37
|
web,
|
|
@@ -37,8 +41,9 @@ const registration = z.discriminatedUnion("kind", [
|
|
|
37
41
|
/** The largest register body, in bytes. */
|
|
38
42
|
const maxDeviceBodyBytes = 8192;
|
|
39
43
|
/**
|
|
40
|
-
* The device a register request's JSON body describes,
|
|
41
|
-
* value: one kind's fields only, each within its format and length, and
|
|
44
|
+
* The device a register request's JSON body describes, and the user it names when it names one, or a 400 that names
|
|
45
|
+
* the first field at fault and never its value: one kind's fields only, each within its format and length, and
|
|
46
|
+
* nothing else.
|
|
42
47
|
*/
|
|
43
48
|
const deviceInput = (body) => {
|
|
44
49
|
const parsed = registration.safeParse(body);
|
|
@@ -46,7 +51,13 @@ const deviceInput = (body) => {
|
|
|
46
51
|
const field = parsed.error.issues[0].path.join(".") || "body";
|
|
47
52
|
throw badRequest("invalid-device", `The device is invalid: ${field}`);
|
|
48
53
|
}
|
|
49
|
-
const
|
|
54
|
+
const { userId } = parsed.data;
|
|
55
|
+
return {
|
|
56
|
+
device: deviceOf(parsed.data),
|
|
57
|
+
...userId !== void 0 && { userId }
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const deviceOf = (device) => {
|
|
50
61
|
const base = { userAgent: device.userAgent ?? null };
|
|
51
62
|
if (device.kind === "web") return {
|
|
52
63
|
...base,
|
|
@@ -7,10 +7,12 @@ import { Device, NewDevice } from "./device.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export type DeviceStore = {
|
|
9
9
|
/**
|
|
10
|
-
* Registers the device for `userId`, or refreshes it when the user has it already
|
|
11
|
-
* to `userId` as a new device: a new id, its fields as given, created and last
|
|
10
|
+
* Registers the device for `userId`, or refreshes it when the user has it already, its session included. An APNs or
|
|
11
|
+
* FCM token another user holds moves to `userId` as a new device: a new id, its fields as given, created and last
|
|
12
|
+
* seen now. A browser's endpoint another user holds stays theirs, unchanged, and the answer is `undefined`: a browser
|
|
13
|
+
* can always subscribe afresh, so an endpoint never changes hands.
|
|
12
14
|
*/
|
|
13
|
-
register: (userId: string, device: NewDevice) => Promise<Device>;
|
|
15
|
+
register: (userId: string, device: NewDevice) => Promise<Device | undefined>;
|
|
14
16
|
/** Removes the device with this id when it is `userId`'s; another user's is left as it is. */
|
|
15
17
|
remove: (userId: string, id: string) => Promise<void>;
|
|
16
18
|
/** The user's devices. */
|
|
@@ -9,7 +9,8 @@ export type ApnsEnvironment = (typeof apnsEnvironments)[number];
|
|
|
9
9
|
* A device as a signed-in user registers it. `token` is unique among all devices: the APNs or FCM token, or for `web`
|
|
10
10
|
* the subscription's endpoint URL. A field that does not apply to the kind is `null`: the Web Push keys (`p256dh`,
|
|
11
11
|
* `auth`) for web only, the APNs `environment` for apns only, the `appId` (the iOS bundle id or Android package) for
|
|
12
|
-
* apns and fcm.
|
|
12
|
+
* apns and fcm. `sessionId` is the id of the sign-in session it was registered in, never its token, or `null` when the
|
|
13
|
+
* app's authenticator names none: with Protobase's auth, the device gets push only while that session stands.
|
|
13
14
|
*/
|
|
14
15
|
export type NewDevice = {
|
|
15
16
|
kind: DeviceKind;
|
|
@@ -19,6 +20,7 @@ export type NewDevice = {
|
|
|
19
20
|
environment: ApnsEnvironment | null;
|
|
20
21
|
appId: string | null;
|
|
21
22
|
userAgent: string | null;
|
|
23
|
+
sessionId: string | null;
|
|
22
24
|
};
|
|
23
25
|
/** A stored device: whose it is, its own id, when it was registered by that user and when last seen. */
|
|
24
26
|
export type Device = NewDevice & {
|
|
@@ -5,7 +5,7 @@ import { ownSignInUser } from "./caller.js";
|
|
|
5
5
|
import { historyCursor, historyPosition } from "./history-cursor.js";
|
|
6
6
|
import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
|
|
7
7
|
import { historyUrl } from "./history-url.js";
|
|
8
|
-
import "./paths.js";
|
|
8
|
+
import { historyIdParam } from "./paths.js";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
//#region src/notifications/history-routes.ts
|
|
@@ -70,11 +70,11 @@ const historyRoutes = (store, origins) => {
|
|
|
70
70
|
await store.markAllRead(c.get("historyUser"), new Date(parsed.data.upTo));
|
|
71
71
|
return c.body(null, 204);
|
|
72
72
|
});
|
|
73
|
-
app.post(
|
|
73
|
+
app.post(`/:${historyIdParam}/read`, ownSignIn, jsonOnly, limitedBody, async (c) => {
|
|
74
74
|
queryOf(c, []);
|
|
75
75
|
const body = await jsonBody(c);
|
|
76
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(
|
|
77
|
+
const id = c.req.param(historyIdParam);
|
|
78
78
|
if (historyIdPattern.test(id)) await store.markRead(c.get("historyUser"), id);
|
|
79
79
|
return c.body(null, 204);
|
|
80
80
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JsonObject } from "./json-value.js";
|
|
2
|
+
import { historyIdPattern } from "@protobase/schema/internal";
|
|
2
3
|
//#region src/notifications/history.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* A notification as the history keeps it for its user's inbox: what a panel shows of it. `title` and `body` are plain
|
|
@@ -22,8 +23,6 @@ export type HistoryPosition = {
|
|
|
22
23
|
createdAt: Date;
|
|
23
24
|
id: string;
|
|
24
25
|
};
|
|
25
|
-
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
26
|
-
export declare const historyIdPattern: RegExp;
|
|
27
26
|
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
28
27
|
export declare const historyLimits: {
|
|
29
28
|
readonly title: 256;
|
|
@@ -31,4 +30,5 @@ export declare const historyLimits: {
|
|
|
31
30
|
readonly url: 2048;
|
|
32
31
|
readonly dataBytes: 4096;
|
|
33
32
|
};
|
|
34
|
-
//#endregion
|
|
33
|
+
//#endregion
|
|
34
|
+
export { historyIdPattern };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isJsonObject, jsonProblem } from "./json-value.js";
|
|
2
|
+
import { historyIdPattern } from "@protobase/schema/internal";
|
|
2
3
|
//#region src/notifications/history.ts
|
|
3
|
-
/** What an entry id looks like in every history store: it goes into a path, so no `/`, `.`, `?`, `#` or `%`. */
|
|
4
|
-
const historyIdPattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
4
|
/** The longest title, body and url, as a push message takes them, and the most bytes of `data` as JSON. */
|
|
6
5
|
const historyLimits = {
|
|
7
6
|
title: 256,
|
|
@@ -3,7 +3,7 @@ import { HistoryStore } from "./history-store.js";
|
|
|
3
3
|
import { PushSender } from "./sender.js";
|
|
4
4
|
//#region src/notifications/options.d.ts
|
|
5
5
|
/** The columns of the devices table, by the name Protobase gives them. */
|
|
6
|
-
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "created_at", "last_seen_at"];
|
|
6
|
+
export declare const deviceColumns: readonly ["id", "user_id", "kind", "token", "p256dh", "auth", "environment", "app_id", "user_agent", "session_id", "created_at", "last_seen_at"];
|
|
7
7
|
export type DeviceColumn = (typeof deviceColumns)[number];
|
|
8
8
|
/** The columns of the history table, by the name Protobase gives them. */
|
|
9
9
|
export declare const historyColumns: readonly ["id", "user_id", "title", "body", "url", "data", "created_at", "read_at"];
|
|
@@ -1,16 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export declare const deviceIdParam = "id";
|
|
4
|
-
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
5
|
-
export declare const devicesPath: (systemPath: string, id?: string) => string;
|
|
6
|
-
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
7
|
-
export declare const historyIdParam = "id";
|
|
8
|
-
/**
|
|
9
|
-
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
10
|
-
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
11
|
-
* path, any but letters, digits, `_` and `-`, is refused.
|
|
12
|
-
*/
|
|
13
|
-
export declare const historyPath: (systemPath: string, part?: "unread" | "read" | {
|
|
14
|
-
read: string;
|
|
15
|
-
}) => string;
|
|
16
|
-
//#endregion
|
|
1
|
+
import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "@protobase/schema/internal";
|
|
2
|
+
export { deviceIdParam, devicesPath, historyIdParam, historyPath };
|
|
@@ -1,22 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
//#region src/notifications/paths.ts
|
|
3
|
-
/** The name of the device id in a device's path. */
|
|
4
|
-
const deviceIdParam = "id";
|
|
5
|
-
/** The path of the caller's devices under the API's system path (`/api`), or of one of them by its id. */
|
|
6
|
-
const devicesPath = (systemPath, id) => `${systemPath}/notifications/devices${id === void 0 ? "" : `/${encodeURIComponent(id)}`}`;
|
|
7
|
-
/** The name of the entry id in the path that marks one entry of the history read. */
|
|
8
|
-
const historyIdParam = "id";
|
|
9
|
-
/**
|
|
10
|
-
* The path of the caller's notification history under the API's system path (`/api`): the list itself, `unread` its
|
|
11
|
-
* unread count, `read` marking all read, or `{ read: id }` marking one entry read. An id that could leave the history's
|
|
12
|
-
* path, any but letters, digits, `_` and `-`, is refused.
|
|
13
|
-
*/
|
|
14
|
-
const historyPath = (systemPath, part) => {
|
|
15
|
-
const base = `${systemPath}/notifications/history`;
|
|
16
|
-
if (part === void 0) return base;
|
|
17
|
-
if (typeof part === "string") return `${base}/${part}`;
|
|
18
|
-
if (!historyIdPattern.test(part.read)) throw new Error("A history entry id is letters, digits, \"_\" and \"-\" only");
|
|
19
|
-
return `${base}/${part.read}/read`;
|
|
20
|
-
};
|
|
21
|
-
//#endregion
|
|
1
|
+
import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "@protobase/schema/internal";
|
|
22
2
|
export { deviceIdParam, devicesPath, historyIdParam, historyPath };
|
|
@@ -25,6 +25,7 @@ const deviceOf = (row) => ({
|
|
|
25
25
|
environment: row.environment,
|
|
26
26
|
appId: row.app_id,
|
|
27
27
|
userAgent: row.user_agent,
|
|
28
|
+
sessionId: row.session_id,
|
|
28
29
|
createdAt: new Date(row.created_at),
|
|
29
30
|
lastSeenAt: new Date(row.last_seen_at)
|
|
30
31
|
});
|
|
@@ -54,6 +55,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
54
55
|
environment: device.environment,
|
|
55
56
|
app_id: device.appId,
|
|
56
57
|
user_agent: device.userAgent,
|
|
58
|
+
session_id: device.sessionId,
|
|
57
59
|
created_at: now,
|
|
58
60
|
last_seen_at: now
|
|
59
61
|
};
|
|
@@ -69,6 +71,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
69
71
|
"environment",
|
|
70
72
|
"app_id",
|
|
71
73
|
"user_agent",
|
|
74
|
+
"session_id",
|
|
72
75
|
"last_seen_at"
|
|
73
76
|
].map((name) => sql`${column(name)} = excluded.${column(name)}`);
|
|
74
77
|
const [stored] = await rows(sql`insert into ${devices} as d (${names}) values (${given})
|
|
@@ -77,6 +80,7 @@ const postgresDeviceStore = (db, options = {}) => {
|
|
|
77
80
|
keep("created_at"),
|
|
78
81
|
...replaced
|
|
79
82
|
])}
|
|
83
|
+
where ${sameUser} or (${sql.ref(`d.${columns.kind}`)} <> 'web' and excluded.${column("kind")} <> 'web')
|
|
80
84
|
returning ${selected}`);
|
|
81
85
|
return stored;
|
|
82
86
|
},
|
|
@@ -26,10 +26,11 @@ const recordOnce = (db, options, job) => async (userId, entry, progress) => {
|
|
|
26
26
|
};
|
|
27
27
|
/**
|
|
28
28
|
* The job that delivers what `sendNotification` sends, through `senders` (guarded, see `notificationSenders`) and the
|
|
29
|
-
* stores `options` configure
|
|
30
|
-
*
|
|
29
|
+
* stores `options` configure; with `liveSessions`, as with Protobase's auth, only to devices whose session stands. A
|
|
30
|
+
* try resumes where the last one stopped (see `deliver`); once delivered, the job keeps no message. A few tries only: a
|
|
31
|
+
* device that keeps asking for a retry is given up on the second time.
|
|
31
32
|
*/
|
|
32
|
-
const pushJob = (options, senders) => defineJob({
|
|
33
|
+
const pushJob = (options, senders, liveSessions) => defineJob({
|
|
33
34
|
name: pushJobName,
|
|
34
35
|
payload: pushPayload,
|
|
35
36
|
maxAttempts: 5,
|
|
@@ -42,7 +43,8 @@ const pushJob = (options, senders) => defineJob({
|
|
|
42
43
|
record: recordOnce(db, options, job),
|
|
43
44
|
save: (progress) => saveJobProgress(db, job, progress),
|
|
44
45
|
hold: () => holdJob(db, job),
|
|
45
|
-
complete: () => replaceJobData(db, job, { complete: true })
|
|
46
|
+
complete: () => replaceJobData(db, job, { complete: true }),
|
|
47
|
+
...liveSessions && { liveSessions }
|
|
46
48
|
});
|
|
47
49
|
}
|
|
48
50
|
});
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
|
-
import {
|
|
3
|
-
import "./paths.js";
|
|
2
|
+
import { ownSignInDevice } from "./caller.js";
|
|
3
|
+
import { deviceIdParam } from "./paths.js";
|
|
4
4
|
import { deviceInput, maxDeviceBodyBytes } from "./device-input.js";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { bodyLimit } from "hono/body-limit";
|
|
7
7
|
//#region src/notifications/routes.ts
|
|
8
8
|
/** The most devices one user keeps: registering one more drops their least recently seen. */
|
|
9
9
|
const maxDevicesPerUser = 20;
|
|
10
|
-
const ownSignIn = async (c, next) => {
|
|
11
|
-
|
|
10
|
+
const ownSignIn = (sessionRequired) => async (c, next) => {
|
|
11
|
+
const { userId, sessionId } = ownSignInDevice(c.get("session"), sessionRequired);
|
|
12
|
+
c.set("deviceUser", userId);
|
|
13
|
+
c.set("deviceSession", sessionId);
|
|
12
14
|
await next();
|
|
13
15
|
};
|
|
14
16
|
const jsonOnly = async (c, next) => {
|
|
@@ -33,21 +35,31 @@ const dropOldest = async (store, userId, kept) => {
|
|
|
33
35
|
};
|
|
34
36
|
/**
|
|
35
37
|
* The caller's own devices, mounted at `devicesPath` behind the API's authenticator: `POST` registers or refreshes the
|
|
36
|
-
* device the request comes from, `DELETE` with its id removes it. Tokens travel in the body
|
|
38
|
+
* device the request comes from, in the caller's session, `DELETE` with its id removes it. Tokens travel in the body
|
|
39
|
+
* only, never the URL. A register is a 409 that stores nothing when the body names a user other than the caller
|
|
40
|
+
* (`other-user`), or when another user holds the browser's endpoint (`device-held`), which a browser answers by
|
|
41
|
+
* subscribing afresh. With `sessionRequired`, a caller whose session has no id is refused.
|
|
37
42
|
*/
|
|
38
|
-
const deviceRoutes = (store) => {
|
|
43
|
+
const deviceRoutes = (store, { sessionRequired = false } = {}) => {
|
|
39
44
|
const app = new Hono();
|
|
40
|
-
|
|
45
|
+
const caller = ownSignIn(sessionRequired);
|
|
46
|
+
app.post("/", caller, jsonOnly, limited, async (c) => {
|
|
41
47
|
const userId = c.get("deviceUser");
|
|
42
|
-
const
|
|
48
|
+
const given = deviceInput(await c.req.json());
|
|
49
|
+
if (given.userId !== void 0 && given.userId !== userId) throw new HttpProblem(409, "other-user", "Conflict", "The device is registered for the user signed in");
|
|
50
|
+
const device = {
|
|
51
|
+
...given.device,
|
|
52
|
+
sessionId: c.get("deviceSession")
|
|
53
|
+
};
|
|
43
54
|
const stored = await store.register(userId, device);
|
|
55
|
+
if (!stored) throw new HttpProblem(409, "device-held", "Conflict", "Another user holds this subscription: subscribe again");
|
|
44
56
|
if (stored.userId !== userId || stored.token !== device.token) throw new Error("The device store answered register with another device");
|
|
45
57
|
await dropOldest(store, userId, stored);
|
|
46
58
|
return c.json(answer(stored));
|
|
47
59
|
});
|
|
48
|
-
app.delete(
|
|
60
|
+
app.delete(`/:${deviceIdParam}`, caller, async (c) => {
|
|
49
61
|
const userId = c.get("deviceUser");
|
|
50
|
-
const id = c.req.param(
|
|
62
|
+
const id = c.req.param(deviceIdParam);
|
|
51
63
|
if ((await store.listForUser(userId)).some((device) => device.id === id)) await store.remove(userId, id);
|
|
52
64
|
return c.body(null, 204);
|
|
53
65
|
});
|
|
@@ -2,6 +2,11 @@ import { quoteIdent } from "../changes/capture-sql.js";
|
|
|
2
2
|
//#region src/notifications/schema-sql.ts
|
|
3
3
|
/** A table by its schema, when one is named, and its name, quoted. */
|
|
4
4
|
const qualified = ({ schema, table }) => schema ? `${quoteIdent(schema)}.${quoteIdent(table)}` : quoteIdent(table);
|
|
5
|
+
/** The session column, added to a devices table written before devices kept the session they were registered in. */
|
|
6
|
+
const deviceSessionColumnSql = ({ schema, table, columns }) => `alter table ${qualified({
|
|
7
|
+
schema,
|
|
8
|
+
table
|
|
9
|
+
})} add column ${quoteIdent(columns.session_id)} text`;
|
|
5
10
|
/**
|
|
6
11
|
* The devices table: the token unique, so registering one moves it; with auth, its user column points at auth's users
|
|
7
12
|
* table and goes with the user. A field that does not apply to the kind must be NULL, and nothing has a default.
|
|
@@ -24,6 +29,7 @@ const devicesTableSql = ({ schema, table, columns }, user) => {
|
|
|
24
29
|
${c.environment} text check (${c.environment} in ('sandbox', 'production')),
|
|
25
30
|
${c.app_id} text,
|
|
26
31
|
${c.user_agent} text,
|
|
32
|
+
${c.session_id} text,
|
|
27
33
|
${c.created_at} timestamptz not null,
|
|
28
34
|
${c.last_seen_at} timestamptz not null,
|
|
29
35
|
check (
|
|
@@ -37,4 +43,4 @@ const devicesTableSql = ({ schema, table, columns }, user) => {
|
|
|
37
43
|
];
|
|
38
44
|
};
|
|
39
45
|
//#endregion
|
|
40
|
-
export { devicesTableSql, qualified };
|
|
46
|
+
export { deviceSessionColumnSql, devicesTableSql, qualified };
|
|
@@ -5,7 +5,8 @@ import { Db } from "@protobase/query";
|
|
|
5
5
|
/**
|
|
6
6
|
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
7
7
|
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
8
|
-
* of the app's own replaces it
|
|
8
|
+
* of the app's own replaces it, and an existing devices table without its session column gets that column;
|
|
9
|
+
* `undefined` when nothing is left.
|
|
9
10
|
*/
|
|
10
11
|
export declare const notificationsMigration: (db: Db, options?: NotificationsOptions, auth?: AdminAuth) => Promise<string | undefined>;
|
|
11
12
|
/** Refuses to start while the devices or the history table lacks what `notifications()` needs, naming it. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { migrationCommand } from "../changes/capture-schema.js";
|
|
2
2
|
import { historyNames, notificationsNames } from "./options.js";
|
|
3
3
|
import { storeSchema } from "../better-auth/auth-tables.js";
|
|
4
|
-
import { devicesTableSql, qualified } from "./schema-sql.js";
|
|
4
|
+
import { deviceSessionColumnSql, devicesTableSql, qualified } from "./schema-sql.js";
|
|
5
5
|
import { historyTableSql } from "./history-sql.js";
|
|
6
6
|
import { sql } from "kysely";
|
|
7
7
|
//#region src/notifications/schema.ts
|
|
@@ -17,29 +17,39 @@ const userIdType = async (db, users) => {
|
|
|
17
17
|
const { rows } = await sql`select format_type(atttypid, atttypmod) as type from pg_attribute where attrelid = ${oid} and attname = 'id'`.execute(db);
|
|
18
18
|
return rows[0].type;
|
|
19
19
|
};
|
|
20
|
+
const columnsOf = async (db, oid) => (await sql`select attname as name, attnum as num from pg_attribute where attrelid = ${oid} and attnum > 0 and not attisdropped`.execute(db)).rows;
|
|
21
|
+
const uniqueOn = async (db, oid, num) => (await sql`select exists (select from pg_index where indrelid = ${oid} and indisunique and indpred is null and indexprs is null and indnatts = 1 and indkey[0] = ${num}) as found`.execute(db)).rows[0].found;
|
|
22
|
+
const cascadesFrom = async (db, oid, num, users) => (await sql`select exists (select from pg_constraint where conrelid = ${oid} and contype = 'f' and confdeltype = 'c'
|
|
23
|
+
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
24
|
+
const lacksSessionColumn = async (db, names) => {
|
|
25
|
+
const oid = await regclass(db, qualified(names));
|
|
26
|
+
return oid !== null && !(await columnsOf(db, oid)).some((row) => row.name === names.columns.session_id);
|
|
27
|
+
};
|
|
20
28
|
/**
|
|
21
29
|
* The SQL that adds the tables `notifications()` keeps devices and each user's history in, under the names its options
|
|
22
30
|
* give; with `auth`, their user columns point at auth's users table. Each table is left out when it exists or a store
|
|
23
|
-
* of the app's own replaces it
|
|
31
|
+
* of the app's own replaces it, and an existing devices table without its session column gets that column;
|
|
32
|
+
* `undefined` when nothing is left.
|
|
24
33
|
*/
|
|
25
34
|
const notificationsMigration = async (db, options = {}, auth) => {
|
|
26
35
|
const devices = !options.store && notificationsNames(options);
|
|
27
36
|
const history = !options.historyStore && historyNames(options);
|
|
28
37
|
const missing = async (names) => names && await regclass(db, qualified(names)) === null;
|
|
29
38
|
const [needsDevices, needsHistory] = [await missing(devices), await missing(history)];
|
|
30
|
-
|
|
39
|
+
const needsSession = devices && !needsDevices && await lacksSessionColumn(db, devices);
|
|
40
|
+
if (!needsDevices && !needsHistory && !needsSession) return void 0;
|
|
31
41
|
const users = auth && authUsersTable(auth);
|
|
32
|
-
const user = users ? {
|
|
42
|
+
const user = users && (needsDevices || needsHistory) ? {
|
|
33
43
|
type: await userIdType(db, users),
|
|
34
44
|
references: `${qualified(users)} (id)`
|
|
35
45
|
} : { type: "text" };
|
|
36
|
-
const statements = [
|
|
46
|
+
const statements = [
|
|
47
|
+
...needsDevices && devices ? devicesTableSql(devices, user) : [],
|
|
48
|
+
...needsSession && devices ? [deviceSessionColumnSql(devices)] : [],
|
|
49
|
+
...needsHistory && history ? historyTableSql(history, user) : []
|
|
50
|
+
];
|
|
37
51
|
return `${[...new Set(statements)].join(";\n\n")};`;
|
|
38
52
|
};
|
|
39
|
-
const columnsOf = async (db, oid) => (await sql`select attname as name, attnum as num from pg_attribute where attrelid = ${oid} and attnum > 0 and not attisdropped`.execute(db)).rows;
|
|
40
|
-
const uniqueOn = async (db, oid, num) => (await sql`select exists (select from pg_index where indrelid = ${oid} and indisunique and indpred is null and indexprs is null and indnatts = 1 and indkey[0] = ${num}) as found`.execute(db)).rows[0].found;
|
|
41
|
-
const cascadesFrom = async (db, oid, num, users) => (await sql`select exists (select from pg_constraint where conrelid = ${oid} and contype = 'f' and confdeltype = 'c'
|
|
42
|
-
and conkey = array[${num}]::int2[] and confrelid = to_regclass(${qualified(users)})) as found`.execute(db)).rows[0].found;
|
|
43
53
|
const missingTable = (what, table) => /* @__PURE__ */ new Error(`The ${what} table ${table} of notifications() is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
44
54
|
/**
|
|
45
55
|
* Refuses to start while the devices table lacks what `notifications()` needs, naming it: the table, each column under
|
|
@@ -9,7 +9,7 @@ export type WebPushOptions = PostOptions & {
|
|
|
9
9
|
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
10
10
|
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
11
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 `refused` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
12
|
+
* answered `refused` with no request. Each device's JSON carries its user's id as `user`. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
13
13
|
*/
|
|
14
14
|
export declare const webPushSender: (settings: VapidSettings, options?: WebPushOptions) => PushSender;
|
|
15
15
|
//#endregion
|
|
@@ -33,7 +33,7 @@ const headersOf = (message) => {
|
|
|
33
33
|
* The built-in Web Push sender (RFC 8030, 8291, 8292): delivers to `web` devices, signed with the VAPID pair and
|
|
34
34
|
* encrypted for each device's keys. It checks the message as `pushMessage` does and throws before any request when it
|
|
35
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 `refused` with no request. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
36
|
+
* answered `refused` with no request. Each device's JSON carries its user's id as `user`. It logs nothing, and its errors and answers carry no endpoint, key or token.
|
|
37
37
|
*/
|
|
38
38
|
const webPushSender = (settings, options = {}) => {
|
|
39
39
|
const authorization = vapidAuthorizer(settings, options.now);
|
|
@@ -58,10 +58,14 @@ const webPushSender = (settings, options = {}) => {
|
|
|
58
58
|
const message = pushMessage(given);
|
|
59
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
60
|
const headers = headersOf(message);
|
|
61
|
-
const
|
|
62
|
-
|
|
61
|
+
const payload = webPushPayload(message);
|
|
62
|
+
const plaintexts = new Map(devices.map((device) => [device.id, Buffer.from(JSON.stringify({
|
|
63
|
+
...payload,
|
|
64
|
+
user: device.userId
|
|
65
|
+
}))]));
|
|
66
|
+
if ([...plaintexts.values()].some((plaintext) => plaintext.length > 3993)) throw new PushMessageError(`message: its Web Push payload is over ${maxPlaintextBytes} bytes`);
|
|
63
67
|
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,
|
|
68
|
+
for (let start = 0; start < devices.length; start += concurrency) answers.push(...await Promise.all(devices.slice(start, start + concurrency).map((device) => sendOne(device, headers, plaintexts.get(device.id)))));
|
|
65
69
|
return answers;
|
|
66
70
|
};
|
|
67
71
|
return {
|
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.21",
|
|
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.21",
|
|
40
|
+
"@protobase/query": "0.45.21",
|
|
41
|
+
"@protobase/schema": "0.45.21",
|
|
42
42
|
"@react-email/render": "^2.1.0",
|
|
43
43
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
44
44
|
"@sentry/core": "^11.7.0",
|