@softr/sdk 0.0.22-alpha.1 → 0.0.22-alpha.2
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.
- package/dist/preview-identity/preview-identity.js +141 -0
- package/dist/vite.js +29 -11
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +1 -1
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/data-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/reading-records/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "../auth/session.js";
|
|
2
|
+
|
|
3
|
+
//#region src/preview-identity/preview-identity.ts
|
|
4
|
+
const PREVIEW_REDEMPTION_PATH = "/__preview/identity";
|
|
5
|
+
const PREVIEW_SIGNED_IN_PATH = "/__preview/signed-in";
|
|
6
|
+
/**
|
|
7
|
+
* Signs the preview in as one of the app's own users by spending the one-time grant studio-api put
|
|
8
|
+
* in this URL. No cookie of its own is forwarded: the grant is the only credential, and studio-api
|
|
9
|
+
* authorized the builder before it issued one.
|
|
10
|
+
*/
|
|
11
|
+
const redeemGrant = async (req, res) => {
|
|
12
|
+
res.setHeader("Cache-Control", "no-store");
|
|
13
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
14
|
+
if (req.method !== "GET") {
|
|
15
|
+
sendJson(res, 405, { error: `${PREVIEW_REDEMPTION_PATH} only supports GET` });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const grant = searchParamsOf(req).get("grant");
|
|
19
|
+
if (!grant) {
|
|
20
|
+
sendJson(res, 400, { error: "no grant" });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const landing = landingPath(searchParamsOf(req).get("next"));
|
|
24
|
+
let session;
|
|
25
|
+
try {
|
|
26
|
+
session = await redeemPreviewGrant(req, grant);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
sendJson(res, statusOf(error), { error: messageOf(error) });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const secure = requestIsHttps(req);
|
|
32
|
+
res.statusCode = 302;
|
|
33
|
+
res.setHeader("Location", landing);
|
|
34
|
+
res.setHeader("Set-Cookie", session.anonymous ? makeSessionClearingCookies(secure) : makeSessionTokenCookie(session.jwt, secure));
|
|
35
|
+
res.end();
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Whether the preview currently holds a session, and nothing else.
|
|
39
|
+
*
|
|
40
|
+
* Presence only, never decoded: whether anyone is signed in is all Studio's picker needs, and
|
|
41
|
+
* decoding would put an app user's identity on a route the studio bundle can read. Asked under the
|
|
42
|
+
* one name this scheme's app would read, so a cookie the app ignores reads as signed out here too
|
|
43
|
+
* rather than as a session it cannot see.
|
|
44
|
+
*/
|
|
45
|
+
const reportSignedIn = (req, res) => {
|
|
46
|
+
res.setHeader("Cache-Control", "no-store");
|
|
47
|
+
if (req.method !== "GET") {
|
|
48
|
+
sendJson(res, 405, { error: `${PREVIEW_SIGNED_IN_PATH} only supports GET` });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
sendJson(res, 200, { signedIn: hasSessionCookie(req.headers.cookie, sessionTokenCookieName(requestIsHttps(req))) });
|
|
52
|
+
};
|
|
53
|
+
var RedemptionError = class extends Error {
|
|
54
|
+
status;
|
|
55
|
+
constructor(status, message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.status = status;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function statusOf(error) {
|
|
61
|
+
return error instanceof RedemptionError ? error.status : 503;
|
|
62
|
+
}
|
|
63
|
+
function messageOf(error) {
|
|
64
|
+
return error instanceof Error ? error.message : String(error);
|
|
65
|
+
}
|
|
66
|
+
async function redeemPreviewGrant(req, grant) {
|
|
67
|
+
const applicationId = process.env.SOFTR_APPLICATION_ID;
|
|
68
|
+
const studioApiUrl = process.env.SOFTR_STUDIO_API_URL?.trim() || studioApiUrlFor(req);
|
|
69
|
+
if (!applicationId || !studioApiUrl) throw new RedemptionError(503, "studio-api is unavailable");
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(`${studioApiUrl}/v1/applications/${encodeURIComponent(applicationId)}/vibe-coding/preview-identity/redeem`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ grant })
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
throw new RedemptionError(503, "studio-api is unreachable");
|
|
79
|
+
}
|
|
80
|
+
if (!response.ok) throw new RedemptionError(response.status, "studio-api refused the preview identity grant");
|
|
81
|
+
const body = await response.json().catch(() => null);
|
|
82
|
+
if (typeof body !== "object" || body === null) throw new RedemptionError(503, "studio-api returned no session");
|
|
83
|
+
const { anonymous, jwt } = body;
|
|
84
|
+
if (anonymous === true) return { anonymous: true };
|
|
85
|
+
if (typeof jwt !== "string" || jwt.length === 0) throw new RedemptionError(503, "studio-api returned no token");
|
|
86
|
+
return {
|
|
87
|
+
anonymous: false,
|
|
88
|
+
jwt
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const STAGING_HOST_PATTERN = /(?:^|\.)staging(\d+)\.softr\.io$/;
|
|
92
|
+
/**
|
|
93
|
+
* The studio-api base URL of the environment this dev server runs in, read off the request's host.
|
|
94
|
+
*
|
|
95
|
+
* The host is the only per-environment signal the app has: it is served at
|
|
96
|
+
* `<slug>-app.vibe-coding-preview.staging12.softr.io`, and staging 12's API is
|
|
97
|
+
* `studio-api-staging12.softr.io`. Every other host gives `null` — localhost included.
|
|
98
|
+
*/
|
|
99
|
+
function studioApiUrlFor(req) {
|
|
100
|
+
const forwarded = req.headers["x-forwarded-host"];
|
|
101
|
+
const name = ((Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? "").split(",")[0].trim().split(":")[0];
|
|
102
|
+
const digits = STAGING_HOST_PATTERN.exec(name)?.[1];
|
|
103
|
+
if (!digits) return null;
|
|
104
|
+
return `https://studio-api-staging${digits.padStart(2, "0")}.softr.io`;
|
|
105
|
+
}
|
|
106
|
+
function hasSessionCookie(header, name) {
|
|
107
|
+
if (!header) return false;
|
|
108
|
+
return header.split(";").some((pair) => {
|
|
109
|
+
const separator = pair.indexOf("=");
|
|
110
|
+
return separator !== -1 && pair.slice(0, separator).trim() === name;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether this request is https, by the same rule the session code reads the cookie back with.
|
|
115
|
+
*
|
|
116
|
+
* h3's `getRequestProtocol` compares `x-forwarded-proto` for exact equality, so it reads anything
|
|
117
|
+
* else — `https, http` from a two-hop chain, or `HTTPS` — as not-https. Being more generous here
|
|
118
|
+
* than it is writes `__Host-session_token` where the app then looks up `session_token`, and the
|
|
119
|
+
* visitor is silently anonymous with no error on either side.
|
|
120
|
+
*/
|
|
121
|
+
function requestIsHttps(req) {
|
|
122
|
+
const forwarded = req.headers["x-forwarded-proto"];
|
|
123
|
+
if (forwarded === "https") return true;
|
|
124
|
+
if (forwarded === "http") return false;
|
|
125
|
+
return Boolean(req.socket?.encrypted);
|
|
126
|
+
}
|
|
127
|
+
function landingPath(next) {
|
|
128
|
+
if (!next || !next.startsWith("/") || next.startsWith("//") || next.includes("\\") || next.includes("://") || /[\u0000-\u001f\u007f]/.test(next)) return "/";
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
function searchParamsOf(req) {
|
|
132
|
+
return new URL(req.url ?? "", "http://request.invalid").searchParams;
|
|
133
|
+
}
|
|
134
|
+
function sendJson(res, status, body) {
|
|
135
|
+
res.statusCode = status;
|
|
136
|
+
res.setHeader("Content-Type", "application/json");
|
|
137
|
+
res.end(JSON.stringify(body));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
export { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn };
|
package/dist/vite.js
CHANGED
|
@@ -31,7 +31,34 @@ async function generateManifest(root) {
|
|
|
31
31
|
* edit engine when the pod supplies one.
|
|
32
32
|
*/
|
|
33
33
|
function softr() {
|
|
34
|
-
return [
|
|
34
|
+
return [
|
|
35
|
+
routesManifest(),
|
|
36
|
+
previewIdentity(),
|
|
37
|
+
editEngine()
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The routes that sign the preview in as one of the app's own users, and report whether it is.
|
|
42
|
+
*
|
|
43
|
+
* They live in this package, not in the edit engine, because signing the preview in means writing
|
|
44
|
+
* the very cookie this app's own session code reads back — one implementation of the cookie format,
|
|
45
|
+
* with no version to keep in step across a package seam.
|
|
46
|
+
*
|
|
47
|
+
* `SOFTR_APPLICATION_ID` is the gate: the pod sets it, and it is what binds a redemption to the
|
|
48
|
+
* application the grant was issued for. An app running on a developer's machine has none, and
|
|
49
|
+
* serves neither route.
|
|
50
|
+
*/
|
|
51
|
+
async function previewIdentity() {
|
|
52
|
+
if (!process.env.SOFTR_APPLICATION_ID) return false;
|
|
53
|
+
const { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn } = await import("./preview-identity/preview-identity.js");
|
|
54
|
+
return {
|
|
55
|
+
name: "softr-preview-identity",
|
|
56
|
+
apply: "serve",
|
|
57
|
+
configureServer(server) {
|
|
58
|
+
server.middlewares.use(PREVIEW_REDEMPTION_PATH, redeemGrant);
|
|
59
|
+
server.middlewares.use(PREVIEW_SIGNED_IN_PATH, reportSignedIn);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
35
62
|
}
|
|
36
63
|
/** Plugin order does not matter: this puts nothing into the module graph. */
|
|
37
64
|
function routesManifest() {
|
|
@@ -61,21 +88,12 @@ async function editEngine() {
|
|
|
61
88
|
if (!dir) return false;
|
|
62
89
|
const entry = pathToFileURL(path.join(dir, "dist", "vite.js")).href;
|
|
63
90
|
try {
|
|
64
|
-
return (await import(entry)).editEngineStudio(
|
|
91
|
+
return (await import(entry)).editEngineStudio();
|
|
65
92
|
} catch (error) {
|
|
66
93
|
console.error(`[softr] could not load the edit engine from ${entry}:`, error);
|
|
67
94
|
return false;
|
|
68
95
|
}
|
|
69
96
|
}
|
|
70
|
-
/** Imported only for a preview, so an ordinary build never loads the session machinery at all. */
|
|
71
|
-
async function sessionCookies() {
|
|
72
|
-
const { makeSessionTokenCookie, makeSessionClearingCookies, sessionTokenCookieName } = await import("./auth/session.js");
|
|
73
|
-
return {
|
|
74
|
-
makeSessionTokenCookie,
|
|
75
|
-
makeSessionClearingCookies,
|
|
76
|
-
sessionTokenCookieName
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
97
|
|
|
80
98
|
//#endregion
|
|
81
99
|
export { MANIFEST_RELATIVE_PATH, generateManifest, softr };
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.22-alpha.
|
|
13
|
+
library_version: 0.0.22-alpha.2
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
|
|
@@ -12,7 +12,7 @@ description: >-
|
|
|
12
12
|
metadata:
|
|
13
13
|
type: sub-skill
|
|
14
14
|
library: softr-sdk
|
|
15
|
-
library_version: 0.0.22-alpha.
|
|
15
|
+
library_version: 0.0.22-alpha.2
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
This skill builds on softr-sdk. Read it first for the entrypoints and the wiring that puts a visitor on route context.
|