@softr/sdk 0.0.7 → 0.0.11

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.
@@ -0,0 +1,85 @@
1
+ import { r as User } from "./user-D23hwTsf.js";
2
+ import "@tanstack/react-start/server-only";
3
+
4
+ //#region src/auth/methods/utils.d.ts
5
+ type Result<T, E = Error> = {
6
+ success: false;
7
+ error: E;
8
+ } | {
9
+ success: true;
10
+ data: T;
11
+ };
12
+ type AuthError = {
13
+ code: AuthErrorCode;
14
+ message: string;
15
+ };
16
+ type AuthErrorCode = "invalidCredentials" | "tooManyAttempts" | "emailTaken" | "invalidDetails" | "invalidCode";
17
+ //#endregion
18
+ //#region src/auth/methods/password.d.ts
19
+ /**
20
+ * Signs a visitor in with an email and password.
21
+ *
22
+ * The cache is primed from the same `/me` the resolver would have called anyway,
23
+ * so the next request costs no round-trip.
24
+ */
25
+ declare function signIn(credentials: {
26
+ email: string;
27
+ password: string;
28
+ }): Promise<Result<User, AuthError>>;
29
+ /**
30
+ * Signs the visitor out and clears the session cookies.
31
+ *
32
+ * This revokes **every** outstanding session for that user in this app, on every
33
+ * device: the platform stamps `tokensInvalidatedAt` and rejects anything issued
34
+ * before it. There is no per-device sign-out.
35
+ *
36
+ * A platform that could not revoke leaves the caller signed out here and
37
+ * possibly still signed in elsewhere — loud outside production, logged in it,
38
+ * because the local half already succeeded.
39
+ */
40
+ declare function signOut(): Promise<void>;
41
+ //#endregion
42
+ //#region src/auth/methods/signup.d.ts
43
+ /**
44
+ * Starts a sign-up flow by emailing a verification code.
45
+ *
46
+ * User only exists once `confirmSignUp` returns the code.
47
+ */
48
+ declare function signUp(details: {
49
+ email: string;
50
+ name?: string;
51
+ }): Promise<Result<null, AuthError>>;
52
+ /**
53
+ * Finishes sign-up flow with the emailed code and signs the visitor in.
54
+ *
55
+ * The platform splits on how the app is configured: a code-only app creates the
56
+ * user on the second `sign-up` request and answers with a token, while a
57
+ * password app answers it with an empty 200 and expects the password on
58
+ * `users/new`.
59
+ */
60
+ declare function confirmSignUp(details: {
61
+ email: string;
62
+ /**
63
+ * What `users/new` creates the account with, and unused on a code-only app,
64
+ * which never reaches that request.
65
+ *
66
+ * TODO: temporary in one respect — it is spent a second time on the
67
+ * 400-after-create workaround below, which goes away with the platform fix.
68
+ */
69
+ password: string;
70
+ code: string;
71
+ name?: string;
72
+ }): Promise<Result<User, AuthError>>;
73
+ //#endregion
74
+ //#region src/auth/server.d.ts
75
+ /**
76
+ * Server-only auth methods, should be called from the app's own `createServerFn` handlers.
77
+ */
78
+ declare const auth: {
79
+ signIn: typeof signIn;
80
+ signOut: typeof signOut;
81
+ signUp: typeof signUp;
82
+ confirmSignUp: typeof confirmSignUp;
83
+ };
84
+ //#endregion
85
+ export { auth };
package/dist/server.js ADDED
@@ -0,0 +1,196 @@
1
+ import { a as readSessionToken, c as writeCookies, d as softAssert, f as todo, n as makeSessionDataCookie, o as fetchVisitor, r as makeSessionTokenCookie, s as platformFetch, t as clearSession, u as panic } from "./session-GUEI96C8.js";
2
+ import { z } from "zod";
3
+ import "@tanstack/react-start/server-only";
4
+
5
+ //#region src/auth/methods/utils.ts
6
+ function authError(code, message) {
7
+ return {
8
+ success: false,
9
+ error: {
10
+ code,
11
+ message
12
+ }
13
+ };
14
+ }
15
+ const errorSchema = z.object({ message: z.string() });
16
+ async function describe(response, action) {
17
+ const body = errorSchema.safeParse(await response.json().catch(() => null));
18
+ const said = body.success && body.data.message ? body.data.message : response.statusText;
19
+ return `Softr platform refused the ${action}: ${response.status} ${said}`.trim();
20
+ }
21
+
22
+ //#endregion
23
+ //#region src/auth/methods/password.ts
24
+ const signInBody = z.object({
25
+ jwt: z.string().nullish(),
26
+ authenticatorAppAction: z.string().nullish()
27
+ });
28
+ /**
29
+ * Signs a visitor in with an email and password.
30
+ *
31
+ * The cache is primed from the same `/me` the resolver would have called anyway,
32
+ * so the next request costs no round-trip.
33
+ */
34
+ async function signIn(credentials) {
35
+ const response = await platformFetch("sign-in", {
36
+ method: "POST",
37
+ headers: {
38
+ "content-type": "application/json",
39
+ accept: "application/json"
40
+ },
41
+ body: JSON.stringify(credentials)
42
+ });
43
+ if (response.status === 401 || response.status === 429) return authError(response.status === 401 ? "invalidCredentials" : "tooManyAttempts", await describe(response, "sign-in"));
44
+ if (response.status >= 500) panic(await describe(response, "sign-in"));
45
+ if (response.status === 400) todo(await describe(response, "sign-in"));
46
+ if (!response.ok) throw new Error(`${await describe(response, "sign-in")}. Check SOFTR_APPLICATION_ID and that the app allows sign-in.`);
47
+ const raw = await response.text();
48
+ const body = signInBody.parse(raw.length === 0 ? {} : JSON.parse(raw));
49
+ if (body.authenticatorAppAction) todo(`Softr platform asked for an authenticator step (${body.authenticatorAppAction}) to finish the sign-in. This app requires an authenticator app, which the SDK cannot finish yet.`);
50
+ const token = body.jwt;
51
+ if (!token) todo("Softr platform answered the sign-in by emailing a one-time code. This app is configured for passwordless sign-in, which the SDK cannot finish yet.");
52
+ const user = await fetchVisitor(token);
53
+ if (!user) panic("Session token issued, but no user could be resolved for it.");
54
+ writeCookies([makeSessionTokenCookie(token), makeSessionDataCookie(user, token)]);
55
+ return {
56
+ success: true,
57
+ data: user
58
+ };
59
+ }
60
+ /**
61
+ * Signs the visitor out and clears the session cookies.
62
+ *
63
+ * This revokes **every** outstanding session for that user in this app, on every
64
+ * device: the platform stamps `tokensInvalidatedAt` and rejects anything issued
65
+ * before it. There is no per-device sign-out.
66
+ *
67
+ * A platform that could not revoke leaves the caller signed out here and
68
+ * possibly still signed in elsewhere — loud outside production, logged in it,
69
+ * because the local half already succeeded.
70
+ */
71
+ async function signOut() {
72
+ const token = readSessionToken();
73
+ clearSession();
74
+ if (!token) return;
75
+ const response = await platformFetch("sign-out", {
76
+ method: "POST",
77
+ headers: {
78
+ authorization: `Bearer ${token}`,
79
+ accept: "application/json"
80
+ }
81
+ });
82
+ softAssert(response.ok || response.status < 500, () => `Softr platform could not revoke the session: ${response.status}. This browser is signed out, but other devices may still be.`);
83
+ }
84
+
85
+ //#endregion
86
+ //#region src/auth/methods/signup.ts
87
+ /**
88
+ * Starts a sign-up flow by emailing a verification code.
89
+ *
90
+ * User only exists once `confirmSignUp` returns the code.
91
+ */
92
+ async function signUp(details) {
93
+ const response = await platformFetch("sign-up", {
94
+ method: "POST",
95
+ headers: {
96
+ "content-type": "application/json",
97
+ accept: "application/json"
98
+ },
99
+ body: JSON.stringify(signUpBody(details))
100
+ });
101
+ if (response.status >= 500) panic(await describe(response, "sign-up"));
102
+ if (response.status === 409) return authError("emailTaken", await describe(response, "sign-up"));
103
+ if (!response.ok) return authError("invalidDetails", await describe(response, "sign-up"));
104
+ return {
105
+ success: true,
106
+ data: null
107
+ };
108
+ }
109
+ /**
110
+ * Finishes sign-up flow with the emailed code and signs the visitor in.
111
+ *
112
+ * The platform splits on how the app is configured: a code-only app creates the
113
+ * user on the second `sign-up` request and answers with a token, while a
114
+ * password app answers it with an empty 200 and expects the password on
115
+ * `users/new`.
116
+ */
117
+ async function confirmSignUp(details) {
118
+ const verified = await platformFetch("sign-up", {
119
+ method: "POST",
120
+ headers: {
121
+ "content-type": "application/json",
122
+ accept: "application/json"
123
+ },
124
+ body: JSON.stringify({
125
+ ...signUpBody(details),
126
+ token: details.code
127
+ })
128
+ });
129
+ if (verified.status >= 500) panic(await describe(verified, "sign-up"));
130
+ if (verified.status === 409) return authError("emailTaken", await describe(verified, "sign-up"));
131
+ if (!verified.ok) return authError("invalidCode", await describe(verified, "sign-up"));
132
+ const verifiedBody = await verified.text();
133
+ let token = tokenBody.parse(verifiedBody.length === 0 ? {} : JSON.parse(verifiedBody)).jwt;
134
+ if (!token) {
135
+ const created = await platformFetch("new", {
136
+ method: "POST",
137
+ headers: {
138
+ "content-type": "application/json",
139
+ accept: "application/json"
140
+ },
141
+ body: JSON.stringify({
142
+ ...signUpBody(details),
143
+ password: details.password,
144
+ token: details.code
145
+ })
146
+ });
147
+ if (created.status >= 500) panic(await describe(created, "sign-up"));
148
+ if (created.status === 409) return authError("emailTaken", await describe(created, "sign-up"));
149
+ if (created.ok) {
150
+ const createdBody = await created.text();
151
+ token = tokenBody.parse(createdBody.length === 0 ? {} : JSON.parse(createdBody)).jwt;
152
+ } else {
153
+ const signedIn = await signIn({
154
+ email: details.email,
155
+ password: details.password
156
+ });
157
+ if (!signedIn.success) return authError("invalidCode", await describe(created, "sign-up"));
158
+ return signedIn;
159
+ }
160
+ }
161
+ if (!token) todo("Softr platform accepted the sign-up and returned no session token. This app is configured for a sign-up flow the SDK cannot finish yet.");
162
+ const user = await fetchVisitor(token);
163
+ if (!user) panic("Session token issued, but no user could be resolved for it.");
164
+ writeCookies([makeSessionTokenCookie(token), makeSessionDataCookie(user, token)]);
165
+ return {
166
+ success: true,
167
+ data: user
168
+ };
169
+ }
170
+ /**
171
+ * `name` is dropped when absent rather than sent as `undefined`: the platform's
172
+ * validator runs on whatever key is present, and an explicit null is not the
173
+ * same request as no name at all.
174
+ */
175
+ function signUpBody(details) {
176
+ return details.name === void 0 ? { email: details.email } : {
177
+ email: details.email,
178
+ name: details.name
179
+ };
180
+ }
181
+ const tokenBody = z.object({ jwt: z.string().nullish() });
182
+
183
+ //#endregion
184
+ //#region src/auth/server.ts
185
+ /**
186
+ * Server-only auth methods, should be called from the app's own `createServerFn` handlers.
187
+ */
188
+ const auth = {
189
+ signIn,
190
+ signOut,
191
+ signUp,
192
+ confirmSignUp
193
+ };
194
+
195
+ //#endregion
196
+ export { auth };
@@ -0,0 +1,360 @@
1
+ import { getRequestHeader, getRequestProtocol, getResponseHeaders, setResponseHeader } from "@tanstack/react-start/server";
2
+ import { z } from "zod";
3
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
4
+
5
+ //#region src/assertions.ts
6
+ function assert(condition, message) {
7
+ if (!condition) throwAtCaller(assert, new AssertionError(unwrapErrorMessage(message)));
8
+ }
9
+ /**
10
+ * Shorthand for throw new AssertionError("Error message")
11
+ * Useful because it is an expression instead of a statement.
12
+ * Can be assigned to any value, because it never returns.
13
+ */
14
+ function panic(message) {
15
+ throwAtCaller(panic, new AssertionError(unwrapErrorMessage(message)));
16
+ }
17
+ function todo(message) {
18
+ throwAtCaller(todo, new UnimplementedError(unwrapErrorMessage(message)));
19
+ }
20
+ /**
21
+ * A soft assertion that throws an error in non prod environments, including
22
+ * staging, but logs an error in production mode.
23
+ * This is useful for cases where something unexpected happens, but you can still
24
+ * continue in a slightly broken state.
25
+ * For example, if a block has defined a style control with a hardcoded id and
26
+ * that style control is not found, this is definitely a bug, but it's still
27
+ * possible to go on rendering with a fallback value for this style.
28
+ * It is recommended to liberally use soft assertions so that we fail loudly in
29
+ * staging and local environments.
30
+ *
31
+ * A hard assertion would be more appropriate if we wanted to display some data
32
+ * from an object but that object is null. It's better to throw an assertion
33
+ * error at the place we detect this, instead of continuing and potentially
34
+ * causing an exception in an unrelated part of the code.
35
+ */
36
+ function softAssert(condition, message) {
37
+ if (!condition) if (process.env.NODE_ENV !== "production") throwAtCaller(softAssert, new SoftAssertionError(unwrapErrorMessage(message)));
38
+ else console.error(`Assertion failed: ${unwrapErrorMessage(message)}`);
39
+ }
40
+ var AssertionError = class extends Error {
41
+ name = "AssertionError";
42
+ };
43
+ var SoftAssertionError = class extends Error {
44
+ name = "SoftAssertionError";
45
+ };
46
+ var UnimplementedError = class extends Error {
47
+ name = "UnimplementedError";
48
+ };
49
+ function unwrapErrorMessage(message) {
50
+ return typeof message === "function" ? message() : message ?? "";
51
+ }
52
+ /**
53
+ * Uses chrome's non-standard Error.captureStackTrace to set the top of the stack
54
+ * trace to start from the caller of the supplied function {@param f}.
55
+ * Useful in assertion functions to make sure that the stack trace shows the spot
56
+ * where the assertion failed, not the place where the assertion was thrown.
57
+ */
58
+ function throwAtCaller(f, ex) {
59
+ if (Error.captureStackTrace) Error.captureStackTrace(ex, f);
60
+ throw ex;
61
+ }
62
+
63
+ //#endregion
64
+ //#region src/cookies.ts
65
+ function parseCookieHeader(header) {
66
+ const cookies = {};
67
+ if (!header) return cookies;
68
+ for (const pair of header.split(";")) {
69
+ const separator = pair.indexOf("=");
70
+ if (separator === -1) continue;
71
+ const name = pair.slice(0, separator).trim();
72
+ if (name.length === 0) continue;
73
+ cookies[name] = decode(pair.slice(separator + 1).trim());
74
+ }
75
+ return cookies;
76
+ }
77
+ function decode(value) {
78
+ try {
79
+ return decodeURIComponent(value);
80
+ } catch {
81
+ return value;
82
+ }
83
+ }
84
+ function readRequestCookies() {
85
+ return parseCookieHeader(getRequestHeader("cookie"));
86
+ }
87
+ function serializeCookie(name, value, { maxAge, secure }) {
88
+ if (name.startsWith("__Host-") && !secure) throw new Error(`Refusing to write ${name} without Secure: a __Host- cookie is only valid when Secure, Path=/ and free of Domain, and a browser silently discards one that is not.`);
89
+ const parts = [
90
+ `${name}=${encodeURIComponent(value)}`,
91
+ "Path=/",
92
+ "HttpOnly"
93
+ ];
94
+ if (secure) parts.push("Secure");
95
+ parts.push("SameSite=Lax", `Max-Age=${maxAge}`);
96
+ return parts.join("; ");
97
+ }
98
+ function writeCookies(cookies) {
99
+ if (cookies.length === 0) return;
100
+ setResponseHeader("Set-Cookie", [...getResponseHeaders().getSetCookie(), ...cookies]);
101
+ }
102
+
103
+ //#endregion
104
+ //#region src/auth/config.ts
105
+ const DEFAULT_SESSION_MAX_AGE = 2160 * 60 * 60;
106
+ function loadSessionConfig() {
107
+ const missing = [];
108
+ const applicationId = process.env.SOFTR_APPLICATION_ID;
109
+ if (!applicationId) missing.push("SOFTR_APPLICATION_ID");
110
+ const sessionSecret = process.env.SOFTR_SESSION_SECRET;
111
+ if (!sessionSecret) missing.push("SOFTR_SESSION_SECRET");
112
+ if (missing.length > 0) throw new Error(`Softr auth is not configured: missing ${missing.join(", ")}.`);
113
+ assert(applicationId && sessionSecret);
114
+ const rawMaxAge = process.env.SOFTR_SESSION_MAX_AGE;
115
+ let sessionMaxAge = DEFAULT_SESSION_MAX_AGE;
116
+ if (rawMaxAge) {
117
+ sessionMaxAge = Number(rawMaxAge);
118
+ if (!Number.isInteger(sessionMaxAge) || sessionMaxAge <= 0) throw new Error(`SOFTR_SESSION_MAX_AGE must be a positive whole number of seconds, got "${rawMaxAge}".`);
119
+ }
120
+ return {
121
+ applicationId,
122
+ sessionSecret,
123
+ sessionMaxAge
124
+ };
125
+ }
126
+ let cached;
127
+ function sessionConfig() {
128
+ cached ??= loadSessionConfig();
129
+ return cached;
130
+ }
131
+
132
+ //#endregion
133
+ //#region src/auth/platform.ts
134
+ const contextSchema = z.object({ platformApiUrl: z.string() });
135
+ /** Calls one of this app's user endpoints on the Softr platform. */
136
+ async function platformFetch(path, init) {
137
+ const { applicationId } = sessionConfig();
138
+ const origin = await platformApiUrl();
139
+ return fetch(`${origin}/v1/applications/${applicationId}/users/${path}`, init);
140
+ }
141
+ /** Origin of the Softr platform API, with no trailing slash. */
142
+ async function platformApiUrl() {
143
+ const pinned = process.env.SOFTR_PLATFORM_API_URL;
144
+ if (pinned) return withoutTrailingSlash(pinned);
145
+ const studioApiUrl = process.env.SOFTR_STUDIO_API_URL;
146
+ if (!studioApiUrl || process.env.NODE_ENV === "production") panic("Softr auth is not configured: SOFTR_PLATFORM_API_URL is not set.");
147
+ const { applicationId } = sessionConfig();
148
+ const url = `${withoutTrailingSlash(studioApiUrl)}/v1/api/applications/${applicationId}/context/public`;
149
+ let response;
150
+ try {
151
+ response = await fetch(url, { headers: { accept: "application/json" } });
152
+ } catch {
153
+ panic(`Could not reach studio-api: ${url}`);
154
+ }
155
+ if (response.status === 404) panic(`studio-api does not know application ${applicationId}`);
156
+ if (!response.ok) panic(`studio-api answered ${response.status} for ${url}`);
157
+ const body = contextSchema.safeParse(await response.json().catch(() => null));
158
+ if (!body.success) panic(`studio-api returned no platformApiUrl: ${url}`);
159
+ return withoutTrailingSlash(body.data.platformApiUrl);
160
+ }
161
+ function withoutTrailingSlash(url) {
162
+ return url.replace(/\/+$/, "");
163
+ }
164
+
165
+ //#endregion
166
+ //#region src/auth/me.ts
167
+ /**
168
+ * `null` means the platform refused the token — any 4xx, because `/me` answers
169
+ * 400 rather than 401 for an expired one, so only the class of the status is
170
+ * informative. Anything else that is not a 200 throws.
171
+ */
172
+ async function fetchVisitor(token) {
173
+ const response = await platformFetch("me", {
174
+ method: "GET",
175
+ headers: {
176
+ authorization: `Bearer ${token}`,
177
+ accept: "application/json"
178
+ }
179
+ });
180
+ if (response.status >= 400 && response.status < 500) return null;
181
+ if (!response.ok) throw new Error(`Softr platform did not answer /me: ${response.status} ${response.statusText}`.trim());
182
+ return toUser(await response.json());
183
+ }
184
+ const meSchema = z.object({
185
+ id: z.string(),
186
+ full_name: z.string().nullish(),
187
+ credentials: z.object({ email: z.string().nullish() }).nullish()
188
+ });
189
+ function toUser(body) {
190
+ const me = meSchema.safeParse(body);
191
+ if (!me.success) throw new Error(`Softr platform answered /me with something that is not a user: ${z.prettifyError(me.error)}`);
192
+ return {
193
+ id: me.data.id,
194
+ email: me.data.credentials?.email ?? "",
195
+ name: me.data.full_name ?? "",
196
+ userGroupIds: []
197
+ };
198
+ }
199
+
200
+ //#endregion
201
+ //#region src/auth/session.ts
202
+ const SESSION_TOKEN = "session_token";
203
+ const SESSION_DATA = "session_data";
204
+ const SESSION_DATA_TTL_SECONDS = 300;
205
+ /**
206
+ * A browser refuses a `__Host-` cookie unless it is `Secure`, `Path=/` and free
207
+ * of `Domain` — that last clause is the one worth having, since it stops a
208
+ * sibling host planting a cookie of this name for us to honour.
209
+ */
210
+ const HOST_PREFIX = "__Host-";
211
+ function requestIsSecure() {
212
+ return getRequestProtocol() === "https";
213
+ }
214
+ function sessionCookie(base) {
215
+ const secure = requestIsSecure();
216
+ return {
217
+ name: secure ? HOST_PREFIX + base : base,
218
+ secure
219
+ };
220
+ }
221
+ /** The platform token this request carries, or `null`. */
222
+ function readSessionToken() {
223
+ const { name } = sessionCookie(SESSION_TOKEN);
224
+ return openEnvelope(readRequestCookies()[name], SESSION_TOKEN);
225
+ }
226
+ /** The cached visitor for `sessionToken`, or `null` on any kind of miss. */
227
+ function readSessionData(sessionToken) {
228
+ const { name } = sessionCookie(SESSION_DATA);
229
+ const payload = parseSessionData(openEnvelope(readRequestCookies()[name], SESSION_DATA));
230
+ if (payload === null) return null;
231
+ if (payload.exp <= Math.floor(Date.now() / 1e3)) return null;
232
+ if (payload.tb !== tokenBinding(sessionToken)) return null;
233
+ return {
234
+ id: payload.id,
235
+ email: payload.email,
236
+ name: payload.name,
237
+ userGroupIds: payload.userGroupIds
238
+ };
239
+ }
240
+ function makeSessionTokenCookie(token) {
241
+ const { name, secure } = sessionCookie(SESSION_TOKEN);
242
+ return serializeCookie(name, sealEnvelope(token, SESSION_TOKEN), {
243
+ maxAge: sessionConfig().sessionMaxAge,
244
+ secure
245
+ });
246
+ }
247
+ function makeSessionDataCookie(user, sessionToken) {
248
+ const { name, secure } = sessionCookie(SESSION_DATA);
249
+ const payload = {
250
+ id: user.id,
251
+ email: user.email,
252
+ name: user.name,
253
+ userGroupIds: user.userGroupIds,
254
+ exp: Math.floor(Date.now() / 1e3) + SESSION_DATA_TTL_SECONDS,
255
+ tb: tokenBinding(sessionToken)
256
+ };
257
+ return serializeCookie(name, sealEnvelope(JSON.stringify(payload), SESSION_DATA), {
258
+ maxAge: SESSION_DATA_TTL_SECONDS,
259
+ secure
260
+ });
261
+ }
262
+ /**
263
+ * Writes rather than returns: a forgotten list would leave the session live.
264
+ */
265
+ function clearSession() {
266
+ clearCookies([SESSION_TOKEN, SESSION_DATA]);
267
+ }
268
+ const sessionDataSchema = z.object({
269
+ id: z.string(),
270
+ email: z.string(),
271
+ name: z.string(),
272
+ userGroupIds: z.array(z.string()),
273
+ exp: z.number(),
274
+ tb: z.string()
275
+ });
276
+ /**
277
+ * Both spellings on https: a jar can hold both pairs across a move between
278
+ * schemes, and the one left behind would revive if the scheme changed back.
279
+ */
280
+ function clearCookies(bases) {
281
+ const secure = requestIsSecure();
282
+ writeCookies(bases.flatMap((base) => secure ? [HOST_PREFIX + base, base] : [base]).map((name) => serializeCookie(name, "", {
283
+ maxAge: 0,
284
+ secure
285
+ })));
286
+ }
287
+ /**
288
+ * Over the plaintext token, never the cookie value: that is resealed with a
289
+ * fresh iv on every write, so a binding over its bytes would be a permanent
290
+ * cache miss.
291
+ */
292
+ function tokenBinding(sessionToken) {
293
+ return createHash("sha256").update(sessionToken).digest("base64url");
294
+ }
295
+ function parseSessionData(json) {
296
+ if (json === null) return null;
297
+ let payload;
298
+ try {
299
+ payload = JSON.parse(json);
300
+ } catch {
301
+ return null;
302
+ }
303
+ const data = sessionDataSchema.safeParse(payload);
304
+ return data.success ? data.data : null;
305
+ }
306
+ const IV_BYTES = 12;
307
+ const TAG_BYTES = 16;
308
+ /** Iv + one byte of ciphertext + tag. Anything shorter is not ours. */
309
+ const MIN_ENVELOPE_BYTES = IV_BYTES + TAG_BYTES + 1;
310
+ /**
311
+ * `Buffer.from(…, "base64url")` skips stray characters rather than throwing.
312
+ */
313
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
314
+ /**
315
+ * One key for both cookies. The application id goes in with the secret so a
316
+ * cookie from staging is inert in production even when the two share a secret.
317
+ */
318
+ function sessionKey() {
319
+ const { applicationId, sessionSecret } = sessionConfig();
320
+ return createHash("sha256").update(Buffer.from(sessionSecret, "hex")).update(applicationId).digest();
321
+ }
322
+ /**
323
+ * `base64url(iv ‖ ct ‖ tag)`, sealed with AES-256-GCM — an AEAD, so opening
324
+ * throws on a single flipped bit and needs no separate signature. The iv must
325
+ * be fresh per seal: nonce reuse under one key leaks the payloads and the
326
+ * authentication subkey.
327
+ *
328
+ * The purpose is bound in as additional authenticated data rather than by
329
+ * giving each cookie a key of its own: one key, and a `session_data` value
330
+ * moved into the token slot then fails to open at all instead of opening into
331
+ * a later field check.
332
+ */
333
+ function sealEnvelope(payload, purpose) {
334
+ const iv = randomBytes(IV_BYTES);
335
+ const cipher = createCipheriv("aes-256-gcm", sessionKey(), iv);
336
+ cipher.setAAD(Buffer.from(purpose));
337
+ const sealed = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
338
+ return Buffer.concat([
339
+ iv,
340
+ sealed,
341
+ cipher.getAuthTag()
342
+ ]).toString("base64url");
343
+ }
344
+ /** `null` for anything that is not one of ours, with no reason attached. */
345
+ function openEnvelope(raw, purpose) {
346
+ if (!raw || !BASE64URL.test(raw)) return null;
347
+ try {
348
+ const bytes = Buffer.from(raw, "base64url");
349
+ if (bytes.length < MIN_ENVELOPE_BYTES) return null;
350
+ const decipher = createDecipheriv("aes-256-gcm", sessionKey(), bytes.subarray(0, IV_BYTES));
351
+ decipher.setAAD(Buffer.from(purpose));
352
+ decipher.setAuthTag(bytes.subarray(bytes.length - TAG_BYTES));
353
+ return Buffer.concat([decipher.update(bytes.subarray(IV_BYTES, bytes.length - TAG_BYTES)), decipher.final()]).toString("utf8");
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ //#endregion
360
+ export { readSessionToken as a, writeCookies as c, softAssert as d, todo as f, readSessionData as i, assert as l, makeSessionDataCookie as n, fetchVisitor as o, makeSessionTokenCookie as r, platformFetch as s, clearSession as t, panic as u };
@@ -0,0 +1,28 @@
1
+ //#region src/auth/user.d.ts
2
+ /**
3
+ * Declare this interface in your app to narrow every group id the SDK takes and
4
+ * returns. Without a declaration, a group id is a plain `string`.
5
+ *
6
+ * @example
7
+ * declare module "@softr/sdk" {
8
+ * interface SoftrUserGroups {
9
+ * ids:
10
+ * | "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
11
+ * | "f9e8d7c6-5b4a-4938-8271-605f4e3d2c1b"
12
+ * }
13
+ * }
14
+ */
15
+ interface SoftrUserGroups {}
16
+ /** A Softr user group id, narrowed to your own ids if you declare {@link SoftrUserGroups}. */
17
+ type SoftrUserGroupId = SoftrUserGroups extends {
18
+ ids: infer Id extends string;
19
+ } ? Id : string;
20
+ /** The signed-in visitor. */
21
+ type User = {
22
+ id: string;
23
+ email: string;
24
+ name: string;
25
+ userGroupIds: Array<SoftrUserGroupId>;
26
+ };
27
+ //#endregion
28
+ export { SoftrUserGroups as n, User as r, SoftrUserGroupId as t };
package/dist/vite.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import "@tanstack/router-generator";
2
- import { Plugin } from "vite";
2
+ import { PluginOption } from "vite";
3
3
 
4
4
  //#region src/routes/discover.d.ts
5
5
 
@@ -25,7 +25,10 @@ type Manifest = {
25
25
  declare const MANIFEST_RELATIVE_PATH: string;
26
26
  /** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
27
27
  declare function generateManifest(root: string): Promise<string>;
28
- /** Plugin order does not matter: this puts nothing into the module graph. */
29
- declare function softr(): Plugin;
28
+ /**
29
+ * Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
30
+ * edit engine when the pod supplies one.
31
+ */
32
+ declare function softr(): PluginOption[];
30
33
  //#endregion
31
34
  export { MANIFEST_RELATIVE_PATH, type Manifest, type ManifestNode, generateManifest, softr };