@k2b/cloud 0.8.0 → 0.9.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.
- package/package.json +2 -2
- package/scripts/build.ts +8 -0
- package/src/api/auth/schemas.ts +7 -1
- package/src/api/auth.ts +3 -2
- package/src/server/app-assets.ts +31 -0
- package/src/server/index.ts +1 -0
- package/src/services/account-lifecycle/index.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +13 -1
- package/src/services/ipa/users.ts +1 -1
- package/src/services/providers/local/users.ts +2 -3
- package/src/ssr/Layout.tsx +2 -1
- package/src/ssr/LayoutRail.tsx +3 -2
- package/src/ssr/MobileNavigation.tsx +10 -8
- package/src/ssr/RailApps.island.tsx +7 -7
- package/src/ssr/index.ts +1 -0
- package/src/ssr/rail-navigation.ts +3 -2
- package/src/ssr/request-path.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@tailwindcss/typography": "0.5.20",
|
|
99
99
|
"@k2b/nessi": "0.12.0",
|
|
100
100
|
"@k2b/ssr": "0.14.0",
|
|
101
|
-
"@k2b/ui": "0.4.
|
|
101
|
+
"@k2b/ui": "0.4.2",
|
|
102
102
|
"@k2b/stdlib": "0.25.0",
|
|
103
103
|
"@k2b/sync": "6.4.0",
|
|
104
104
|
"@nats-io/transport-node": "3.4.0",
|
package/scripts/build.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* _ssr/<island>.js hydration bundles (auto-emitted by the SSR plugin)
|
|
18
18
|
* public/<id>/app.css Tailwind, if the app has src/styles/app.css
|
|
19
19
|
* public/<id>/... anything from <appDir>/public/
|
|
20
|
+
* assets/... anything from <appDir>/src/assets/ (appAssetPath)
|
|
20
21
|
*
|
|
21
22
|
* If the app needs additional artefacts (core's global.css, logo, katex),
|
|
22
23
|
* it ships a `scripts/build-extras.ts` that this script runs at the end.
|
|
@@ -129,6 +130,7 @@ try {
|
|
|
129
130
|
__CLOUD_RELEASE__: JSON.stringify(release),
|
|
130
131
|
__CLOUD_SYNC_VERSION__: JSON.stringify(syncVersion),
|
|
131
132
|
__CLOUD_PDF_RENDER_WORKER__: JSON.stringify("./pdf-render/worker.js"),
|
|
133
|
+
__CLOUD_APP_ASSETS__: JSON.stringify("./assets/"),
|
|
132
134
|
},
|
|
133
135
|
plugins: [(app?.plugin ?? plugin)()],
|
|
134
136
|
});
|
|
@@ -167,6 +169,12 @@ if (existsSync(appPublic)) {
|
|
|
167
169
|
await cp(appPublic, resolve(distPublic, appId), { recursive: true });
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
// Server-side assets the bundle reads through appAssetPath().
|
|
173
|
+
const appAssets = resolve(appDir, "src/assets");
|
|
174
|
+
if (existsSync(appAssets)) {
|
|
175
|
+
await cp(appAssets, resolve(dist, "assets"), { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
170
178
|
if (appId !== "core" && app) {
|
|
171
179
|
await writeAppFavicon({
|
|
172
180
|
publicDir: distPublic,
|
package/src/api/auth/schemas.ts
CHANGED
|
@@ -9,7 +9,13 @@ export const LoginSchema = z.object({
|
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
export const EmailLoginSchema = z.object({
|
|
12
|
-
email: z
|
|
12
|
+
email: z
|
|
13
|
+
.string()
|
|
14
|
+
.trim()
|
|
15
|
+
.min(1)
|
|
16
|
+
.max(254)
|
|
17
|
+
.refine((value) => !value.includes("@") || z.email().safeParse(value).success, "Invalid email address")
|
|
18
|
+
.describe("Email address or username of the account"),
|
|
13
19
|
category: z.enum(["guest", "login"]).optional(),
|
|
14
20
|
acceptedAgb: z.literal(true).optional(),
|
|
15
21
|
redirectTo: z.string().max(2048).optional(),
|
package/src/api/auth.ts
CHANGED
|
@@ -188,7 +188,8 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
|
|
|
188
188
|
describeRoute({
|
|
189
189
|
tags: ["Auth"],
|
|
190
190
|
summary: "Request magic link login",
|
|
191
|
-
description:
|
|
191
|
+
description:
|
|
192
|
+
"Request a magic link token for local account sign-in. The `email` field accepts the email address or the username of the account; the link is always sent to the account's email address.",
|
|
192
193
|
responses: {
|
|
193
194
|
200: jsonResponse(MessageResponseSchema, "Request accepted"),
|
|
194
195
|
400: jsonResponse(ErrorResponseSchema, "Email sign-in not available"),
|
|
@@ -203,7 +204,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
|
|
|
203
204
|
return c.json({ message: requestResult.message }, requestResult.status);
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
log.info("Magic link requested", { email });
|
|
207
|
+
log.info("Magic link requested", { identifier: email });
|
|
207
208
|
return c.json({
|
|
208
209
|
message: "If this email can sign in with a login code, a code has been sent.",
|
|
209
210
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { env } from "../config/env";
|
|
4
|
+
|
|
5
|
+
/** Set by `scripts/build.ts`; the bundle lives next to `dist/assets/`. */
|
|
6
|
+
declare const __CLOUD_APP_ASSETS__: string | undefined;
|
|
7
|
+
|
|
8
|
+
// `resolve` normalizes both forms: a file URL keeps its trailing slash, which
|
|
9
|
+
// would otherwise break the containment check below (`/app/assets//...`).
|
|
10
|
+
const assetsRoot = (): string =>
|
|
11
|
+
resolve(
|
|
12
|
+
typeof __CLOUD_APP_ASSETS__ === "string"
|
|
13
|
+
? fileURLToPath(new URL(__CLOUD_APP_ASSETS__, import.meta.url))
|
|
14
|
+
: resolve(env.APP_DIR ?? process.cwd(), "src/assets"),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path of a server-side application asset.
|
|
19
|
+
*
|
|
20
|
+
* Applications keep files their server reads at runtime (document templates,
|
|
21
|
+
* seed data, binaries) under `src/assets/`. The production build copies that
|
|
22
|
+
* directory to `dist/assets/`, so the same call works from the source tree
|
|
23
|
+
* (`APP_DIR`, or the working directory, plus `src/assets`) and from the
|
|
24
|
+
* bundled image. The path must stay inside the assets directory.
|
|
25
|
+
*/
|
|
26
|
+
export function appAssetPath(...segments: string[]): string {
|
|
27
|
+
const root = assetsRoot();
|
|
28
|
+
const path = resolve(root, ...segments);
|
|
29
|
+
if (path !== root && !path.startsWith(root + sep)) throw new Error(`Asset path escapes src/assets: ${segments.join("/")}`);
|
|
30
|
+
return path;
|
|
31
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { ApiErrorBody, ApiErrorResponse, ApiErrorStatus } from "./api";
|
|
|
3
3
|
export { api, respond, respondMessage } from "./api";
|
|
4
4
|
export type { CreateApiClientConfig } from "./api-client";
|
|
5
5
|
export { api as apiClient } from "./api-client";
|
|
6
|
+
export { appAssetPath } from "./app-assets";
|
|
6
7
|
export type { AppContext } from "./app-context";
|
|
7
8
|
export { defineHelp, defineHelpCollection, type HelpCollection, type HelpDefinition, type HelpDefinitionDocument } from "./help";
|
|
8
9
|
export { DEFAULT_LOCALE, getLocale, LOCALE_COOKIE, LOCALE_HEADER, locale, preferredLocale, resolveLocale } from "./locale";
|
|
@@ -457,7 +457,6 @@ export const accountLifecycle = {
|
|
|
457
457
|
});
|
|
458
458
|
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
459
459
|
});
|
|
460
|
-
await session.revokeAllForUser(userId);
|
|
461
460
|
summary.changed += 1;
|
|
462
461
|
} catch (error) {
|
|
463
462
|
summary.failed += 1;
|
|
@@ -507,7 +506,6 @@ export const accountLifecycle = {
|
|
|
507
506
|
});
|
|
508
507
|
await tx`DELETE FROM auth.users WHERE id = ${userId}::uuid`;
|
|
509
508
|
});
|
|
510
|
-
await session.revokeAllForUser(userId);
|
|
511
509
|
summary.changed += 1;
|
|
512
510
|
} catch (error) {
|
|
513
511
|
summary.failed += 1;
|
|
@@ -50,11 +50,23 @@ const sendIpaEmailLoginHint = async (
|
|
|
50
50
|
if (result.status === "error") log.error("FreeIPA login hint delivery failed", { notificationId: result.id });
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
// A username resolves to the stored email of exactly one account; anything
|
|
54
|
+
// else (unknown, ambiguous casing, no email) behaves like an unknown email.
|
|
55
|
+
const resolveEmailForUsername = async (username: string): Promise<string | null> => {
|
|
56
|
+
const rows = await sql<{ mail: string | null }[]>`
|
|
57
|
+
SELECT mail FROM auth.users WHERE lower(uid) = lower(${username}) LIMIT 2
|
|
58
|
+
`;
|
|
59
|
+
const mail = rows.length === 1 ? rows[0]?.mail : null;
|
|
60
|
+
return mail ? normalizeEmail(mail) : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
53
63
|
export const request = async (
|
|
54
64
|
params: { email: string; redirectTo?: string; locale?: string; category?: "guest" | "login" },
|
|
55
65
|
notificationSender: AuthNotificationSender,
|
|
56
66
|
): Promise<{ ok: true } | { ok: false; status: 400; message: string }> => {
|
|
57
|
-
const
|
|
67
|
+
const identifier = params.email.trim();
|
|
68
|
+
const email = identifier.includes("@") ? normalizeEmail(identifier) : await resolveEmailForUsername(identifier);
|
|
69
|
+
if (!email) return { ok: true };
|
|
58
70
|
const hasIpaUser = await hasIpaAccountForEmail(email);
|
|
59
71
|
const userRows = hasIpaUser
|
|
60
72
|
? []
|
|
@@ -847,7 +847,7 @@ export const deleteUser = async (params: {
|
|
|
847
847
|
});
|
|
848
848
|
throw dbError;
|
|
849
849
|
}
|
|
850
|
-
|
|
850
|
+
// Deleting the row invalidates every session; no epoch bump is possible or needed.
|
|
851
851
|
|
|
852
852
|
return { ok: true, data: undefined };
|
|
853
853
|
};
|
|
@@ -8,7 +8,6 @@ import { writeLocalAccount } from "../../accounts/posix";
|
|
|
8
8
|
import type { AuditActor } from "../../audit";
|
|
9
9
|
import { generateUniqueAbbreviation } from "../../ipa/users";
|
|
10
10
|
import { isUniqueViolation } from "../../postgres";
|
|
11
|
-
import { session } from "../../session";
|
|
12
11
|
import * as settings from "../../settings";
|
|
13
12
|
|
|
14
13
|
type DbRow = Record<string, unknown>;
|
|
@@ -265,8 +264,8 @@ export const remove = async (params: { id: string; actor: { userId: string; uid:
|
|
|
265
264
|
});
|
|
266
265
|
await tx`DELETE FROM auth.users WHERE id = ${params.id}::uuid`;
|
|
267
266
|
});
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
// Deleting the row invalidates every session: session lookups find no user.
|
|
268
|
+
// A separate epoch bump would fail because the row is gone.
|
|
270
269
|
|
|
271
270
|
return { ok: true, data: undefined };
|
|
272
271
|
};
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -26,6 +26,7 @@ import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
|
26
26
|
import { platformMessages } from "./platform-messages";
|
|
27
27
|
import RegisteredHelpDocuments from "./RegisteredHelpDocuments.island";
|
|
28
28
|
import { sortRailApps } from "./rail-navigation";
|
|
29
|
+
import { requestPath } from "./request-path";
|
|
29
30
|
import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
|
|
30
31
|
import TimezoneCookie from "./TimezoneCookie.island";
|
|
31
32
|
|
|
@@ -257,7 +258,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
257
258
|
openAppsLabel={t.openApps}
|
|
258
259
|
apps={allApps}
|
|
259
260
|
railSettings={c.get("railPreferences") ?? defaultRailPreferences()}
|
|
260
|
-
|
|
261
|
+
currentPath={requestPath(c)}
|
|
261
262
|
profileAvatarSrc={profileAvatarSrc}
|
|
262
263
|
profileName={profileName}
|
|
263
264
|
searchHelpApps={searchHelpApps}
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -19,7 +19,8 @@ type LayoutRailProps = {
|
|
|
19
19
|
openAppsLabel: string;
|
|
20
20
|
apps: RailApp[];
|
|
21
21
|
railSettings: RailSnapshot;
|
|
22
|
-
|
|
22
|
+
/** `pathname + search` of the request; the island resolves it against the browser origin. */
|
|
23
|
+
currentPath: string;
|
|
23
24
|
profileAvatarSrc?: string;
|
|
24
25
|
profileName: string;
|
|
25
26
|
searchHelpApps: GlobalSearchHelpApp[];
|
|
@@ -46,7 +47,7 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
46
47
|
</a>
|
|
47
48
|
</div>
|
|
48
49
|
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
|
|
49
|
-
<RailApps apps={props.apps} settings={props.railSettings}
|
|
50
|
+
<RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
|
|
50
51
|
<AppLaunchpad
|
|
51
52
|
profile={{ name: props.profileName, theme: props.theme }}
|
|
52
53
|
apps={props.launchpadApps}
|
|
@@ -32,11 +32,13 @@ function MobileNavigation(props: {
|
|
|
32
32
|
);
|
|
33
33
|
return (
|
|
34
34
|
<BottomSheet onDismiss={props.dismiss}>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
{/* Without an app menu the launchpad gets the whole sheet; the hidden
|
|
36
|
+
heading still names the dialog, and the handle and backdrop dismiss it. */}
|
|
37
|
+
<Show when={workspace()} fallback={<h2 class="sr-only">{t().allApps}</h2>}>
|
|
38
|
+
<BottomSheet.Header
|
|
39
|
+
title={workspace()?.label || t().apps}
|
|
40
|
+
close={() => void props.dismiss()}
|
|
41
|
+
actions={
|
|
40
42
|
<SegmentedControl
|
|
41
43
|
class="cloud-mobile-menu__switch"
|
|
42
44
|
size="md"
|
|
@@ -48,9 +50,9 @@ function MobileNavigation(props: {
|
|
|
48
50
|
{ value: "apps", label: t().allApps },
|
|
49
51
|
]}
|
|
50
52
|
/>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
}
|
|
54
|
+
/>
|
|
55
|
+
</Show>
|
|
54
56
|
<BottomSheet.Body>
|
|
55
57
|
<Show
|
|
56
58
|
when={workspace() && tab() === "app"}
|
|
@@ -5,9 +5,9 @@ import { appAccentStyle } from "./app-appearance";
|
|
|
5
5
|
import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
|
|
6
6
|
import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } from "./rail-navigation";
|
|
7
7
|
|
|
8
|
-
function RailIcon(props: { link: RailLink;
|
|
8
|
+
function RailIcon(props: { link: RailLink; currentPath: string }) {
|
|
9
9
|
let target: HTMLAnchorElement | undefined;
|
|
10
|
-
const active = () => railLinkActive(props.link, props.
|
|
10
|
+
const active = () => railLinkActive(props.link, props.currentPath);
|
|
11
11
|
return (
|
|
12
12
|
<>
|
|
13
13
|
<a
|
|
@@ -26,14 +26,14 @@ function RailIcon(props: { link: RailLink; currentUrl: string }) {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export default function RailApps(props: { apps: RailApp[]; settings: RailSnapshot;
|
|
29
|
+
export default function RailApps(props: { apps: RailApp[]; settings: RailSnapshot; currentPath: string }) {
|
|
30
30
|
const locale = useLocale();
|
|
31
31
|
const [settings, setSettings] = createSignal(props.settings);
|
|
32
|
-
const [
|
|
32
|
+
const [currentPath, setCurrentPath] = createSignal(props.currentPath);
|
|
33
33
|
const navigation = createMemo(() => projectRailNavigation(props.apps, settings(), locale()));
|
|
34
34
|
onMount(() => {
|
|
35
35
|
const sync = () => setSettings(readRailContext()?.settings ?? props.settings);
|
|
36
|
-
const syncLocation = () =>
|
|
36
|
+
const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
|
|
37
37
|
sync();
|
|
38
38
|
syncLocation();
|
|
39
39
|
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
@@ -51,11 +51,11 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
|
|
|
51
51
|
style={{ flex: "0 1 auto", "scrollbar-width": "none", "scrollbar-gutter": "auto", "overflow-x": "hidden" }}
|
|
52
52
|
data-cloud-rail-apps
|
|
53
53
|
>
|
|
54
|
-
<For each={navigation().shortcuts}>{(link) => <RailIcon link={link}
|
|
54
|
+
<For each={navigation().shortcuts}>{(link) => <RailIcon link={link} currentPath={currentPath()} />}</For>
|
|
55
55
|
<Show when={navigation().shortcuts.length > 0 && navigation().apps.length > 0}>
|
|
56
56
|
<div role="separator" class="my-1 h-px w-5 shrink-0" style={{ background: "var(--ui-divider)" }} />
|
|
57
57
|
</Show>
|
|
58
|
-
<For each={navigation().apps}>{(link) => <RailIcon link={link}
|
|
58
|
+
<For each={navigation().apps}>{(link) => <RailIcon link={link} currentPath={currentPath()} />}</For>
|
|
59
59
|
</ScrollArea>
|
|
60
60
|
);
|
|
61
61
|
}
|
package/src/ssr/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
type MinimalLayoutPreferencePosition,
|
|
10
10
|
type MinimalLayoutProps,
|
|
11
11
|
} from "./MinimalLayout";
|
|
12
|
+
export { requestPath } from "./request-path";
|
|
12
13
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
13
14
|
export type { UrlFilterField } from "./url-filter";
|
|
14
15
|
export { createUrlFilter, flag, list, oneOf, page, text } from "./url-filter";
|
|
@@ -48,8 +48,9 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
|
|
|
48
48
|
};
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/** `currentPath` is the route as `pathname + search`; an absolute URL is accepted but never required. */
|
|
52
|
+
export const railLinkActive = (link: RailLink, currentPath: string): boolean => {
|
|
53
|
+
const current = new URL(currentPath, "https://cloud.invalid");
|
|
53
54
|
const target = new URL(link.match, current);
|
|
54
55
|
if (target.origin !== current.origin) return false;
|
|
55
56
|
if (link.exact) return target.pathname === current.pathname && target.search === current.search && target.hash === current.hash;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route of the current request as `pathname + search`.
|
|
3
|
+
*
|
|
4
|
+
* Behind the gateway `c.req.raw.url` carries the internal upstream origin
|
|
5
|
+
* (`http://app-mail:3000`), never the public one. Islands and links must
|
|
6
|
+
* therefore receive the route as a path and resolve it against
|
|
7
|
+
* `window.location.origin` in the browser.
|
|
8
|
+
*/
|
|
9
|
+
export const requestPath = (c: { req: { raw: { url: string } } }): string => {
|
|
10
|
+
const url = new URL(c.req.raw.url);
|
|
11
|
+
return `${url.pathname}${url.search}`;
|
|
12
|
+
};
|