chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user