void 0.20.2 → 0.20.3
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/{auth-cmd-gniL2fNt.mjs → auth-cmd-CzwquNiP.mjs} +3 -3
- package/dist/{auth-link-NZdjCmSc.mjs → auth-link-ElDTgF7j.mjs} +3 -3
- package/dist/{build-cmd-sI18tX_O.mjs → build-cmd-BpJe6boP.mjs} +1 -1
- package/dist/{cache-IHn5MwBC.mjs → cache-D98YTqeE.mjs} +1 -1
- package/dist/{cancel-deploy-C5qTOdLi.mjs → cancel-deploy-CPbEQLMG.mjs} +1 -1
- package/dist/cli/cli.mjs +27 -26
- package/dist/{client-dHfSJvAN.mjs → client-BQBrZoCX.mjs} +143 -78
- package/dist/{connect-Bfk31O_8.mjs → connect-WCQZ_u3m.mjs} +3 -3
- package/dist/{create-project-Bk9Z0-Jg.mjs → create-project-D0oXA090.mjs} +2 -3
- package/dist/{db-BkRoptAt.mjs → db-DJ-9qs3S.mjs} +2 -2
- package/dist/{delete-DouASY9P.mjs → delete-BZ4-WaGm.mjs} +1 -1
- package/dist/{deploy-DTaWUS1S.mjs → deploy-BAhhcg5q.mjs} +17 -10
- package/dist/{domain-1RhhOVrC.mjs → domain-BxAyhxXN.mjs} +1 -1
- package/dist/{email-C-lGh51B.mjs → email-Bj7Cvdwp.mjs} +2 -2
- package/dist/{env-DJHsPE7Z.mjs → env-DP_EErve.mjs} +1 -1
- package/dist/{github-cmd-PW7ZnWTp.mjs → github-cmd-C-z_xRrQ.mjs} +13 -19
- package/dist/index.mjs +4 -4
- package/dist/{init-BWZ7q5Z4.mjs → init-BGktCXgA.mjs} +4 -4
- package/dist/{link-Rmvu2Wl_.mjs → link-D2kbqhWb.mjs} +2 -2
- package/dist/{list-DEE2S6mY.mjs → list-CvkK_G7k.mjs} +1 -1
- package/dist/{login-Uvferzmm.mjs → login-WIjNc77c.mjs} +2 -2
- package/dist/{logs-27FenuiC.mjs → logs-dLUFCapG.mjs} +1 -1
- package/dist/{operator-cmd-CjOTmAYE.mjs → operator-cmd-CKJ7xIRs.mjs} +2 -2
- package/dist/{platform-auth-config-DrbQXXiW.mjs → platform-auth-config-CdVWRRJr.mjs} +2 -2
- package/dist/{platform-auth-protection-Bhtvp0B_.mjs → platform-auth-protection-Drl0qhrn.mjs} +2 -2
- package/dist/{platform-auth-recovery-CeOKGVeJ.mjs → platform-auth-recovery-CmKEWpDo.mjs} +2 -2
- package/dist/{platform-cmd-BFhieCdV.mjs → platform-cmd-5q_k56xS.mjs} +3 -3
- package/dist/{platform-domain-C74PULqV.mjs → platform-domain-4GiDlcqx.mjs} +2 -2
- package/dist/{platform-lifecycle-BwAIgz-t.mjs → platform-lifecycle-R9xAxvtG.mjs} +61 -25
- package/dist/{platform-management-COogu_Se.mjs → platform-management-BfWsXHEW.mjs} +5 -3
- package/dist/{platform-recovery-ewqLefp1.mjs → platform-recovery-CbK-I1FB.mjs} +2 -2
- package/dist/{project-cmd-DmZK9Hxf.mjs → project-cmd-CTdmnzvc.mjs} +12 -12
- package/dist/{project-team-D8jOJMUJ.mjs → project-team-CGxsQe3_.mjs} +4 -2
- package/dist/{project-token-DA34bf-C.mjs → project-token-Cirx7uwZ.mjs} +1 -1
- package/dist/{requests-CUExwGQQ.mjs → requests-4Nq59hOr.mjs} +1 -1
- package/dist/{rollback-CDNGU1gr.mjs → rollback-Dr7u0Ljx.mjs} +1 -1
- package/dist/runtime/remote/index.mjs +49 -8
- package/dist/runtime/sandbox.d.mts +4 -56
- package/dist/runtime/sandbox.mjs +81 -220
- package/dist/{secret-Bzzi2e9E.mjs → secret-AcPi-FoA.mjs} +1 -1
- package/package.json +7 -7
- package/skills/void/SKILL.md +2 -2
- package/skills/void/docs/guide/deployment.md +14 -14
- package/skills/void/docs/guide/email.md +12 -10
- package/skills/void/docs/guide/platform/administration/access.md +163 -0
- package/skills/void/docs/guide/platform/administration/email.md +121 -0
- package/skills/void/docs/guide/platform/administration/operations.md +97 -0
- package/skills/void/docs/guide/platform/administration/projects.md +54 -0
- package/skills/void/docs/guide/platform/development/local.md +119 -0
- package/skills/void/docs/guide/platform/development/runtime.md +124 -0
- package/skills/void/docs/guide/platform/development/schema-ci.md +95 -0
- package/skills/void/docs/guide/platform/installation/ci.md +55 -0
- package/skills/void/docs/guide/platform/installation/credentials.md +80 -0
- package/skills/void/docs/guide/platform/installation/domains.md +68 -0
- package/skills/void/docs/guide/platform/installation/first-deployment.md +82 -0
- package/skills/void/docs/guide/platform/installation/maintenance.md +137 -0
- package/skills/void/docs/guide/platform/installation/prerequisites.md +86 -0
- package/skills/void/docs/guide/platform/installation/setup.md +169 -0
- package/skills/void/docs/guide/platform/installation/uninstall.md +54 -0
- package/skills/void/docs/guide/platform-administration.md +6 -414
- package/skills/void/docs/guide/platform-development.md +5 -316
- package/skills/void/docs/guide/project-collaboration.md +1 -1
- package/skills/void/docs/guide/sandboxes.md +9 -24
- package/skills/void/docs/guide/self-hosted-platform.md +11 -694
- package/skills/void/docs/reference/api.md +34 -34
- package/skills/void/docs/reference/cli.md +28 -11
- package/skills/void/docs/reference/config.md +1 -1
- package/skills/void/docs/reference/resource-inference.md +10 -10
package/dist/runtime/sandbox.mjs
CHANGED
|
@@ -4,14 +4,7 @@ import { Sandbox, getSandbox as getSandbox$1 } from "@cloudflare/sandbox";
|
|
|
4
4
|
//#region src/runtime/sandbox.ts
|
|
5
5
|
const DEFAULT_SANDBOX_ID = "default";
|
|
6
6
|
const DEFAULT_SANDBOX_BINDING = "SANDBOX";
|
|
7
|
-
const
|
|
8
|
-
const SANDBOX_GATE_BINDING = "__VOID_PROXY";
|
|
9
|
-
const HEARTBEAT_MS = 3e5;
|
|
10
|
-
/**
|
|
11
|
-
* Thrown by `getSandbox()` when the platform gate denies a new acquire because
|
|
12
|
-
* the user has hit a concurrency or runtime budget cap. Surface this to the
|
|
13
|
-
* caller so they can render a friendly error; do not retry transparently.
|
|
14
|
-
*/
|
|
7
|
+
const SANDBOX_SERVICE_BINDING = "__VOID_SANDBOX";
|
|
15
8
|
var SandboxLimitError = class extends Error {
|
|
16
9
|
name = "SandboxLimitError";
|
|
17
10
|
reason;
|
|
@@ -20,7 +13,6 @@ var SandboxLimitError = class extends Error {
|
|
|
20
13
|
this.reason = reason;
|
|
21
14
|
}
|
|
22
15
|
};
|
|
23
|
-
/** Managed platform Sandbox is unavailable or its activation gate cannot be verified. */
|
|
24
16
|
var SandboxUnavailableError = class extends Error {
|
|
25
17
|
name = "SandboxUnavailableError";
|
|
26
18
|
constructor(message = "Managed Sandbox is unavailable on this Void platform.") {
|
|
@@ -31,233 +23,102 @@ let sandboxBindingName = DEFAULT_SANDBOX_BINDING;
|
|
|
31
23
|
function __setSandboxBindingName(bindingName) {
|
|
32
24
|
sandboxBindingName = bindingName;
|
|
33
25
|
}
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
userId: runtimeEnv.__VOID_USER_ID,
|
|
40
|
-
projectId: runtimeEnv.__PROJECT_ID,
|
|
41
|
-
slug: runtimeEnv.__VOID_PROJECT_SLUG
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function getSandboxInstanceId(ctx) {
|
|
45
|
-
return ctx.id.toString();
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Flush the accumulated runtime delta to the usage queue. No-op when the
|
|
49
|
-
* usage-queue binding is not bound (e.g. local dev without the platform
|
|
50
|
-
* proxy). Errors are swallowed and logged — usage emission must never crash
|
|
51
|
-
* the sandbox lifecycle.
|
|
52
|
-
*/
|
|
53
|
-
async function emitRuntimeSeconds(ctx, sandboxRuntimeSeconds) {
|
|
54
|
-
if (sandboxRuntimeSeconds <= 0) return;
|
|
55
|
-
const queue = getRawRuntimeEnv()[SANDBOX_USAGE_QUEUE_BINDING];
|
|
56
|
-
if (!queue) return;
|
|
57
|
-
const event = {
|
|
58
|
-
requests: 0,
|
|
59
|
-
responseBytes: 0,
|
|
60
|
-
sandboxRuntimeSeconds,
|
|
61
|
-
sandboxInstanceId: getSandboxInstanceId(ctx),
|
|
62
|
-
...getUsageContext()
|
|
63
|
-
};
|
|
64
|
-
try {
|
|
65
|
-
await queue.send(event);
|
|
66
|
-
} catch (error) {
|
|
67
|
-
console.error("[void/sandbox] failed to emit usage event:", error);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
function isManagedPlatformSandbox(runtimeEnv) {
|
|
71
|
-
return [
|
|
72
|
-
SANDBOX_GATE_BINDING,
|
|
73
|
-
"__PROJECT_ID",
|
|
74
|
-
"__VOID_PROXY_TOKEN",
|
|
75
|
-
"__VOID_USER_ID",
|
|
76
|
-
"__VOID_PROJECT_SLUG"
|
|
77
|
-
].some((binding) => runtimeEnv[binding] !== void 0);
|
|
26
|
+
function rethrowSandboxError(error) {
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
for (const reason of ["concurrency", "runtime_budget"]) if (message.includes(`VOID_SANDBOX_${reason}`)) throw new SandboxLimitError(reason);
|
|
29
|
+
if (message.includes("VOID_SANDBOX_unavailable")) throw new SandboxUnavailableError();
|
|
30
|
+
throw error;
|
|
78
31
|
}
|
|
79
|
-
function
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
gate,
|
|
87
|
-
projectId,
|
|
88
|
-
aiToken
|
|
32
|
+
function managedClient(capability) {
|
|
33
|
+
const call = async (target, operation, args) => {
|
|
34
|
+
try {
|
|
35
|
+
return decodeManagedResult(await target.invoke(operation, prepareManagedArgs(operation, args)));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
rethrowSandboxError(error);
|
|
38
|
+
}
|
|
89
39
|
};
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
40
|
+
return new Proxy(Object.create(null), { get(_target, property) {
|
|
41
|
+
if (typeof property !== "string" || property === "then") return;
|
|
42
|
+
if (property === "tunnels") return new Proxy(Object.create(null), { get(_tunnels, method) {
|
|
43
|
+
if (typeof method !== "string" || method === "then") return;
|
|
44
|
+
return (...args) => call(capability, "tunnels", [method, ...args]);
|
|
45
|
+
} });
|
|
46
|
+
return (...args) => call(capability, property, args);
|
|
47
|
+
} });
|
|
48
|
+
function decodeManagedResult(value) {
|
|
49
|
+
if (Array.isArray(value)) return value.map(decodeManagedResult);
|
|
50
|
+
if (!value || typeof value !== "object") return value;
|
|
51
|
+
const record = value;
|
|
52
|
+
if (record.__voidSandboxType === "process") {
|
|
53
|
+
const { capability: processCapability, __voidSandboxType: _type, ...snapshot } = value;
|
|
54
|
+
return Object.assign(snapshot, {
|
|
55
|
+
kill: (...args) => call(processCapability, "kill", args),
|
|
56
|
+
getStatus: (...args) => call(processCapability, "getStatus", args),
|
|
57
|
+
getLogs: (...args) => call(processCapability, "getLogs", args),
|
|
58
|
+
waitForLog: (...args) => call(processCapability, "waitForLog", args),
|
|
59
|
+
waitForPort: (...args) => call(processCapability, "waitForPort", args),
|
|
60
|
+
waitForExit: (...args) => call(processCapability, "waitForExit", args)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (record.__voidSandboxType === "session") {
|
|
64
|
+
const session = value;
|
|
65
|
+
return new Proxy({ id: session.id }, { get(target, property) {
|
|
66
|
+
if (property === "id") return target.id;
|
|
67
|
+
if (typeof property !== "string" || property === "then") return;
|
|
68
|
+
return (...args) => call(session.capability, property, args);
|
|
69
|
+
} });
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
108
72
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
73
|
+
function prepareManagedArgs(operation, args) {
|
|
74
|
+
if (operation !== "startProcess") return args;
|
|
75
|
+
const [command, options, ...rest] = args;
|
|
76
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) return args;
|
|
77
|
+
const onStart = options.onStart;
|
|
78
|
+
if (typeof onStart !== "function") return args;
|
|
79
|
+
return [
|
|
80
|
+
command,
|
|
81
|
+
{
|
|
82
|
+
...options,
|
|
83
|
+
onStart: (process) => onStart(decodeManagedResult(process))
|
|
84
|
+
},
|
|
85
|
+
...rest
|
|
86
|
+
];
|
|
112
87
|
}
|
|
113
|
-
if (!response.ok) throw new SandboxUnavailableError(`Managed Sandbox activation was rejected by the platform gate (HTTP ${response.status}).`);
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Release notification to the gate so it can drop the active lease counter.
|
|
117
|
-
* Returns the in-flight fetch as a Promise (errors swallowed) so callers can
|
|
118
|
-
* route it through `ctx.waitUntil()` and keep it alive past lifecycle
|
|
119
|
-
* teardown. Failure is silent — the gate maintains its own TTL fallback.
|
|
120
|
-
*/
|
|
121
|
-
function notifyGateRelease(ctx) {
|
|
122
|
-
const identity = resolveGateIdentity();
|
|
123
|
-
if (!identity) return Promise.resolve();
|
|
124
|
-
const sandboxId = getSandboxInstanceId(ctx);
|
|
125
|
-
return identity.gate.fetch("https://gate/sandbox/release", {
|
|
126
|
-
method: "POST",
|
|
127
|
-
headers: { "content-type": "application/json" },
|
|
128
|
-
body: JSON.stringify({
|
|
129
|
-
projectId: identity.projectId,
|
|
130
|
-
aiToken: identity.aiToken,
|
|
131
|
-
sandboxId
|
|
132
|
-
})
|
|
133
|
-
}).then(() => void 0).catch(() => {});
|
|
134
88
|
}
|
|
135
89
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* sessions whose `acquiredAt` ages past the threshold between acquire and
|
|
139
|
-
* release, letting new acquires exceed `sandboxMaxConcurrentInstances`. The
|
|
140
|
-
* 5 min heartbeat sits well inside the sweep window, so each successful
|
|
141
|
-
* refresh resets the stale clock. Returns the in-flight fetch as a Promise
|
|
142
|
-
* (errors swallowed) so callers can route it through `ctx.waitUntil()` and
|
|
143
|
-
* keep the alarm path itself off the critical path — a failed refresh just
|
|
144
|
-
* means the slot ages out naturally, which matches the pre-existing failure
|
|
145
|
-
* mode if the gate goes down entirely.
|
|
146
|
-
*/
|
|
147
|
-
function notifyGateRefresh(ctx) {
|
|
148
|
-
const identity = resolveGateIdentity();
|
|
149
|
-
if (!identity) return Promise.resolve();
|
|
150
|
-
const sandboxId = getSandboxInstanceId(ctx);
|
|
151
|
-
return identity.gate.fetch("https://gate/sandbox/refresh", {
|
|
152
|
-
method: "POST",
|
|
153
|
-
headers: { "content-type": "application/json" },
|
|
154
|
-
body: JSON.stringify({
|
|
155
|
-
projectId: identity.projectId,
|
|
156
|
-
aiToken: identity.aiToken,
|
|
157
|
-
sandboxId
|
|
158
|
-
})
|
|
159
|
-
}).then(() => void 0).catch(() => {});
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Sandbox variant that participates in Void platform metering: it records
|
|
163
|
-
* container runtime seconds into DO storage, emits periodic heartbeats to
|
|
164
|
-
* the usage queue, and notifies the gate when the container stops.
|
|
165
|
-
*
|
|
166
|
-
* Generated code from `void inference` wires user `Sandbox` subclasses to
|
|
167
|
-
* extend this class on platform deploys so per-tenant accounting works
|
|
168
|
-
* without user opt-in. On local dev (no usage-queue / gate bindings), all
|
|
169
|
-
* overrides degrade to no-ops and the sandbox behaves like the upstream
|
|
170
|
-
* `Sandbox` class.
|
|
90
|
+
* Retained for generated native/local Worker entries. Managed deployments host
|
|
91
|
+
* the platform-owned Sandbox class in a separate Worker and never use this DO.
|
|
171
92
|
*/
|
|
172
93
|
var VoidPlatformSandbox = class extends Sandbox {
|
|
173
94
|
async onStart() {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const orphanedAnchor = stored.get(STORAGE_KEY_LAST_FLUSH_TS) ?? stored.get(STORAGE_KEY_START_TS);
|
|
178
|
-
if (orphanedAnchor !== void 0 && orphanedAnchor < now) {
|
|
179
|
-
const sandboxRuntimeSeconds = (now - orphanedAnchor) / 1e3;
|
|
180
|
-
await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds);
|
|
181
|
-
await this.ctx.storage.delete([STORAGE_KEY_START_TS, STORAGE_KEY_LAST_FLUSH_TS]);
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
await super.onStart();
|
|
185
|
-
} catch (err) {
|
|
186
|
-
this.ctx.waitUntil(notifyGateRelease(this.ctx));
|
|
187
|
-
throw err;
|
|
188
|
-
}
|
|
189
|
-
const startedAt = Date.now();
|
|
190
|
-
await this.ctx.storage.put({
|
|
191
|
-
[STORAGE_KEY_START_TS]: startedAt,
|
|
192
|
-
[STORAGE_KEY_LAST_FLUSH_TS]: startedAt
|
|
193
|
-
});
|
|
194
|
-
await this.ctx.storage.setAlarm(startedAt + HEARTBEAT_MS);
|
|
195
|
-
}
|
|
196
|
-
async alarm(alarmProps) {
|
|
197
|
-
await super.alarm(alarmProps);
|
|
198
|
-
const lastFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS);
|
|
199
|
-
if (typeof lastFlush !== "number") return;
|
|
200
|
-
const now = Date.now();
|
|
201
|
-
const sandboxRuntimeSeconds = Math.max(0, (now - lastFlush) / 1e3);
|
|
202
|
-
await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds);
|
|
203
|
-
this.ctx.waitUntil(notifyGateRefresh(this.ctx));
|
|
204
|
-
await Promise.all([this.ctx.storage.put(STORAGE_KEY_LAST_FLUSH_TS, now), this.ctx.storage.setAlarm(now + HEARTBEAT_MS)]);
|
|
205
|
-
}
|
|
206
|
-
async onStop() {
|
|
207
|
-
await this.flushFinal();
|
|
208
|
-
this.ctx.waitUntil(notifyGateRelease(this.ctx));
|
|
209
|
-
await super.onStop();
|
|
210
|
-
}
|
|
211
|
-
async onActivityExpired() {
|
|
212
|
-
await this.flushFinal();
|
|
213
|
-
this.ctx.waitUntil(notifyGateRelease(this.ctx));
|
|
214
|
-
await super.onActivityExpired();
|
|
215
|
-
}
|
|
216
|
-
async flushFinal() {
|
|
217
|
-
const lastFlush = await this.ctx.storage.get(STORAGE_KEY_LAST_FLUSH_TS);
|
|
218
|
-
if (typeof lastFlush === "number") {
|
|
219
|
-
const sandboxRuntimeSeconds = Math.max(0, (Date.now() - lastFlush) / 1e3);
|
|
220
|
-
await emitRuntimeSeconds(this.ctx, sandboxRuntimeSeconds);
|
|
221
|
-
}
|
|
222
|
-
await this.ctx.storage.delete(STORAGE_KEY_START_TS);
|
|
223
|
-
await this.ctx.storage.delete(STORAGE_KEY_LAST_FLUSH_TS);
|
|
95
|
+
const env = this.env;
|
|
96
|
+
if (env.__PROJECT_ID !== void 0 || env.__VOID_PROXY !== void 0) throw new SandboxUnavailableError("Managed Sandboxes require the platform Sandbox controller. Redeploy this application.");
|
|
97
|
+
await super.onStart();
|
|
224
98
|
}
|
|
225
99
|
};
|
|
226
|
-
/**
|
|
227
|
-
* Resolve the platform-provided sandbox stub for the given id, optionally
|
|
228
|
-
* coordinating with the platform gate to enforce per-user concurrency and
|
|
229
|
-
* runtime budgets.
|
|
230
|
-
*
|
|
231
|
-
* When the gate binding (`__VOID_PROXY` by default) is present AND the
|
|
232
|
-
* platform's `__PROJECT_ID` + `__VOID_PROXY_TOKEN` identity vars are
|
|
233
|
-
* available, we POST to `https://gate/sandbox/acquire` with the HMAC-signed
|
|
234
|
-
* body the shared proxy auth middleware expects. A `409` response means the
|
|
235
|
-
* user has exceeded a budget — we throw `SandboxLimitError` so the caller
|
|
236
|
-
* can render a clear message. Any other non-OK status (or a thrown transport
|
|
237
|
-
* error from the service binding) fails closed before returning a usable
|
|
238
|
-
* managed stub.
|
|
239
|
-
*
|
|
240
|
-
* When all reserved platform identity bindings are absent, this is a native
|
|
241
|
-
* Cloudflare or local deployment and returns the upstream stub directly.
|
|
242
|
-
*
|
|
243
|
-
* The stub is constructed BEFORE calling the gate so we can pass its opaque,
|
|
244
|
-
* namespace-specific Durable Object id (`stub.id.toString()`) as `sandboxId`.
|
|
245
|
-
* The same id is available as `ctx.id.toString()` inside lifecycle hooks, so
|
|
246
|
-
* acquire, heartbeat, and release use one globally unique instance identity
|
|
247
|
-
* even when separate projects or deployments both request the name `default`.
|
|
248
|
-
* Constructing the stub does NOT start a container — that only happens on
|
|
249
|
-
* the first method call against the stub.
|
|
250
|
-
*/
|
|
100
|
+
/** Resolve an isolated Sandbox on the current deployment. Starts on first use. */
|
|
251
101
|
async function getSandbox(id = DEFAULT_SANDBOX_ID, options) {
|
|
252
102
|
const { binding = sandboxBindingName, ...sandboxOptions } = options ?? {};
|
|
253
|
-
const
|
|
254
|
-
const
|
|
103
|
+
const runtimeEnv = getRawRuntimeEnv();
|
|
104
|
+
const service = runtimeEnv[SANDBOX_SERVICE_BINDING];
|
|
105
|
+
if (service) {
|
|
106
|
+
if (binding !== sandboxBindingName) throw new SandboxUnavailableError(`Managed Sandbox binding ${binding} is unavailable.`);
|
|
107
|
+
try {
|
|
108
|
+
return managedClient(await service.getSandbox(id, {
|
|
109
|
+
normalizeId: true,
|
|
110
|
+
...sandboxOptions
|
|
111
|
+
}));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
rethrowSandboxError(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (runtimeEnv.__PROJECT_ID !== void 0 || runtimeEnv.__VOID_PROXY !== void 0) throw new SandboxUnavailableError("This deployment has no managed Sandbox controller. Redeploy this application.");
|
|
117
|
+
return getSandbox$1(requireRuntimeBinding(binding), id, {
|
|
255
118
|
normalizeId: true,
|
|
256
119
|
...sandboxOptions
|
|
257
120
|
});
|
|
258
|
-
await acquireManagedSandbox(stub.id.toString());
|
|
259
|
-
return stub;
|
|
260
121
|
}
|
|
261
122
|
const sandbox = { get: getSandbox };
|
|
262
123
|
//#endregion
|
|
263
|
-
export { DEFAULT_SANDBOX_BINDING, DEFAULT_SANDBOX_ID,
|
|
124
|
+
export { DEFAULT_SANDBOX_BINDING, DEFAULT_SANDBOX_ID, SANDBOX_SERVICE_BINDING, Sandbox, SandboxLimitError, SandboxUnavailableError, VoidPlatformSandbox, __setSandboxBindingName, getSandbox, sandbox };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { A as outro, C as cancel, D as log, E as intro, I as isCancel, j as password, k as note, r as cliTitle, w as confirm } from "./output-B0cfNSx5.mjs";
|
|
2
2
|
import { S as readProjectConfig, l as getToken, x as readDeploymentPlatform } from "./auth-DPl6kck4.mjs";
|
|
3
3
|
import { m as validateSecretValues } from "./env-validation-CF6KvTRf.mjs";
|
|
4
|
-
import { n as PlatformClient } from "./client-
|
|
4
|
+
import { n as PlatformClient } from "./client-BQBrZoCX.mjs";
|
|
5
5
|
import { r as resolveProjectBySlug, t as getRequestedProjectSlug } from "./resolve-project--Vxawf7z.mjs";
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
//#region src/cli/secret.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "void",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/voidzero-dev/void.git",
|
|
@@ -318,11 +318,11 @@
|
|
|
318
318
|
"@cloudflare/vite-plugin": "1.54.7",
|
|
319
319
|
"@cloudflare/workers-types": "^4.20260702.1",
|
|
320
320
|
"@napi-rs/keyring": "2.0.0",
|
|
321
|
-
"@void/deploy-cloudflare": "0.20.
|
|
322
|
-
"@void/deploy-core": "0.20.
|
|
323
|
-
"@void/edge": "0.20.
|
|
324
|
-
"@void/isr": "0.20.
|
|
325
|
-
"@void/platform": "0.20.
|
|
321
|
+
"@void/deploy-cloudflare": "0.20.3",
|
|
322
|
+
"@void/deploy-core": "0.20.3",
|
|
323
|
+
"@void/edge": "0.20.3",
|
|
324
|
+
"@void/isr": "0.20.3",
|
|
325
|
+
"@void/platform": "0.20.3",
|
|
326
326
|
"better-auth": "^1.7.3",
|
|
327
327
|
"better-sqlite3": "^13.0.3",
|
|
328
328
|
"blake3-jit": "^1.1.0",
|
|
@@ -368,7 +368,7 @@
|
|
|
368
368
|
"zod": "^4.6.1"
|
|
369
369
|
},
|
|
370
370
|
"peerDependencies": {
|
|
371
|
-
"@void/md": "0.20.
|
|
371
|
+
"@void/md": "0.20.3",
|
|
372
372
|
"arktype": ">=2.0.0",
|
|
373
373
|
"valibot": ">=1.0.0-beta.7",
|
|
374
374
|
"vite": "^8.0.0",
|
package/skills/void/SKILL.md
CHANGED
|
@@ -23,7 +23,7 @@ read permission; `void platform install --plan` verifies coverage. Use the
|
|
|
23
23
|
self-hosted platform guide for certificate setup rather than enabling a paid
|
|
24
24
|
product without an explicit request.
|
|
25
25
|
|
|
26
|
-
For first-time platform setup, follow `docs/guide/self-hosted-platform.md` in order and introduce credentials when the user reaches that step. Recommend a domain; offer explicit `void platform install --workers-dev` for testing before one is ready. PostgreSQL, credentials for the selected login methods, runtime/R2 credentials, and saved signing/encryption keys are required. GitHub is the default and is optional. `--auth-config` accepts nonsecret provider configuration with environment secret references; `--plan` does not read those secrets. Configurable installations finish first-administrator setup using a one-time code and browser identity confirmation. Domain-free installation skips zone/DNS/certificate operations and can use browser login. `void platform domain set <domain>` later performs resumable DNS/HTTPS setup while preserving existing test URLs and the API origin. The command checks the deployed runtime token's cache-purge permission for the new zone; it does not ask users to retrieve that token. Prefer interactive prompts for secrets and keep CI variables in the CI workflow. Void creates the platform infrastructure and database tables.
|
|
26
|
+
For first-time platform setup, follow the installation pages linked from `docs/guide/self-hosted-platform.md` in order and introduce credentials when the user reaches that step. Recommend a domain; offer explicit `void platform install --workers-dev` for testing before one is ready. PostgreSQL, credentials for the selected login methods, runtime/R2 credentials, and saved signing/encryption keys are required. GitHub is the default and is optional. `--auth-config` accepts nonsecret provider configuration with environment secret references; `--plan` does not read those secrets. Configurable installations finish first-administrator setup using a one-time code and browser identity confirmation. Domain-free installation skips zone/DNS/certificate operations and can use browser login. `void platform domain set <domain>` later performs resumable DNS/HTTPS setup while preserving existing test URLs and the API origin. The command checks the deployed runtime token's cache-purge permission for the new zone; it does not ask users to retrieve that token. Prefer interactive prompts for secrets and keep CI variables in the CI workflow. Void creates the platform infrastructure and database tables.
|
|
27
27
|
|
|
28
28
|
For an existing Cloudflare site, run `void deploy` with its root `wrangler.jsonc` or `wrangler.json`. An unlinked project gets a prompt to link and deploy using the existing Worker and resources. Accepting verifies and saves the destination, then continues deployment. Keep new application resources, migrations, auth setup, and secret changes separate from this first handoff; ISR has its own explicit cache choice. The active Worker Version must be the latest upload so inherited secrets have an unambiguous source. Failed builds retain the link for retry. Read `docs/integrations/cloudflare.md` for the supported configuration and rollback behavior.
|
|
29
29
|
|
|
@@ -91,7 +91,7 @@ Cloudflare browser login does not grant AI Gateway access. A platform installati
|
|
|
91
91
|
|
|
92
92
|
Use `--runtime <directory>` on platform install, upgrade, repair, enable, or rollback when deploying a locally built `@void/platform` runtime. The directory must contain the generated integrity manifest, Worker artifacts, and migration tree. Build it with `vp run --filter @void/platform build`; Git source revisions are detected automatically, with `-dirty` for uncommitted changes. `VOID_PLATFORM_SOURCE_REVISION` is an optional override. Do not treat this as a safety bypass: custom and packaged runtimes follow the same verification and rollback path.
|
|
93
93
|
|
|
94
|
-
For self-hosted platform installation, require a dedicated empty PostgreSQL database. Interactive lifecycle commands use Cloudflare browser OAuth with keyring storage. Wrangler OAuth has Zone Read, so a plan that creates a zone or DNS record requires a scoped `CLOUDFLARE_API_TOKEN` with Account → Zone → Edit and/or Zone → DNS → Edit; finish Workers onboarding and choose a workers.dev subdomain for a fresh Cloudflare account. The installed platform uses a separate `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Its core permissions include Hyperdrive: Write at account scope and Cache Purge: Purge on the application zone; SSL and Certificates: Edit is needed only when a custom runtime enables custom project domains. The installer preflights account and Hyperdrive access and safely verifies Cache Purge for an existing zone with a unique nonexistent URL. Fresh installs require unique per-installation values from the operator's secret manager: `VOID_PLATFORM_JWT_SECRET` with at least 32 random bytes and `VOID_PLATFORM_PROJECT_SECRET_KEY` as canonical base64 for 32 random bytes. Never let a local checkpoint or ephemeral CI runner be their only custodian. Enabling email generates a separate signing key in encrypted recovery state; supply `VOID_PLATFORM_EMAIL_SIGNING_SECRET` from the secret manager for headless installs without persistent recovery files. The installer transactionally claims the database for one installation, pins that URL after a successful claim, never removes the claim during uninstall, and uses it for a cross-host lifecycle lock plus a secret-free authoritative lifecycle manifest. Local recovery checkpoints are AES-256-GCM encrypted with an OS-keychain key; never place platform secrets in plaintext files. After discovery on a new machine, provide `VOID_PLATFORM_DATABASE_URL`. For email-enabled installations without encrypted recovery state, also restore the original `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs this key, not JWT or runtime credentials. Normal upgrades preserve other deployed Worker secrets; restore the original runtime, GitHub, R2, JWT, and project-encryption values when recreating the API or proxy as documented.
|
|
94
|
+
For self-hosted platform installation, require a dedicated empty PostgreSQL database. Interactive lifecycle commands use Cloudflare browser OAuth with keyring storage. Wrangler OAuth has Zone Read, so a plan that creates a zone or DNS record requires a scoped `CLOUDFLARE_API_TOKEN` with Account → Zone → Edit and/or Zone → DNS → Edit; finish Workers onboarding and choose a workers.dev subdomain for a fresh Cloudflare account. The installed platform uses a separate `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Its core permissions include Hyperdrive: Write at account scope and Cache Purge: Purge on the application zone; SSL and Certificates: Edit is needed only when a custom runtime enables custom project domains. Managed Sandbox apps additionally require Workers Paid plus Account → Containers → Edit and Account → Cloudchamber → Edit on that runtime token; these are checked on the first Sandbox application deploy, not during platform install or upgrade. The installer preflights account and Hyperdrive access and safely verifies Cache Purge for an existing zone with a unique nonexistent URL. Fresh installs require unique per-installation values from the operator's secret manager: `VOID_PLATFORM_JWT_SECRET` with at least 32 random bytes and `VOID_PLATFORM_PROJECT_SECRET_KEY` as canonical base64 for 32 random bytes. Never let a local checkpoint or ephemeral CI runner be their only custodian. Enabling email generates a separate signing key in encrypted recovery state; supply `VOID_PLATFORM_EMAIL_SIGNING_SECRET` from the secret manager for headless installs without persistent recovery files. The installer transactionally claims the database for one installation, pins that URL after a successful claim, never removes the claim during uninstall, and uses it for a cross-host lifecycle lock plus a secret-free authoritative lifecycle manifest. Local recovery checkpoints are AES-256-GCM encrypted with an OS-keychain key; never place platform secrets in plaintext files. After discovery on a new machine, provide `VOID_PLATFORM_DATABASE_URL`. For email-enabled installations without encrypted recovery state, also restore the original `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs this key, not JWT or runtime credentials. Normal upgrades preserve other deployed Worker secrets; restore the original runtime, GitHub, R2, JWT, and project-encryption values when recreating the API or proxy as documented. Upgrades enable managed Sandboxes automatically. An upgrade from the tenant-owned legacy may still require `void platform system sandbox-drain`: preview with `--plan`, follow each `nextCursor`, then apply with `--yes` until the DB-backed response reports `complete: true`; never delete an unverified app by name. The installer trusts only DB-backed sandbox-drain probe protocol v1. Before it accepts an empty inventory, the database admission barrier makes older deployment inserts finish and become visible or rejects them after the protocol floor is armed. Lifecycle redeploys preserve unmanaged Worker routes and custom domains in both the new trigger state and rollback snapshot. They stop before migrations or uploads when live Hyperdrive origin metadata differs from the pinned database. Platform upgrade SQL is forward-only: packaged hashes and the live Drizzle prefix must match, pending migrations require an exact source-version/schema rollback edge, and the old version remains authoritative until target health succeeds. Use `void platform rollback --runtime <earlier>` only when the installed runtime declares the exact earlier version/schema compatible; pass `--from-runtime` for the exact current custom artifact. Rollback preserves the forward database schema and cannot lower a database-required safety protocol. Safe uninstall retains Worker routes, custom domains, and R2 for explicit manual cleanup because Cloudflare cannot condition their deletion on an immutable generation.
|
|
95
95
|
|
|
96
96
|
For self-hosted recovery, `VOID_PLATFORM_PROJECT_SECRET_KEY` restores an original `v1` project-secret keyring. After rotation, restore every retained version with `VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON` and its active entry with `VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION`. These inputs restore a missing API Worker and never replace the live keyring of an existing Worker. Inject them from a secret manager without logging them or writing plaintext files.
|
|
97
97
|
|
|
@@ -14,23 +14,23 @@ Direct Cloudflare deployment runs an application in your account. A self-hosted
|
|
|
14
14
|
Void platform provides that deployment service to a team using the operator's
|
|
15
15
|
account. Both use the same application APIs, with the following differences:
|
|
16
16
|
|
|
17
|
-
| Feature | Direct Cloudflare | Core self-hosted platform
|
|
18
|
-
| ------------------------------------------------ | -------------------------------- |
|
|
19
|
-
| Static sites, SPAs, and native Pages SSR | Supported | Supported
|
|
20
|
-
| Routing rules, WebSockets, queues, cron, and ISR | Supported | Supported
|
|
21
|
-
| D1, KV, R2, and external SQL through Hyperdrive | Supported | Supported
|
|
22
|
-
| Workers AI and provider requests | Your account and gateway | Installation gateway and proxy
|
|
23
|
-
| Runtime logs | Live Cloudflare tail | Retained platform logs
|
|
24
|
-
| Application rollback | Worker versions | Retained platform deployments
|
|
25
|
-
| Typed Durable State | Supported | Unavailable
|
|
26
|
-
| Sandboxes | Requires Workers Paid and Docker |
|
|
27
|
-
| Custom application domains | Supported | Unavailable in the core installation
|
|
28
|
-
| Generated GitHub deployment workflow | Supported | Use your CI with a scoped developer token
|
|
29
|
-
| User dashboard and managed GitHub builds | Not required | Not included in a standard installation
|
|
17
|
+
| Feature | Direct Cloudflare | Core self-hosted platform |
|
|
18
|
+
| ------------------------------------------------ | -------------------------------- | --------------------------------------------- |
|
|
19
|
+
| Static sites, SPAs, and native Pages SSR | Supported | Supported |
|
|
20
|
+
| Routing rules, WebSockets, queues, cron, and ISR | Supported | Supported |
|
|
21
|
+
| D1, KV, R2, and external SQL through Hyperdrive | Supported | Supported |
|
|
22
|
+
| Workers AI and provider requests | Your account and gateway | Installation gateway and proxy |
|
|
23
|
+
| Runtime logs | Live Cloudflare tail | Retained platform logs |
|
|
24
|
+
| Application rollback | Worker versions | Retained platform deployments |
|
|
25
|
+
| Typed Durable State | Supported | Unavailable |
|
|
26
|
+
| Sandboxes | Requires Workers Paid and Docker | Requires Workers Paid on the platform account |
|
|
27
|
+
| Custom application domains | Supported | Unavailable in the core installation |
|
|
28
|
+
| Generated GitHub deployment workflow | Supported | Use your CI with a scoped developer token |
|
|
29
|
+
| User dashboard and managed GitHub builds | Not required | Not included in a standard installation |
|
|
30
30
|
|
|
31
31
|
Ordinary native applications remain compatible with Workers Free within its
|
|
32
32
|
quotas. Installing a team platform requires Workers for Platforms and the
|
|
33
|
-
[documented infrastructure](./
|
|
33
|
+
[documented infrastructure](./platform/installation/domains.md#cloudflare-footprint).
|
|
34
34
|
Application rollback never reverses database migrations.
|
|
35
35
|
|
|
36
36
|
Routing-rule parity applies to native Void applications and static deployments.
|
|
@@ -10,7 +10,7 @@ Send transactional email from your app via [Cloudflare's `send_email` binding](h
|
|
|
10
10
|
import { sendEmail } from 'void/email';
|
|
11
11
|
|
|
12
12
|
const result = await sendEmail({
|
|
13
|
-
from: 'Acme <acme+noreply@mail.
|
|
13
|
+
from: 'Acme <acme+noreply@mail.example.com>', // use your project's sender address
|
|
14
14
|
to: 'user@example.com',
|
|
15
15
|
subject: 'Welcome',
|
|
16
16
|
text: 'Thanks for signing up!',
|
|
@@ -36,9 +36,13 @@ hits first, because a recipient you have not verified yet fails per-recipient.
|
|
|
36
36
|
|
|
37
37
|
## Setup
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
On a platform with email enabled, your app needs no email configuration before
|
|
40
|
+
`void deploy`. Ask your administrator for the platform's shared mail domain. A
|
|
41
|
+
self-hosted administrator [enables email during installation or upgrade](/guide/platform/installation/credentials#runtime-token-permissions); installations without it do not offer platform email. Void Cloud uses `mail.void.cloud`.
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
Each project on an email-enabled platform has:
|
|
44
|
+
|
|
45
|
+
- **Sender** — `<your-slug>+noreply@<mail-domain>`. Used as the default `from` if you omit it. The platform administrator configures the mail zone and its Email Routing, DKIM, SPF, and DMARC records. Project slugs are capped at 56 characters so this local part fits RFC 5321's 64 octets; a project created before the cap with a longer slug must pass `from` explicitly.
|
|
42
46
|
- **No worker binding to add** — outbound mail is sent by the Void proxy, which holds the
|
|
43
47
|
platform `send_email` binding. Your worker never gets one, so there is nothing to configure.
|
|
44
48
|
- **Your own address as a recipient** — the email on your Void account is registered as a recipient when the project is created. It is verified at once when Cloudflare already holds it verified for the platform (you clicked its link for an earlier project of yours); otherwise Cloudflare mails it a verification link, and until you click that link and run `void email destinations` — the listing is what records the click — a send to yourself comes back `ok: false` with a per-recipient `UNVERIFIED_DESTINATION` in `result.deliveries`.
|
|
@@ -61,7 +65,7 @@ Cloudflare emails the recipient with a verification link. Once they click it and
|
|
|
61
65
|
void email destinations
|
|
62
66
|
```
|
|
63
67
|
|
|
64
|
-
The project owner's email
|
|
68
|
+
The project owner's account email is added automatically when the project is created on an email-enabled platform, so it skips `void email allow` — not the verification. See [Setup](#setup) for when it is verified at once and when there is a link to click.
|
|
65
69
|
|
|
66
70
|
::: warning When this is the right fit
|
|
67
71
|
The shared sender is great for: ops alerts to the team, notifications to the project owner, reply-by-email flows on top of inbound, internal/app-internal mail.
|
|
@@ -71,7 +75,7 @@ For SaaS sending to arbitrary end-users (every signup gets a welcome email), the
|
|
|
71
75
|
|
|
72
76
|
## Your own domain on the platform
|
|
73
77
|
|
|
74
|
-
The shared sender
|
|
78
|
+
The shared sender uses the platform's configured mail domain. To send — and receive — at a domain you own, register its Cloudflare zone with the project:
|
|
75
79
|
|
|
76
80
|
```sh
|
|
77
81
|
void email domain add acme.com
|
|
@@ -120,7 +124,7 @@ At most 50 recipients across `to`, `cc` and `bcc` per call. Each address is chec
|
|
|
120
124
|
|
|
121
125
|
`Address` accepts either a string (`"hello@acme.dev"` or `"Name <hello@acme.dev>"`) or an object (`{ email, name? }`). Display names with non-ASCII characters are RFC 2047 encoded automatically.
|
|
122
126
|
|
|
123
|
-
On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug
|
|
127
|
+
On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@<mail-domain>` or `<project-slug>+<tag>@<mail-domain>`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme <acme+noreply@mail.example.com>`. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@<mail-domain>` for you.
|
|
124
128
|
|
|
125
129
|
On your own Cloudflare account, `from` defaults to `email.from` from `void.json` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`.
|
|
126
130
|
|
|
@@ -128,7 +132,6 @@ On your own Cloudflare account, `from` defaults to `email.from` from `void.json`
|
|
|
128
132
|
|
|
129
133
|
```ts
|
|
130
134
|
await sendEmail({
|
|
131
|
-
from: 'Acme <acme+noreply@mail.void.cloud>',
|
|
132
135
|
to: 'user@example.com',
|
|
133
136
|
subject: 'Your receipt',
|
|
134
137
|
text: 'Receipt attached.',
|
|
@@ -146,7 +149,6 @@ For inline images (e.g. logos referenced from HTML), set `disposition: 'inline'`
|
|
|
146
149
|
|
|
147
150
|
```ts
|
|
148
151
|
await sendEmail({
|
|
149
|
-
from: 'Acme <acme+noreply@mail.void.cloud>',
|
|
150
152
|
to: 'user@example.com',
|
|
151
153
|
subject: 'Hello',
|
|
152
154
|
html: '<img src="cid:logo" alt="Acme">',
|
|
@@ -241,7 +243,7 @@ The dev inbox is **not** available under a Class B or C framework — SvelteKit,
|
|
|
241
243
|
|
|
242
244
|
```ts
|
|
243
245
|
const result = await sendEmail({
|
|
244
|
-
from: 'acme+noreply@mail.
|
|
246
|
+
from: 'acme+noreply@mail.example.com', // replace with your project sender
|
|
245
247
|
to: 'verified@acme.dev',
|
|
246
248
|
subject: 'Skips the dev inbox',
|
|
247
249
|
text: 'Not captured — and not delivered either.',
|
|
@@ -264,7 +266,7 @@ describe('signup flow', () => {
|
|
|
264
266
|
const inbox = createEmailTestHarness();
|
|
265
267
|
|
|
266
268
|
await sendEmail({
|
|
267
|
-
from: 'acme+noreply@mail.
|
|
269
|
+
from: 'acme+noreply@mail.example.com', // use your project sender
|
|
268
270
|
to: 'user@example.com',
|
|
269
271
|
subject: 'Welcome',
|
|
270
272
|
text: 'Hi!',
|