@frockbot/cloudflare 0.0.0 → 0.7.291
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { BRAND_V1 } from "#brand";
|
|
2
|
+
import {
|
|
3
|
+
admissionRefusalCopyV1,
|
|
4
|
+
type AccountAccessV1,
|
|
5
|
+
type AccountAdmissionDecisionV1,
|
|
6
|
+
type AdmissionIdentityV1,
|
|
7
|
+
type AdmissionModeV1,
|
|
8
|
+
type AdmissionRefusalReasonV1,
|
|
9
|
+
type EmailInvitationV1,
|
|
10
|
+
type IdentityCreationRequestV1,
|
|
11
|
+
} from "@frockbot/app/admin/shared";
|
|
12
|
+
|
|
13
|
+
export interface AdmissionEvaluationV1 {
|
|
14
|
+
decision: AccountAdmissionDecisionV1;
|
|
15
|
+
/** Whether this admission moves the account to `active`. */
|
|
16
|
+
activate: boolean;
|
|
17
|
+
/** Whether the email invitation that justified it is spent. */
|
|
18
|
+
redeemInvitation: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function refused(reason: AdmissionRefusalReasonV1): AdmissionEvaluationV1 {
|
|
22
|
+
return {
|
|
23
|
+
decision: { schemaVersion: 1, admitted: false, reason },
|
|
24
|
+
activate: false,
|
|
25
|
+
redeemInvitation: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The beta-access rule, with no I/O so every row of it is testable.
|
|
31
|
+
*
|
|
32
|
+
* `invitation` is the invitation for this identity's *verified* email, or
|
|
33
|
+
* null; the caller never passes one for an unverified address. The admission
|
|
34
|
+
* contract is documented in `docs/beta-access.md`.
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateAdmissionV1(input: {
|
|
37
|
+
mode: AdmissionModeV1;
|
|
38
|
+
identity: AdmissionIdentityV1;
|
|
39
|
+
access: AccountAccessV1 | null;
|
|
40
|
+
invitation: EmailInvitationV1 | null;
|
|
41
|
+
}): AdmissionEvaluationV1 {
|
|
42
|
+
const { mode, identity, access, invitation } = input;
|
|
43
|
+
if (identity.isAdmin) {
|
|
44
|
+
return {
|
|
45
|
+
decision: { schemaVersion: 1, admitted: true, basis: "admin" },
|
|
46
|
+
activate: false,
|
|
47
|
+
redeemInvitation: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
if (access) {
|
|
51
|
+
switch (access.state) {
|
|
52
|
+
case "active":
|
|
53
|
+
return {
|
|
54
|
+
decision: { schemaVersion: 1, admitted: true, basis: "active" },
|
|
55
|
+
activate: false,
|
|
56
|
+
redeemInvitation: false,
|
|
57
|
+
};
|
|
58
|
+
case "paused":
|
|
59
|
+
return refused("account-paused");
|
|
60
|
+
case "ended":
|
|
61
|
+
return refused("account-ended");
|
|
62
|
+
case "blocked":
|
|
63
|
+
return refused("account-blocked");
|
|
64
|
+
case "invited":
|
|
65
|
+
// Closing admission is exactly "admit nobody new", and an invited
|
|
66
|
+
// account has not been admitted yet.
|
|
67
|
+
if (mode === "closed") return refused("admission-closed");
|
|
68
|
+
return {
|
|
69
|
+
decision: { schemaVersion: 1, admitted: true, basis: "invitation" },
|
|
70
|
+
activate: true,
|
|
71
|
+
redeemInvitation: invitation !== null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (invitation) {
|
|
76
|
+
if (mode === "closed") return refused("admission-closed");
|
|
77
|
+
return {
|
|
78
|
+
decision: { schemaVersion: 1, admitted: true, basis: "invitation" },
|
|
79
|
+
activate: true,
|
|
80
|
+
redeemInvitation: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (mode === "open") {
|
|
84
|
+
return {
|
|
85
|
+
decision: { schemaVersion: 1, admitted: true, basis: "open" },
|
|
86
|
+
activate: true,
|
|
87
|
+
redeemInvitation: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return refused(
|
|
91
|
+
mode === "invite-only" ? "invitation-required" : "admission-closed",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the identity provider may write a brand-new identity. It runs
|
|
97
|
+
* before there is a User id, so only the mode, the admin allowlist and a
|
|
98
|
+
* verified invitation can answer it; an identity that is written still has to
|
|
99
|
+
* pass `evaluateAdmissionV1` on every request.
|
|
100
|
+
*/
|
|
101
|
+
export function identityMayBeCreatedV1(
|
|
102
|
+
mode: AdmissionModeV1,
|
|
103
|
+
request: IdentityCreationRequestV1,
|
|
104
|
+
invitation: EmailInvitationV1 | null,
|
|
105
|
+
): boolean {
|
|
106
|
+
if (request.isAdmin || mode === "open") return true;
|
|
107
|
+
return mode === "invite-only" && request.emailVerified && invitation !== null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const ACCOUNT_ADMISSION_UNAVAILABLE_MESSAGE = `${BRAND_V1.productName} couldn't check this account's access. Try again in a moment.`;
|
|
111
|
+
|
|
112
|
+
const NO_STORE = { "cache-control": "no-store" } as const;
|
|
113
|
+
|
|
114
|
+
function escapeHtml(value: string): string {
|
|
115
|
+
return value
|
|
116
|
+
.replaceAll("&", "&")
|
|
117
|
+
.replaceAll("<", "<")
|
|
118
|
+
.replaceAll(">", ">")
|
|
119
|
+
.replaceAll('"', """);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A refusal a client can act on: the copy to show and the reason it is true.
|
|
124
|
+
* Only the browser's document request gets a page; everything else is JSON.
|
|
125
|
+
*/
|
|
126
|
+
export function admissionRefusedResponse(
|
|
127
|
+
reason: AdmissionRefusalReasonV1,
|
|
128
|
+
page: boolean,
|
|
129
|
+
): Response {
|
|
130
|
+
const copy = admissionRefusalCopyV1(BRAND_V1.productName)[reason];
|
|
131
|
+
if (!page) {
|
|
132
|
+
return Response.json(
|
|
133
|
+
{ error: copy.title, code: "account-access-refused", reason },
|
|
134
|
+
{ status: 403, headers: NO_STORE },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new Response(
|
|
138
|
+
`<!doctype html>
|
|
139
|
+
<html lang="en">
|
|
140
|
+
<head>
|
|
141
|
+
<meta charset="utf-8">
|
|
142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
143
|
+
<title>${escapeHtml(BRAND_V1.productName)}</title>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<main data-reason="${escapeHtml(reason)}">
|
|
147
|
+
<p>${escapeHtml(BRAND_V1.productName)}</p>
|
|
148
|
+
<h1>${escapeHtml(copy.title)}</h1>
|
|
149
|
+
<p>${escapeHtml(copy.detail)}</p>
|
|
150
|
+
<a href="/sign-out">Sign out</a>
|
|
151
|
+
</main>
|
|
152
|
+
</body>
|
|
153
|
+
</html>`,
|
|
154
|
+
{
|
|
155
|
+
status: 403,
|
|
156
|
+
headers: {
|
|
157
|
+
...NO_STORE,
|
|
158
|
+
"content-type": "text/html; charset=utf-8",
|
|
159
|
+
"content-security-policy":
|
|
160
|
+
"default-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
161
|
+
"x-content-type-options": "nosniff",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The authority could not answer. Never a 401: the session is fine, and a
|
|
169
|
+
* client that signed out on this would throw away a valid sign-in.
|
|
170
|
+
*/
|
|
171
|
+
export function admissionUnavailableResponse(): Response {
|
|
172
|
+
return Response.json(
|
|
173
|
+
{
|
|
174
|
+
error: ACCOUNT_ADMISSION_UNAVAILABLE_MESSAGE,
|
|
175
|
+
code: "account-access-unavailable",
|
|
176
|
+
},
|
|
177
|
+
{ status: 503, headers: { ...NO_STORE, "retry-after": "5" } },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The steps of deleting an account, over this Worker's bindings.
|
|
3
|
+
*
|
|
4
|
+
* The saga itself — its order, its record, its retries — is
|
|
5
|
+
* `@frockbot/app/account/deletion`. This file is what each step does here:
|
|
6
|
+
* which object, bucket, index or provider it reaches and how it knows it is
|
|
7
|
+
* done. What only the User's own Durable Object can do — its Bots, its Group
|
|
8
|
+
* Chats, its ledger and its Memory tables — comes in as seams, so every step
|
|
9
|
+
* is testable without one.
|
|
10
|
+
*
|
|
11
|
+
* Each step only deletes, and each is safe to repeat: a provider answering
|
|
12
|
+
* "already gone" is a deletion, never an error.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
AccountDeletionRecordV1,
|
|
16
|
+
AccountDeletionStepOutcomeV1,
|
|
17
|
+
AccountDeletionStepV1,
|
|
18
|
+
} from "@frockbot/app/account/deletion";
|
|
19
|
+
import {
|
|
20
|
+
StripeClient,
|
|
21
|
+
deleteAccountCustomersV1,
|
|
22
|
+
} from "@frockbot/app/billing/stripe";
|
|
23
|
+
import { ComposioClient } from "@frockbot/app/connect/composio";
|
|
24
|
+
import type { MemoryVectorIndex } from "@frockbot/app/memory/types";
|
|
25
|
+
import { uploadUserPrefixV1 } from "@frockbot/app/uploads/shared";
|
|
26
|
+
import {
|
|
27
|
+
WORKSPACE_OBJECT_PREFIX,
|
|
28
|
+
workspaceObjectPrefixV1,
|
|
29
|
+
} from "@frockbot/core/workspace-store";
|
|
30
|
+
import { stripeConfig, type BillingEnv } from "./billing.js";
|
|
31
|
+
import {
|
|
32
|
+
computerHostBindingV1,
|
|
33
|
+
createComputerHostV1,
|
|
34
|
+
type ComputerHostEnvV1,
|
|
35
|
+
} from "./computer-host.js";
|
|
36
|
+
import { DEPLOYMENT_POLICY_SINGLETON_NAME } from "./deployment-policy.js";
|
|
37
|
+
|
|
38
|
+
export interface AccountDeletionEnvV1 extends BillingEnv, ComputerHostEnvV1 {
|
|
39
|
+
DEPLOYMENT_POLICY?: DurableObjectNamespace;
|
|
40
|
+
VOICE_ASSISTANTS?: DurableObjectNamespace;
|
|
41
|
+
COMPOSIO_API_KEY?: string;
|
|
42
|
+
MEMORY_FILES?: R2Bucket;
|
|
43
|
+
MEMORY_INDEX?: MemoryVectorIndex;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What only the User Durable Object can do, handed to the steps. */
|
|
47
|
+
export interface AccountDeletionUserSeamsV1 {
|
|
48
|
+
/**
|
|
49
|
+
* Deletes every Group Chat through its own delete command: the first pass
|
|
50
|
+
* names them in the cursor, the next deletes those it names.
|
|
51
|
+
*/
|
|
52
|
+
deleteGroupChats(
|
|
53
|
+
cursor: string | undefined,
|
|
54
|
+
): Promise<AccountDeletionStepOutcomeV1>;
|
|
55
|
+
/** Deletes every Bot through the Bot delete saga. */
|
|
56
|
+
deleteBots(): Promise<AccountDeletionStepOutcomeV1>;
|
|
57
|
+
/** The payment customer the ledger recorded, if it recorded one. */
|
|
58
|
+
recordedPaymentCustomer(): string | undefined;
|
|
59
|
+
/** User and Group Chat Memory vector ids, a page at a time. */
|
|
60
|
+
vectorIdsAfter(cursor: string | undefined, limit: number): string[];
|
|
61
|
+
/** Forgets the sign-in identity, its sessions and linked accounts. */
|
|
62
|
+
deleteIdentity(): Promise<void>;
|
|
63
|
+
/** Erases the voice session object. */
|
|
64
|
+
eraseVoice(): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Revokes the grants the User's MCP servers issued, forgetting each once
|
|
67
|
+
* its server has answered — or, with `giveUp`, whether it did or not.
|
|
68
|
+
* Answers how many servers did not answer.
|
|
69
|
+
*/
|
|
70
|
+
revokeMcpGrants(giveUp: boolean): Promise<number>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const COMPLETE: AccountDeletionStepOutcomeV1 = { status: "complete" };
|
|
74
|
+
/** One listing page, and one bulk delete, of object storage or the index. */
|
|
75
|
+
const PAGE = 1_000;
|
|
76
|
+
/**
|
|
77
|
+
* Passes an MCP server that does not answer its revocation is given — about
|
|
78
|
+
* half a minute of the saga's backoff — before its grant is forgotten
|
|
79
|
+
* unrevoked, so a server that is gone never holds a deletion up.
|
|
80
|
+
*/
|
|
81
|
+
export const MCP_REVOCATION_ATTEMPTS_V1 = 5;
|
|
82
|
+
|
|
83
|
+
interface DeploymentPolicyDeletionRpc {
|
|
84
|
+
closeAccountForDeletion(input: unknown): Promise<unknown>;
|
|
85
|
+
forgetAccount(input: unknown): Promise<unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function deploymentPolicy(
|
|
89
|
+
env: AccountDeletionEnvV1,
|
|
90
|
+
): DeploymentPolicyDeletionRpc | undefined {
|
|
91
|
+
const namespace = env.DEPLOYMENT_POLICY;
|
|
92
|
+
// SAFETY: the binding names DeploymentPolicy; these are its deletion doors.
|
|
93
|
+
return namespace
|
|
94
|
+
? (namespace.get(
|
|
95
|
+
namespace.idFromName(DEPLOYMENT_POLICY_SINGLETON_NAME),
|
|
96
|
+
) as unknown as DeploymentPolicyDeletionRpc)
|
|
97
|
+
: undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every object-storage prefix that holds one User's files: the User's own
|
|
102
|
+
* Skills and Memory, every Plugin-declared root, and every Bot root and Bot's
|
|
103
|
+
* uploads — which the Bot delete saga already removed, and which are swept
|
|
104
|
+
* again in case a Bot was lost before it could be.
|
|
105
|
+
*
|
|
106
|
+
* Content-addressed stores — Skill bodies, Plugin artifacts and exported
|
|
107
|
+
* templates — are not here. One address can hold the same bytes for two
|
|
108
|
+
* accounts, so deleting it could break another account's Skill or Plugin;
|
|
109
|
+
* once this account is gone nothing names them.
|
|
110
|
+
*/
|
|
111
|
+
export function accountObjectPrefixesV1(userId: string): string[] {
|
|
112
|
+
const user = encodeURIComponent(userId);
|
|
113
|
+
return [
|
|
114
|
+
workspaceObjectPrefixV1({ kind: "user-instructions", userId }),
|
|
115
|
+
workspaceObjectPrefixV1({ kind: "user-memory", userId }),
|
|
116
|
+
`${WORKSPACE_OBJECT_PREFIX}/package-declared:${user}:`,
|
|
117
|
+
`${WORKSPACE_OBJECT_PREFIX}/bot-instructions:${user}:`,
|
|
118
|
+
`${WORKSPACE_OBJECT_PREFIX}/bot-memory:${user}:`,
|
|
119
|
+
uploadUserPrefixV1(userId),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One page of every prefix; done once a pass finds nothing left. */
|
|
124
|
+
async function deleteAccountObjects(
|
|
125
|
+
bucket: R2Bucket,
|
|
126
|
+
userId: string,
|
|
127
|
+
): Promise<AccountDeletionStepOutcomeV1> {
|
|
128
|
+
let remaining = false;
|
|
129
|
+
for (const prefix of accountObjectPrefixesV1(userId)) {
|
|
130
|
+
const page = await bucket.list({ prefix, limit: PAGE });
|
|
131
|
+
const keys = page.objects.map((object) => object.key);
|
|
132
|
+
if (keys.length > 0) await bucket.delete(keys);
|
|
133
|
+
if (page.truncated) remaining = true;
|
|
134
|
+
}
|
|
135
|
+
return remaining ? { status: "pending" } : COMPLETE;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The provider's accounts and triggers for this User, found by asking the
|
|
140
|
+
* provider rather than by what this application recorded: a sign-in started
|
|
141
|
+
* and never finished leaves an account no Connection names. Triggers go
|
|
142
|
+
* first, then the accounts, each revoked upstream.
|
|
143
|
+
*
|
|
144
|
+
* A pass that deleted anything is followed by one more that lists again and
|
|
145
|
+
* deletes whatever it still finds, and the step then ends. A provider that
|
|
146
|
+
* keeps listing an account it has accepted the deletion of is keeping its own
|
|
147
|
+
* record, and a third pass would only delete it a third time.
|
|
148
|
+
*/
|
|
149
|
+
async function deleteConnectedApps(
|
|
150
|
+
client: ComposioClient,
|
|
151
|
+
userId: string,
|
|
152
|
+
record: AccountDeletionRecordV1,
|
|
153
|
+
): Promise<AccountDeletionStepOutcomeV1> {
|
|
154
|
+
const triggers = await client.listTriggerInstanceIds(userId);
|
|
155
|
+
for (const id of triggers) await client.deleteTriggerInstance(id);
|
|
156
|
+
const accounts = await client.listConnectedAccountIds(userId);
|
|
157
|
+
for (const id of accounts) await client.deleteConnectedAccount(id);
|
|
158
|
+
if (triggers.length + accounts.length === 0 || record.cursor === "verify")
|
|
159
|
+
return COMPLETE;
|
|
160
|
+
return { status: "pending", cursor: "verify" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The payment customer, which ends the subscription with it. With no
|
|
165
|
+
* payments configured there is nothing to reach — unless the ledger recorded
|
|
166
|
+
* a customer, which means payments were on once and a live subscription may
|
|
167
|
+
* still be charging. That is refused, and retried, until someone restores
|
|
168
|
+
* the configuration: finishing the deletion around it would leave the person
|
|
169
|
+
* paying for an account that no longer exists.
|
|
170
|
+
*/
|
|
171
|
+
async function deletePayments(
|
|
172
|
+
env: AccountDeletionEnvV1,
|
|
173
|
+
userId: string,
|
|
174
|
+
recorded: string | undefined,
|
|
175
|
+
): Promise<AccountDeletionStepOutcomeV1> {
|
|
176
|
+
let config: ReturnType<typeof stripeConfig>;
|
|
177
|
+
try {
|
|
178
|
+
config = stripeConfig(env);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (recorded === undefined) return COMPLETE;
|
|
181
|
+
throw new Error(
|
|
182
|
+
`payments are not configured, so customer ${recorded} cannot be deleted`,
|
|
183
|
+
{ cause: error },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
await deleteAccountCustomersV1(new StripeClient(config), userId, recorded);
|
|
187
|
+
return COMPLETE;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One page of Memory vectors, deleted from the index by id. */
|
|
191
|
+
async function deleteMemoryVectors(
|
|
192
|
+
index: MemoryVectorIndex,
|
|
193
|
+
seams: AccountDeletionUserSeamsV1,
|
|
194
|
+
record: AccountDeletionRecordV1,
|
|
195
|
+
): Promise<AccountDeletionStepOutcomeV1> {
|
|
196
|
+
const page = seams.vectorIdsAfter(record.cursor, PAGE);
|
|
197
|
+
if (page.length === 0) return COMPLETE;
|
|
198
|
+
await index.deleteByIds(page);
|
|
199
|
+
return { status: "pending", cursor: page.at(-1)! };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Runs one step of one account's deletion. */
|
|
203
|
+
export async function runAccountDeletionStepV1(
|
|
204
|
+
env: AccountDeletionEnvV1,
|
|
205
|
+
seams: AccountDeletionUserSeamsV1,
|
|
206
|
+
step: AccountDeletionStepV1,
|
|
207
|
+
record: AccountDeletionRecordV1,
|
|
208
|
+
): Promise<AccountDeletionStepOutcomeV1> {
|
|
209
|
+
const { userId } = record;
|
|
210
|
+
switch (step) {
|
|
211
|
+
case "access":
|
|
212
|
+
await deploymentPolicy(env)?.closeAccountForDeletion({
|
|
213
|
+
schemaVersion: 1,
|
|
214
|
+
userId,
|
|
215
|
+
});
|
|
216
|
+
return COMPLETE;
|
|
217
|
+
case "voice":
|
|
218
|
+
if (env.VOICE_ASSISTANTS) await seams.eraseVoice();
|
|
219
|
+
return COMPLETE;
|
|
220
|
+
case "groups":
|
|
221
|
+
return seams.deleteGroupChats(record.cursor);
|
|
222
|
+
case "bots":
|
|
223
|
+
return seams.deleteBots();
|
|
224
|
+
case "computer": {
|
|
225
|
+
const binding = computerHostBindingV1(env);
|
|
226
|
+
if (!binding) return COMPLETE;
|
|
227
|
+
const host = createComputerHostV1(binding);
|
|
228
|
+
if (!host.teardown)
|
|
229
|
+
throw new Error("this Computer host cannot tear a Computer down");
|
|
230
|
+
await host.teardown({ userId });
|
|
231
|
+
return COMPLETE;
|
|
232
|
+
}
|
|
233
|
+
case "connected-apps":
|
|
234
|
+
return env.COMPOSIO_API_KEY
|
|
235
|
+
? deleteConnectedApps(
|
|
236
|
+
new ComposioClient({ apiKey: env.COMPOSIO_API_KEY }),
|
|
237
|
+
userId,
|
|
238
|
+
record,
|
|
239
|
+
)
|
|
240
|
+
: COMPLETE;
|
|
241
|
+
case "mcp-servers": {
|
|
242
|
+
// A failure is what backs the saga off between passes; the record's
|
|
243
|
+
// count of them is what bounds how long a silent server is waited on.
|
|
244
|
+
const owed = await seams.revokeMcpGrants(
|
|
245
|
+
record.attempts + 1 >= MCP_REVOCATION_ATTEMPTS_V1,
|
|
246
|
+
);
|
|
247
|
+
if (owed > 0)
|
|
248
|
+
throw new Error(
|
|
249
|
+
`${owed} MCP server${owed === 1 ? "" : "s"} did not answer the revocation`,
|
|
250
|
+
);
|
|
251
|
+
return COMPLETE;
|
|
252
|
+
}
|
|
253
|
+
case "payments":
|
|
254
|
+
return deletePayments(env, userId, seams.recordedPaymentCustomer());
|
|
255
|
+
case "files":
|
|
256
|
+
return env.MEMORY_FILES
|
|
257
|
+
? deleteAccountObjects(env.MEMORY_FILES, userId)
|
|
258
|
+
: COMPLETE;
|
|
259
|
+
case "memory-vectors":
|
|
260
|
+
return env.MEMORY_INDEX
|
|
261
|
+
? deleteMemoryVectors(env.MEMORY_INDEX, seams, record)
|
|
262
|
+
: COMPLETE;
|
|
263
|
+
case "identity":
|
|
264
|
+
await seams.deleteIdentity();
|
|
265
|
+
return COMPLETE;
|
|
266
|
+
case "admission":
|
|
267
|
+
await deploymentPolicy(env)?.forgetAccount({
|
|
268
|
+
schemaVersion: 1,
|
|
269
|
+
userId,
|
|
270
|
+
...(record.email === undefined ? {} : { email: record.email }),
|
|
271
|
+
});
|
|
272
|
+
return COMPLETE;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
2
|
+
import {
|
|
3
|
+
createAdminOperationsV1,
|
|
4
|
+
type AdminOperationsHostV1,
|
|
5
|
+
type AdminOperationsV1,
|
|
6
|
+
} from "@frockbot/app/admin/operations";
|
|
7
|
+
import {
|
|
8
|
+
decodeAdminUserBillingV1,
|
|
9
|
+
decodeUserFeaturesV1,
|
|
10
|
+
type AccountAccessV1,
|
|
11
|
+
type AccountAccessViewV1,
|
|
12
|
+
type AdminUserBillingV1,
|
|
13
|
+
type AdminUserListViewV1,
|
|
14
|
+
type AdminWriteResultV1,
|
|
15
|
+
type DeploymentPolicyV1,
|
|
16
|
+
type EmailInvitationV1,
|
|
17
|
+
type UserFeaturesV1,
|
|
18
|
+
} from "@frockbot/app/admin/shared";
|
|
19
|
+
import type {
|
|
20
|
+
HostedModelRatesV1,
|
|
21
|
+
HostedModelRatesViewV1,
|
|
22
|
+
} from "@frockbot/app/billing/rates";
|
|
23
|
+
import { createDeploymentPolicyAdminHost } from "./deployment-policy-admin-host.js";
|
|
24
|
+
import { DEPLOYMENT_POLICY_SINGLETON_NAME } from "./deployment-policy.js";
|
|
25
|
+
import { rpcJsonSnapshotV1 } from "./durable-rpc.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What administration needs from the Worker's environment: the access
|
|
29
|
+
* authority, the identity store and one account's User Durable Object.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately narrower than the app's `Env`. Administration reaches three
|
|
32
|
+
* bindings, and naming them here is what says so.
|
|
33
|
+
*/
|
|
34
|
+
export interface AdminEntrypointEnvV1 {
|
|
35
|
+
AUTH_DB: D1Database;
|
|
36
|
+
USER_CONFIGURATIONS: DurableObjectNamespace;
|
|
37
|
+
DEPLOYMENT_POLICY: DurableObjectNamespace;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DeploymentPolicyAdminRpc {
|
|
41
|
+
readPolicy(input: unknown): Promise<unknown>;
|
|
42
|
+
setAdmissionMode(input: unknown): Promise<unknown>;
|
|
43
|
+
readAccountAccess(input: unknown): Promise<unknown>;
|
|
44
|
+
setAccountAccess(input: unknown): Promise<unknown>;
|
|
45
|
+
inviteEmail(input: unknown): Promise<unknown>;
|
|
46
|
+
readModelRatesView(input: unknown): Promise<unknown>;
|
|
47
|
+
saveModelRates(input: unknown): Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface UserAccountRpc {
|
|
51
|
+
readFeatures(input: unknown): Promise<unknown>;
|
|
52
|
+
setFeatures(input: unknown): Promise<unknown>;
|
|
53
|
+
readBillingBalance(input: unknown): Promise<unknown>;
|
|
54
|
+
grantComplimentaryCredit(input: unknown): Promise<unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function userAccountStub(
|
|
58
|
+
env: AdminEntrypointEnvV1,
|
|
59
|
+
userId: string,
|
|
60
|
+
): UserAccountRpc {
|
|
61
|
+
const id = env.USER_CONFIGURATIONS.idFromName(userId);
|
|
62
|
+
// SAFETY: Wrangler binds USER_CONFIGURATIONS to UserConfiguration; workers-types cannot infer its account features and billing RPC surface.
|
|
63
|
+
return env.USER_CONFIGURATIONS.get(id) as unknown as UserAccountRpc;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function deploymentPolicyStub(
|
|
67
|
+
env: AdminEntrypointEnvV1,
|
|
68
|
+
): DeploymentPolicyAdminRpc {
|
|
69
|
+
// SAFETY: Wrangler binds DEPLOYMENT_POLICY to DeploymentPolicy; workers-types cannot infer its authority RPC surface.
|
|
70
|
+
return env.DEPLOYMENT_POLICY.getByName(
|
|
71
|
+
DEPLOYMENT_POLICY_SINGLETON_NAME,
|
|
72
|
+
) as unknown as DeploymentPolicyAdminRpc;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createAdminOperationsHostV1(
|
|
76
|
+
env: AdminEntrypointEnvV1,
|
|
77
|
+
): AdminOperationsHostV1 {
|
|
78
|
+
return {
|
|
79
|
+
...createDeploymentPolicyAdminHost(() => deploymentPolicyStub(env)),
|
|
80
|
+
listUsers: async () => {
|
|
81
|
+
const result = await env.AUTH_DB.prepare(
|
|
82
|
+
'select "id", "email", "name" from "user" order by "createdAt" desc limit 200',
|
|
83
|
+
).all<{ id: string; email: string; name: string }>();
|
|
84
|
+
return (result.results ?? []).map((user) => ({
|
|
85
|
+
userId: user.id,
|
|
86
|
+
email: user.email,
|
|
87
|
+
name: user.name,
|
|
88
|
+
}));
|
|
89
|
+
},
|
|
90
|
+
readUserFeatures: async (userId) =>
|
|
91
|
+
decodeUserFeaturesV1(
|
|
92
|
+
rpcJsonSnapshotV1(
|
|
93
|
+
await userAccountStub(env, userId).readFeatures({
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
userId,
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
setUserFeatures: async (userId, command, updatedBy) =>
|
|
100
|
+
decodeUserFeaturesV1(
|
|
101
|
+
rpcJsonSnapshotV1(
|
|
102
|
+
await userAccountStub(env, userId).setFeatures({
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
userId,
|
|
105
|
+
command,
|
|
106
|
+
updatedBy,
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
readUserBilling: async (userId) =>
|
|
111
|
+
decodeAdminUserBillingV1(
|
|
112
|
+
rpcJsonSnapshotV1(
|
|
113
|
+
await userAccountStub(env, userId).readBillingBalance({ userId }),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
grantUserCredit: async (userId, command, grantedBy) =>
|
|
117
|
+
decodeAdminUserBillingV1(
|
|
118
|
+
rpcJsonSnapshotV1(
|
|
119
|
+
await userAccountStub(env, userId).grantComplimentaryCredit({
|
|
120
|
+
userId,
|
|
121
|
+
command: {
|
|
122
|
+
id: command.id,
|
|
123
|
+
micros: command.cents * 10_000,
|
|
124
|
+
grantedBy,
|
|
125
|
+
reason: command.reason,
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Administration, reachable only over a service binding.
|
|
135
|
+
*
|
|
136
|
+
* No HTTP route reaches this class: the admin portal
|
|
137
|
+
* (`apps/admin-portal`) binds it as `APP` and is the one caller, so the
|
|
138
|
+
* authority that decides who administers the deployment is Cloudflare Access
|
|
139
|
+
* plus the admin emails list, checked there, and never a session in this
|
|
140
|
+
* Worker (ADR 0028). Every method decodes its input the way the Durable
|
|
141
|
+
* Objects behind it do, and every compare-and-swap answers `applied` or
|
|
142
|
+
* `conflict` rather than throwing, because a conflict is an answer.
|
|
143
|
+
*/
|
|
144
|
+
export class AdminEntrypoint extends WorkerEntrypoint<AdminEntrypointEnvV1> {
|
|
145
|
+
private get operations(): AdminOperationsV1 {
|
|
146
|
+
return createAdminOperationsV1(createAdminOperationsHostV1(this.env));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
readPolicy(): Promise<DeploymentPolicyV1> {
|
|
150
|
+
return this.operations.readPolicy();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setAdmissionMode(
|
|
154
|
+
input: unknown,
|
|
155
|
+
): Promise<AdminWriteResultV1<DeploymentPolicyV1>> {
|
|
156
|
+
return this.operations.setAdmissionMode(input);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
listAccounts(): Promise<AdminUserListViewV1> {
|
|
160
|
+
return this.operations.listAccounts();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
readAccountAccess(input: unknown): Promise<AccountAccessViewV1> {
|
|
164
|
+
return this.operations.readAccountAccess(input);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
setAccountAccess(
|
|
168
|
+
input: unknown,
|
|
169
|
+
): Promise<AdminWriteResultV1<AccountAccessV1>> {
|
|
170
|
+
return this.operations.setAccountAccess(input);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
inviteEmail(input: unknown): Promise<EmailInvitationV1> {
|
|
174
|
+
return this.operations.inviteEmail(input);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setAccountFeatures(input: unknown): Promise<UserFeaturesV1> {
|
|
178
|
+
return this.operations.setAccountFeatures(input);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
grantCredit(input: unknown): Promise<AdminUserBillingV1> {
|
|
182
|
+
return this.operations.grantCredit(input);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
readModelRates(): Promise<HostedModelRatesViewV1> {
|
|
186
|
+
return this.operations.readModelRates();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
saveModelRates(
|
|
190
|
+
input: unknown,
|
|
191
|
+
): Promise<AdminWriteResultV1<HostedModelRatesV1>> {
|
|
192
|
+
return this.operations.saveModelRates(input);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { adminEmailsV1 } from "@frockbot/app/admin/shared";
|
|
2
|
+
|
|
3
|
+
export { adminEmailsV1 };
|
|
4
|
+
|
|
5
|
+
/** The one identity a development stack signs in as; an admin there. */
|
|
6
|
+
export const DEVELOPMENT_USER_ID = "development";
|
|
7
|
+
|
|
8
|
+
export interface GatewayIdentityV1 {
|
|
9
|
+
id: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
mode: "better-auth" | "development";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isDeploymentAdminV1(
|
|
15
|
+
identity: GatewayIdentityV1,
|
|
16
|
+
configuredEmails: string | undefined,
|
|
17
|
+
): boolean {
|
|
18
|
+
if (identity.mode === "development" && identity.id === DEVELOPMENT_USER_ID) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const emails = adminEmailsV1(configuredEmails);
|
|
22
|
+
if (identity.email && emails.has(identity.email.trim().toLowerCase())) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return identity.mode === "development" && emails.size === 0;
|
|
26
|
+
}
|