wizards 0.2.2 → 0.2.4
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/apps/runtime/dist/app.js +44 -0
- package/apps/runtime/dist/auth/account.js +16 -2
- package/apps/runtime/dist/auth/index.js +14 -1
- package/apps/runtime/dist/connectors/external.js +5 -0
- package/apps/runtime/dist/db/client.js +24 -3
- package/apps/runtime/dist/db/control-schema.js +12 -3
- package/apps/runtime/dist/db/migrations/control/0005_chilly_oracle.sql +1 -0
- package/apps/runtime/dist/db/migrations/control/0006_sleepy_mojo.sql +1 -0
- package/apps/runtime/dist/db/migrations/control/meta/0005_snapshot.json +358 -0
- package/apps/runtime/dist/db/migrations/control/meta/0006_snapshot.json +365 -0
- package/apps/runtime/dist/db/migrations/control/meta/_journal.json +14 -0
- package/apps/runtime/dist/db/migrations/tenant/0005_reflective_forgotten_one.sql +2 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0005_snapshot.json +1659 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +6 -0
- package/apps/runtime/dist/engine/asks.js +2 -0
- package/apps/runtime/dist/engine/push.js +58 -0
- package/apps/runtime/dist/engine/runner.js +6 -0
- package/apps/runtime/dist/env.js +13 -0
- package/apps/runtime/dist/index.js +6 -0
- package/apps/runtime/dist/limits.js +21 -0
- package/apps/runtime/dist/manage.js +16 -3
- package/apps/runtime/dist/mcp/tools.js +4 -2
- package/apps/runtime/dist/routes/api.js +96 -6
- package/apps/runtime/dist/routes/runs.js +68 -2
- package/apps/runtime/dist/routes/studio.js +79 -11
- package/apps/runtime/dist/services/access.js +62 -0
- package/apps/runtime/dist/services/architect.js +3 -2
- package/apps/runtime/dist/services/cloud.js +298 -39
- package/apps/runtime/dist/services/errors.js +1 -0
- package/apps/runtime/dist/services/files.js +53 -4
- package/apps/runtime/dist/services/project-files.js +6 -0
- package/apps/runtime/dist/services/projects.js +38 -14
- package/apps/runtime/dist/services/spaces.js +400 -0
- package/apps/runtime/dist/services/wizards.js +19 -8
- package/apps/runtime/dist/tenants/control.js +47 -0
- package/apps/runtime/dist/update.js +3 -3
- package/apps/web/dist/assets/{ChangelogDialog-4HCt88rf.js → ChangelogDialog-lGa0JKY1.js} +1 -1
- package/apps/web/dist/assets/ChatPanel-Dk_45uca.js +1 -0
- package/apps/web/dist/assets/{CreditsDialog-D3fZRqXH.js → CreditsDialog-BPMWo6tp.js} +1 -1
- package/apps/web/dist/assets/EditorPage-Dt4MCcrU.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-CjUK5046.js → FlowPreview-gwtUv8-X.js} +1 -1
- package/apps/web/dist/assets/HomePage-Bi9FK3_1.js +1 -0
- package/apps/web/dist/assets/ImportWizard-D6y4bKlF.js +1 -0
- package/apps/web/dist/assets/{LocalRuntime-DupxjXff.js → LocalRuntime-BjzPSsHr.js} +10 -10
- package/apps/web/dist/assets/NewWizardPage-2HbCaPSt.js +2 -0
- package/apps/web/dist/assets/PublicRunner-Dac_e6wg.js +1 -0
- package/apps/web/dist/assets/RunnerView-BC6crf1p.js +2 -0
- package/apps/web/dist/assets/SettingsPage-BfXxa4SU.js +2 -0
- package/apps/web/dist/assets/{SetupPage-xiqJMR0J.js → SetupPage-CYKVspBJ.js} +1 -1
- package/apps/web/dist/assets/{SharePage-C8B9BUSU.js → SharePage-DFIHsNS-.js} +1 -1
- package/apps/web/dist/assets/{api-KMi35npD.js → api-BTfKh3La.js} +1 -1
- package/apps/web/dist/assets/changelog-CLmll9By.js +1 -0
- package/apps/web/dist/assets/index-CIwJmOjo.css +1 -0
- package/apps/web/dist/assets/{index-2Ktgo6f1.js → index-DOpyzQiU.js} +3 -3
- package/apps/web/dist/assets/{meta-4R1Lnr25.js → meta-BbVhbYf6.js} +1 -1
- package/apps/web/dist/assets/oss-credits-Hu4LieLA.js +1 -0
- package/apps/web/dist/assets/outputs-n_yhxmMk.js +69 -0
- package/apps/web/dist/assets/{session-B6-6VomQ.js → session-D_Zggq_t.js} +1 -1
- package/apps/web/dist/assets/type-BMJpS6_D.js +1 -0
- package/apps/web/dist/assets/ui-C3bTWmdM.js +1 -0
- package/apps/web/dist/index.html +5 -5
- package/apps/web/dist/mcp/flow.html +54 -54
- package/package.json +1 -1
- package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +0 -1
- package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +0 -2
- package/apps/web/dist/assets/HomePage-Cn5TCZk7.js +0 -1
- package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +0 -2
- package/apps/web/dist/assets/PublicRunner-Axs1zwqe.js +0 -1
- package/apps/web/dist/assets/RunnerView-Bhv6RNME.js +0 -2
- package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +0 -2
- package/apps/web/dist/assets/changelog-C2LDEzLQ.js +0 -1
- package/apps/web/dist/assets/index-B9YVjvVY.css +0 -1
- package/apps/web/dist/assets/oss-credits-roB62QJu.js +0 -1
- package/apps/web/dist/assets/outputs-Dhqtf-SA.js +0 -69
- package/apps/web/dist/assets/type-Bj9H3eOK.js +0 -1
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +0 -1
|
@@ -13,6 +13,12 @@ export const project = sqliteTable("project", {
|
|
|
13
13
|
.$type()
|
|
14
14
|
.notNull()
|
|
15
15
|
.default([]),
|
|
16
|
+
/**
|
|
17
|
+
* `local`: the project of a local install, synced here when that install publishes. It keeps
|
|
18
|
+
* the ids it has there and is changed only by the next sync. Null: made on this runtime.
|
|
19
|
+
*/
|
|
20
|
+
origin: text("origin", { enum: ["local"] }),
|
|
21
|
+
syncedAt: integer("synced_at", { mode: "timestamp_ms" }),
|
|
16
22
|
createdAt: createdAt(),
|
|
17
23
|
updatedAt: updatedAt(),
|
|
18
24
|
}, (t) => [index("project_tenant").on(t.tenantId)]);
|
|
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
|
|
|
2
2
|
import { nanoid } from "nanoid";
|
|
3
3
|
import { db, schema } from "../db/client.js";
|
|
4
4
|
import { signalChanged } from "./events.js";
|
|
5
|
+
import { pushRun } from "./push.js";
|
|
5
6
|
/** How long a step waits for the person before it goes on without them. */
|
|
6
7
|
const ASK_TIMEOUT_MS = 15 * 60_000;
|
|
7
8
|
/** Runs nobody watches (an MCP client drives them): a question there would wait for no one. */
|
|
@@ -32,6 +33,7 @@ export async function askPerson(runId, ask, signal) {
|
|
|
32
33
|
waiting.set(runId, { ask: full, settle: finish });
|
|
33
34
|
});
|
|
34
35
|
await setAsk(runId, full);
|
|
36
|
+
void pushRun(runId, "asks");
|
|
35
37
|
try {
|
|
36
38
|
return await result;
|
|
37
39
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { control, controlDb, db, schema } from "../db/client.js";
|
|
3
|
+
import { managed, pushToDevice } from "../manage.js";
|
|
4
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
5
|
+
const TEXT = {
|
|
6
|
+
en: {
|
|
7
|
+
done: "Your result is ready.",
|
|
8
|
+
failed: "The run stopped with an error.",
|
|
9
|
+
waiting: "The wizard waits for you.",
|
|
10
|
+
asks: "The wizard has a question for you.",
|
|
11
|
+
},
|
|
12
|
+
de: {
|
|
13
|
+
done: "Dein Ergebnis ist bereit.",
|
|
14
|
+
failed: "Der Durchlauf ist mit einem Fehler stehen geblieben.",
|
|
15
|
+
waiting: "Der Wizard wartet auf dich.",
|
|
16
|
+
asks: "Der Wizard hat eine Frage an dich.",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
export const pushAvailable = () => managed;
|
|
20
|
+
export async function setPushDevice(runId, device) {
|
|
21
|
+
await controlDb
|
|
22
|
+
.update(control.runIndex)
|
|
23
|
+
.set({ push: device })
|
|
24
|
+
.where(eq(control.runIndex.runId, runId));
|
|
25
|
+
}
|
|
26
|
+
/** Tells the run's device, if the app gave one; never fails the run. */
|
|
27
|
+
export async function pushRun(runId, kind) {
|
|
28
|
+
if (!managed) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const row = await controlDb.query.runIndex.findFirst({
|
|
33
|
+
where: eq(control.runIndex.runId, runId),
|
|
34
|
+
});
|
|
35
|
+
const device = row?.push;
|
|
36
|
+
if (!device) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const run = await db.query.run.findFirst({ where: eq(schema.run.id, runId) });
|
|
40
|
+
if (!run) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
await pushToDevice({
|
|
44
|
+
tenantId: currentTenant(),
|
|
45
|
+
device: { token: device.token, platform: device.platform },
|
|
46
|
+
title: run.definition.title,
|
|
47
|
+
body: TEXT[device.lang][kind],
|
|
48
|
+
data: { runId, kind },
|
|
49
|
+
});
|
|
50
|
+
// An ended run has nothing more to say.
|
|
51
|
+
if (kind === "done" || kind === "failed") {
|
|
52
|
+
await setPushDevice(runId, null);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error("[push]", err.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -19,6 +19,7 @@ import { currentTenant } from "../tenants/tenant.js";
|
|
|
19
19
|
import { askPerson, clearStaleAsk, unattended } from "./asks.js";
|
|
20
20
|
import { emitEvent, recentEvents, signalChanged } from "./events.js";
|
|
21
21
|
import { readPageInput } from "./input.js";
|
|
22
|
+
import { pushRun } from "./push.js";
|
|
22
23
|
import { blockingMessage, missingModels } from "./requirements.js";
|
|
23
24
|
import { releaseResources, resourcesFor } from "./resources.js";
|
|
24
25
|
import { runAutomaticStep } from "./steps.js";
|
|
@@ -115,6 +116,9 @@ async function updateRun(runId, patch) {
|
|
|
115
116
|
await releaseRun(runId);
|
|
116
117
|
// Plugins hear of it; the run does not wait for them.
|
|
117
118
|
void runEnded(runId, patch.status);
|
|
119
|
+
if (patch.status !== "cancelled") {
|
|
120
|
+
void pushRun(runId, patch.status);
|
|
121
|
+
}
|
|
118
122
|
}
|
|
119
123
|
signalChanged(runId);
|
|
120
124
|
}
|
|
@@ -281,6 +285,8 @@ async function drive(runId, signal) {
|
|
|
281
285
|
}
|
|
282
286
|
if (step.type === "page" || step.type === "review") {
|
|
283
287
|
await updateRun(runId, { status: "waiting_input" });
|
|
288
|
+
// A step that ran is over and the next one is the person's: a phone in a pocket hears it.
|
|
289
|
+
void pushRun(runId, "waiting");
|
|
284
290
|
return;
|
|
285
291
|
}
|
|
286
292
|
if (!(await canSpend())) {
|
package/apps/runtime/dist/env.js
CHANGED
|
@@ -192,6 +192,17 @@ export const env = {
|
|
|
192
192
|
secretAccessKey: str("S3_SECRET_ACCESS_KEY"),
|
|
193
193
|
},
|
|
194
194
|
turnstile: { siteKey: str("TURNSTILE_SITE_KEY"), secret: str("TURNSTILE_SECRET_KEY") },
|
|
195
|
+
/**
|
|
196
|
+
* The mobile app (apps/mobile): a phone opens this runtime's `/w/` and `/s/` links in it.
|
|
197
|
+
* Served as /.well-known/apple-app-site-association and /.well-known/assetlinks.json.
|
|
198
|
+
*/
|
|
199
|
+
mobile: {
|
|
200
|
+
/** `<team id>.<bundle id>` of the iOS apps; empty = no file. */
|
|
201
|
+
iosAppIds: list("MOBILE_IOS_APP_IDS"),
|
|
202
|
+
androidPackage: str("MOBILE_ANDROID_PACKAGE", "ai.engenty.wizards"),
|
|
203
|
+
/** SHA-256 fingerprints of the Android app's signing certificates; empty = no file. */
|
|
204
|
+
androidCertSha256: list("MOBILE_ANDROID_CERT_SHA256"),
|
|
205
|
+
},
|
|
195
206
|
/** Plugins (docs/content/dev/plugins). Code in these folders runs with the runtime's own rights. */
|
|
196
207
|
plugins: {
|
|
197
208
|
/** Folders to look in, besides the `modules/` this runtime ships with. */
|
|
@@ -214,6 +225,8 @@ export const env = {
|
|
|
214
225
|
* can set another number per tenant; the database holds any number either way.
|
|
215
226
|
*/
|
|
216
227
|
projects: num("LIMIT_PROJECTS", 1),
|
|
228
|
+
/** Wizards a local install may keep on this runtime in its synced project. */
|
|
229
|
+
syncedWizards: num("LIMIT_SYNCED_WIZARDS", 50),
|
|
217
230
|
/** End-user runs (no account) and their shared links are deleted after this many days. */
|
|
218
231
|
resultTtlDays: num("RESULT_TTL_DAYS", 7),
|
|
219
232
|
/** What a wizard keeps for a person (lists, files, accounts) goes when unused this long. */
|
|
@@ -68,6 +68,12 @@ async function purge() {
|
|
|
68
68
|
}
|
|
69
69
|
void purge().catch((err) => console.error("[retention]", err));
|
|
70
70
|
setInterval(() => void purge().catch((err) => console.error("[retention]", err)), 3600_000).unref();
|
|
71
|
+
// A runtime that runs alone sends again what did not arrive in its account's cloud.
|
|
72
|
+
if (!managed) {
|
|
73
|
+
const { retryFailedSends } = await import("./services/cloud.js");
|
|
74
|
+
const retry = () => withTenant(LOCAL_TENANT, () => retryFailedSends("local")).catch((err) => console.error("[cloud] retry failed:", err));
|
|
75
|
+
setInterval(() => void retry(), 60_000).unref();
|
|
76
|
+
}
|
|
71
77
|
async function shutdown(code = 0) {
|
|
72
78
|
clearRunning(env.dataDir);
|
|
73
79
|
server.close();
|
|
@@ -55,3 +55,24 @@ export async function verifyTurnstile(token, ip) {
|
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Times per key, kept in this process; old ones drop out as the key is asked. */
|
|
59
|
+
const seen = new Map();
|
|
60
|
+
/** Counts one more try for `key`; false once `max` tries fell within the last `windowMs`. */
|
|
61
|
+
export function allowed(key, max, windowMs) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const recent = (seen.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
64
|
+
if (recent.length >= max) {
|
|
65
|
+
seen.set(key, recent);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
recent.push(now);
|
|
69
|
+
seen.set(key, recent);
|
|
70
|
+
if (seen.size > 50_000) {
|
|
71
|
+
for (const [k, times] of seen) {
|
|
72
|
+
if (!times.some((t) => now - t < windowMs)) {
|
|
73
|
+
seen.delete(k);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
@@ -107,9 +107,18 @@ export async function tenantInfo(id, fresh = false) {
|
|
|
107
107
|
if (!fresh && hit && Date.now() - hit.at < 60_000) {
|
|
108
108
|
return hit.value;
|
|
109
109
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
try {
|
|
111
|
+
const value = await call("GET", `/v1/tenants/${encodeURIComponent(id)}`);
|
|
112
|
+
tenants.set(id, { at: Date.now(), value });
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// The Manage-App is out of reach for a moment: what it said within the hour still counts.
|
|
117
|
+
if (hit && !(err instanceof ManageError) && Date.now() - hit.at < 3600_000) {
|
|
118
|
+
return hit.value;
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
113
122
|
}
|
|
114
123
|
export function forgetTenant(id) {
|
|
115
124
|
tenants.delete(id);
|
|
@@ -129,6 +138,10 @@ export function runUsage(tenantId, runId) {
|
|
|
129
138
|
export function bookUsage(input) {
|
|
130
139
|
return call("POST", "/v1/usage", input);
|
|
131
140
|
}
|
|
141
|
+
/** A push to the mobile app on one device; the Manage-App holds the APNs and FCM keys. */
|
|
142
|
+
export function pushToDevice(input) {
|
|
143
|
+
return call("POST", "/v1/push", input);
|
|
144
|
+
}
|
|
132
145
|
/** Code or refresh token → tokens. A confidential client sends its secret, a public one only its id. */
|
|
133
146
|
export async function tokenRequest(params, client, base = env.manage.url) {
|
|
134
147
|
const d = await discovery(base);
|
|
@@ -4,13 +4,14 @@ import { wizardOpSchema } from "../authoring/ops.js";
|
|
|
4
4
|
import { importFromRegistry, listConnectors, searchRegistry } from "../connectors/external.js";
|
|
5
5
|
import { RunConflict } from "../engine/runner.js";
|
|
6
6
|
import { pluginToolsOf } from "../plugins/registry.js";
|
|
7
|
+
import { publishAndSync, pushSharing } from "../services/cloud.js";
|
|
7
8
|
import { ServiceError } from "../services/errors.js";
|
|
8
9
|
import { deleteFile, listFiles, readFileText, writeFile } from "../services/files.js";
|
|
9
10
|
import { marketplaceWizard, searchMarketplace } from "../services/marketplace.js";
|
|
10
11
|
import { defaultProject, listProjects, ownedProject } from "../services/projects.js";
|
|
11
12
|
import { answerRunAsk, answerRunPage, controlRun, reviewRun, runReport, startRun, startTestRun, testRunReport, } from "../services/runs.js";
|
|
12
13
|
import { checkDraftWidget } from "../services/widgets.js";
|
|
13
|
-
import { createWizard, editWizard, listWizards, ownedWizard, parseDraft,
|
|
14
|
+
import { createWizard, editWizard, listWizards, ownedWizard, parseDraft, shareUrl, studioUrl, updateWizardSettings, wizardState, writeDraft, } from "../services/wizards.js";
|
|
14
15
|
import { FLOW_VIEW_KEY, flowAppMeta, flowView } from "./flow-app.js";
|
|
15
16
|
const definition = z
|
|
16
17
|
.record(z.string(), z.unknown())
|
|
@@ -280,7 +281,7 @@ Returns the new revision and the issues.`,
|
|
|
280
281
|
title: "Publish wizard",
|
|
281
282
|
description: "Publish the draft as a new version behind the share link. Refused while the draft has issues. Only when the admin asked for it.",
|
|
282
283
|
input: z.object({ wizardId: z.string() }),
|
|
283
|
-
}, async ({ wizardId }) =>
|
|
284
|
+
}, async ({ wizardId }) => publishAndSync(who.userId, wizardId));
|
|
284
285
|
tool("set_sharing", "wizards:publish", {
|
|
285
286
|
title: "Set sharing",
|
|
286
287
|
description: "Turn the share link on or off, or change how many runs it allows per day.",
|
|
@@ -294,6 +295,7 @@ Returns the new revision and the issues.`,
|
|
|
294
295
|
shareEnabled: enabled,
|
|
295
296
|
dailyRunLimit,
|
|
296
297
|
});
|
|
298
|
+
await pushSharing(who.userId, wizardId);
|
|
297
299
|
return {
|
|
298
300
|
shareEnabled: w.shareEnabled,
|
|
299
301
|
dailyRunLimit: w.dailyRunLimit,
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { withTenant } from "../db/client.js";
|
|
5
|
+
import { allowed } from "../limits.js";
|
|
4
6
|
import { authenticate, principalOf } from "../mcp/auth.js";
|
|
5
7
|
import { ServiceError } from "../services/errors.js";
|
|
6
8
|
import { deleteFile, draftFiles, writeFile } from "../services/files.js";
|
|
9
|
+
import { checkOnServer, checkSchema, listSyncedSpaces, patchSyncedWizard, removeSyncedSpace, removeSyncedWizard, syncSettingsSchema, syncWizard, syncWizardSchema, } from "../services/spaces.js";
|
|
7
10
|
import { createWizard, ownedWizard, publishWizard, writeDraft } from "../services/wizards.js";
|
|
8
|
-
import { tenantStatus } from "../tenants/control.js";
|
|
11
|
+
import { codeOf, tenantStatus } from "../tenants/control.js";
|
|
9
12
|
const importSchema = z.object({
|
|
10
13
|
/** An earlier import's wizard: it is updated instead of a new one being made. */
|
|
11
14
|
wizardId: z.string().optional(),
|
|
@@ -17,12 +20,97 @@ const importSchema = z.object({
|
|
|
17
20
|
.default([]),
|
|
18
21
|
publish: z.boolean().default(false),
|
|
19
22
|
});
|
|
23
|
+
const HOUR = 60 * 60 * 1000;
|
|
24
|
+
/** A wizard with its workspace: 25 MB of files as base64, and the definition. */
|
|
25
|
+
const WIZARD_BYTES = 48 * 1024 * 1024;
|
|
26
|
+
const tooLarge = bodyLimit({
|
|
27
|
+
maxSize: WIZARD_BYTES,
|
|
28
|
+
onError: (c) => c.json({ error: "Request body too large.", code: "too_large" }, 413),
|
|
29
|
+
});
|
|
20
30
|
/**
|
|
21
|
-
* The
|
|
22
|
-
*
|
|
23
|
-
* other write: this is how a desktop app publishes to the cloud.
|
|
31
|
+
* The caller of an API call, signed in like an MCP client (access token or API key), with the
|
|
32
|
+
* scopes the call needs; or the answer that turns it away.
|
|
24
33
|
*/
|
|
25
|
-
|
|
34
|
+
async function caller(c, scopes) {
|
|
35
|
+
const authInfo = await authenticate(c.req.raw);
|
|
36
|
+
if (authInfo instanceof Response) {
|
|
37
|
+
return authInfo;
|
|
38
|
+
}
|
|
39
|
+
const who = principalOf(authInfo);
|
|
40
|
+
if (scopes.some((scope) => !who.scopes.includes(scope))) {
|
|
41
|
+
return c.json({ error: "This token may not do that.", code: "insufficient_scope" }, 403);
|
|
42
|
+
}
|
|
43
|
+
if ((await tenantStatus(who.tenantId)) === "suspended") {
|
|
44
|
+
return c.json({ error: "tenant_suspended" }, 403);
|
|
45
|
+
}
|
|
46
|
+
return who;
|
|
47
|
+
}
|
|
48
|
+
/** A local install sends when it publishes: more than this an hour is not an install at work. */
|
|
49
|
+
function withinSyncLimit(c, who) {
|
|
50
|
+
return allowed(`sync:${who.tenantId}`, 240, HOUR)
|
|
51
|
+
? null
|
|
52
|
+
: c.json({ error: "Zu viele Übertragungen. Bitte später noch einmal.", code: "rate_limited" }, 429);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The runtime's API for other programs. `/spaces/…` is how a local install's project comes
|
|
56
|
+
* here: the wizards it publishes, under the ids they have there (docs/manage-contract.md).
|
|
57
|
+
* `POST /wizards/import` takes a whole wizard as a new one of a tenant that builds here.
|
|
58
|
+
*/
|
|
59
|
+
export const apiRoutes = new Hono()
|
|
60
|
+
.get("/spaces", async (c) => {
|
|
61
|
+
const who = await caller(c, ["wizards:read"]);
|
|
62
|
+
if (who instanceof Response) {
|
|
63
|
+
return who;
|
|
64
|
+
}
|
|
65
|
+
return withTenant(who.tenantId, async () => c.json({ spaces: await listSyncedSpaces() }));
|
|
66
|
+
})
|
|
67
|
+
.post("/spaces/check", async (c) => {
|
|
68
|
+
const who = await caller(c, ["wizards:read"]);
|
|
69
|
+
if (who instanceof Response) {
|
|
70
|
+
return who;
|
|
71
|
+
}
|
|
72
|
+
const body = checkSchema.parse(await c.req.json());
|
|
73
|
+
return withTenant(who.tenantId, async () => c.json({ problems: await checkOnServer(body) }));
|
|
74
|
+
})
|
|
75
|
+
.put("/spaces/:spaceId/wizards/:wizardId", tooLarge, async (c) => {
|
|
76
|
+
const who = await caller(c, ["wizards:write", "wizards:publish"]);
|
|
77
|
+
if (who instanceof Response) {
|
|
78
|
+
return who;
|
|
79
|
+
}
|
|
80
|
+
const limited = withinSyncLimit(c, who);
|
|
81
|
+
if (limited) {
|
|
82
|
+
return limited;
|
|
83
|
+
}
|
|
84
|
+
const body = syncWizardSchema.parse(await c.req.json());
|
|
85
|
+
return withTenant(who.tenantId, async () => c.json(await syncWizard(c.req.param("spaceId"), c.req.param("wizardId"), body)));
|
|
86
|
+
})
|
|
87
|
+
.patch("/spaces/:spaceId/wizards/:wizardId", async (c) => {
|
|
88
|
+
const who = await caller(c, ["wizards:publish"]);
|
|
89
|
+
if (who instanceof Response) {
|
|
90
|
+
return who;
|
|
91
|
+
}
|
|
92
|
+
const limited = withinSyncLimit(c, who);
|
|
93
|
+
if (limited) {
|
|
94
|
+
return limited;
|
|
95
|
+
}
|
|
96
|
+
const body = syncSettingsSchema.parse(await c.req.json());
|
|
97
|
+
return withTenant(who.tenantId, async () => c.json(await patchSyncedWizard(c.req.param("spaceId"), c.req.param("wizardId"), body)));
|
|
98
|
+
})
|
|
99
|
+
.delete("/spaces/:spaceId/wizards/:wizardId", async (c) => {
|
|
100
|
+
const who = await caller(c, ["wizards:write"]);
|
|
101
|
+
if (who instanceof Response) {
|
|
102
|
+
return who;
|
|
103
|
+
}
|
|
104
|
+
return withTenant(who.tenantId, async () => c.json(await removeSyncedWizard(who.userId, c.req.param("spaceId"), c.req.param("wizardId"))));
|
|
105
|
+
})
|
|
106
|
+
.delete("/spaces/:spaceId", async (c) => {
|
|
107
|
+
const who = await caller(c, ["wizards:write"]);
|
|
108
|
+
if (who instanceof Response) {
|
|
109
|
+
return who;
|
|
110
|
+
}
|
|
111
|
+
return withTenant(who.tenantId, async () => c.json(await removeSyncedSpace(c.req.param("spaceId"))));
|
|
112
|
+
})
|
|
113
|
+
.post("/wizards/import", tooLarge, async (c) => {
|
|
26
114
|
const authInfo = await authenticate(c.req.raw);
|
|
27
115
|
if (authInfo instanceof Response) {
|
|
28
116
|
return authInfo;
|
|
@@ -69,6 +157,8 @@ export const apiRoutes = new Hono().post("/wizards/import", async (c) => {
|
|
|
69
157
|
});
|
|
70
158
|
}
|
|
71
159
|
const published = await publishWizard(who.userId, wizardId);
|
|
72
|
-
|
|
160
|
+
// The ID the mobile app takes.
|
|
161
|
+
const code = await codeOf(current.shareToken);
|
|
162
|
+
return c.json({ wizardId, revision: written.revision, issues: [], ...published, code });
|
|
73
163
|
});
|
|
74
164
|
});
|
|
@@ -12,6 +12,7 @@ import { ConnectError, connectionViews, connectWithCredentials, disconnect, fini
|
|
|
12
12
|
import { db, schema, withTenant } from "../db/client.js";
|
|
13
13
|
import { answerAsk, pendingAsk } from "../engine/asks.js";
|
|
14
14
|
import { signalChanged, subscribe } from "../engine/events.js";
|
|
15
|
+
import { pushAvailable, setPushDevice } from "../engine/push.js";
|
|
15
16
|
import { liveResources } from "../engine/resources.js";
|
|
16
17
|
import { cancel, createRun, goBack, NoCreditsError, projectIdOf, RunConflict, RunInputError, retry, reviewStep, runView, submitPage, } from "../engine/runner.js";
|
|
17
18
|
import { basePath, env } from "../env.js";
|
|
@@ -26,7 +27,7 @@ import { brandView } from "../services/brand.js";
|
|
|
26
27
|
import { ServiceError } from "../services/errors.js";
|
|
27
28
|
import { sharedRun, shareImage, shareRun, shareView, unshareRun } from "../services/shares.js";
|
|
28
29
|
import { clearStore, deleteRows, listRows, listSecrets, readStoreFile, StoreError, saveRows, scopeOf, storeFiles, updateRow, } from "../store/index.js";
|
|
29
|
-
import { tenantOfLink, tenantOfRun, tenantStatus, visitorOverLimit } from "../tenants/control.js";
|
|
30
|
+
import { tenantOfLink, tenantOfRun, tenantStatus, tokenOfCode, visitorOverLimit, } from "../tenants/control.js";
|
|
30
31
|
import { byteRange, uploadLimit, wizardManifest } from "./delivery.js";
|
|
31
32
|
const VISITOR_COOKIE = "wz_vid";
|
|
32
33
|
/** Kept files a review shows inline. */
|
|
@@ -43,8 +44,15 @@ function clientIp(c) {
|
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The mobile app's own requests (a run's view, downloads) name the visitor in a header: iOS
|
|
49
|
+
* merges a Cookie header the app sets with its own cookie store (`wz_vid=a,wz_vid=a`). The header
|
|
50
|
+
* comes first; a browser never sends it across sites without the runtime's say (CORS), so it is
|
|
51
|
+
* as good as the cookie.
|
|
52
|
+
*/
|
|
53
|
+
const VISITOR_HEADER = "x-wizards-visitor";
|
|
46
54
|
function visitorId(c, create, embedded = false) {
|
|
47
|
-
let vid = getCookie(c, VISITOR_COOKIE)
|
|
55
|
+
let vid = c.req.header(VISITOR_HEADER) || getCookie(c, VISITOR_COOKIE) || null;
|
|
48
56
|
if (!vid && create) {
|
|
49
57
|
vid = nanoid(24);
|
|
50
58
|
const secure = env.appUrl.startsWith("https");
|
|
@@ -120,7 +128,41 @@ const wizardLink = tenantFrom((c) => tenantOfLink(c.req.param("token") ?? "", "w
|
|
|
120
128
|
const resultLink = tenantFrom((c) => tenantOfLink(c.req.param("token") ?? "", "result"));
|
|
121
129
|
const logoLink = tenantFrom((c) => tenantOfLink(c.req.param("id") ?? "", "logo"));
|
|
122
130
|
const runTenant = tenantFrom((c) => tenantOfRun(c.req.param("id") ?? ""));
|
|
131
|
+
/** Wrong guesses at a wizard's ID, per address and hour: 32^8 IDs stay out of reach. */
|
|
132
|
+
const codeMisses = new Map();
|
|
133
|
+
const CODE_MISSES_PER_HOUR = 30;
|
|
134
|
+
function codeLookupBlocked(ip) {
|
|
135
|
+
const key = hashIp(ip) ?? "unknown";
|
|
136
|
+
const entry = codeMisses.get(key);
|
|
137
|
+
if (entry && Date.now() - entry.since > 3600_000) {
|
|
138
|
+
codeMisses.delete(key);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return (entry?.n ?? 0) >= CODE_MISSES_PER_HOUR;
|
|
142
|
+
}
|
|
143
|
+
function codeMissed(ip) {
|
|
144
|
+
const key = hashIp(ip) ?? "unknown";
|
|
145
|
+
const entry = codeMisses.get(key) ?? { n: 0, since: Date.now() };
|
|
146
|
+
entry.n += 1;
|
|
147
|
+
codeMisses.set(key, entry);
|
|
148
|
+
if (codeMisses.size > 10_000) {
|
|
149
|
+
codeMisses.delete(codeMisses.keys().next().value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
123
152
|
export const publicRoutes = new Hono()
|
|
153
|
+
// A wizard's ID, typed into the mobile app: answers its share token.
|
|
154
|
+
.get("/codes/:code", async (c) => {
|
|
155
|
+
const ip = clientIp(c);
|
|
156
|
+
if (codeLookupBlocked(ip)) {
|
|
157
|
+
return c.json({ error: "too many tries" }, 429);
|
|
158
|
+
}
|
|
159
|
+
const found = await tokenOfCode(c.req.param("code"));
|
|
160
|
+
if (!found || (await tenantStatus(found.tenantId)) === "suspended") {
|
|
161
|
+
codeMissed(ip);
|
|
162
|
+
return c.json({ error: "not found" }, 404);
|
|
163
|
+
}
|
|
164
|
+
return c.json({ token: found.token, url: `${env.appUrl}/w/${found.token}` });
|
|
165
|
+
})
|
|
124
166
|
.use("/wizards/:token", wizardLink)
|
|
125
167
|
.use("/wizards/:token/*", wizardLink)
|
|
126
168
|
.use("/logos/:id", logoLink)
|
|
@@ -636,6 +678,30 @@ export const runRoutes = new Hono()
|
|
|
636
678
|
await clearStore(scopeOf(run));
|
|
637
679
|
signalChanged(run.id);
|
|
638
680
|
return c.json({ ok: true });
|
|
681
|
+
})
|
|
682
|
+
// The mobile app's device for this run: told when it is done, failed or waits for the person.
|
|
683
|
+
.post("/:id/notify", async (c) => {
|
|
684
|
+
const run = await accessibleRun(c, c.req.param("id"));
|
|
685
|
+
if (!run) {
|
|
686
|
+
return c.json({ error: "not found" }, 404);
|
|
687
|
+
}
|
|
688
|
+
const body = z
|
|
689
|
+
.object({
|
|
690
|
+
token: z.string().min(8).max(512),
|
|
691
|
+
platform: z.enum(["ios", "android"]),
|
|
692
|
+
lang: z.enum(["en", "de"]).default("en"),
|
|
693
|
+
})
|
|
694
|
+
.parse(await c.req.json());
|
|
695
|
+
await setPushDevice(run.id, body);
|
|
696
|
+
return c.json({ push: pushAvailable() });
|
|
697
|
+
})
|
|
698
|
+
.delete("/:id/notify", async (c) => {
|
|
699
|
+
const run = await accessibleRun(c, c.req.param("id"));
|
|
700
|
+
if (!run) {
|
|
701
|
+
return c.json({ error: "not found" }, 404);
|
|
702
|
+
}
|
|
703
|
+
await setPushDevice(run.id, null);
|
|
704
|
+
return c.json({ ok: true });
|
|
639
705
|
})
|
|
640
706
|
.post("/:id/share", async (c) => {
|
|
641
707
|
const run = await accessibleRun(c, c.req.param("id"));
|