chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -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;
}