Skip to content
This repository was archived by the owner on Aug 30, 2026. It is now read-only.
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
48 changes: 48 additions & 0 deletions __tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ describe("node-sdk auth — request shaping", () => {
});
});

it("signOut forwards the optional pushDevice identifier for atomic push deregistration", async () => {
const { client, projectInstance } = makeClient();
await signOut(client, {
refreshToken: "rt1",
pushDevice: { platform: "android", token: "device-token-1" },
});
expect(projectInstance.post).toHaveBeenCalledWith("/auth/sign-out", {
refreshToken: "rt1",
pushDevice: { platform: "android", token: "device-token-1" },
});
});

it("signOut forwards a web pushDevice identifier as a subscription", async () => {
const { client, projectInstance } = makeClient();
const subscription = {
endpoint: "https://push.example/abc",
keys: { p256dh: "p", auth: "a" },
};
await signOut(client, {
refreshToken: "rt1",
pushDevice: { platform: "web", subscription },
});
expect(projectInstance.post).toHaveBeenCalledWith("/auth/sign-out", {
refreshToken: "rt1",
pushDevice: { platform: "web", subscription },
});
});

it("requestNewAccessToken posts the full body to /auth/request-new-access-token", async () => {
const { client, projectInstance } = makeClient();
await requestNewAccessToken(client, { refreshToken: "rt1" });
Expand Down Expand Up @@ -85,6 +113,26 @@ describe("node-sdk auth — request shaping", () => {
});
});

it("changePassword FORWARDS an optional pushDevice, naming the device to spare", async () => {
// A password change deletes every push binding that user holds. A
// service-key caller usually has no device to name and should omit this —
// but when its own client told it which handset it is on, naming that
// device keeps it receiving notifications while every other one goes quiet.
const { client, projectInstance } = makeClient();
await changePassword(client, {
userId: "u1",
password: "old-pw",
newPassword: "new-pw",
pushDevice: { platform: "ios", token: "device-of-the-handset-to-keep" },
});
expect(projectInstance.post).toHaveBeenCalledWith("/auth/change-password", {
userId: "u1",
password: "old-pw",
newPassword: "new-pw",
pushDevice: { platform: "ios", token: "device-of-the-handset-to-keep" },
});
});

it("verifyEmail posts the full body to /auth/verify-email", async () => {
const { client, projectInstance } = makeClient();
await verifyEmail(client, { token: "tok1" });
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ export type {
PushEventType,
MuteDuration,
NotificationPreferences,
PushDeviceIdentifier,
WebPushSubscription,
} from "./interfaces/Push";
export type { UpdateNotificationPreferencesProps } from "./modules/push/updateNotificationPreferences";
export type {
Expand Down
57 changes: 57 additions & 0 deletions src/interfaces/Push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,60 @@ export type MuteDuration = (typeof MUTE_DURATIONS)[number];
export interface NotificationPreferences {
disabledTypes: PushEventType[];
}

/**
* A Web Push subscription in its SERIALIZED form — the JSON the server reads,
* not the live browser object.
*
* A `PushSubscription` straight from `PushManager.subscribe()` does NOT satisfy
* this type. It exposes its keys only through `getKey(name)`; there is no `keys`
* property to read, so it is not assignable here under strict TypeScript, and
* passing one through a cast sends a body the server rejects.
*
* To produce a value of this type:
*
* 1. **Serialize** the subscription with `subscription.toJSON()`.
* 2. **Confirm** `endpoint`, `keys.p256dh` and `keys.auth` are all present —
* `PushSubscriptionJSON` types every one of them as optional, while the
* server requires all three as non-empty strings and 400s otherwise.
*
* ```ts
* const json = subscription.toJSON();
* if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
* throw new Error("Incomplete Web Push subscription");
* }
* const webPushSubscription: WebPushSubscription = {
* endpoint: json.endpoint,
* keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
* };
* ```
*
* Mirrors the server's `webPushSubscriptionSchema`
* (server `src/v7/validation/push-notifications/push-notifications.schema.ts`).
*
* On this server-side SDK the value normally arrives already serialized, from
* the browser client that created the subscription — the steps above are what
* that client must do before sending it to you, and the same checks are worth
* repeating on receipt.
*/
export interface WebPushSubscription {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}

/**
* Identifies one physical device. Mirrors the server's device-identifier body
* exactly (server `src/v7/validation/push-notifications/push-notifications.schema.ts`):
* a provider token for `ios`/`android`, a Web Push subscription for `web`. The
* union encodes the server's own cross-check — it rejects the same
* combinations the server's `superRefine` rejects.
*
* Passed to `auth.signOut` to unbind that device's push binding in the same
* transaction as the sign-out.
*/
export type PushDeviceIdentifier =
| { platform: "ios" | "android"; token: string }
| { platform: "web"; subscription: WebPushSubscription };
21 changes: 21 additions & 0 deletions src/modules/auth/changePassword.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import { SublayHttpClient } from "../../core/client";
import { PushDeviceIdentifier } from "../../interfaces/Push";

export interface ChangePasswordProps {
userId: string;
/** The user's current password (verified before the change). */
password: string;
newPassword: string;
/**
* Optional. One physical device whose push binding should SURVIVE the
* change.
*
* A password change deletes every push binding the user holds, so an
* intruder's device stops receiving notification content. A server-side
* caller normally has no device to name and should omit this — every binding
* then goes, which is the right default when acting on someone's behalf.
* Supply it only when your own client told you which device it is on and you
* want that one to keep receiving notifications.
*/
pushDevice?: PushDeviceIdentifier;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export interface ChangePasswordResponse {
success: boolean;
message: string;
}

/**
* Change a user's password.
*
* Ends every session for that user, so all their devices must sign in again
* with the new password. (A call made by a signed-in user with their own
* access token keeps that one session; a service key is not a session, so
* there is nothing to spare here.)
*/
export async function changePassword(
client: SublayHttpClient,
data: ChangePasswordProps
Expand Down
15 changes: 15 additions & 0 deletions src/modules/auth/signOut.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { SublayHttpClient } from "../../core/client";
import { PushDeviceIdentifier } from "../../interfaces/Push";

export interface SignOutProps {
refreshToken: string;
/**
* Optional. When supplied — and the project has the `push` bundle — the
* server deletes this user's push binding for that device in the SAME
* transaction as the token-family destroy: signing out unbinds the device's
* push, or nothing happens at all.
*
* If the unbind fails the request fails (HTTP 500, code
* `auth/device-deregistration-failed`) and NOTHING is committed — the
* session survives so the caller can retry. Do not treat a failed sign-out
* as signed out.
*
* Omit it and the request is byte-identical to before this field existed.
*/
pushDevice?: PushDeviceIdentifier;
}

export async function signOut(
Expand Down
Loading