UNET API - v0.0.0
    Preparing search index...

    UNET API - v0.0.0

    @unet/id-web

    Sign in with UNET for web apps. Authorization code flow + PKCE; you never touch passwords or keys.

    1. Developers Console → Apps → New app → Single-page site or mobile/desktop app (public app, no secret). Return URL = the page that finishes sign-in, e.g. https://app.example/auth/callback (or http://localhost:5173/auth/callback for dev).
    2. Use the App ID below.
    import { UnetId, AccessDenied } from "@unet/id-web";

    const unet = new UnetId({
    clientId: "unet_xxx",
    redirectUri: `${location.origin}/auth/callback`,
    scopes: ["openid", "profile", "offline_access"], // offline_access → refresh tokens
    });

    // any page, on startup
    const user = await unet.restore(); // UnetUser | null

    // "Sign in with UNET" button
    await unet.signIn({ returnTo: "/dashboard" }); // redirects to unet.social

    // /auth/callback page
    try {
    const { user, returnTo } = await unet.handleRedirectCallback();
    location.replace(returnTo);
    } catch (e) {
    if (e instanceof AccessDenied) { /* user pressed Deny */ }
    }

    // call your own backend with a fresh token (auto-refresh)
    const token = await unet.getAccessToken();

    await unet.signOut(); // revokes + clears

    Enable Read posts and profiles (content:read) and/or Search UNET (search:read) on your app in the Developers Console and request them in scopes. Calls act as the signed-in user (blocks, private follows and NSFW settings apply); the SDK attaches the token, refreshes it and retries once on 401.

    const page = await unet.api.feed({ limit: 20 });          // { posts, nextCursor }
    const more = await unet.api.feed({ cursor: page.nextCursor });
    const following = await unet.api.feed({ type: "following" }); // "global" (default) | "following" | "foryou"
    const tabs = await unet.api.userReplies("alice"); // + userReposts(name), userMusic(name): the profile tabs
    const post = await unet.api.post(123); // + counts, viewer.liked/reposted
    const prof = await unet.api.profile("@alice", { limit: 20 });// { profile, posts, nextOffset }
    const hits = await unet.api.search("unet"); // { users, posts, hashtags, topics }

    Errors: InsufficientScope (403), RateLimited (429, retryAfterSeconds), ApiError (status 404/400/5xx), SessionExpired (401). Limit: 120 requests/min per app and user. Users who granted content:read can remove your app anytime in unet.social → Settings → Connected apps (takes effect immediately).

    Scopes: notifications:read (likes, replies, mentions, follows — never DMs or account notices) and posts:write (publish as the user; cannot edit/delete posts, message or follow).

    const n = await unet.api.notifications({ limit: 50, unreadOnly: true }); // { notifications, unreadCount, nextOffset }
    await unet.api.markNotificationsRead(n.notifications[0].id); // no id = mark all; returns unread count

    // Text
    await unet.api.createPost({ content: "Hello from my UNET client" });

    // Everything first-party clients can do
    await unet.api.createPost({
    content: "Trip",
    longFormContent: "…", // long post (content = lead)
    images: [file1, file2], // ≤10; or `voice` (voice/video file or recording) or `audio` — audio stands alone
    voice, voiceDuration: 12_000, // MILLISECONDS (videoDuration for a video); audioDuration is in seconds
    gifUrl, hashtags: ["unet"], topicName: "Travel",
    quotePostId: 123, commentPermission: "mutual_follows", // everyone | mutual_follows | selected_users | disabled
    commentAllowedUserIds: [1, 2],
    isNsfw: false, isAiContent: false, allowTranslation: true, federateExclude: false,
    place: { id, name, lat, lon },
    scheduledAt: new Date(Date.now() + 3600_000),
    });

    Likes, reposts, comments (posts:interact; reading comments is content:read):

    await unet.api.like(postId);            // idempotent; unlike(), repost(), unrepost() likewise
    const { comments, nextOffset } = await unet.api.comments(postId, { sort: "newest" });
    const replies = await unet.api.replies(comments[0].id);
    const c = await unet.api.comment(postId, { content: "nice", parentId: comments[0].id }); // or { gifUrl }
    await unet.api.likeComment(c.id);
    await unet.api.deleteComment(c.id); // posts:manage

    A 403 from the API is either a missing permission (InsufficientScope) or a rule (ApiError with code: "not_allowed", e.g. comments disabled on that post, blocked, reposting your own post).

    Follows, saved, likes, safety, profile:

    await unet.api.follow("@alice");            // { following, pending } — pending for private accounts
    const { users } = await unet.api.followers("alice"); // 403 "connections_private" if hidden
    await unet.api.save(postId); const { posts, nextCursor } = await unet.api.saved(); // library:write / library:read
    const mine = await unet.api.likes({ cursor: nextCursor });
    await unet.api.block("spammer"); await unet.api.mute("loud"); // safety:manage
    await unet.api.report({ targetType: "post", targetId: 9, reason: "spam" });
    const article = await unet.api.longForm(postId); // full long-post text
    const file = await unet.api.media(post.video!.url); // video / audio file as a Blob (play via URL.createObjectURL)
    await unet.api.updateProfile({ bio: "Hi", links: [{ title: "Site", url: "https://example.com" }] }); // profile:write
    await unet.api.uploadAvatar(file); await unet.api.uploadBanner(file); await unet.api.removeBanner(); // profile:write (images ≤ 10 MB)
    const s = await unet.api.settings(); // settings:read — { privacy, notifications, content, feed }
    await unet.api.updateSettings({ privacy: { dmPrivacy: "followed" }, notifications: { likes: false } }); // settings:write, send only what changes
    await unet.api.addMutedKeyword("spoiler"); await unet.api.removeMutedKeyword("spoiler"); // settings:write
    await unet.api.subscribeToAuthor("alice"); await unet.api.unsubscribeFromAuthor("alice"); // follows:write — the profile bell (you must follow them)
    await unet.api.pinPost(postId); await unet.api.unpinPost(postId); // posts:manage — your own posts
    await unet.api.notInterested(postId); await unet.api.notInterestedItems(); await unet.api.removeNotInterested(id); // posts:interact / settings:read / settings:write
    const { count, users } = await unet.api.mutualConnections("alice"); // content:read (Profile.mutual has a preview)
    const likes = await unet.api.postLikes(postId, { limit: 20 }); // who liked YOUR post (also postReposts); 403 not_allowed on others' posts
    const fans = await unet.api.commentLikes(commentId); // same for YOUR comment (also commentReposts)
    const here = await unet.api.placePosts(post.place.id, { lat: post.place.lat, lon: post.place.lon }); // posts at a place
    const t = await unet.api.translatePost(postId); // { text, targetLang, sourceLang, cached }; post.allowTranslation says if it may

    Avatar/banner uploads are not exposed. Likes, saves and follows are idempotent. A 403 with code: "not_allowed" means a rule (blocked, private connections, cannot follow), not a missing permission.

    GIF picker (backed by the same provider as the UNET apps):

    const { gifs, nextOffset } = await unet.api.gifs.search("cat");   // or gifs.trending()
    await unet.api.createPost({ content: "meow", gifUrl: gifs[0].gifUrl }); // previewUrl is the small thumbnail

    Place, scheduled posts, edit and delete:

    const [cafe] = await unet.api.searchPlaces("blue bottle");   // posts:write → pass as createPost({ place: cafe })
    const queued = await unet.api.scheduledPosts(); // your unpublished posts (post.scheduledAt)
    await unet.api.updatePost(id, { content: "fixed typo", hashtags: ["unet"] }); // posts:manage
    await unet.api.reschedulePost(id, new Date(Date.now() + 3600_000));
    await unet.api.deletePost(id); // also cancels a scheduled post

    posts:manage is a separate permission ("Edit and delete your posts") so apps that only publish never get it.

    Media goes through the same virus/CSAM scans, size limits, moderation and AI detection as the UNET apps (request cap 250 MB). createPost always sends an Idempotency-Key (auto-generated); reuse your own key when you retry the same user action so it can never double-post. Limit: 10 posts / 10 min, 100 / day per app and user — see RateLimited.retryAfterSeconds.

    const rt = unet.api.realtime({ notifications: true, posts: [post.id], feeds: ["following"] });
    rt.on("notification", () => reloadNotifications()); // { kind, id, postId, commentId }
    rt.on("post", (e) => e.change !== "deleted" && reloadPost(e.postId)); // { postId, change }
    rt.on("feed", () => showNewPostsBanner()); // { feed, change: "new_post", postId }
    rt.on("open", (e) => e.reconnected && reloadEverythingOnScreen());
    rt.update({ notifications: true, posts: [other.id] }); // change what is watched
    rt.close();

    The server only says that something changed; you then call the ordinary endpoints, so every privacy rule applies. It uses server-sent events over fetch (the token is a header, never in the address), reconnects by itself with a growing pause, asks for a fresh token when needed and renews each stream before its token would expire. A missing permission, a post you may not see or a signed-out user ends that stream for good (error with fatal: true); direct messages never travel on it. Needs notifications:read for notifications and content:read for posts / feeds. If the server has no stream (older versions) the call fails with fatal and you keep polling.

    const unet = new UnetId({ ..., onTrace: (t) => console.log(t.sdk, t.method, t.url, t.status, t.ms, t.requestBody, t.responseBody) });
    

    Called after every HTTP request the SDK makes (discovery, token exchange, userinfo, API calls, revocation) with headers, bodies, status and timing; t.sdk says which SDK call caused it (e.g. api.feed(), token (refresh_token)). Tokens, codes and verifiers are shortened, so traces are safe to paste into a bug report. The test client in ../web-client renders this as a Debug panel with copy-as-cURL.

    unet.hasScope("posts:write");            // granted scopes of this session (getGrantedScopes())
    await unet.signIn({ prompt: "consent" }); // ask the user again after adding scopes (also loginHint, extraParams)

    const ctl = new AbortController();
    await unet.api.withSignal(ctl.signal).feed(); // cancel with ctl.abort() → rejects with AbortError

    const unet = new UnetId({ ..., timeoutMs: 60_000, retries: 2 }); // JSON calls time out (uploads don't); retries: 429 + GET 5xx, off by default

    import { paginate } from "@unet/id-web";
    for await (const page of paginate((p) => unet.api.feed({ cursor: p.cursor }))) show(page.posts);
    for await (const page of paginate((p) => unet.api.followers("alice", { offset: p.offset }))) show(page.users);

    await unet.restore({ revalidate: false }); // instant start from the cached profile, no network
    await unet.refreshUser(); // reload the profile

    With storage: "local" several tabs stay in sync (sign-in/out, profile) and token refreshes are serialised across tabs (Web Locks) so a rotating refresh token is never spent twice.

    • storage: "session" (default), "local" (survives restarts; readable by any script on your origin — use a strict CSP) or "memory"; or pass your own KeyValueStore.
    • A user can remove your app in unet.social → Settings → Connected apps; next refresh throws SessionExpired and the SDK clears the session.
    • Backend verification: the access token is opaque. On your server: const user = await verifyAccessToken(token) (import { verifyAccessToken } from "@unet/id-web", Node 18+; null = invalid token). Never trust a client-sent sub alone.
    • Errors: AccessDenied, StateMismatch, SessionExpired, NetworkError, base UnetIdError. A bare 403 without the server's insufficient_scope (proxy/WAF) is an ApiError with code: "forbidden"; a 2xx that is not JSON is ApiError invalid_response.
    npm install && npm test && npm run build
    
    • New: more of the viewer's relationship on people — Profile.viewer.followedBy / blocked / muted, Profile.connectionsVisible, SearchUser.followers / viewer, Notification.actor.isPrivate / viewer, and PersonRef.viewer (follow state) on follower, following, mutual and likes / reposts lists. All absent on older servers.
    • New: Fediverse profiles like UNET ones — api.lookupFediverseActor("name@host") (open a profile from a plain handle), api.fediverseActorPosts(id, { replies }) (their posts and their replies apart), api.fediverseActorConnections(id, "followers" | "following"), and FediverseItem.inReplyTo.
    • New: api.commentLikes() / api.commentReposts() — who liked / reposted your comment ("View activity" of a comment; the post twins are postLikes() / postReposts()).
    • New: api.realtime() — live hints (new notifications, changes on posts you show, new posts in a feed) over server-sent events: Realtime with on / update / close, automatic reconnect, parseEvents.
    • New: comment pictures — api.uploadCommentImage(), api.comment({ images }) (files, uploaded for you) or { imageKeys }, and Comment.images.
    • New: api.postSubscription() / subscribeToPost() / unsubscribeFromPost() — watch one post for new comment activity (the bell on the post page).
    • New: Comment.likedByAuthor on comment lists (the author of the post liked the comment).
    • New: api.postLikes() / api.postReposts() (the author's view of who reacted), api.translatePost(), api.uploadAvatar() / uploadBanner() / removeBanner(), Post.allowTranslation, api.placePosts(), Post.isPinned, Post.hashId (and api.post() accepts it), Post.video.posterUrl.
    • Fix: a failed refresh signs the person out only when the server says the grant is gone (invalid_grant); invalid_request, 5xx and network errors keep the tokens and the next call tries again.
    • New: api.fediverse() / setFediverse() / fediverseFeed() (the Fediverse: status and switch, and the feed of accounts you follow there).
    • New: the rest of the Fediverse — searchFediverse(), fediverseActor() / fediverseActorPosts(), followOnFediverse() / unfollowOnFediverse(), blockOnFediverse() / unblockOnFediverse() / fediverseBlocked(), fediversePost() / fediverseThread(), likeOnFediverse() / boostOnFediverse() (and their undo), replyOnFediverse() / updateFediverseReply() / deleteFediverseReply(), voteInFediversePoll(), fediverseHashtag().
    • Change: api.pinPost() fails with 409 pin_limit_reached at three pinned posts.
    • New: api.mediaLink() — a short-lived link a <video src> streams from (byte ranges, no full download).
    • New: api.hashtagPosts() and api.topicPosts() (posts of a hashtag or a topic).
    • New: api.subscribeToAuthor() / unsubscribeFromAuthor() (the profile bell, Profile.viewer.subscribed), api.pinPost() / unpinPost(), api.notInterested() / notInterestedItems() / removeNotInterested(), api.mutualConnections(), Profile.links, Profile.mutual, FeedSettings.excludedTopicIds / excludedTopics.
    • New: api.settings() / updateSettings() (privacy, notifications, translation and link previews, “For you” feed), addMutedKeyword() / removeMutedKeyword(); scopes settings:read and settings:write (request them in scope to use these).
    • Fix: a 403 is InsufficientScope only when the server says insufficient_scope; otherwise ApiError (forbidden).
    • Fix: a 2xx response that is not JSON throws ApiError (invalid_response) instead of returning {}.
    • Fix: refresh is serialised across tabs (Web Locks) and picks up tokens another tab already refreshed; storage: "local" tabs mirror sign-in/out.
    • Fix: callback iss is checked (RFC 9207) when the server sends it.
    • New: getGrantedScopes(), hasScope(), refreshUser(), restore({ revalidate: false }), cached discovery and profile.
    • New: signIn({ prompt, loginHint, extraParams }).
    • New: timeoutMs, retries / maxRetryDelayMs, api.withSignal(signal), paginate().
    • New: verifyAccessToken() for backends.
    • New: Profile.viewer (following, pending) when the server provides it.
    • Build: source maps and declaration maps, engines, CHANGELOG.
    • Initial release.