@superwall/server 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,73 +0,0 @@
1
- $ bun test
2
- bun test v1.3.11 (af24e281)
3
-
4
- src/requires.test.ts:
5
- (pass) sw.requires() > calls next() when entitled [1.40ms]
6
- (pass) sw.requires() > default 403 when not entitled [0.13ms]
7
- (pass) sw.requires() > fail-closed when no userId can be extracted [0.06ms]
8
- (pass) sw.requires() > allowAnonymous lets the handler decide [0.05ms]
9
- (pass) sw.requires() > onUnauthorized override [1.02ms]
10
- (pass) sw.requires() > per-route userId override wins [0.24ms]
11
- (pass) sw.requires() > network errors pass to next(err) [0.14ms]
12
- (pass) sw.requires() > multiple-AND spec rejects when any missing [0.07ms]
13
- (pass) sw.requires() > ANY spec passes when one active [0.06ms]
14
- (pass) sw.requires() > normalizes spec at registration time (fails loud) [0.04ms]
15
-
16
- src/cache.test.ts:
17
- (pass) InMemoryCache > get returns null on miss
18
- (pass) InMemoryCache > set + get returns the entry [0.08ms]
19
- (pass) InMemoryCache > expired entry is dropped on read [0.04ms]
20
- (pass) InMemoryCache > delete removes the entry [0.03ms]
21
- (pass) InMemoryCache > clear empties the cache [0.03ms]
22
- (pass) InMemoryCache > LRU evicts oldest when maxEntries exceeded [0.12ms]
23
- (pass) InMemoryCache > read refreshes LRU position so recent reads survive eviction [0.04ms]
24
-
25
- src/superwall.test.ts:
26
- (pass) Superwall() factory > rejects missing apiKey [0.06ms]
27
- (pass) Superwall() factory > getEntitlements returns parsed bucket [0.29ms]
28
- (pass) Superwall() factory > userHas (string) returns true when entitlement is active [0.06ms]
29
- (pass) Superwall() factory > userHas (string) returns false when entitlement is inactive
30
- (pass) Superwall() factory > userHas (array) requires all [0.15ms]
31
- (pass) Superwall() factory > userHas ({ any }) returns true when any active [0.11ms]
32
- (pass) Superwall() factory > caches subsequent reads for the same userId [0.05ms]
33
- (pass) Superwall() factory > invalidate forces a refetch [0.09ms]
34
- (pass) Superwall() factory > invalidateAll clears every entry [0.09ms]
35
- (pass) Superwall() factory > TTL expiry forces a refetch [5.91ms]
36
- (pass) Superwall() factory > onRequest fires with cache hit info [0.15ms]
37
- (pass) Superwall() factory > onRequest throwing does not break the request [0.07ms]
38
-
39
- src/fetcher.test.ts:
40
- (pass) fetchEntitlements > parses customerInfo.entitlements envelope [0.28ms]
41
- (pass) fetchEntitlements > parses top-level entitlements array [0.10ms]
42
- (pass) fetchEntitlements > encodes the userId in the URL path [0.16ms]
43
- (pass) fetchEntitlements > includes Authorization and X-App-User-ID headers [0.25ms]
44
- (pass) fetchEntitlements > 401 → SuperwallAuthError [0.14ms]
45
- (pass) fetchEntitlements > 403 → SuperwallAuthError [0.10ms]
46
- (pass) fetchEntitlements > 404 → SuperwallNotFoundError [0.07ms]
47
- (pass) fetchEntitlements > 500 → SuperwallNetworkError with status [0.13ms]
48
- (pass) fetchEntitlements > fetch throw → SuperwallNetworkError [0.12ms]
49
- (pass) fetchEntitlements > AbortError → SuperwallTimeoutError [6.50ms]
50
- (pass) fetchEntitlements > malformed JSON → SuperwallDecodingError [0.16ms]
51
- (pass) fetchEntitlements > uses the configured environment for the host [0.05ms]
52
-
53
- src/spec.test.ts:
54
- (pass) normalizeSpec > string [0.02ms]
55
- (pass) normalizeSpec > array → all
56
- (pass) normalizeSpec > { all } [0.02ms]
57
- (pass) normalizeSpec > { any } [0.02ms]
58
- (pass) normalizeSpec > rejects empty string [0.03ms]
59
- (pass) normalizeSpec > rejects empty array [0.03ms]
60
- (pass) normalizeSpec > rejects empty all [0.02ms]
61
- (pass) normalizeSpec > rejects empty any [0.02ms]
62
- (pass) normalizeSpec > rejects malformed object [0.04ms]
63
- (pass) findMissing (mode: all) > returns empty when all entitlements active [0.07ms]
64
- (pass) findMissing (mode: all) > returns the missing entitlement
65
- (pass) findMissing (mode: all) > ignores extra active entitlements [0.03ms]
66
- (pass) findMissing (mode: all) > inactive entitlements are not counted [0.03ms]
67
- (pass) findMissing (mode: any) > returns empty when any one is active [0.03ms]
68
- (pass) findMissing (mode: any) > returns all listed when none active [0.02ms]
69
-
70
- 56 pass
71
- 0 fail
72
- 84 expect() calls
73
- Ran 56 tests across 5 files. [42.00ms]
@@ -1 +0,0 @@
1
- $ tsc --noEmit
package/src/cache.test.ts DELETED
@@ -1,70 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { InMemoryCache } from "./cache.ts";
3
- import type { CacheEntry } from "./types.ts";
4
-
5
- const entry = (id: string, expiresAt: number): CacheEntry => ({
6
- expiresAt,
7
- value: {
8
- active: [{ id, type: "SERVICE_LEVEL", isActive: true, productIds: [] }],
9
- inactive: [],
10
- all: [{ id, type: "SERVICE_LEVEL", isActive: true, productIds: [] }],
11
- },
12
- });
13
-
14
- describe("InMemoryCache", () => {
15
- test("get returns null on miss", () => {
16
- const c = new InMemoryCache(10);
17
- expect(c.get("nope")).toBeNull();
18
- });
19
-
20
- test("set + get returns the entry", () => {
21
- const c = new InMemoryCache(10);
22
- const e = entry("pro", Date.now() + 1000);
23
- c.set("u1", e);
24
- expect(c.get("u1")).toBe(e);
25
- });
26
-
27
- test("expired entry is dropped on read", () => {
28
- const c = new InMemoryCache(10);
29
- c.set("u1", entry("pro", Date.now() - 1));
30
- expect(c.get("u1")).toBeNull();
31
- expect(c.size).toBe(0);
32
- });
33
-
34
- test("delete removes the entry", () => {
35
- const c = new InMemoryCache(10);
36
- c.set("u1", entry("pro", Date.now() + 1000));
37
- c.delete("u1");
38
- expect(c.get("u1")).toBeNull();
39
- });
40
-
41
- test("clear empties the cache", () => {
42
- const c = new InMemoryCache(10);
43
- c.set("a", entry("pro", Date.now() + 1000));
44
- c.set("b", entry("pro", Date.now() + 1000));
45
- c.clear();
46
- expect(c.size).toBe(0);
47
- });
48
-
49
- test("LRU evicts oldest when maxEntries exceeded", () => {
50
- const c = new InMemoryCache(2);
51
- c.set("a", entry("pro", Date.now() + 1000));
52
- c.set("b", entry("pro", Date.now() + 1000));
53
- c.set("c", entry("pro", Date.now() + 1000));
54
- expect(c.size).toBe(2);
55
- expect(c.get("a")).toBeNull();
56
- expect(c.get("b")).not.toBeNull();
57
- expect(c.get("c")).not.toBeNull();
58
- });
59
-
60
- test("read refreshes LRU position so recent reads survive eviction", () => {
61
- const c = new InMemoryCache(2);
62
- c.set("a", entry("pro", Date.now() + 1000));
63
- c.set("b", entry("pro", Date.now() + 1000));
64
- c.get("a"); // refresh
65
- c.set("c", entry("pro", Date.now() + 1000));
66
- expect(c.get("a")).not.toBeNull();
67
- expect(c.get("b")).toBeNull();
68
- expect(c.get("c")).not.toBeNull();
69
- });
70
- });
package/src/cache.ts DELETED
@@ -1,53 +0,0 @@
1
- import type { CacheAdapter, CacheEntry } from "./types.ts";
2
-
3
- /**
4
- * In-memory LRU cache. Entries are evicted (a) on TTL expiry when read,
5
- * and (b) when the map exceeds `maxEntries` (oldest-insertion-order first).
6
- *
7
- * Single-process only. For multi-instance deployments, supply a Redis or
8
- * KV adapter via `cache.storage`.
9
- */
10
- export class InMemoryCache implements CacheAdapter {
11
- readonly #map = new Map<string, CacheEntry>();
12
- readonly #maxEntries: number;
13
-
14
- constructor(maxEntries: number) {
15
- this.#maxEntries = maxEntries;
16
- }
17
-
18
- get(key: string): CacheEntry | null {
19
- const entry = this.#map.get(key);
20
- if (!entry) return null;
21
- if (entry.expiresAt <= Date.now()) {
22
- this.#map.delete(key);
23
- return null;
24
- }
25
- // Refresh LRU position — re-insert moves to the end.
26
- this.#map.delete(key);
27
- this.#map.set(key, entry);
28
- return entry;
29
- }
30
-
31
- set(key: string, value: CacheEntry): void {
32
- if (this.#map.has(key)) this.#map.delete(key);
33
- this.#map.set(key, value);
34
- while (this.#map.size > this.#maxEntries) {
35
- const oldest = this.#map.keys().next().value;
36
- if (oldest === undefined) break;
37
- this.#map.delete(oldest);
38
- }
39
- }
40
-
41
- delete(key: string): void {
42
- this.#map.delete(key);
43
- }
44
-
45
- clear(): void {
46
- this.#map.clear();
47
- }
48
-
49
- // Test helper — current size.
50
- get size(): number {
51
- return this.#map.size;
52
- }
53
- }
@@ -1,183 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import {
3
- SuperwallAuthError,
4
- SuperwallDecodingError,
5
- SuperwallNetworkError,
6
- SuperwallNotFoundError,
7
- SuperwallTimeoutError,
8
- } from "@superwall/core";
9
- import { fetchEntitlements, type FetcherConfig } from "./fetcher.ts";
10
- import type { FetchLike } from "./types.ts";
11
-
12
- const realFetch = globalThis.fetch;
13
- const setFetch = (impl: FetchLike): void => {
14
- (globalThis as unknown as { fetch: FetchLike }).fetch = impl;
15
- };
16
-
17
- interface MockResponseInit {
18
- status?: number;
19
- json?: unknown;
20
- jsonThrows?: boolean;
21
- }
22
-
23
- const mockResponse = (init: MockResponseInit = {}): Response => {
24
- const status = init.status ?? 200;
25
- const body: string | null =
26
- init.json !== undefined ? JSON.stringify(init.json) : null;
27
- const res = new Response(body, {
28
- status,
29
- headers: { "content-type": "application/json" },
30
- });
31
- if (init.jsonThrows) {
32
- Object.defineProperty(res, "json", {
33
- value: () => Promise.reject(new SyntaxError("bad json")),
34
- });
35
- }
36
- return res;
37
- };
38
-
39
- const baseConfig = (overrides: Partial<FetcherConfig> = {}): FetcherConfig => ({
40
- apiKey: "key_test",
41
- environment: "release",
42
- timeoutMs: 1000,
43
- ...overrides,
44
- });
45
-
46
- const urlOf = (input: string | URL | Request): string =>
47
- typeof input === "string"
48
- ? input
49
- : input instanceof URL
50
- ? input.toString()
51
- : input.url;
52
-
53
- afterEach(() => {
54
- (globalThis as unknown as { fetch: typeof realFetch }).fetch = realFetch;
55
- });
56
-
57
- describe("fetchEntitlements", () => {
58
- test("parses customerInfo.entitlements envelope", async () => {
59
- setFetch(async () =>
60
- mockResponse({
61
- json: {
62
- customerInfo: {
63
- entitlements: [
64
- { id: "pro", isActive: true, productIds: ["p1"] },
65
- { id: "team", isActive: false, productIds: [] },
66
- ],
67
- },
68
- },
69
- }),
70
- );
71
- const ents = await fetchEntitlements(baseConfig(), "user_1");
72
- expect(ents.active.map((e) => e.id)).toEqual(["pro"]);
73
- expect(ents.inactive.map((e) => e.id)).toEqual(["team"]);
74
- expect(ents.all).toHaveLength(2);
75
- });
76
-
77
- test("parses top-level entitlements array", async () => {
78
- setFetch(async () =>
79
- mockResponse({
80
- json: {
81
- entitlements: [{ id: "pro", isActive: true, productIds: [] }],
82
- },
83
- }),
84
- );
85
- const ents = await fetchEntitlements(baseConfig(), "user_1");
86
- expect(ents.active.map((e) => e.id)).toEqual(["pro"]);
87
- });
88
-
89
- test("encodes the userId in the URL path", async () => {
90
- let calledUrl = "";
91
- setFetch(async (input) => {
92
- calledUrl = urlOf(input);
93
- return mockResponse({ json: { entitlements: [] } });
94
- });
95
- await fetchEntitlements(baseConfig(), "user/special:1");
96
- expect(calledUrl).toContain("/users/user%2Fspecial%3A1/entitlements");
97
- });
98
-
99
- test("includes Authorization and X-App-User-ID headers", async () => {
100
- let calledHeaders: Headers | null = null;
101
- setFetch(async (_input, init) => {
102
- calledHeaders = new Headers(init?.headers);
103
- return mockResponse({ json: { entitlements: [] } });
104
- });
105
- await fetchEntitlements(baseConfig(), "user_1");
106
- expect(calledHeaders!.get("authorization")).toBe("Bearer key_test");
107
- expect(calledHeaders!.get("x-app-user-id")).toBe("user_1");
108
- expect(calledHeaders!.get("x-platform")).toBe("Server");
109
- });
110
-
111
- test("401 → SuperwallAuthError", async () => {
112
- setFetch(async () => mockResponse({ status: 401 }));
113
- await expect(fetchEntitlements(baseConfig(), "u")).rejects.toBeInstanceOf(
114
- SuperwallAuthError,
115
- );
116
- });
117
-
118
- test("403 → SuperwallAuthError", async () => {
119
- setFetch(async () => mockResponse({ status: 403 }));
120
- await expect(fetchEntitlements(baseConfig(), "u")).rejects.toBeInstanceOf(
121
- SuperwallAuthError,
122
- );
123
- });
124
-
125
- test("404 → SuperwallNotFoundError", async () => {
126
- setFetch(async () => mockResponse({ status: 404 }));
127
- await expect(fetchEntitlements(baseConfig(), "u")).rejects.toBeInstanceOf(
128
- SuperwallNotFoundError,
129
- );
130
- });
131
-
132
- test("500 → SuperwallNetworkError with status", async () => {
133
- setFetch(async () => mockResponse({ status: 500 }));
134
- try {
135
- await fetchEntitlements(baseConfig(), "u");
136
- throw new Error("should have thrown");
137
- } catch (err) {
138
- expect(err).toBeInstanceOf(SuperwallNetworkError);
139
- expect((err as SuperwallNetworkError).status).toBe(500);
140
- }
141
- });
142
-
143
- test("fetch throw → SuperwallNetworkError", async () => {
144
- setFetch(async () => {
145
- throw new Error("ECONNREFUSED");
146
- });
147
- await expect(fetchEntitlements(baseConfig(), "u")).rejects.toBeInstanceOf(
148
- SuperwallNetworkError,
149
- );
150
- });
151
-
152
- test("AbortError → SuperwallTimeoutError", async () => {
153
- setFetch(async (_input, init) => {
154
- return new Promise<Response>((_resolve, reject) => {
155
- init?.signal?.addEventListener("abort", () => {
156
- const err = new Error("aborted");
157
- err.name = "AbortError";
158
- reject(err);
159
- });
160
- });
161
- });
162
- await expect(
163
- fetchEntitlements(baseConfig({ timeoutMs: 5 }), "u"),
164
- ).rejects.toBeInstanceOf(SuperwallTimeoutError);
165
- });
166
-
167
- test("malformed JSON → SuperwallDecodingError", async () => {
168
- setFetch(async () => mockResponse({ jsonThrows: true }));
169
- await expect(fetchEntitlements(baseConfig(), "u")).rejects.toBeInstanceOf(
170
- SuperwallDecodingError,
171
- );
172
- });
173
-
174
- test("uses the configured environment for the host", async () => {
175
- let calledUrl = "";
176
- setFetch(async (input) => {
177
- calledUrl = urlOf(input);
178
- return mockResponse({ json: { entitlements: [] } });
179
- });
180
- await fetchEntitlements(baseConfig({ environment: "developer" }), "u");
181
- expect(calledUrl).toContain("subscriptions-api.superwall.dev");
182
- });
183
- });
package/src/fetcher.ts DELETED
@@ -1,116 +0,0 @@
1
- import {
2
- parseEntitlements,
3
- resolveHosts,
4
- SuperwallAuthError,
5
- SuperwallDecodingError,
6
- SuperwallNetworkError,
7
- SuperwallNotFoundError,
8
- SuperwallTimeoutError,
9
- type Entitlements,
10
- type NetworkEnvironment,
11
- type WebEntitlementsResponse,
12
- } from "@superwall/core";
13
- import type { FetchLike } from "./types.ts";
14
-
15
- const SDK_VERSION = "0.0.0";
16
-
17
- export interface FetcherConfig {
18
- readonly apiKey: string;
19
- readonly environment: NetworkEnvironment;
20
- readonly timeoutMs: number;
21
- }
22
-
23
- const requireFetch = (): FetchLike => {
24
- if (typeof globalThis !== "undefined" && "fetch" in globalThis) {
25
- return globalThis.fetch.bind(globalThis) as FetchLike;
26
- }
27
- throw new SuperwallNetworkError(
28
- "No fetch implementation available — Superwall needs `globalThis.fetch` (Node 18+, Bun, Deno, Workers).",
29
- );
30
- };
31
-
32
- /**
33
- * GET /subscriptions-api/public/v1/users/{userId}/entitlements
34
- *
35
- * Maps status codes to typed error classes. Returns the parsed
36
- * Entitlements bucket on success.
37
- */
38
- export const fetchEntitlements = async (
39
- cfg: FetcherConfig,
40
- userId: string,
41
- ): Promise<Entitlements> => {
42
- const hosts = resolveHosts(cfg.environment);
43
- const url = `https://${hosts.subscriptions}/subscriptions-api/public/v1/users/${encodeURIComponent(userId)}/entitlements`;
44
- const headers: Record<string, string> = {
45
- Authorization: `Bearer ${cfg.apiKey}`,
46
- "Content-Type": "application/json",
47
- "X-Platform": "Server",
48
- "X-Platform-Environment": "SDK",
49
- "X-Platform-Wrapper": "Server",
50
- "X-SDK-Version": SDK_VERSION,
51
- "X-App-User-ID": userId,
52
- };
53
-
54
- const controller = new AbortController();
55
- const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
56
-
57
- let response: Response;
58
- try {
59
- response = await requireFetch()(url, {
60
- method: "GET",
61
- headers,
62
- signal: controller.signal,
63
- });
64
- } catch (cause) {
65
- clearTimeout(timer);
66
- if (cause instanceof Error && cause.name === "AbortError") {
67
- throw new SuperwallTimeoutError(
68
- `Entitlements request timed out after ${cfg.timeoutMs}ms`,
69
- { url, timeoutMs: cfg.timeoutMs },
70
- );
71
- }
72
- throw new SuperwallNetworkError(
73
- `Entitlements network error: ${describe(cause)}`,
74
- { url, cause },
75
- );
76
- }
77
- clearTimeout(timer);
78
-
79
- if (response.status === 401 || response.status === 403) {
80
- throw new SuperwallAuthError(
81
- `Entitlements auth failed (status ${response.status}). Check SUPERWALL_API_KEY.`,
82
- { url },
83
- );
84
- }
85
- if (response.status === 404) {
86
- throw new SuperwallNotFoundError(
87
- `Entitlements lookup returned 404 for user ${userId}.`,
88
- { url },
89
- );
90
- }
91
- if (!response.ok) {
92
- throw new SuperwallNetworkError(
93
- `Entitlements returned ${response.status}`,
94
- { url, status: response.status },
95
- );
96
- }
97
-
98
- let body: WebEntitlementsResponse;
99
- try {
100
- body = (await response.json()) as WebEntitlementsResponse;
101
- } catch (cause) {
102
- throw new SuperwallDecodingError(
103
- `Entitlements JSON decode failed: ${describe(cause)}`,
104
- { url, cause },
105
- );
106
- }
107
-
108
- return parseEntitlements(body);
109
- };
110
-
111
- const describe = (cause: unknown): string =>
112
- cause instanceof Error
113
- ? cause.message
114
- : typeof cause === "string"
115
- ? cause
116
- : JSON.stringify(cause);
package/src/index.ts DELETED
@@ -1,50 +0,0 @@
1
- // @superwall/server — server-side entitlement enforcement for the Superwall web SDK.
2
- //
3
- // Quickstart:
4
- //
5
- // import { Superwall } from "@superwall/server"
6
- //
7
- // const sw = Superwall({
8
- // apiKey: process.env.SUPERWALL_API_KEY!,
9
- // userId: (req) => req.session?.userId ?? null,
10
- // })
11
- //
12
- // app.get("/api/export", sw.requires("pro"), exportHandler)
13
- //
14
- // The browser SDK's local subscription state is writable from DevTools. This
15
- // package gates routes server-to-server against Superwall's `/entitlements`
16
- // endpoint so tampering with the client cannot grant access to real resources.
17
-
18
- export { Superwall } from "./superwall.ts";
19
- export type {
20
- SuperwallOptions,
21
- SuperwallInstance,
22
- EntitlementSpec,
23
- RequiresOptions,
24
- UserIdExtractor,
25
- CacheAdapter,
26
- CacheOptions,
27
- RequestInfo as OnRequestInfo,
28
- UnauthorizedContext,
29
- ConnectStyleRequest,
30
- ConnectStyleResponse,
31
- ConnectStyleNext,
32
- } from "./types.ts";
33
-
34
- // Re-export shared domain types so consumers don't need to also depend on @superwall/core.
35
- export type {
36
- Entitlement,
37
- Entitlements,
38
- SubscriptionStatus,
39
- NetworkEnvironment,
40
- CustomEnvironmentHosts,
41
- } from "@superwall/core";
42
-
43
- export {
44
- SuperwallError,
45
- SuperwallNetworkError,
46
- SuperwallAuthError,
47
- SuperwallNotFoundError,
48
- SuperwallTimeoutError,
49
- SuperwallDecodingError,
50
- } from "@superwall/core";