mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Add initial Push Notifications support
This commit is contained in:
parent
acd2bcb469
commit
d096ab830c
|
|
@ -8,6 +8,7 @@ Versia Server `0.8.0` is fully backwards compatible with `0.7.0`.
|
||||||
|
|
||||||
- Outbound federation, inbox processing and data fetching are now handled by a queue system (like most federated software).
|
- Outbound federation, inbox processing and data fetching are now handled by a queue system (like most federated software).
|
||||||
- Added an administration UI for managing the queue.
|
- Added an administration UI for managing the queue.
|
||||||
|
- Added [Push Notifications](https://docs.joinmastodon.org/methods/push) support.
|
||||||
- Upgraded Bun to `1.1.42`.
|
- Upgraded Bun to `1.1.42`.
|
||||||
- Implemented support for the [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
|
- Implemented support for the [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
|
||||||
- Implement [**Shared Inboxes**](https://versia.pub/federation#inboxes) support.
|
- Implement [**Shared Inboxes**](https://versia.pub/federation#inboxes) support.
|
||||||
|
|
|
||||||
31
api/api/v1/push/subscription/index.delete.schema.ts
Normal file
31
api/api/v1/push/subscription/index.delete.schema.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { auth } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
|
||||||
|
export const route = createRoute({
|
||||||
|
method: "delete",
|
||||||
|
path: "/api/v1/push/subscription",
|
||||||
|
summary: "Remove current subscription",
|
||||||
|
description: "Removes the current Web Push API subscription.",
|
||||||
|
externalDocs: {
|
||||||
|
url: "https://docs.joinmastodon.org/methods/push/#delete",
|
||||||
|
},
|
||||||
|
middleware: [
|
||||||
|
auth({
|
||||||
|
auth: true,
|
||||||
|
permissions: [RolePermissions.UsePushNotifications],
|
||||||
|
scopes: ["push"],
|
||||||
|
}),
|
||||||
|
] as const,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"PushSubscription successfully deleted or did not exist previously.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
23
api/api/v1/push/subscription/index.delete.ts
Normal file
23
api/api/v1/push/subscription/index.delete.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { route } from "./index.delete.schema";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { token } = context.get("auth");
|
||||||
|
|
||||||
|
const ps = await PushSubscription.fromToken(token);
|
||||||
|
|
||||||
|
if (!ps) {
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
"No push subscription associated with this access token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ps.delete();
|
||||||
|
|
||||||
|
return context.json({}, 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
85
api/api/v1/push/subscription/index.get.schema.ts
Normal file
85
api/api/v1/push/subscription/index.get.schema.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { auth } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
|
||||||
|
export const WebPushSubscriptionInput = z
|
||||||
|
.object({
|
||||||
|
subscription: z.object({
|
||||||
|
endpoint: z.string().url().openapi({
|
||||||
|
example: "https://yourdomain.example/listener",
|
||||||
|
description: "Where push alerts will be sent to.",
|
||||||
|
}),
|
||||||
|
keys: z
|
||||||
|
.object({
|
||||||
|
p256dh: z.string().base64().openapi({
|
||||||
|
description:
|
||||||
|
"User agent public key. Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve.",
|
||||||
|
example:
|
||||||
|
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoKCJeHCy69ywHcb3dAR/T8Sud5ljSFHJkuiR6it1ycqAjGTe5F1oZ0ef5QiMX/zdQ+d4jSKiO7RztIz+o/eGuQ==",
|
||||||
|
}),
|
||||||
|
auth: z.string().base64().length(24).openapi({
|
||||||
|
description:
|
||||||
|
"Auth secret. Base64 encoded string of 16 bytes of random data.",
|
||||||
|
example: "u67u09PXZW4ncK9l9mAXkA==",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
}),
|
||||||
|
data: z
|
||||||
|
.object({
|
||||||
|
policy: z
|
||||||
|
.enum(["all", "followed", "follower", "none"])
|
||||||
|
.default("all")
|
||||||
|
.openapi({
|
||||||
|
description:
|
||||||
|
"Specify whether to receive push notifications from all, followed, follower, or none users.",
|
||||||
|
}),
|
||||||
|
alerts: PushSubscription.schema.shape.alerts,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default({
|
||||||
|
policy: "all",
|
||||||
|
alerts: {
|
||||||
|
mention: false,
|
||||||
|
favourite: false,
|
||||||
|
reblog: false,
|
||||||
|
follow: false,
|
||||||
|
poll: false,
|
||||||
|
follow_request: false,
|
||||||
|
status: false,
|
||||||
|
update: false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
"admin.report": false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const route = createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/api/v1/push/subscription",
|
||||||
|
summary: "Get current subscription",
|
||||||
|
description:
|
||||||
|
"View the PushSubscription currently associated with this access token.",
|
||||||
|
externalDocs: {
|
||||||
|
url: "https://docs.joinmastodon.org/methods/push/#get",
|
||||||
|
},
|
||||||
|
middleware: [
|
||||||
|
auth({
|
||||||
|
auth: true,
|
||||||
|
permissions: [RolePermissions.UsePushNotifications],
|
||||||
|
scopes: ["push"],
|
||||||
|
}),
|
||||||
|
] as const,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "WebPushSubscription",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: PushSubscription.schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
21
api/api/v1/push/subscription/index.get.ts
Normal file
21
api/api/v1/push/subscription/index.get.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { route } from "./index.get.schema";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { token } = context.get("auth");
|
||||||
|
|
||||||
|
const ps = await PushSubscription.fromToken(token);
|
||||||
|
|
||||||
|
if (!ps) {
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
"No push subscription associated with this access token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json(ps.toApi(), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
44
api/api/v1/push/subscription/index.post.schema.ts
Normal file
44
api/api/v1/push/subscription/index.post.schema.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { auth, jsonOrForm } from "@/api";
|
||||||
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { WebPushSubscriptionInput } from "./index.get.schema";
|
||||||
|
|
||||||
|
export const route = createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/api/v1/push/subscription",
|
||||||
|
summary: "Subscribe to push notifications",
|
||||||
|
description:
|
||||||
|
"Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
|
||||||
|
externalDocs: {
|
||||||
|
url: "https://docs.joinmastodon.org/methods/push/#create",
|
||||||
|
},
|
||||||
|
middleware: [
|
||||||
|
auth({
|
||||||
|
auth: true,
|
||||||
|
permissions: [RolePermissions.UsePushNotifications],
|
||||||
|
scopes: ["push"],
|
||||||
|
}),
|
||||||
|
jsonOrForm(),
|
||||||
|
] as const,
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: WebPushSubscriptionInput,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description:
|
||||||
|
"A new PushSubscription has been generated, which will send the requested alerts to your endpoint.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: PushSubscription.schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
45
api/api/v1/push/subscription/index.post.ts
Normal file
45
api/api/v1/push/subscription/index.post.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { route } from "./index.post.schema";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { user, token } = context.get("auth");
|
||||||
|
const { subscription, data } = context.req.valid("json");
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.alerts["admin.report"] &&
|
||||||
|
!user.hasPermission(RolePermissions.ManageReports)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
`You do not have the '${RolePermissions.ManageReports}' permission to receive report alerts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.alerts["admin.sign_up"] &&
|
||||||
|
!user.hasPermission(RolePermissions.ManageAccounts)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
`You do not have the '${RolePermissions.ManageAccounts}' permission to receive sign-up alerts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PushSubscription.clearAllOfToken(token);
|
||||||
|
|
||||||
|
const ps = await PushSubscription.insert({
|
||||||
|
alerts: data.alerts,
|
||||||
|
policy: data.policy,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
publicKey: subscription.keys.p256dh,
|
||||||
|
authSecret: subscription.keys.auth,
|
||||||
|
tokenId: token.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json(ps.toApi(), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
43
api/api/v1/push/subscription/index.put.schema.ts
Normal file
43
api/api/v1/push/subscription/index.put.schema.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { auth, jsonOrForm } from "@/api";
|
||||||
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { WebPushSubscriptionInput } from "./index.get.schema";
|
||||||
|
|
||||||
|
export const route = createRoute({
|
||||||
|
method: "put",
|
||||||
|
path: "/api/v1/push/subscription",
|
||||||
|
summary: "Change types of notifications",
|
||||||
|
description:
|
||||||
|
"Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.",
|
||||||
|
externalDocs: {
|
||||||
|
url: "https://docs.joinmastodon.org/methods/push/#update",
|
||||||
|
},
|
||||||
|
middleware: [
|
||||||
|
auth({
|
||||||
|
auth: true,
|
||||||
|
permissions: [RolePermissions.UsePushNotifications],
|
||||||
|
scopes: ["push"],
|
||||||
|
}),
|
||||||
|
jsonOrForm(),
|
||||||
|
] as const,
|
||||||
|
request: {
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: WebPushSubscriptionInput.shape.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "The WebPushSubscription has been updated.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: PushSubscription.schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
51
api/api/v1/push/subscription/index.put.ts
Normal file
51
api/api/v1/push/subscription/index.put.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { apiRoute } from "@/api";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
|
import { route } from "./index.put.schema";
|
||||||
|
|
||||||
|
export default apiRoute((app) =>
|
||||||
|
app.openapi(route, async (context) => {
|
||||||
|
const { user, token } = context.get("auth");
|
||||||
|
const { alerts, policy } = context.req.valid("json");
|
||||||
|
|
||||||
|
const ps = await PushSubscription.fromToken(token);
|
||||||
|
|
||||||
|
if (!ps) {
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
"No push subscription associated with this access token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
alerts["admin.report"] &&
|
||||||
|
!user.hasPermission(RolePermissions.ManageReports)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
`You do not have the '${RolePermissions.ManageReports}' permission to receive report alerts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
alerts["admin.sign_up"] &&
|
||||||
|
!user.hasPermission(RolePermissions.ManageAccounts)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
`You do not have the '${RolePermissions.ManageAccounts}' permission to receive sign-up alerts`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ps.update({
|
||||||
|
policy,
|
||||||
|
alerts: {
|
||||||
|
...ps.data.alerts,
|
||||||
|
...alerts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return context.json(ps.toApi(), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
304
api/api/v1/push/subscription/index.test.ts
Normal file
304
api/api/v1/push/subscription/index.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { PushSubscription } from "@versia/kit/db";
|
||||||
|
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||||
|
|
||||||
|
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await PushSubscription.clearAllOfToken(tokens[0]);
|
||||||
|
await PushSubscription.clearAllOfToken(tokens[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/v1/push/subscriptions", () => {
|
||||||
|
test("should create a push subscription", async () => {
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
keys: {
|
||||||
|
p256dh: "test",
|
||||||
|
auth: "testthatis24charactersha",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should retrieve the same push subscription", async () => {
|
||||||
|
await PushSubscription.insert({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
"admin.report": false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
favourite: false,
|
||||||
|
follow: false,
|
||||||
|
follow_request: false,
|
||||||
|
mention: false,
|
||||||
|
poll: false,
|
||||||
|
reblog: false,
|
||||||
|
status: false,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
authSecret: "test",
|
||||||
|
publicKey: "test",
|
||||||
|
tokenId: tokens[1].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update a push subscription", async () => {
|
||||||
|
await PushSubscription.insert({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
"admin.report": false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
favourite: false,
|
||||||
|
follow: false,
|
||||||
|
follow_request: false,
|
||||||
|
mention: false,
|
||||||
|
poll: false,
|
||||||
|
reblog: false,
|
||||||
|
status: false,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
authSecret: "test",
|
||||||
|
publicKey: "test",
|
||||||
|
tokenId: tokens[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
alerts: {
|
||||||
|
update: false,
|
||||||
|
favourite: true,
|
||||||
|
},
|
||||||
|
policy: "follower",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
alerts: {
|
||||||
|
update: false,
|
||||||
|
favourite: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permissions", () => {
|
||||||
|
test("should not allow watching admin reports without permissions", async () => {
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
alerts: {
|
||||||
|
"admin.report": true,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
keys: {
|
||||||
|
p256dh: "test",
|
||||||
|
auth: "testthatis24charactersha",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
error: expect.stringContaining("permission"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow watching admin reports with permissions", async () => {
|
||||||
|
await users[0].update({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
alerts: {
|
||||||
|
"admin.report": true,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
keys: {
|
||||||
|
p256dh: "test",
|
||||||
|
auth: "testthatis24charactersha",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
await users[0].update({
|
||||||
|
isAdmin: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not allow editing to add admin reports without permissions", async () => {
|
||||||
|
await PushSubscription.insert({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
"admin.report": false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
favourite: false,
|
||||||
|
follow: false,
|
||||||
|
follow_request: false,
|
||||||
|
mention: false,
|
||||||
|
poll: false,
|
||||||
|
reblog: false,
|
||||||
|
status: false,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
authSecret: "test",
|
||||||
|
publicKey: "test",
|
||||||
|
tokenId: tokens[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
alerts: {
|
||||||
|
"admin.report": true,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
error: expect.stringContaining("permission"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should allow editing to add admin reports with permissions", async () => {
|
||||||
|
await users[0].update({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await PushSubscription.insert({
|
||||||
|
endpoint: "https://example.com",
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
"admin.report": false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
favourite: false,
|
||||||
|
follow: false,
|
||||||
|
follow_request: false,
|
||||||
|
mention: false,
|
||||||
|
poll: false,
|
||||||
|
reblog: false,
|
||||||
|
status: false,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
authSecret: "test",
|
||||||
|
publicKey: "test",
|
||||||
|
tokenId: tokens[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
alerts: {
|
||||||
|
"admin.report": true,
|
||||||
|
},
|
||||||
|
policy: "all",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toMatchObject({
|
||||||
|
alerts: {
|
||||||
|
update: true,
|
||||||
|
"admin.report": true,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
favourite: false,
|
||||||
|
follow: false,
|
||||||
|
follow_request: false,
|
||||||
|
mention: false,
|
||||||
|
poll: false,
|
||||||
|
reblog: false,
|
||||||
|
status: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await users[0].update({
|
||||||
|
isAdmin: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
2
app.ts
2
app.ts
|
|
@ -110,7 +110,7 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
|
||||||
const route: ApiRouteExports = await import(path);
|
const route: ApiRouteExports = await import(path);
|
||||||
|
|
||||||
if (!route.default) {
|
if (!route.default) {
|
||||||
throw new Error(`Route ${path} does not have the correct exports.`);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
route.default(app);
|
route.default(app);
|
||||||
|
|
|
||||||
256
classes/database/pushsubscription.ts
Normal file
256
classes/database/pushsubscription.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { z } from "@hono/zod-openapi";
|
||||||
|
import type {
|
||||||
|
Alerts,
|
||||||
|
PushSubscription as ApiPushSubscription,
|
||||||
|
} from "@versia/client/types";
|
||||||
|
import { type Token, db } from "@versia/kit/db";
|
||||||
|
import { PushSubscriptions } from "@versia/kit/tables";
|
||||||
|
import {
|
||||||
|
type InferInsertModel,
|
||||||
|
type InferSelectModel,
|
||||||
|
type SQL,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { BaseInterface } from "./base.ts";
|
||||||
|
|
||||||
|
type PushSubscriptionType = InferSelectModel<typeof PushSubscriptions>;
|
||||||
|
|
||||||
|
export class PushSubscription extends BaseInterface<
|
||||||
|
typeof PushSubscriptions,
|
||||||
|
PushSubscriptionType
|
||||||
|
> {
|
||||||
|
public static schema = z.object({
|
||||||
|
id: z.string().uuid().openapi({
|
||||||
|
example: "24eb1891-accc-43b4-b213-478e37d525b4",
|
||||||
|
description: "The ID of the Web Push subscription in the database.",
|
||||||
|
}),
|
||||||
|
endpoint: z.string().url().openapi({
|
||||||
|
example: "https://yourdomain.example/listener",
|
||||||
|
description: "Where push alerts will be sent to.",
|
||||||
|
}),
|
||||||
|
alerts: z
|
||||||
|
.object({
|
||||||
|
mention: z.boolean().optional().openapi({
|
||||||
|
example: true,
|
||||||
|
description: "Receive mention notifications?",
|
||||||
|
}),
|
||||||
|
favourite: z.boolean().optional().openapi({
|
||||||
|
example: true,
|
||||||
|
description: "Receive favourite notifications?",
|
||||||
|
}),
|
||||||
|
reblog: z.boolean().optional().openapi({
|
||||||
|
example: true,
|
||||||
|
description: "Receive reblog notifications?",
|
||||||
|
}),
|
||||||
|
follow: z.boolean().optional().openapi({
|
||||||
|
example: true,
|
||||||
|
description: "Receive follow notifications?",
|
||||||
|
}),
|
||||||
|
poll: z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description: "Receive poll notifications?",
|
||||||
|
}),
|
||||||
|
follow_request: z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description: "Receive follow request notifications?",
|
||||||
|
}),
|
||||||
|
status: z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"Receive new subscribed account notifications?",
|
||||||
|
}),
|
||||||
|
update: z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description: "Receive status edited notifications?",
|
||||||
|
}),
|
||||||
|
"admin.sign_up": z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"Receive new user signup notifications? Must have a role with the appropriate permissions.",
|
||||||
|
}),
|
||||||
|
"admin.report": z.boolean().optional().openapi({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"Receive new report notifications? Must have a role with the appropriate permissions.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.default({})
|
||||||
|
.openapi({
|
||||||
|
example: {
|
||||||
|
mention: true,
|
||||||
|
favourite: true,
|
||||||
|
reblog: true,
|
||||||
|
follow: true,
|
||||||
|
poll: false,
|
||||||
|
follow_request: false,
|
||||||
|
status: false,
|
||||||
|
update: false,
|
||||||
|
"admin.sign_up": false,
|
||||||
|
"admin.report": false,
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Which alerts should be delivered to the endpoint.",
|
||||||
|
}),
|
||||||
|
server_key: z.string().openapi({
|
||||||
|
example:
|
||||||
|
"BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=",
|
||||||
|
description: "The streaming server’s VAPID key.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
public static $type: PushSubscriptionType;
|
||||||
|
|
||||||
|
public async reload(): Promise<void> {
|
||||||
|
const reloaded = await PushSubscription.fromId(this.data.id);
|
||||||
|
|
||||||
|
if (!reloaded) {
|
||||||
|
throw new Error("Failed to reload subscription");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = reloaded.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromId(
|
||||||
|
id: string | null,
|
||||||
|
): Promise<PushSubscription | null> {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PushSubscription.fromSql(eq(PushSubscriptions.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromIds(ids: string[]): Promise<PushSubscription[]> {
|
||||||
|
return await PushSubscription.manyFromSql(
|
||||||
|
inArray(PushSubscriptions.id, ids),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromToken(
|
||||||
|
token: Token,
|
||||||
|
): Promise<PushSubscription | null> {
|
||||||
|
return await PushSubscription.fromSql(
|
||||||
|
eq(PushSubscriptions.tokenId, token.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromSql(
|
||||||
|
sql: SQL<unknown> | undefined,
|
||||||
|
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||||
|
): Promise<PushSubscription | null> {
|
||||||
|
const found = await db.query.PushSubscriptions.findFirst({
|
||||||
|
where: sql,
|
||||||
|
orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new PushSubscription(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async manyFromSql(
|
||||||
|
sql: SQL<unknown> | undefined,
|
||||||
|
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
extra?: Parameters<typeof db.query.PushSubscriptions.findMany>[0],
|
||||||
|
): Promise<PushSubscription[]> {
|
||||||
|
const found = await db.query.PushSubscriptions.findMany({
|
||||||
|
where: sql,
|
||||||
|
orderBy,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
with: extra?.with,
|
||||||
|
});
|
||||||
|
|
||||||
|
return found.map((s) => new PushSubscription(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(
|
||||||
|
newSubscription: Partial<PushSubscriptionType>,
|
||||||
|
): Promise<PushSubscriptionType> {
|
||||||
|
await db
|
||||||
|
.update(PushSubscriptions)
|
||||||
|
.set(newSubscription)
|
||||||
|
.where(eq(PushSubscriptions.id, this.id));
|
||||||
|
|
||||||
|
const updated = await PushSubscription.fromId(this.data.id);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error("Failed to update subscription");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = updated.data;
|
||||||
|
return updated.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public save(): Promise<PushSubscriptionType> {
|
||||||
|
return this.update(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async clearAllOfToken(token: Token): Promise<void> {
|
||||||
|
await db
|
||||||
|
.delete(PushSubscriptions)
|
||||||
|
.where(eq(PushSubscriptions.tokenId, token.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(ids?: string[]): Promise<void> {
|
||||||
|
if (Array.isArray(ids)) {
|
||||||
|
await db
|
||||||
|
.delete(PushSubscriptions)
|
||||||
|
.where(inArray(PushSubscriptions.id, ids));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.delete(PushSubscriptions)
|
||||||
|
.where(eq(PushSubscriptions.id, this.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async insert(
|
||||||
|
data: InferInsertModel<typeof PushSubscriptions>,
|
||||||
|
): Promise<PushSubscription> {
|
||||||
|
const inserted = (
|
||||||
|
await db.insert(PushSubscriptions).values(data).returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const subscription = await PushSubscription.fromId(inserted.id);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error("Failed to insert subscription");
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get id(): string {
|
||||||
|
return this.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAlerts(): Alerts {
|
||||||
|
return {
|
||||||
|
mention: this.data.alerts.mention ?? false,
|
||||||
|
favourite: this.data.alerts.favourite ?? false,
|
||||||
|
reblog: this.data.alerts.reblog ?? false,
|
||||||
|
follow: this.data.alerts.follow ?? false,
|
||||||
|
poll: this.data.alerts.poll ?? false,
|
||||||
|
follow_request: this.data.alerts.follow_request ?? false,
|
||||||
|
status: this.data.alerts.status ?? false,
|
||||||
|
update: this.data.alerts.update ?? false,
|
||||||
|
"admin.sign_up": this.data.alerts["admin.sign_up"] ?? false,
|
||||||
|
"admin.report": this.data.alerts["admin.report"] ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public toApi(): ApiPushSubscription {
|
||||||
|
return {
|
||||||
|
id: this.data.id,
|
||||||
|
alerts: this.getAlerts(),
|
||||||
|
endpoint: this.data.endpoint,
|
||||||
|
// FIXME: Add real key
|
||||||
|
server_key: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,7 @@ ManageFollows: "follows",
|
||||||
ManageOwnFollows: "owner:follow",
|
ManageOwnFollows: "owner:follow",
|
||||||
ManageOwnApps: "owner:app",
|
ManageOwnApps: "owner:app",
|
||||||
Search: "search",
|
Search: "search",
|
||||||
|
UsePushNotifications: "push_notifications",
|
||||||
ViewPublicTimelines: "public_timelines",
|
ViewPublicTimelines: "public_timelines",
|
||||||
ViewPrimateTimelines: "private_timelines",
|
ViewPrimateTimelines: "private_timelines",
|
||||||
IgnoreRateLimits: "ignore_rate_limits",
|
IgnoreRateLimits: "ignore_rate_limits",
|
||||||
|
|
|
||||||
14
drizzle/migrations/0040_good_nocturne.sql
Normal file
14
drizzle/migrations/0040_good_nocturne.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE "PushSubscriptions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
|
||||||
|
"endpoint" text NOT NULL,
|
||||||
|
"public_key" text NOT NULL,
|
||||||
|
"auth_secret" text NOT NULL,
|
||||||
|
"alerts" jsonb NOT NULL,
|
||||||
|
"policy" text NOT NULL,
|
||||||
|
"created_at" timestamp(3) DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp(3) DEFAULT now() NOT NULL,
|
||||||
|
"tokenId" uuid NOT NULL,
|
||||||
|
CONSTRAINT "PushSubscriptions_tokenId_unique" UNIQUE("tokenId")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "PushSubscriptions" ADD CONSTRAINT "PushSubscriptions_tokenId_Tokens_id_fk" FOREIGN KEY ("tokenId") REFERENCES "public"."Tokens"("id") ON DELETE cascade ON UPDATE cascade;
|
||||||
2323
drizzle/migrations/meta/0040_snapshot.json
Normal file
2323
drizzle/migrations/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -281,6 +281,13 @@
|
||||||
"when": 1734555117380,
|
"when": 1734555117380,
|
||||||
"tag": "0039_special_serpent_society",
|
"tag": "0039_special_serpent_society",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1735776034097,
|
||||||
|
"tag": "0040_good_nocturne",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,49 @@ export const Emojis = pgTable("Emojis", {
|
||||||
category: text("category"),
|
category: text("category"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const PushSubscriptions = pgTable("PushSubscriptions", {
|
||||||
|
id: id(),
|
||||||
|
endpoint: text("endpoint").notNull(),
|
||||||
|
publicKey: text("public_key").notNull(),
|
||||||
|
authSecret: text("auth_secret").notNull(),
|
||||||
|
alerts: jsonb("alerts").notNull().$type<
|
||||||
|
Partial<{
|
||||||
|
mention: boolean;
|
||||||
|
favourite: boolean;
|
||||||
|
reblog: boolean;
|
||||||
|
follow: boolean;
|
||||||
|
poll: boolean;
|
||||||
|
follow_request: boolean;
|
||||||
|
status: boolean;
|
||||||
|
update: boolean;
|
||||||
|
"admin.sign_up": boolean;
|
||||||
|
"admin.report": boolean;
|
||||||
|
}>
|
||||||
|
>(),
|
||||||
|
policy: text("policy")
|
||||||
|
.notNull()
|
||||||
|
.$type<"all" | "followed" | "follower" | "none">(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
tokenId: uuid("tokenId")
|
||||||
|
.references(() => Tokens.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
onUpdate: "cascade",
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.unique(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PushSubscriptionsRelations = relations(
|
||||||
|
PushSubscriptions,
|
||||||
|
({ one }) => ({
|
||||||
|
token: one(Tokens, {
|
||||||
|
fields: [PushSubscriptions.tokenId],
|
||||||
|
references: [Tokens.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const Reactions = pgTable("Reaction", {
|
export const Reactions = pgTable("Reaction", {
|
||||||
id: id(),
|
id: id(),
|
||||||
uri: uri(),
|
uri: uri(),
|
||||||
|
|
@ -548,6 +591,7 @@ export enum RolePermissions {
|
||||||
ManageOwnFollows = "owner:follow",
|
ManageOwnFollows = "owner:follow",
|
||||||
ManageOwnApps = "owner:app",
|
ManageOwnApps = "owner:app",
|
||||||
Search = "search",
|
Search = "search",
|
||||||
|
UsePushNotifications = "push_notifications",
|
||||||
ViewPublicTimelines = "public_timelines",
|
ViewPublicTimelines = "public_timelines",
|
||||||
ViewPrivateTimelines = "private_timelines",
|
ViewPrivateTimelines = "private_timelines",
|
||||||
IgnoreRateLimits = "ignore_rate_limits",
|
IgnoreRateLimits = "ignore_rate_limits",
|
||||||
|
|
@ -583,6 +627,7 @@ export const DEFAULT_ROLES = [
|
||||||
RolePermissions.ManageOwnFollows,
|
RolePermissions.ManageOwnFollows,
|
||||||
RolePermissions.ManageOwnApps,
|
RolePermissions.ManageOwnApps,
|
||||||
RolePermissions.Search,
|
RolePermissions.Search,
|
||||||
|
RolePermissions.UsePushNotifications,
|
||||||
RolePermissions.ViewPublicTimelines,
|
RolePermissions.ViewPublicTimelines,
|
||||||
RolePermissions.ViewPrivateTimelines,
|
RolePermissions.ViewPrivateTimelines,
|
||||||
RolePermissions.OAuth,
|
RolePermissions.OAuth,
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
"@oclif/core": "^4.2.0",
|
"@oclif/core": "^4.2.0",
|
||||||
"@sentry/bun": "^8.47.0",
|
"@sentry/bun": "^8.47.0",
|
||||||
"@tufjs/canonical-json": "^2.0.0",
|
"@tufjs/canonical-json": "^2.0.0",
|
||||||
"@versia/client": "^0.1.4",
|
"@versia/client": "^0.1.5",
|
||||||
"@versia/federation": "^0.1.4",
|
"@versia/federation": "^0.1.4",
|
||||||
"@versia/kit": "workspace:*",
|
"@versia/kit": "workspace:*",
|
||||||
"altcha-lib": "^1.2.0",
|
"altcha-lib": "^1.2.0",
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,4 @@ export { Like } from "~/classes/database/like.ts";
|
||||||
export { Token } from "~/classes/database/token.ts";
|
export { Token } from "~/classes/database/token.ts";
|
||||||
export { Notification } from "~/classes/database/notification.ts";
|
export { Notification } from "~/classes/database/notification.ts";
|
||||||
export { Reaction } from "~/classes/database/reaction.ts";
|
export { Reaction } from "~/classes/database/reaction.ts";
|
||||||
|
export { PushSubscription } from "~/classes/database/pushsubscription.ts";
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,10 @@ export const checkRouteNeedsChallenge = async (
|
||||||
|
|
||||||
type HonoEnvWithAuth = HonoEnv & {
|
type HonoEnvWithAuth = HonoEnv & {
|
||||||
Variables: {
|
Variables: {
|
||||||
auth: AuthData & { user: NonNullable<AuthData["user"]> };
|
auth: AuthData & {
|
||||||
|
user: NonNullable<AuthData["user"]>;
|
||||||
|
token: NonNullable<AuthData["token"]>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue