Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ INTEGRATION_ENCRYPTION_KEY_VERSION=1
# T3CODE_BOT_TOKEN=
# AI_MODEL=

# iOS push notifications: an APNs auth key (Apple Developer > Keys, with APNs enabled). The key is
# the .p8 file's PEM, or its base64 body. Local workerd cannot reach APNs (no HTTP/2), so pushes
# fail there; they work on deployed stages.
# APNS_KEY_ID=
# APNS_TEAM_ID=
# APNS_PRIVATE_KEY=

# Maple's OAuth tokens for the public API v1
# CLERK_SECRET_KEY=

Expand Down
60 changes: 60 additions & 0 deletions apps/backend/src/rpc/handlers/push-devices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Runs in the caller's `UserObject`, which holds their push devices (`worker/objects/user/push.ts`).
* The api Worker routes every call to the caller's own object; the handlers refuse any other.
* Registering also claims the token in the directory, which takes it from a previous owner
* (`worker/objects/user/push-ownership.ts`).
*/
import { Database } from "@hazel/db"
import { CurrentUser, ErrorUtils, policy, withRemapDbErrors } from "@hazel/domain"
import { PushDeviceRpcs } from "@hazel/domain/rpc"
import { Effect } from "effect"
import { currentObject } from "../../lib/current-object"
import { registerDevice, unregisterDevice } from "../../worker/objects/user/push"
import { claimToken, releaseToken } from "../../worker/objects/user/push-ownership"

/** The actor owns the `UserObject` the call runs in. */
const ownObject = (action: string) =>
ErrorUtils.refailUnauthorized(
"PushDevice",
action,
)(
policy("PushDevice", action, (actor) =>
Effect.map(
currentObject,
(object) =>
object._tag === "Some" && object.value.kind === "user" && object.value.id === actor.id,
),
),
)

export const PushDeviceRpcLive = PushDeviceRpcs.toLayer(
Effect.gen(function* () {
const db = yield* Database.Database

return {
"pushDevice.register": (payload) =>
db
.transaction(
Effect.gen(function* () {
yield* ownObject("register")
const user = yield* CurrentUser.Context
const registeredAt = yield* registerDevice(payload)
yield* claimToken(user.id, payload.token, registeredAt)
}),
)
.pipe(withRemapDbErrors("PushDevice", "create")),

"pushDevice.unregister": ({ token }) =>
db
.transaction(
Effect.gen(function* () {
yield* ownObject("unregister")
const user = yield* CurrentUser.Context
yield* unregisterDevice(token)
yield* releaseToken(user.id, token)
}),
)
.pipe(withRemapDbErrors("PushDevice", "delete")),
}
}),
)
2 changes: 2 additions & 0 deletions apps/backend/src/rpc/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export const routes: RouteTable = {
"userPresenceStatus.update": user,
"userPresenceStatus.heartbeat": user,
"userPresenceStatus.clearStatus": user,
"pushDevice.register": user,
"pushDevice.unregister": user,

// OrgObject
// The org's search index, filtered by the caller's `channel_access` there.
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/rpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
OrganizationMemberRpcs,
OrganizationRpcs,
PinnedMessageRpcs,
PushDeviceRpcs,
TypingIndicatorRpcs,
UserPresenceStatusRpcs,
UserRpcs,
Expand All @@ -47,6 +48,7 @@ import { NotificationRpcLive } from "./handlers/notifications"
import { OrganizationMemberRpcLive } from "./handlers/organization-members"
import { OrganizationRpcLive } from "./handlers/organizations"
import { PinnedMessageRpcLive } from "./handlers/pinned-messages"
import { PushDeviceRpcLive } from "./handlers/push-devices"
import { TypingIndicatorRpcLive } from "./handlers/typing-indicators"
import { UserPresenceStatusRpcLive } from "./handlers/user-presence-status"
import { UserRpcLive } from "./handlers/users"
Expand Down Expand Up @@ -77,6 +79,7 @@ const BaseRpcs = MessageRpcs.merge(
OrganizationMemberRpcs,
UserRpcs,
UserPresenceStatusRpcs,
PushDeviceRpcs,
).merge(
ChannelRpcs,
ChannelMemberRpcs,
Expand Down Expand Up @@ -110,6 +113,7 @@ const rpcGroups = [
["OrganizationMemberRpcs", OrganizationMemberRpcs],
["UserRpcs", UserRpcs],
["UserPresenceStatusRpcs", UserPresenceStatusRpcs],
["PushDeviceRpcs", PushDeviceRpcs],
["ChannelRpcs", ChannelRpcs],
["ChannelMemberRpcs", ChannelMemberRpcs],
["ChannelSectionRpcs", ChannelSectionRpcs],
Expand Down Expand Up @@ -152,6 +156,7 @@ export const RpcHandlersLive = Layer.empty
Layer.provideMerge(OrganizationMemberRpcLive),
Layer.provideMerge(UserRpcLive),
Layer.provideMerge(UserPresenceStatusRpcLive),
Layer.provideMerge(PushDeviceRpcLive),
)
.pipe(
Layer.provideMerge(ChannelRpcLive),
Expand Down
212 changes: 212 additions & 0 deletions apps/backend/src/services/apns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { ConfigProvider, Effect, Layer } from "effect"
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"
import type { HttpClientRequest } from "effect/http"
import { describe, expect, it } from "vitest"
import { ApnsClient, type ApnsNotification, importApnsKey, pkcs8Of, signProviderToken } from "./apns"

const decoder = new TextDecoder()

const fromBase64Url = (value: string) =>
Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0))

const json = (part: string) => JSON.parse(decoder.decode(fromBase64Url(part)))

const generateKey = async () => {
const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
"sign",
"verify",
])) as CryptoKeyPair
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey))
const base64 = btoa(String.fromCharCode(...pkcs8))
const pem = `-----BEGIN PRIVATE KEY-----\n${base64.match(/.{1,64}/g)!.join("\n")}\n-----END PRIVATE KEY-----`
return { pair, base64, pem }
}

const notification: ApnsNotification = {
environment: "development",
token: "ab".repeat(32),
topic: "app.hazel.ios",
expiration: 1_900_000_000,
collapseId: "n-1",
payload: {
aps: {
alert: { title: "Ada · #general", body: "hi" },
sound: "default",
badge: 2,
"thread-id": "c-1",
},
hazel: { path: "/acme/chat/c-1", channelId: "c-1", notificationId: "n-1" },
},
}

describe("APNs provider token", () => {
it("signs an ES256 JWT the public key verifies", async () => {
const { pair, pem } = await generateKey()
const key = await importApnsKey(pem)
const token = await signProviderToken({
key,
keyId: "KEY123",
teamId: "TEAM456",
nowMs: 1_800_000_000_500,
})
const [header, claims, signature] = token.split(".")
expect(json(header!)).toEqual({ alg: "ES256", kid: "KEY123" })
expect(json(claims!)).toEqual({ iss: "TEAM456", iat: 1_800_000_000 })
const raw = fromBase64Url(signature!)
// JWS ES256: the raw r || s, 64 bytes.
expect(raw.length).toBe(64)
const valid = await crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
pair.publicKey,
raw,
new TextEncoder().encode(`${header}.${claims}`),
)
expect(valid).toBe(true)
})

it("reads the key as PEM, PEM with escaped newlines, or bare base64", async () => {
const { base64, pem } = await generateKey()
const bytes = pkcs8Of(base64)
expect(pkcs8Of(pem)).toEqual(bytes)
expect(pkcs8Of(pem.replace(/\n/g, "\\n"))).toEqual(bytes)
})
})

describe("ApnsClient", () => {
const clientWith = async (answer: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const { pem } = await generateKey()
const requests: Array<HttpClientRequest.HttpClientRequest> = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
requests.push(request)
return HttpClientResponse.fromWeb(request, answer(request))
}),
),
)
const config = ConfigProvider.layer(
ConfigProvider.fromUnknown({
APNS_KEY_ID: "KEY123",
APNS_TEAM_ID: `TEAM-${crypto.randomUUID()}`,
APNS_PRIVATE_KEY: pem,
}),
)
const layer = ApnsClient.layer.pipe(Layer.provide(Layer.mergeAll(http, config)))
return { layer, requests }
}

it("posts to the environment's host with the APNs headers, reusing one provider token", async () => {
const { layer, requests } = await clientWith(() => new Response(null, { status: 200 }))
const results = await Effect.runPromise(
Effect.gen(function* () {
const apns = yield* ApnsClient
expect(apns.enabled).toBe(true)
const first = yield* apns.send(notification)
const second = yield* apns.send({ ...notification, environment: "production" })
return [first, second]
}).pipe(Effect.provide(layer)),
)
expect(results).toEqual([
{ status: 200, reason: null },
{ status: 200, reason: null },
])
expect(requests.map((request) => request.url)).toEqual([
`https://api.sandbox.push.apple.com/3/device/${notification.token}`,
`https://api.push.apple.com/3/device/${notification.token}`,
])
const headers = requests[0]!.headers
expect(headers["apns-topic"]).toBe("app.hazel.ios")
expect(headers["apns-push-type"]).toBe("alert")
expect(headers["apns-priority"]).toBe("10")
expect(headers["apns-expiration"]).toBe("1900000000")
expect(headers["apns-collapse-id"]).toBe("n-1")
// No trace propagation to Apple.
expect(headers.traceparent).toBeUndefined()
expect(headers.b3).toBeUndefined()
expect(headers.authorization).toMatch(/^bearer [\w-]+\.[\w-]+\.[\w-]+$/)
expect(requests[1]!.headers.authorization).toBe(headers.authorization)
const body = requests[0]!.body
expect(body._tag).toBe("Uint8Array")
if (body._tag === "Uint8Array") {
expect(JSON.parse(decoder.decode(body.body))).toEqual(notification.payload)
}
})

it("reads the reason of a refusal", async () => {
const { layer } = await clientWith(
() => new Response(JSON.stringify({ reason: "Unregistered", timestamp: 1 }), { status: 410 }),
)
const result = await Effect.runPromise(
ApnsClient.use((apns) => apns.send(notification)).pipe(Effect.provide(layer)),
)
expect(result).toEqual({ status: 410, reason: "Unregistered" })
})

it("re-signs the provider token once APNs calls it expired", async () => {
let answers = [403, 200]
const { layer, requests } = await clientWith(() => {
const status = answers[0]!
answers = answers.slice(1)
return status === 403
? new Response(JSON.stringify({ reason: "ExpiredProviderToken" }), { status })
: new Response(null, { status })
})
const results = await Effect.runPromise(
ApnsClient.use((apns) => Effect.all([apns.send(notification), apns.send(notification)])).pipe(
Effect.provide(layer),
),
)
expect(results).toEqual([
{ status: 403, reason: "ExpiredProviderToken" },
{ status: 200, reason: null },
])
expect(requests[1]!.headers.authorization).not.toBe(requests[0]!.headers.authorization)
})

it("fails a request that never reached APNs without naming the device", async () => {
const { pem } = await generateKey()
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request, description: "no HTTP/2" }),
}),
),
),
)
const config = ConfigProvider.layer(
ConfigProvider.fromUnknown({ APNS_KEY_ID: "K", APNS_TEAM_ID: "T", APNS_PRIVATE_KEY: pem }),
)
const error = await Effect.runPromise(
ApnsClient.use((apns) => Effect.flip(apns.send(notification))).pipe(
Effect.provide(ApnsClient.layer.pipe(Layer.provide(Layer.mergeAll(http, config)))),
),
)
expect(error._tag).toBe("ApnsRequestError")
expect(error.message).toBe("APNs request failed (TransportError)")
expect(JSON.stringify(error)).not.toContain(notification.token)
})

it("is off without config", async () => {
const enabled = await Effect.runPromise(
ApnsClient.use((apns) => Effect.succeed(apns.enabled)).pipe(
Effect.provide(
ApnsClient.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("no request expected")),
),
ConfigProvider.layer(ConfigProvider.fromUnknown({})),
),
),
),
),
),
)
expect(enabled).toBe(false)
})
})
Loading
Loading