@trylight/app 0.1.9 → 0.2.0
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/README.md +3 -18
- package/dist/auth/config.d.ts +5 -0
- package/dist/auth/config.js +16 -0
- package/dist/auth/context.d.ts +11 -0
- package/dist/auth/context.js +1 -0
- package/dist/auth/core.d.ts +64 -0
- package/dist/auth/core.js +50 -0
- package/dist/auth/db.d.ts +7 -0
- package/dist/auth/db.js +57 -0
- package/dist/auth/lookup.d.ts +47 -0
- package/dist/auth/lookup.js +120 -0
- package/dist/auth/mode.d.ts +8 -0
- package/dist/auth/mode.js +52 -0
- package/dist/auth/next-path.d.ts +1 -0
- package/dist/auth/next-path.js +10 -0
- package/dist/auth/plaza.d.ts +68 -0
- package/dist/auth/plaza.js +154 -0
- package/dist/auth/problems.d.ts +4 -0
- package/dist/auth/problems.js +36 -0
- package/dist/auth/users.d.ts +8 -0
- package/dist/auth/users.js +25 -0
- package/dist/brand.generated.d.ts +2 -0
- package/dist/brand.generated.js +3 -0
- package/dist/chat/paths.d.ts +10 -0
- package/dist/chat/paths.js +10 -0
- package/dist/chat/server.d.ts +24 -0
- package/dist/chat/server.js +161 -0
- package/dist/client.d.ts +3 -3
- package/dist/client.js +2 -2
- package/dist/db/cli.js +3 -40
- package/dist/db/migrations.d.ts +2 -2
- package/dist/db/migrations.js +2 -2
- package/dist/invoke/contracts.d.ts +28 -0
- package/dist/invoke/contracts.js +1 -0
- package/dist/invoke/error.d.ts +11 -0
- package/dist/invoke/error.js +13 -0
- package/dist/invoke/index.d.ts +73 -0
- package/dist/invoke/index.js +274 -0
- package/dist/invoke/paths.d.ts +2 -0
- package/dist/invoke/paths.js +2 -0
- package/dist/invoke/server.d.ts +45 -0
- package/dist/invoke/server.js +259 -0
- package/dist/platform/index.d.ts +71 -0
- package/dist/platform/index.js +80 -0
- package/dist/platform/pane-bridge.d.ts +1 -0
- package/dist/platform/pane-bridge.js +216 -0
- package/dist/platform/server-fn-door.d.ts +27 -0
- package/dist/platform/server-fn-door.js +32 -0
- package/dist/platform.d.ts +2 -2
- package/dist/platform.js +3 -3
- package/dist/request-error.d.ts +10 -0
- package/dist/request-error.js +29 -0
- package/dist/schema.d.ts +1 -1
- package/dist/server/db-retry.d.ts +7 -0
- package/dist/server/db-retry.js +58 -0
- package/dist/server/entities.js +1 -1
- package/dist/server/health.d.ts +17 -0
- package/dist/server/health.js +42 -0
- package/dist/server/index.d.ts +7 -6
- package/dist/server/index.js +7 -6
- package/dist/server/sentry-tunnel.d.ts +6 -0
- package/dist/server/sentry-tunnel.js +52 -0
- package/dist/server/session.d.ts +1 -1
- package/dist/server/session.js +2 -2
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +7 -0
- package/package.json +12 -10
- package/dist/db/baseline.d.ts +0 -36
- package/dist/db/baseline.js +0 -439
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform origins allowed to frame an org app AND to drive its preview
|
|
3
|
+
* bridge. One list on purpose: who may frame the app and who may talk to it
|
|
4
|
+
* must never drift apart. The app's origins come from config/brand.json; the
|
|
5
|
+
* Vercel alias and localhost are here because the platform is developed
|
|
6
|
+
* locally against real deployed org apps.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PLAZA_FRAME_ORIGINS: readonly string[];
|
|
9
|
+
export declare const FRAME_ANCESTORS: string;
|
|
10
|
+
export type PlatformEnv = {
|
|
11
|
+
/** Vercel's environment name; undefined locally. */
|
|
12
|
+
readonly vercelEnv?: string;
|
|
13
|
+
/** MII_ORG_ID / VITE_MII_ORG_ID — the tag that makes telemetry attributable. */
|
|
14
|
+
readonly orgId?: string;
|
|
15
|
+
};
|
|
16
|
+
export type MiiErrorPolicy = {
|
|
17
|
+
readonly enabled: boolean;
|
|
18
|
+
readonly environment: string;
|
|
19
|
+
readonly tracesSampleRate: 0;
|
|
20
|
+
readonly sendDefaultPii: false;
|
|
21
|
+
readonly initialScope: {
|
|
22
|
+
readonly tags: {
|
|
23
|
+
readonly org_id: string | undefined;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The Sentry init options an org app must not diverge from, for any Sentry
|
|
29
|
+
* SDK (`Sentry.init({ dsn, ...miiErrorPolicy(...) })`). The Sentry project is
|
|
30
|
+
* shared across every org app, so an untagged event is un-attributable: the
|
|
31
|
+
* policy stays OFF rather than emit one. Errors are the only quota the
|
|
32
|
+
* platform pays for — tracing stays off. `force` mirrors SENTRY_FORCE_ENABLE
|
|
33
|
+
* for testing locally.
|
|
34
|
+
*/
|
|
35
|
+
export declare function miiErrorPolicy({ vercelEnv, orgId, dsn, force }: PlatformEnv & {
|
|
36
|
+
readonly dsn?: string;
|
|
37
|
+
readonly force?: boolean;
|
|
38
|
+
}): MiiErrorPolicy;
|
|
39
|
+
export type MiiAnalyticsPolicy = {
|
|
40
|
+
readonly enabled: boolean;
|
|
41
|
+
/** posthog-js init options: `posthog.init("in-house", options)`. */
|
|
42
|
+
readonly options: {
|
|
43
|
+
readonly api_host: string;
|
|
44
|
+
/** posthog-js "defaults era" — a date literal its ConfigDefaults union accepts. */
|
|
45
|
+
readonly defaults: "2026-06-25";
|
|
46
|
+
readonly person_profiles: "identified_only";
|
|
47
|
+
readonly advanced_disable_flags: true;
|
|
48
|
+
readonly disable_external_dependency_loading: true;
|
|
49
|
+
readonly disable_surveys: true;
|
|
50
|
+
readonly disable_session_recording: true;
|
|
51
|
+
readonly capture_exceptions: false;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Analytics is in-house: posthog-js is purely the capture SDK, events go to
|
|
56
|
+
* the ingest endpoint and no analytics vendor is involved. Off in local
|
|
57
|
+
* dev unless forced, so clicking around locally doesn't pollute product data.
|
|
58
|
+
* Cloud-only SDK features stay off so the client never phones home.
|
|
59
|
+
*/
|
|
60
|
+
export declare function miiAnalyticsPolicy({ vercelEnv, ingestHost, force }: PlatformEnv & {
|
|
61
|
+
readonly ingestHost: string;
|
|
62
|
+
readonly force?: boolean;
|
|
63
|
+
}): MiiAnalyticsPolicy;
|
|
64
|
+
/**
|
|
65
|
+
* Whether this browser is automation (the org's own agents, E2E runs), which
|
|
66
|
+
* analytics must keep separable from real users (`is_agent=true`).
|
|
67
|
+
* `?mii_agent=1` is the manual override and persists via localStorage;
|
|
68
|
+
* Playwright-family browsers set `navigator.webdriver`.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isAgentBrowser(win: Window): boolean;
|
|
71
|
+
export { mayCallServerFn, type ServerFnName } from "./server-fn-door.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The platform policies every org app must agree on, framework-free and
|
|
2
|
+
// dependency-free. The framing list, the error-reporting rules and the
|
|
3
|
+
// analytics rules are contracts: an app that drifts on any of them breaks its
|
|
4
|
+
// deal with the platform (a pane it cannot be shown in, errors nobody can attribute,
|
|
5
|
+
// events that pollute product data). Frameworks come and go around this file;
|
|
6
|
+
// nothing in it may import one.
|
|
7
|
+
import { APP_ORIGINS } from "../brand.generated.js";
|
|
8
|
+
/**
|
|
9
|
+
* Platform origins allowed to frame an org app AND to drive its preview
|
|
10
|
+
* bridge. One list on purpose: who may frame the app and who may talk to it
|
|
11
|
+
* must never drift apart. The app's origins come from config/brand.json; the
|
|
12
|
+
* Vercel alias and localhost are here because the platform is developed
|
|
13
|
+
* locally against real deployed org apps.
|
|
14
|
+
*/
|
|
15
|
+
export const PLAZA_FRAME_ORIGINS = [
|
|
16
|
+
...APP_ORIGINS,
|
|
17
|
+
"https://miiplaza.vercel.app",
|
|
18
|
+
"http://localhost:3000",
|
|
19
|
+
"http://localhost:3111"
|
|
20
|
+
];
|
|
21
|
+
export const FRAME_ANCESTORS = `frame-ancestors 'self' ${PLAZA_FRAME_ORIGINS.join(" ")}`;
|
|
22
|
+
const isDeployed = (vercelEnv) => vercelEnv === "production" || vercelEnv === "preview";
|
|
23
|
+
/**
|
|
24
|
+
* The Sentry init options an org app must not diverge from, for any Sentry
|
|
25
|
+
* SDK (`Sentry.init({ dsn, ...miiErrorPolicy(...) })`). The Sentry project is
|
|
26
|
+
* shared across every org app, so an untagged event is un-attributable: the
|
|
27
|
+
* policy stays OFF rather than emit one. Errors are the only quota the
|
|
28
|
+
* platform pays for — tracing stays off. `force` mirrors SENTRY_FORCE_ENABLE
|
|
29
|
+
* for testing locally.
|
|
30
|
+
*/
|
|
31
|
+
export function miiErrorPolicy({ vercelEnv = "development", orgId, dsn, force = false }) {
|
|
32
|
+
return {
|
|
33
|
+
enabled: Boolean(dsn) && Boolean(orgId) && (isDeployed(vercelEnv) || force),
|
|
34
|
+
environment: vercelEnv,
|
|
35
|
+
tracesSampleRate: 0,
|
|
36
|
+
sendDefaultPii: false,
|
|
37
|
+
initialScope: { tags: { org_id: orgId } }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Analytics is in-house: posthog-js is purely the capture SDK, events go to
|
|
42
|
+
* the ingest endpoint and no analytics vendor is involved. Off in local
|
|
43
|
+
* dev unless forced, so clicking around locally doesn't pollute product data.
|
|
44
|
+
* Cloud-only SDK features stay off so the client never phones home.
|
|
45
|
+
*/
|
|
46
|
+
export function miiAnalyticsPolicy({ vercelEnv = "development", ingestHost, force = false }) {
|
|
47
|
+
return {
|
|
48
|
+
enabled: isDeployed(vercelEnv) || force,
|
|
49
|
+
options: {
|
|
50
|
+
api_host: ingestHost,
|
|
51
|
+
defaults: "2026-06-25",
|
|
52
|
+
person_profiles: "identified_only",
|
|
53
|
+
advanced_disable_flags: true,
|
|
54
|
+
disable_external_dependency_loading: true,
|
|
55
|
+
disable_surveys: true,
|
|
56
|
+
disable_session_recording: true,
|
|
57
|
+
capture_exceptions: false
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether this browser is automation (the org's own agents, E2E runs), which
|
|
63
|
+
* analytics must keep separable from real users (`is_agent=true`).
|
|
64
|
+
* `?mii_agent=1` is the manual override and persists via localStorage;
|
|
65
|
+
* Playwright-family browsers set `navigator.webdriver`.
|
|
66
|
+
*/
|
|
67
|
+
export function isAgentBrowser(win) {
|
|
68
|
+
try {
|
|
69
|
+
if (new URLSearchParams(win.location.search).has("mii_agent")) {
|
|
70
|
+
win.localStorage.setItem("mii_agent", "1");
|
|
71
|
+
}
|
|
72
|
+
return (win.navigator.webdriver === true ||
|
|
73
|
+
win.localStorage.getItem("mii_agent") === "1");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// localStorage can be unavailable (privacy modes); never break the app.
|
|
77
|
+
return win.navigator.webdriver === true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export { mayCallServerFn } from "./server-fn-door.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function MiiPaneBridge(): null;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// How an org app talks to the platform when the platform is showing it in a pane.
|
|
3
|
+
//
|
|
4
|
+
// The platform embeds the deployment in an iframe. That iframe is a different origin,
|
|
5
|
+
// so the browser will not let the platform read the route inside it, catch its
|
|
6
|
+
// errors, or find an element in it — a preview with none of those is a picture
|
|
7
|
+
// of an app rather than something you can work on. This posts the missing
|
|
8
|
+
// facts outward and answers the two questions the platform can ask.
|
|
9
|
+
//
|
|
10
|
+
// The platform speaks first. Until an allowed origin says hello this app posts
|
|
11
|
+
// nothing at all, so being framed by someone else leaks nothing: no route, no
|
|
12
|
+
// errors, no DOM. That handshake is also why the parent origin is never
|
|
13
|
+
// guessed from `document.referrer`, which an iframe can suppress.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately no analytics — the ingest pipe already records what people do.
|
|
16
|
+
// This is only the live channel to the pane, and it leaves no trace.
|
|
17
|
+
import { useEffect, useRef } from "react";
|
|
18
|
+
import { z } from "zod/v4";
|
|
19
|
+
import { PLAZA_FRAME_ORIGINS } from "./index.js";
|
|
20
|
+
const PLAZA_ORIGINS = PLAZA_FRAME_ORIGINS;
|
|
21
|
+
/** The platform → app. Anything else the platform posts is not for this bridge. */
|
|
22
|
+
const inbound = z.discriminatedUnion("type", [
|
|
23
|
+
z.object({ type: z.literal("mii-preview:hello") }),
|
|
24
|
+
z.object({ type: z.literal("mii-preview:select"), on: z.boolean() }),
|
|
25
|
+
z.object({ type: z.literal("mii-preview:locate"), id: z.string(), selector: z.string() })
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Route changes without a router dependency: popstate covers back/forward,
|
|
29
|
+
* and pushState/replaceState are wrapped to announce themselves, which every
|
|
30
|
+
* SPA router ultimately calls. The wrap is installed once per mount and
|
|
31
|
+
* restored on unmount.
|
|
32
|
+
*/
|
|
33
|
+
function onRouteChange(callback) {
|
|
34
|
+
const announce = () => queueMicrotask(callback);
|
|
35
|
+
const wrap = (method) => {
|
|
36
|
+
const original = window.history[method].bind(window.history);
|
|
37
|
+
const wrapped = (...args) => {
|
|
38
|
+
original(...args);
|
|
39
|
+
announce();
|
|
40
|
+
};
|
|
41
|
+
window.history[method] = wrapped;
|
|
42
|
+
return () => {
|
|
43
|
+
window.history[method] = original;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
const restorePush = wrap("pushState");
|
|
47
|
+
const restoreReplace = wrap("replaceState");
|
|
48
|
+
window.addEventListener("popstate", announce);
|
|
49
|
+
return () => {
|
|
50
|
+
restorePush();
|
|
51
|
+
restoreReplace();
|
|
52
|
+
window.removeEventListener("popstate", announce);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export function MiiPaneBridge() {
|
|
56
|
+
// Set by the handshake, read by the route announcements. A ref rather than
|
|
57
|
+
// state: nothing renders from it.
|
|
58
|
+
const plazaRef = useRef(null);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// `window.parent === window` when this app is the whole tab, which is the
|
|
61
|
+
// ordinary case — someone just using the app.
|
|
62
|
+
if (window.parent === window)
|
|
63
|
+
return;
|
|
64
|
+
const describe = () => ({
|
|
65
|
+
path: window.location.pathname + window.location.search,
|
|
66
|
+
title: document.title
|
|
67
|
+
});
|
|
68
|
+
const send = (message) => {
|
|
69
|
+
const plaza = plazaRef.current;
|
|
70
|
+
if (plaza !== null)
|
|
71
|
+
window.parent.postMessage(message, plaza);
|
|
72
|
+
};
|
|
73
|
+
// Runtime failures the pane would otherwise never hear about. Sentry gets
|
|
74
|
+
// these too, but Sentry is somewhere you go afterwards — an agent watching
|
|
75
|
+
// its own change land needs to know now.
|
|
76
|
+
const onError = (event) => send({
|
|
77
|
+
type: "mii-preview:error",
|
|
78
|
+
message: event.message,
|
|
79
|
+
source: `${event.filename}:${event.lineno}`
|
|
80
|
+
});
|
|
81
|
+
const onRejection = (event) => send({
|
|
82
|
+
type: "mii-preview:error",
|
|
83
|
+
message: String(event.reason),
|
|
84
|
+
source: "unhandled rejection"
|
|
85
|
+
});
|
|
86
|
+
// Select mode. Hovering paints one outline element — reused, never
|
|
87
|
+
// stacked — and clicking reports what was picked instead of activating
|
|
88
|
+
// it: you are pointing at a button to talk about it, not pressing it.
|
|
89
|
+
let selecting = false;
|
|
90
|
+
const outline = document.createElement("div");
|
|
91
|
+
outline.setAttribute("aria-hidden", "true");
|
|
92
|
+
// Injected imperatively and deliberately outside the design system: this
|
|
93
|
+
// paints over the app, so it must not inherit the app's own theming.
|
|
94
|
+
outline.style.cssText =
|
|
95
|
+
"position:fixed;pointer-events:none;z-index:2147483647;display:none;" +
|
|
96
|
+
"border:2px solid #4f7cff;border-radius:3px;background:rgba(79,124,255,0.12)";
|
|
97
|
+
const onMove = (event) => {
|
|
98
|
+
const { target } = event;
|
|
99
|
+
if (!selecting || !(target instanceof Element))
|
|
100
|
+
return;
|
|
101
|
+
const rect = target.getBoundingClientRect();
|
|
102
|
+
outline.style.display = "block";
|
|
103
|
+
outline.style.top = `${rect.top}px`;
|
|
104
|
+
outline.style.left = `${rect.left}px`;
|
|
105
|
+
outline.style.width = `${rect.width}px`;
|
|
106
|
+
outline.style.height = `${rect.height}px`;
|
|
107
|
+
};
|
|
108
|
+
const onClick = (event) => {
|
|
109
|
+
const { target } = event;
|
|
110
|
+
if (!selecting || !(target instanceof Element))
|
|
111
|
+
return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
event.stopPropagation();
|
|
114
|
+
// `data-el` is the codebase's label convention — the analytics ingest
|
|
115
|
+
// reads the same attribute, so an element already named for a chart
|
|
116
|
+
// arrives here under the name it already has.
|
|
117
|
+
const label = target.getAttribute("data-el");
|
|
118
|
+
send({
|
|
119
|
+
type: "mii-preview:picked",
|
|
120
|
+
label: label === null ? target.textContent.trim().slice(0, 80) : label,
|
|
121
|
+
selector: selectorFor(target),
|
|
122
|
+
tag: target.tagName.toLowerCase(),
|
|
123
|
+
...describe()
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const onMessage = (event) => {
|
|
127
|
+
if (!PLAZA_ORIGINS.includes(event.origin))
|
|
128
|
+
return;
|
|
129
|
+
plazaRef.current = event.origin;
|
|
130
|
+
const read = inbound.safeParse(event.data);
|
|
131
|
+
if (!read.success)
|
|
132
|
+
return;
|
|
133
|
+
const message = read.data;
|
|
134
|
+
if (message.type === "mii-preview:hello") {
|
|
135
|
+
send({ type: "mii-preview:ready", ...describe() });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (message.type === "mii-preview:select") {
|
|
139
|
+
selecting = message.on;
|
|
140
|
+
document.body.style.cursor = selecting ? "crosshair" : "";
|
|
141
|
+
if (!selecting)
|
|
142
|
+
outline.style.display = "none";
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// Where is this element, right now, in this window? The platform uses the
|
|
146
|
+
// answer to put a cursor on it. Asked by element rather than by
|
|
147
|
+
// coordinates so the answer stays right when this window is a
|
|
148
|
+
// different size than the one being replayed.
|
|
149
|
+
const found = document.querySelector(message.selector);
|
|
150
|
+
send({
|
|
151
|
+
type: "mii-preview:located",
|
|
152
|
+
id: message.id,
|
|
153
|
+
rect: found === null ? null : rectOf(found)
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
// A navigation reports itself; the listener set above stays put.
|
|
157
|
+
const stopRouteWatch = onRouteChange(() => {
|
|
158
|
+
const plaza = plazaRef.current;
|
|
159
|
+
if (plaza === null)
|
|
160
|
+
return;
|
|
161
|
+
window.parent.postMessage({ type: "mii-preview:route", ...describe() }, plaza);
|
|
162
|
+
});
|
|
163
|
+
document.body.appendChild(outline);
|
|
164
|
+
window.addEventListener("error", onError);
|
|
165
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
166
|
+
window.addEventListener("message", onMessage);
|
|
167
|
+
document.addEventListener("mousemove", onMove, true);
|
|
168
|
+
document.addEventListener("click", onClick, true);
|
|
169
|
+
return () => {
|
|
170
|
+
stopRouteWatch();
|
|
171
|
+
window.removeEventListener("error", onError);
|
|
172
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
173
|
+
window.removeEventListener("message", onMessage);
|
|
174
|
+
document.removeEventListener("mousemove", onMove, true);
|
|
175
|
+
document.removeEventListener("click", onClick, true);
|
|
176
|
+
document.body.style.cursor = "";
|
|
177
|
+
outline.remove();
|
|
178
|
+
};
|
|
179
|
+
}, []);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function rectOf(element) {
|
|
183
|
+
const rect = element.getBoundingClientRect();
|
|
184
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* A selector that finds this element again in another window. Prefers the
|
|
188
|
+
* names that survive a re-render — an id, the `data-el` label — and only
|
|
189
|
+
* counts siblings when an element carries no name of its own.
|
|
190
|
+
*/
|
|
191
|
+
function selectorFor(element) {
|
|
192
|
+
const parts = [];
|
|
193
|
+
for (let node = element; node !== null; node = node.parentElement) {
|
|
194
|
+
if (node.isSameNode(document.body))
|
|
195
|
+
break;
|
|
196
|
+
if (node.id !== "") {
|
|
197
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const label = node.getAttribute("data-el");
|
|
201
|
+
if (label !== null) {
|
|
202
|
+
parts.unshift(`[data-el="${CSS.escape(label)}"]`);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const current = node;
|
|
206
|
+
const tag = current.tagName.toLowerCase();
|
|
207
|
+
const parent = current.parentElement;
|
|
208
|
+
if (parent === null) {
|
|
209
|
+
parts.unshift(tag);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
const twins = [...parent.children].filter((child) => child.tagName === current.tagName);
|
|
213
|
+
parts.unshift(twins.length === 1 ? tag : `${tag}:nth-of-type(${twins.indexOf(current) + 1})`);
|
|
214
|
+
}
|
|
215
|
+
return parts.join(" > ");
|
|
216
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server function by where it is declared, as TanStack Start's compiler
|
|
3
|
+
* names it for middleware (`serverFnMeta`): `<file>#<variable>`, e.g.
|
|
4
|
+
* `src/routes/index.tsx#submitWaitlist`.
|
|
5
|
+
*/
|
|
6
|
+
export type ServerFnName = `${string}#${string}`;
|
|
7
|
+
export type ServerFnCall = {
|
|
8
|
+
readonly fn: {
|
|
9
|
+
readonly filename: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
};
|
|
12
|
+
readonly method: string;
|
|
13
|
+
/** The app's own list of functions anyone may call (a public waitlist form). */
|
|
14
|
+
readonly publicServerFns: readonly ServerFnName[];
|
|
15
|
+
/** Only asked when the function is not open to everyone. */
|
|
16
|
+
readonly session: () => Promise<{
|
|
17
|
+
readonly user: unknown;
|
|
18
|
+
readonly mode: "public" | "members";
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Whether this call may run. Anyone may call the sign-in functions and the
|
|
23
|
+
* app's public list. Otherwise a members-only app needs a signed-in member for
|
|
24
|
+
* everything, and a public app lets anyone read (GET) but only a signed-in
|
|
25
|
+
* person write.
|
|
26
|
+
*/
|
|
27
|
+
export declare function mayCallServerFn(call: ServerFnCall): Promise<boolean>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The door on an org app's server functions. A page's door (its route guard)
|
|
2
|
+
// never sees a server function called directly by URL, so without this an app
|
|
3
|
+
// whose functions forget their own guard serves the org's data to anyone.
|
|
4
|
+
/**
|
|
5
|
+
* The template's server functions a signed-out visitor needs to become signed
|
|
6
|
+
* in: who am I, the two ways to sign in, signing out, and the config the login
|
|
7
|
+
* page renders with.
|
|
8
|
+
*/
|
|
9
|
+
const SIGN_IN_SERVER_FNS = [
|
|
10
|
+
"src/lib/auth.ts#getSessionUser",
|
|
11
|
+
"src/lib/auth.ts#redeemPaneCode",
|
|
12
|
+
"src/lib/auth.ts#loginWithPassword",
|
|
13
|
+
"src/lib/auth.ts#logout",
|
|
14
|
+
"src/routes/__root.tsx#getPlatformConfig"
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Whether this call may run. Anyone may call the sign-in functions and the
|
|
18
|
+
* app's public list. Otherwise a members-only app needs a signed-in member for
|
|
19
|
+
* everything, and a public app lets anyone read (GET) but only a signed-in
|
|
20
|
+
* person write.
|
|
21
|
+
*/
|
|
22
|
+
export async function mayCallServerFn(call) {
|
|
23
|
+
const name = `${call.fn.filename}#${call.fn.name}`;
|
|
24
|
+
if (SIGN_IN_SERVER_FNS.includes(name) ||
|
|
25
|
+
call.publicServerFns.includes(name)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const { user, mode } = await call.session();
|
|
29
|
+
if (user !== null)
|
|
30
|
+
return true;
|
|
31
|
+
return mode === "public" && call.method === "GET";
|
|
32
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS, type ServerFnName } from "
|
|
2
|
-
export { MiiPaneBridge } from "
|
|
1
|
+
export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS, type ServerFnName } from "./platform/index.js";
|
|
2
|
+
export { MiiPaneBridge } from "./platform/pane-bridge.js";
|
package/dist/platform.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The platform contract a page and the global middleware need: framing by
|
|
2
2
|
// the platform's App pane, the server-function door, analytics and error policies,
|
|
3
|
-
// and the pane bridge.
|
|
4
|
-
export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "
|
|
5
|
-
export { MiiPaneBridge } from "
|
|
3
|
+
// and the pane bridge.
|
|
4
|
+
export { FRAME_ANCESTORS, isAgentBrowser, mayCallServerFn, miiAnalyticsPolicy, miiErrorPolicy, PLAZA_FRAME_ORIGINS } from "./platform/index.js";
|
|
5
|
+
export { MiiPaneBridge } from "./platform/pane-bridge.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A refusal from one of this app's own platform routes. */
|
|
2
|
+
export declare class RequestError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly code: string;
|
|
5
|
+
constructor(status: number, code: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function errorBody(text: string, fallback: string): {
|
|
8
|
+
code: string;
|
|
9
|
+
detail: string;
|
|
10
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A refusal from one of this app's own platform routes. */
|
|
2
|
+
export class RequestError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
constructor(status, code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
// The platform answers { error: <sentence>, code? }. Whatever sits in front of the app
|
|
12
|
+
// (a CDN, a dev server) may answer with a page of HTML instead: `fallback` is
|
|
13
|
+
// the sentence for an answer that carries none.
|
|
14
|
+
export function errorBody(text, fallback) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { code: "error", detail: fallback };
|
|
21
|
+
}
|
|
22
|
+
const body = Object(parsed);
|
|
23
|
+
return { code: asText(body.code, "error"), detail: asText(body.error, fallback) };
|
|
24
|
+
}
|
|
25
|
+
/** `value` when it is text, else `fallback`. */
|
|
26
|
+
function asText(value, fallback) {
|
|
27
|
+
// eslint-disable-next-line no-restricted-syntax -- the chat widget's eager bundle rides on every org-app page; a schema library here would too
|
|
28
|
+
return typeof value === "string" ? value : fallback;
|
|
29
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -179,7 +179,7 @@ export declare const lightChanges: PgTableWithColumns<{
|
|
|
179
179
|
tableName: "light_changes";
|
|
180
180
|
dataType: "string";
|
|
181
181
|
columnType: "PgText";
|
|
182
|
-
data: "
|
|
182
|
+
data: "database" | "agent" | "person";
|
|
183
183
|
driverParam: string;
|
|
184
184
|
notNull: true;
|
|
185
185
|
hasDefault: false;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type DbRetryOptions = {
|
|
2
|
+
readonly attempts?: number;
|
|
3
|
+
readonly baseDelayMs?: number;
|
|
4
|
+
/** Injected by tests so they do not spend real seconds sleeping. */
|
|
5
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
6
|
+
};
|
|
7
|
+
export declare function withDbRetry<T>(operation: () => Promise<T>, { attempts, baseDelayMs, sleep }?: DbRetryOptions): Promise<T>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Ported from the Next template's src/lib/db-retry.ts, where it earned its
|
|
2
|
+
// shape in production (transient EAI_AGAIN across several orgs, 2026-08-22):
|
|
3
|
+
// postgres.js connects lazily, so a resolver blip surfaces on the first query
|
|
4
|
+
// and nothing underneath tries again. Retry the connect, and only the connect
|
|
5
|
+
// — a wrong password or a missing table must still fail on attempt one.
|
|
6
|
+
import { z } from "zod/v4";
|
|
7
|
+
const TRANSIENT_CODES = new Set([
|
|
8
|
+
"EAI_AGAIN",
|
|
9
|
+
"ENOTFOUND",
|
|
10
|
+
"ETIMEDOUT",
|
|
11
|
+
"ECONNRESET",
|
|
12
|
+
"ECONNREFUSED",
|
|
13
|
+
"ENETUNREACH",
|
|
14
|
+
"EHOSTUNREACH",
|
|
15
|
+
"CONNECT_TIMEOUT"
|
|
16
|
+
]);
|
|
17
|
+
const DB_RETRY_ATTEMPTS = 4;
|
|
18
|
+
const BASE_DELAY_MS = 250;
|
|
19
|
+
/** A Node system error names its code; anything wrapping one names it as its cause. */
|
|
20
|
+
const coded = z.object({ code: z.string() });
|
|
21
|
+
const caused = z.object({ cause: z.unknown() });
|
|
22
|
+
/** The error's code, or the wrapped one's; "unknown" for an error that names none. */
|
|
23
|
+
function errorCode(error) {
|
|
24
|
+
let current = error;
|
|
25
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
26
|
+
const named = coded.safeParse(current);
|
|
27
|
+
if (named.success)
|
|
28
|
+
return named.data.code;
|
|
29
|
+
const wrapped = caused.safeParse(current);
|
|
30
|
+
if (!wrapped.success)
|
|
31
|
+
return "unknown";
|
|
32
|
+
current = wrapped.data.cause;
|
|
33
|
+
}
|
|
34
|
+
return "unknown";
|
|
35
|
+
}
|
|
36
|
+
function isTransientConnectionError(error) {
|
|
37
|
+
return TRANSIENT_CODES.has(errorCode(error));
|
|
38
|
+
}
|
|
39
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
40
|
+
export async function withDbRetry(operation, { attempts = DB_RETRY_ATTEMPTS, baseDelayMs = BASE_DELAY_MS, sleep = wait } = {}) {
|
|
41
|
+
let lastError;
|
|
42
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
43
|
+
try {
|
|
44
|
+
return await operation();
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!isTransientConnectionError(error))
|
|
48
|
+
throw error;
|
|
49
|
+
lastError = error;
|
|
50
|
+
if (attempt < attempts)
|
|
51
|
+
await sleep(baseDelayMs * 2 ** (attempt - 1));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Could not reach the database after ${attempts} attempts ` +
|
|
55
|
+
`(${errorCode(lastError)}). The host did not resolve or ` +
|
|
56
|
+
`accept a connection. This is a transient network fault, not a ` +
|
|
57
|
+
`credential, schema or permission problem — run the command again.`, { cause: lastError });
|
|
58
|
+
}
|
package/dist/server/entities.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { and, asc, desc, eq, getTableColumns, getTableName, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
|
6
6
|
import { isDeepStrictEqual } from "node:util";
|
|
7
7
|
import { createSchemaFactory } from "drizzle-zod";
|
|
8
|
-
import { isSameOrigin } from "
|
|
8
|
+
import { isSameOrigin } from "../chat/server.js";
|
|
9
9
|
import { z } from "zod/v4";
|
|
10
10
|
import { ENTITIES_PATH, LIST_LIMIT } from "../entities.js";
|
|
11
11
|
import { PLATFORM_COLUMNS, fieldSource, isEntity } from "../schema.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DbRetryOptions } from "./db-retry.js";
|
|
2
|
+
export type HealthHandlerOptions = {
|
|
3
|
+
/** Defaults to process.env.DATABASE_URL, read per request; null when unset. */
|
|
4
|
+
readonly databaseUrl?: () => string | null;
|
|
5
|
+
/** Defaults to process.env.MII_ORG_ID. */
|
|
6
|
+
readonly orgId?: () => string | null;
|
|
7
|
+
/** Test hook: shrink attempts/delays so failure paths don't spend seconds. */
|
|
8
|
+
readonly retry?: DbRetryOptions;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/health — the readiness gate. The sandbox refuses to report an
|
|
12
|
+
* instance as up, and the platform refuses to wake one, until this answers ok. It
|
|
13
|
+
* proves the one dependency that page loads actually need: the database
|
|
14
|
+
* answers a query. A transient DNS fault must not read as an unhealthy app,
|
|
15
|
+
* hence the connect retry.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createHealthHandler({ databaseUrl, orgId, retry }?: HealthHandlerOptions): () => Promise<Response>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The readiness route the platform calls on every org app, as a web-standard
|
|
2
|
+
// handler (Request → Response).
|
|
3
|
+
import postgres from "postgres";
|
|
4
|
+
import { withDbRetry } from "./db-retry.js";
|
|
5
|
+
/** An env var as it is now: its text, or null when unset. */
|
|
6
|
+
function envVar(name) {
|
|
7
|
+
const { [name]: value = null } = process.env;
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/health — the readiness gate. The sandbox refuses to report an
|
|
12
|
+
* instance as up, and the platform refuses to wake one, until this answers ok. It
|
|
13
|
+
* proves the one dependency that page loads actually need: the database
|
|
14
|
+
* answers a query. A transient DNS fault must not read as an unhealthy app,
|
|
15
|
+
* hence the connect retry.
|
|
16
|
+
*/
|
|
17
|
+
export function createHealthHandler({ databaseUrl = () => envVar("DATABASE_URL"), orgId = () => envVar("MII_ORG_ID"), retry } = {}) {
|
|
18
|
+
return async function health() {
|
|
19
|
+
const url = databaseUrl();
|
|
20
|
+
if (url === null || url === "") {
|
|
21
|
+
return Response.json({ ok: false, error: "DATABASE_URL is not configured" }, { status: 503 });
|
|
22
|
+
}
|
|
23
|
+
const sql = postgres(url, { max: 1, prepare: false, connect_timeout: 10 });
|
|
24
|
+
try {
|
|
25
|
+
const [result = { database: null, now: null }] = await withDbRetry(() => sql `
|
|
26
|
+
select current_database() as database, now()::text as now
|
|
27
|
+
`, retry);
|
|
28
|
+
return Response.json({
|
|
29
|
+
ok: true,
|
|
30
|
+
orgId: orgId(),
|
|
31
|
+
database: result.database,
|
|
32
|
+
databaseTime: result.now
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return Response.json({ ok: false, error: err instanceof Error ? err.message : "Unknown error" }, { status: 503 });
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await sql.end();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|