326 lines
9.3 KiB
TypeScript
326 lines
9.3 KiB
TypeScript
import {
|
|
WEB_PUSH_PROTOCOLS,
|
|
webPushFailure,
|
|
webPushSuccess,
|
|
type PushAuthoritySnapshot,
|
|
type WebPushResult,
|
|
} from "../../contracts/web-push.ts";
|
|
|
|
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
|
|
export const WEB_PUSH_REGISTRATION_OPERATIONS = Object.freeze({
|
|
register: "REGISTER_WEB_PUSH_SUBSCRIPTION",
|
|
reconcile: "RECONCILE_WEB_PUSH_SUBSCRIPTION",
|
|
revoke: "REVOKE_WEB_PUSH_ASSOCIATION",
|
|
} as const);
|
|
|
|
export type NativePushSubscriptionMaterial = Readonly<{
|
|
endpoint: string;
|
|
p256dh: string;
|
|
auth: string;
|
|
expirationTime: number | null;
|
|
}>;
|
|
|
|
export type WebPushRegistrationExecutor = Readonly<{
|
|
execute(input: Readonly<{
|
|
operationId:
|
|
(typeof WEB_PUSH_REGISTRATION_OPERATIONS)[keyof typeof WEB_PUSH_REGISTRATION_OPERATIONS];
|
|
body: unknown;
|
|
idempotencyKey?: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<unknown>>;
|
|
}>;
|
|
|
|
export type WebPushRegistrationCommit = Readonly<{
|
|
associationEpoch: string;
|
|
sessionBindingEpoch: string;
|
|
}>;
|
|
|
|
export type WebPushReconciliation =
|
|
| Readonly<{ state: "ABSENT" }>
|
|
| Readonly<{
|
|
state: "ACTIVE";
|
|
associationEpoch: string;
|
|
sessionBindingEpoch: string;
|
|
}>;
|
|
|
|
export interface WebPushRegistrationGateway {
|
|
register(input: Readonly<{
|
|
material: NativePushSubscriptionMaterial;
|
|
authority: PushAuthoritySnapshot;
|
|
idempotencyKey: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<WebPushRegistrationCommit>>;
|
|
|
|
reconcile(input: Readonly<{
|
|
material: NativePushSubscriptionMaterial;
|
|
authority: PushAuthoritySnapshot;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<WebPushReconciliation>>;
|
|
|
|
revoke(input: Readonly<{
|
|
associationEpoch: string;
|
|
signal?: AbortSignal;
|
|
}>): Promise<WebPushResult<Readonly<{
|
|
state: "REVOKED" | "ALREADY_GONE";
|
|
}>>>;
|
|
}
|
|
|
|
export function createWebPushRegistrationGateway(
|
|
executor: WebPushRegistrationExecutor,
|
|
): WebPushRegistrationGateway {
|
|
const gateway: WebPushRegistrationGateway = {
|
|
async register(input) {
|
|
if (
|
|
!validMaterial(input.material) ||
|
|
!validAuthority(input.authority) ||
|
|
!validOpaqueId(input.idempotencyKey)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_CREATE");
|
|
}
|
|
const response = await execute(
|
|
{
|
|
operationId: WEB_PUSH_REGISTRATION_OPERATIONS.register,
|
|
body: Object.freeze({
|
|
protocol: "WEB_PUSH_REGISTER_COMMAND_V1",
|
|
subscription: snapshotMaterial(input.material),
|
|
fenceGeneration: input.authority.fenceGeneration,
|
|
sessionBindingEpoch: input.authority.sessionBindingEpoch,
|
|
releaseEpoch: input.authority.releaseEpoch,
|
|
}),
|
|
idempotencyKey: input.idempotencyKey,
|
|
signal: input.signal,
|
|
},
|
|
"SUBSCRIPTION_CREATE",
|
|
);
|
|
if (!response.ok) return response;
|
|
const decoded = decodeRegistration(response.value);
|
|
return decoded
|
|
? webPushSuccess(decoded)
|
|
: webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_CREATE");
|
|
},
|
|
|
|
async reconcile(input) {
|
|
if (
|
|
!validMaterial(input.material) ||
|
|
!validAuthority(input.authority)
|
|
) {
|
|
return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_RECONCILE");
|
|
}
|
|
const response = await execute(
|
|
{
|
|
operationId: WEB_PUSH_REGISTRATION_OPERATIONS.reconcile,
|
|
body: Object.freeze({
|
|
protocol: "WEB_PUSH_RECONCILE_COMMAND_V1",
|
|
subscription: snapshotMaterial(input.material),
|
|
fenceGeneration: input.authority.fenceGeneration,
|
|
sessionBindingEpoch: input.authority.sessionBindingEpoch,
|
|
releaseEpoch: input.authority.releaseEpoch,
|
|
}),
|
|
signal: input.signal,
|
|
},
|
|
"SUBSCRIPTION_RECONCILE",
|
|
);
|
|
if (!response.ok) return response;
|
|
const decoded = decodeReconciliation(response.value);
|
|
return decoded
|
|
? webPushSuccess(decoded)
|
|
: webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_RECONCILE");
|
|
},
|
|
|
|
async revoke(input) {
|
|
if (!validOpaqueId(input.associationEpoch)) {
|
|
return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_REVOKE");
|
|
}
|
|
const response = await execute(
|
|
{
|
|
operationId: WEB_PUSH_REGISTRATION_OPERATIONS.revoke,
|
|
body: Object.freeze({
|
|
protocol: "WEB_PUSH_REVOKE_COMMAND_V1",
|
|
associationEpoch: input.associationEpoch,
|
|
}),
|
|
signal: input.signal,
|
|
},
|
|
"SUBSCRIPTION_REVOKE",
|
|
);
|
|
if (!response.ok) return response;
|
|
const decoded = decodeRevoke(response.value);
|
|
return decoded
|
|
? webPushSuccess(decoded)
|
|
: webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_REVOKE");
|
|
},
|
|
};
|
|
return Object.freeze(gateway);
|
|
|
|
async function execute(
|
|
input: Parameters<WebPushRegistrationExecutor["execute"]>[0],
|
|
operation:
|
|
| "SUBSCRIPTION_CREATE"
|
|
| "SUBSCRIPTION_RECONCILE"
|
|
| "SUBSCRIPTION_REVOKE",
|
|
): Promise<WebPushResult<unknown>> {
|
|
try {
|
|
return await executor.execute(input);
|
|
} catch {
|
|
return webPushFailure("NATIVE_FAILURE", operation, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
function decodeRegistration(
|
|
value: unknown,
|
|
): WebPushRegistrationCommit | null {
|
|
if (!exactRecord(value, [
|
|
"associationEpoch",
|
|
"protocol",
|
|
"sessionBindingEpoch",
|
|
])) {
|
|
return null;
|
|
}
|
|
return value.protocol === WEB_PUSH_PROTOCOLS.registration &&
|
|
validOpaqueId(value.associationEpoch) &&
|
|
validOpaqueId(value.sessionBindingEpoch)
|
|
? Object.freeze({
|
|
associationEpoch: value.associationEpoch,
|
|
sessionBindingEpoch: value.sessionBindingEpoch,
|
|
})
|
|
: null;
|
|
}
|
|
|
|
function decodeReconciliation(
|
|
value: unknown,
|
|
): WebPushReconciliation | null {
|
|
if (
|
|
exactRecord(value, ["protocol", "state"]) &&
|
|
value.protocol === WEB_PUSH_PROTOCOLS.reconciliation &&
|
|
value.state === "ABSENT"
|
|
) {
|
|
return Object.freeze({ state: "ABSENT" });
|
|
}
|
|
if (
|
|
!exactRecord(value, [
|
|
"associationEpoch",
|
|
"protocol",
|
|
"sessionBindingEpoch",
|
|
"state",
|
|
]) ||
|
|
value.protocol !== WEB_PUSH_PROTOCOLS.reconciliation ||
|
|
value.state !== "ACTIVE" ||
|
|
!validOpaqueId(value.associationEpoch) ||
|
|
!validOpaqueId(value.sessionBindingEpoch)
|
|
) {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
state: "ACTIVE",
|
|
associationEpoch: value.associationEpoch,
|
|
sessionBindingEpoch: value.sessionBindingEpoch,
|
|
});
|
|
}
|
|
|
|
function decodeRevoke(
|
|
value: unknown,
|
|
): Readonly<{ state: "REVOKED" | "ALREADY_GONE" }> | null {
|
|
return exactRecord(value, ["protocol", "state"]) &&
|
|
value.protocol === WEB_PUSH_PROTOCOLS.revoke &&
|
|
(value.state === "REVOKED" || value.state === "ALREADY_GONE")
|
|
? Object.freeze({ state: value.state })
|
|
: null;
|
|
}
|
|
|
|
function exactRecord(
|
|
value: unknown,
|
|
keys: readonly string[],
|
|
): value is Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const actual = Object.keys(value).sort();
|
|
return (
|
|
actual.length === keys.length &&
|
|
actual.every((key, index) => key === keys[index])
|
|
);
|
|
}
|
|
|
|
function validOpaqueId(value: unknown): value is string {
|
|
return typeof value === "string" && OPAQUE_ID.test(value);
|
|
}
|
|
|
|
function validAuthority(value: PushAuthoritySnapshot): boolean {
|
|
return (
|
|
validOpaqueId(value.fenceGeneration) &&
|
|
validOpaqueId(value.sessionBindingEpoch) &&
|
|
validOpaqueId(value.releaseEpoch)
|
|
);
|
|
}
|
|
|
|
function validMaterial(value: NativePushSubscriptionMaterial): boolean {
|
|
const p256dh = base64UrlDecode(value?.p256dh);
|
|
const auth = base64UrlDecode(value?.auth);
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
typeof value.endpoint !== "string" ||
|
|
value.endpoint.length > 4_096 ||
|
|
!/^[A-Za-z0-9_-]{87}$/u.test(value.p256dh) ||
|
|
!/^[A-Za-z0-9_-]{22}$/u.test(value.auth) ||
|
|
p256dh?.byteLength !== 65 ||
|
|
p256dh[0] !== 4 ||
|
|
auth?.byteLength !== 16 ||
|
|
(value.expirationTime !== null &&
|
|
(!Number.isFinite(value.expirationTime) ||
|
|
value.expirationTime <= 0))
|
|
) {
|
|
return false;
|
|
}
|
|
try {
|
|
const endpoint = new URL(value.endpoint);
|
|
return (
|
|
endpoint.protocol === "https:" &&
|
|
!endpoint.username &&
|
|
!endpoint.password &&
|
|
!endpoint.hash
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function snapshotMaterial(
|
|
value: NativePushSubscriptionMaterial,
|
|
): NativePushSubscriptionMaterial {
|
|
return Object.freeze({
|
|
endpoint: value.endpoint,
|
|
p256dh: value.p256dh,
|
|
auth: value.auth,
|
|
expirationTime: value.expirationTime,
|
|
});
|
|
}
|
|
|
|
function base64UrlDecode(value: unknown): Uint8Array | null {
|
|
if (typeof value !== "string") return null;
|
|
const alphabet =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
const output = new Uint8Array(
|
|
new ArrayBuffer(Math.floor((value.length * 6) / 8)),
|
|
);
|
|
let accumulator = 0;
|
|
let bitCount = 0;
|
|
let outputIndex = 0;
|
|
for (const character of value) {
|
|
const digit = alphabet.indexOf(character);
|
|
if (digit < 0) return null;
|
|
accumulator = (accumulator << 6) | digit;
|
|
bitCount += 6;
|
|
if (bitCount >= 8) {
|
|
bitCount -= 8;
|
|
output[outputIndex] = (accumulator >> bitCount) & 0xff;
|
|
outputIndex += 1;
|
|
}
|
|
}
|
|
return bitCount < 6 &&
|
|
outputIndex === output.length &&
|
|
(bitCount === 0 ||
|
|
(accumulator & ((1 << bitCount) - 1)) === 0)
|
|
? output
|
|
: null;
|
|
}
|