Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/db-schema/accounts.ts
5837 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import { has } from "lodash";
7
8
import { NOTES } from "./crm";
9
import { SCHEMA as schema } from "./index";
10
import { checkAccountName } from "./name-rules";
11
import { Table } from "./types";
12
13
import {
14
DEFAULT_FONT_SIZE,
15
DEFAULT_NEW_FILENAMES,
16
NEW_FILENAMES,
17
OTHER_SETTINGS_USER_DEFINED_LLM,
18
} from "./defaults";
19
20
import { ssoDispayedName } from "@cocalc/util/auth";
21
import { checkRequiredSSO } from "@cocalc/util/auth-check-required-sso";
22
import { ssoNormalizeExclusiveDomains } from "@cocalc/util/sso-normalize-domains";
23
import { isUserDefinedModelType } from "./llm-utils";
24
25
import { DEFAULT_LOCALE } from "@cocalc/util/consts/locale";
26
import { Strategy } from "@cocalc/util/types/sso";
27
28
async function getEmailAddressForAccountId(
29
db,
30
account_id: string,
31
): Promise<string | undefined> {
32
const { rows } = await db.async_query({
33
query: "SELECT email_address FROM accounts",
34
where: { "account_id = $::UUID": account_id },
35
});
36
if (rows.length === 0) {
37
return undefined;
38
}
39
return rows[0].email_address ?? undefined;
40
}
41
42
async function getStrategiesSSO(db): Promise<Strategy[]> {
43
const { rows } = await db.async_query({
44
query: `
45
SELECT strategy,
46
COALESCE(info -> 'icon', conf -> 'icon') as icon,
47
COALESCE(info -> 'display', conf -> 'display') as display,
48
COALESCE(info -> 'public', conf -> 'public') as public,
49
COALESCE(info -> 'exclusive_domains', conf -> 'exclusive_domains') as exclusive_domains,
50
COALESCE(info -> 'do_not_hide', 'false'::JSONB) as do_not_hide,
51
COALESCE(info -> 'update_on_login', 'false'::JSONB) as update_on_login
52
FROM passport_settings
53
WHERE strategy != 'site_conf'
54
AND COALESCE(info ->> 'disabled', conf ->> 'disabled', 'false') != 'true'
55
`,
56
});
57
58
return rows.map((row) => {
59
ssoNormalizeExclusiveDomains(row);
60
return {
61
name: row.strategy,
62
display: ssoDispayedName({
63
display: row.display,
64
name: row.strategy,
65
}),
66
icon: row.icon ?? undefined,
67
backgroundColor: "",
68
public: row.public ?? true,
69
exclusiveDomains: row.exclusive_domains ?? [],
70
doNotHide: row.do_not_hide ?? false,
71
updateOnLogin: row.update_on_login ?? false,
72
};
73
});
74
}
75
76
export const USER_SEARCH_LIMIT = 250;
77
export const ADMIN_SEARCH_LIMIT = 2500;
78
79
export const USE_BALANCE_TOWARD_SUBSCRIPTIONS =
80
"use_balance_toward_subscriptions";
81
export const USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT = true;
82
83
// AutoBalance: Every parameter is in dollars.
84
export interface AutoBalance {
85
// deposit money when the balance goes below this
86
trigger: number;
87
// amount to automatically add
88
amount: number;
89
// max amount of money to add per day
90
max_day: number;
91
// max amount of money to add per week
92
max_week: number;
93
// max amount of money to add per month
94
max_month: number;
95
// period -- which of max_day, max_week, or max_month to actually enforce.
96
// we always enforce **exactly one of them**.
97
period: "day" | "week" | "month";
98
// switch to disable/enable this.
99
enabled: boolean;
100
// if credit was not added, last reason why (at most 1024 characters)
101
reason?: string;
102
// ms since epoch of last attempt
103
time?: number;
104
// how much has been added at the moment when we last updated.
105
status?: { day: number; week: number; month: number };
106
}
107
108
// each of the parameters above must be a number in the
109
// given interval below.
110
// All fields should always be explicitly specified.
111
export const AUTOBALANCE_RANGES = {
112
trigger: [5, 250],
113
amount: [10, 250],
114
max_day: [5, 1000],
115
max_week: [5, 5000],
116
max_month: [5, 10000],
117
} as const;
118
119
export const AUTOBALANCE_DEFAULTS: AutoBalance = {
120
trigger: 10,
121
amount: 20,
122
max_day: 200,
123
max_week: 1000,
124
max_month: 2500,
125
period: "week",
126
enabled: true,
127
} as const;
128
129
export const DARK_MODE_DEFAULTS = {
130
brightness: 100,
131
contrast: 90,
132
sepia: 0,
133
} as const;
134
135
// throw error if not valid
136
export function ensureAutoBalanceValid(obj) {
137
if (obj == null) {
138
return;
139
}
140
if (typeof obj != "object") {
141
throw Error("must be an object");
142
}
143
for (const key in AUTOBALANCE_RANGES) {
144
if (obj[key] == null) {
145
throw Error(`${key} must be specified`);
146
}
147
}
148
for (const key in obj) {
149
if (key == "period") {
150
if (!["day", "week", "month"].includes(obj[key])) {
151
throw Error(`${key} must be 'day', 'week' or 'month'`);
152
}
153
continue;
154
}
155
if (key == "enabled") {
156
if (typeof obj[key] != "boolean") {
157
throw Error(`${key} must be boolean`);
158
}
159
continue;
160
}
161
if (key == "reason") {
162
if (typeof obj[key] != "string") {
163
throw Error(`${key} must be a string`);
164
}
165
if (obj[key].length > 1024) {
166
throw Error(`${key} must be at most 1024 characters`);
167
}
168
continue;
169
}
170
if (key == "time") {
171
if (typeof obj[key] != "number") {
172
throw Error(`${key} must be a number`);
173
}
174
continue;
175
}
176
if (key == "status") {
177
if (typeof obj[key] != "object") {
178
throw Error(`${key} must be an object`);
179
}
180
continue;
181
}
182
const range = AUTOBALANCE_RANGES[key];
183
if (range == null) {
184
throw Error(`invalid key '${key}'`);
185
}
186
const value = obj[key];
187
if (typeof value != "number") {
188
throw Error("every value must be a number");
189
}
190
if (value < range[0]) {
191
throw Error(`${key} must be at least ${range[0]}`);
192
}
193
if (value > range[1]) {
194
throw Error(`${key} must be at most ${range[1]}`);
195
}
196
}
197
}
198
199
// throw error if not valid; also clamps max_tokens to safe range
200
export function ensureUserDefinedLLMValid(configs: any): void {
201
if (!Array.isArray(configs)) {
202
throw new Error("user_defined_llm must be an array");
203
}
204
205
const maxLengths = {
206
display: 128,
207
model: 256,
208
endpoint: 256,
209
apiKey: 1024,
210
icon: 1024,
211
};
212
213
for (const llm of configs) {
214
// Validate required fields
215
if (typeof llm.id !== "number") {
216
throw new Error("user_defined_llm: id must be a number");
217
}
218
if (!isUserDefinedModelType(llm.service)) {
219
throw new Error(
220
`user_defined_llm: service must be one of the supported services, got '${llm.service}'`,
221
);
222
}
223
if (typeof llm.model !== "string" || !llm.model) {
224
throw new Error("user_defined_llm: model is required");
225
}
226
if (typeof llm.display !== "string" || !llm.display) {
227
throw new Error("user_defined_llm: display is required");
228
}
229
if (typeof llm.endpoint !== "string") {
230
throw new Error("user_defined_llm: endpoint must be a string");
231
}
232
if (typeof llm.apiKey !== "string") {
233
throw new Error("user_defined_llm: apiKey must be a string");
234
}
235
if (llm.display.length > maxLengths.display) {
236
throw new Error(
237
`user_defined_llm: display must be at most ${maxLengths.display} characters`,
238
);
239
}
240
if (llm.model.length > maxLengths.model) {
241
throw new Error(
242
`user_defined_llm: model must be at most ${maxLengths.model} characters`,
243
);
244
}
245
if (llm.endpoint.length > maxLengths.endpoint) {
246
throw new Error(
247
`user_defined_llm: endpoint must be at most ${maxLengths.endpoint} characters`,
248
);
249
}
250
if (llm.apiKey.length > maxLengths.apiKey) {
251
throw new Error(
252
`user_defined_llm: apiKey must be at most ${maxLengths.apiKey} characters`,
253
);
254
}
255
// apiKey is required for most services, but optional for ollama and custom_openai
256
const requiresApiKey =
257
llm.service !== "ollama" && llm.service !== "custom_openai";
258
if (requiresApiKey && !llm.apiKey) {
259
throw new Error("user_defined_llm: apiKey is required for this service");
260
}
261
262
// Validate and clamp max_tokens
263
if (llm.max_tokens != null) {
264
if (typeof llm.max_tokens !== "number") {
265
throw new Error("user_defined_llm: max_tokens must be a number");
266
}
267
if (!Number.isInteger(llm.max_tokens)) {
268
throw new Error("user_defined_llm: max_tokens must be an integer");
269
}
270
// Clamp to safe range
271
if (llm.max_tokens < 1000) {
272
llm.max_tokens = 1000;
273
}
274
if (llm.max_tokens > 2000000) {
275
llm.max_tokens = 2000000;
276
}
277
}
278
279
// Validate optional icon
280
if (llm.icon != null) {
281
if (typeof llm.icon !== "string") {
282
throw new Error("user_defined_llm: icon must be a string");
283
}
284
if (llm.icon.length > maxLengths.icon) {
285
throw new Error(
286
`user_defined_llm: icon must be at most ${maxLengths.icon} characters`,
287
);
288
}
289
}
290
}
291
}
292
293
Table({
294
name: "accounts",
295
fields: {
296
account_id: {
297
type: "uuid",
298
desc: "The uuid that determines the user account",
299
render: { type: "account" },
300
title: "Account",
301
},
302
created: {
303
type: "timestamp",
304
desc: "When the account was created.",
305
},
306
ephemeral: {
307
type: "number",
308
desc: "If set, number of milliseconds this account is allowed to exist after creation.",
309
},
310
created_by: {
311
type: "string",
312
pg_type: "inet",
313
desc: "IP address that created the account.",
314
},
315
creation_actions_done: {
316
type: "boolean",
317
desc: "Set to true after all creation actions (e.g., add to projects) associated to this account are succesfully completed.",
318
},
319
password_hash: {
320
type: "string",
321
pg_type: "VARCHAR(173)",
322
desc: "Hash of the password. This is 1000 iterations of sha512 with salt of length 32.",
323
},
324
deleted: {
325
type: "boolean",
326
desc: "True if the account has been deleted.",
327
},
328
name: {
329
type: "string",
330
pg_type: "VARCHAR(39)",
331
desc: "The username of this user. This is optional but globally unique across all accoutns *and* organizations. It can be between 1 and 39 characters from a-z A-Z 0-9 - and must not start with a dash.",
332
},
333
org: {
334
type: "string",
335
prg_type: "VARCHAR(39)",
336
desc: "If this account is associated to an organization, then this is the *name* of the organization. An account may be associated with at most one organization.",
337
},
338
email_address: {
339
type: "string",
340
pg_type: "VARCHAR(254)", // see http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address
341
desc: "The email address of the user. This is optional, since users may instead be associated to passport logins.",
342
unique: true,
343
render: { type: "email_address" },
344
}, // only one record in database can have this email address (if given)
345
email_address_before_delete: {
346
type: "string",
347
desc: "The email address of the user before they deleted their account.",
348
},
349
email_address_verified: {
350
type: "map",
351
desc: 'Verified email addresses as { "[email protected]" : <timestamp>, ... }',
352
},
353
email_address_challenge: {
354
type: "map",
355
desc: 'Contains random token for verification of an address: {"email": "...", "token": <random>, "time" : <timestamp for timeout>}',
356
},
357
email_address_problem: {
358
type: "map",
359
desc: 'Describes a problem with a given email address. example: { "[email protected]" : { "type": "bounce", "time": "2018-...", "mesg": "554 5.7.1 <....>: Recipient address rejected: Access denied, user does not exist", "status": <status code>}}',
360
},
361
passports: {
362
type: "map",
363
desc: 'Map from string ("[strategy]-[id]") derived from passport name and id to the corresponding profile',
364
},
365
editor_settings: {
366
type: "map",
367
desc: "Description of configuration settings for the editor. See the user_query get defaults.",
368
},
369
other_settings: {
370
type: "map",
371
desc: "Miscellaneous overall configuration settings for CoCalc, e.g., confirm close on exit?",
372
},
373
first_name: {
374
type: "string",
375
pg_type: "VARCHAR(254)", // some limit (actually around 3000) is required for indexing
376
desc: "The first name of this user.",
377
render: { type: "text", maxLength: 254, editable: true },
378
},
379
last_name: {
380
type: "string",
381
pg_type: "VARCHAR(254)",
382
desc: "The last name of this user.",
383
render: { type: "text", maxLength: 254, editable: true },
384
},
385
banned: {
386
type: "boolean",
387
desc: "Whether or not this user is banned.",
388
render: {
389
type: "boolean",
390
editable: true,
391
},
392
},
393
terminal: {
394
type: "map",
395
desc: "Settings for the terminal, e.g., font_size, etc. (see get query)",
396
},
397
autosave: {
398
type: "integer",
399
desc: "File autosave interval in seconds",
400
},
401
evaluate_key: {
402
type: "string",
403
desc: "Key used to evaluate code in Sage worksheet.",
404
},
405
font_size: {
406
type: "integer",
407
desc: "Default font-size for the editor, jupyter, etc. (px)",
408
},
409
last_active: {
410
type: "timestamp",
411
desc: "When this user was last active.",
412
},
413
stripe_customer_id: {
414
type: "string",
415
desc: "The id of this customer in the stripe billing system.",
416
},
417
stripe_customer: {
418
type: "map",
419
desc: "Information about customer from the point of view of stripe (exactly what is returned by stripe.customers.retrieve) ALMOST DEPRECATED -- THIS IS ONLY USED FOR OLD LEGACY UPGRADES.",
420
},
421
coupon_history: {
422
type: "map",
423
desc: "Information about which coupons the customer has used and the number of times",
424
},
425
profile: {
426
type: "map",
427
desc: "Information related to displaying an avatar for this user's location and presence in a document or chatroom.",
428
},
429
customize: {
430
type: "map",
431
desc: "Admin-managed configuration overrides for this account.",
432
},
433
groups: {
434
type: "array",
435
pg_type: "TEXT[]",
436
desc: "Array of groups that this user belongs to; usually empty. The only group right now is 'admin', which grants admin rights.",
437
},
438
ssh_keys: {
439
type: "map",
440
desc: "Map from ssh key fingerprints to ssh key objects.",
441
},
442
api_key: {
443
type: "string",
444
desc: "Optional API key that grants full API access to anything this account can access. Key is of the form 'sk_9QabcrqJFy7JIhvAGih5c6Nb', where the random part is 24 characters (base 62).",
445
unique: true,
446
},
447
sign_up_usage_intent: {
448
type: "string",
449
desc: "What user intended to use CoCalc for at sign up",
450
render: { type: "text" },
451
},
452
lti_id: {
453
type: "array",
454
pg_type: "TEXT[]",
455
desc: "LTI ISS and user ID",
456
},
457
lti_data: {
458
type: "map",
459
desc: "extra information related to LTI",
460
},
461
unlisted: {
462
type: "boolean",
463
desc: "If true then exclude user for full name searches (but not exact email address searches).",
464
render: {
465
type: "boolean",
466
editable: true,
467
},
468
},
469
tags: {
470
type: "array",
471
pg_type: "TEXT[]",
472
desc: "Tags expressing what this user is most interested in doing.",
473
render: { type: "string-tags", editable: true },
474
},
475
tours: {
476
type: "array",
477
pg_type: "TEXT[]",
478
desc: "Tours that user has seen, so once they are here they are hidden from the UI. The special tour 'all' means to disable all tour buttons.",
479
render: { type: "string-tags" },
480
},
481
notes: NOTES,
482
salesloft_id: {
483
type: "integer",
484
desc: "The id of corresponding person in salesloft, if they exist there.",
485
render: {
486
type: "number",
487
integer: true,
488
editable: true,
489
min: 1,
490
},
491
},
492
purchase_closing_day: {
493
type: "integer",
494
desc: "Day of the month when pay-as-you-go purchases are cutoff and charged for this user. It happens at midnight UTC on this day. This should be an integer between 1 and 28.",
495
render: {
496
type: "number",
497
editable: false, // Do NOT change this without going through the reset-closing-date api call...
498
min: 1,
499
max: 28,
500
},
501
},
502
min_balance: {
503
type: "number",
504
pg_type: "REAL",
505
desc: "The minimum allowed balance for this user. This is a quota we impose for safety, not something they set. Admins may change this in response to a support request. For most users this is not set at all hence 0, but for some special enterprise-style customers to whom we extend 'credit', it will be set.",
506
render: {
507
title: "Minimum Allowed Balance (USD)",
508
type: "number",
509
integer: false,
510
editable: true,
511
max: 0,
512
},
513
},
514
balance: {
515
type: "number",
516
pg_type: "REAL",
517
desc: "Last computed balance for this user. NOT a source of truth. Meant to ensure all frontend clients show the same thing. Probably also useful for db queries and maybe analytics.",
518
render: {
519
title: "Account Balance (USD)",
520
type: "number",
521
integer: false,
522
editable: false,
523
},
524
},
525
balance_alert: {
526
type: "boolean",
527
desc: "If true, the UI will very strongly encourage user to open their balance modal.",
528
render: {
529
type: "boolean",
530
editable: true,
531
},
532
},
533
auto_balance: {
534
type: "map",
535
desc: "Determines protocol for automatically adding money to account. This is relevant for pay as you go users. The interface AutoBalance describes the parameters. The user can in theory set this to anything, but ]",
536
},
537
stripe_checkout_session: {
538
type: "map",
539
desc: "Part of the current open stripe checkout session object, namely {id:?, url:?}, but none of the other info. When user is going to add credit to their account, we create a stripe checkout session and store it here until they complete checking out. This makes it possible to guide them back to the checkout session, in case anything goes wrong, and also avoids confusion with potentially multiple checkout sessions at once.",
540
},
541
stripe_usage_subscription: {
542
type: "string",
543
pg_type: "varchar(256)",
544
desc: "Id of this user's stripe metered usage subscription, if they have one.",
545
},
546
email_daily_statements: {
547
type: "boolean",
548
desc: "If true, try to send daily statements to user showing all of their purchases. If false or not set, then do not. NOTE: we always try to email monthly statements to users.",
549
render: {
550
type: "boolean",
551
editable: true,
552
},
553
},
554
owner_id: {
555
type: "uuid",
556
desc: "If one user (owner_id) creates an account for another user via the API, then this records who created the account. They may have special privileges at some point.",
557
render: { type: "account" },
558
title: "Owner",
559
},
560
unread_message_count: {
561
type: "integer",
562
desc: "Number of unread messages in the messages table for this user. This gets updated whenever the messages table for this user gets changed, making it easier to have UI etc when there are unread messages.",
563
render: {
564
type: "number",
565
editable: false,
566
min: 0,
567
},
568
},
569
last_message_summary: {
570
type: "timestamp",
571
desc: "The last time the system sent an email to this user with a summary about new messages (see messages.ts).",
572
},
573
},
574
rules: {
575
desc: "All user accounts.",
576
primary_key: "account_id",
577
// db_standby: "unsafe",
578
pg_indexes: [
579
"(lower(first_name) text_pattern_ops)",
580
"(lower(last_name) text_pattern_ops)",
581
"created_by",
582
"created",
583
"last_active DESC NULLS LAST",
584
"lti_id",
585
"unlisted",
586
"((passports IS NOT NULL))",
587
"((ssh_keys IS NOT NULL))", // used by ssh-gateway to speed up getting all users
588
],
589
crm_indexes: [
590
"(lower(first_name) text_pattern_ops)",
591
"(lower(last_name) text_pattern_ops)",
592
"(lower(email_address) text_pattern_ops)",
593
"created",
594
"last_active DESC NULLS LAST",
595
],
596
pg_unique_indexes: [
597
"api_key", // we use the map api_key --> account_id, so it better be unique
598
"LOWER(name)", // ensure user-assigned name is case sensitive globally unique
599
], // note that we actually require uniqueness across accounts and organizations
600
// and this index is just a step in that direction; full uniquness must be
601
// checked as an extra step.
602
user_query: {
603
get: {
604
throttle_changes: 500,
605
pg_where: [{ "account_id = $::UUID": "account_id" }],
606
fields: {
607
// Exactly what from the below is sync'd by default with the frontend app client is explicitly
608
// listed in frontend/account/table.ts
609
account_id: null,
610
email_address: null,
611
org: null,
612
lti_id: null,
613
stripe_checkout_session: null,
614
email_address_verified: null,
615
email_address_problem: null,
616
editor_settings: {
617
/* NOTE: there is a editor_settings.jupyter = { kernel...} that isn't documented here. */
618
strip_trailing_whitespace: false,
619
show_trailing_whitespace: false,
620
line_wrapping: true,
621
line_numbers: true,
622
jupyter_line_numbers: false,
623
smart_indent: true,
624
electric_chars: true,
625
match_brackets: true,
626
auto_close_brackets: true,
627
code_folding: true,
628
match_xml_tags: true,
629
auto_close_xml_tags: true,
630
auto_close_latex: true,
631
spaces_instead_of_tabs: true,
632
multiple_cursors: true,
633
track_revisions: true,
634
extra_button_bar: true,
635
build_on_save: true,
636
first_line_number: 1,
637
indent_unit: 4,
638
tab_size: 4,
639
bindings: "standard",
640
theme: "default",
641
undo_depth: 300,
642
jupyter_classic: false,
643
jupyter_window: false,
644
disable_jupyter_windowing: true,
645
show_exec_warning: true,
646
physical_keyboard: "default",
647
keyboard_variant: "",
648
ask_jupyter_kernel: true,
649
show_my_other_cursors: false,
650
disable_jupyter_virtualization: true,
651
},
652
other_settings: {
653
katex: true,
654
confirm_close: false,
655
mask_files: false,
656
dim_file_extensions: false,
657
page_size: 500,
658
standby_timeout_m: 15,
659
default_file_sort: "name",
660
[NEW_FILENAMES]: DEFAULT_NEW_FILENAMES,
661
show_global_info2: null,
662
first_steps: true,
663
newsletter: false,
664
time_ago_absolute: false,
665
// if true, do not show warning when using non-member projects
666
no_free_warnings: false,
667
allow_mentions: true,
668
dark_mode: false,
669
dark_mode_brightness: DARK_MODE_DEFAULTS.brightness,
670
dark_mode_contrast: DARK_MODE_DEFAULTS.contrast,
671
dark_mode_sepia: DARK_MODE_DEFAULTS.sepia,
672
news_read_until: 0,
673
hide_project_popovers: false,
674
hide_file_popovers: false,
675
hide_button_tooltips: false,
676
[OTHER_SETTINGS_USER_DEFINED_LLM]: "[]",
677
i18n: DEFAULT_LOCALE,
678
no_email_new_messages: false,
679
[USE_BALANCE_TOWARD_SUBSCRIPTIONS]:
680
USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT,
681
hide_navbar_balance: false,
682
},
683
name: null,
684
first_name: "",
685
last_name: "",
686
terminal: {
687
font_size: DEFAULT_FONT_SIZE,
688
color_scheme: "default",
689
font: "monospace",
690
},
691
autosave: 45,
692
evaluate_key: "Shift-Enter",
693
font_size: DEFAULT_FONT_SIZE,
694
passports: {},
695
groups: [],
696
last_active: null,
697
stripe_customer: null,
698
coupon_history: null,
699
profile: {
700
image: undefined,
701
color: "rgb(170,170,170)",
702
},
703
customize: null,
704
ssh_keys: {},
705
created: null,
706
ephemeral: null,
707
unlisted: false,
708
tags: null,
709
tours: null,
710
min_balance: null,
711
balance: null,
712
balance_alert: null,
713
auto_balance: null,
714
purchase_closing_day: null,
715
stripe_usage_subscription: null,
716
email_daily_statements: null,
717
unread_message_count: null,
718
},
719
},
720
set: {
721
fields: {
722
account_id: "account_id",
723
name: true,
724
editor_settings: true,
725
other_settings: true,
726
first_name: true,
727
last_name: true,
728
terminal: true,
729
autosave: true,
730
evaluate_key: true,
731
font_size: true,
732
profile: true,
733
ssh_keys: true,
734
sign_up_usage_intent: true,
735
unlisted: true,
736
tags: true,
737
tours: true,
738
email_daily_statements: true,
739
// obviously min_balance can't be set!
740
auto_balance: true,
741
},
742
async check_hook(db, obj, account_id, _project_id, cb) {
743
// db is of type PostgreSQL defined in @cocalc/database/postgres/types
744
if (obj["name"] != null) {
745
// NOTE: there is no way to unset/remove a username after one is set...
746
try {
747
checkAccountName(obj["name"]);
748
} catch (err) {
749
cb(err.toString());
750
return;
751
}
752
const id = await db.nameToAccountOrOrganization(obj["name"]);
753
if (id != null && id != account_id) {
754
cb(
755
`name "${obj["name"]}" is already taken by another organization or account`,
756
);
757
return;
758
}
759
}
760
761
// Hook to truncate some text fields to at most 254 characters, to avoid
762
// further trouble down the line.
763
for (const field of ["first_name", "last_name", "email_address"]) {
764
if (obj[field] != null) {
765
obj[field] = obj[field].slice(0, 254);
766
if (field != "email_address" && !obj[field]) {
767
// name fields can't be empty
768
cb(`${field} must be nonempty`);
769
return;
770
}
771
}
772
}
773
774
// Make sure auto_balance is valid.
775
if (obj["auto_balance"] != null) {
776
try {
777
ensureAutoBalanceValid(obj["auto_balance"]);
778
} catch (err) {
779
cb(`${err}`);
780
return;
781
}
782
}
783
784
// if account is exclusively controlled by SSO, you're maybe prohibited from changing account details
785
const current_email_address = await getEmailAddressForAccountId(
786
db,
787
account_id,
788
);
789
//console.log({ current_email_address });
790
if (typeof current_email_address === "string") {
791
const strategies: Strategy[] = await getStrategiesSSO(db);
792
const strategy = checkRequiredSSO({
793
strategies,
794
email: current_email_address,
795
});
796
// we got a required exclusive SSO for the given account_id
797
if (strategy != null) {
798
// if user tries to change email_address (including null/undefined)
799
if (has(obj, "email_address")) {
800
cb(`You are not allowed to change your email address.`);
801
return;
802
}
803
// ... or tries to change first or last name, but strategy has update_on_login set
804
if (
805
strategy.updateOnLogin &&
806
(has(obj, "first_name") || has(obj, "last_name"))
807
) {
808
cb(
809
`You are not allowed to change your first or last name. You have to change it at your single-sign-on provider: ${strategy.display}.`,
810
);
811
return;
812
}
813
}
814
}
815
816
// Validate user-defined LLM configs
817
if (
818
obj["other_settings"]?.[OTHER_SETTINGS_USER_DEFINED_LLM] != null
819
) {
820
try {
821
const configs = JSON.parse(
822
obj["other_settings"][OTHER_SETTINGS_USER_DEFINED_LLM],
823
);
824
ensureUserDefinedLLMValid(configs);
825
// Save back the validated/clamped configs (mutates obj)
826
obj["other_settings"][OTHER_SETTINGS_USER_DEFINED_LLM] =
827
JSON.stringify(configs);
828
} catch (err) {
829
cb(`Invalid user_defined_llm configuration: ${err}`);
830
return;
831
}
832
}
833
834
cb();
835
},
836
},
837
},
838
},
839
});
840
841
export const EDITOR_BINDINGS = {
842
standard: "Standard",
843
sublime: "Sublime",
844
vim: "Vim",
845
emacs: "Emacs",
846
};
847
848
export const EDITOR_COLOR_SCHEMES: { [name: string]: string } = {
849
default: "Default",
850
"3024-day": "3024 day",
851
"3024-night": "3024 night",
852
abcdef: "abcdef",
853
abbott: "Abbott",
854
"ayu-dark": "Ayu dark",
855
"ayu-mirage": "Ayu mirage",
856
//'ambiance-mobile' : 'Ambiance mobile' # doesn't highlight python, confusing
857
ambiance: "Ambiance",
858
"base16-dark": "Base 16 dark",
859
"base16-light": "Base 16 light",
860
bespin: "Bespin",
861
blackboard: "Blackboard",
862
"cocalc-dark": "CoCalc Dark",
863
"cocalc-light": "CoCalc Light",
864
cobalt: "Cobalt",
865
colorforth: "Colorforth",
866
darcula: "Darcula",
867
dracula: "Dracula",
868
"duotone-dark": "Duotone Dark",
869
"duotone-light": "Duotone Light",
870
eclipse: "Eclipse",
871
elegant: "Elegant",
872
"erlang-dark": "Erlang dark",
873
"gruvbox-dark": "Gruvbox-Dark",
874
hopscotch: "Hopscotch",
875
icecoder: "Icecoder",
876
idea: "Idea", // this messes with the global hinter CSS!
877
isotope: "Isotope",
878
juejin: "Juejin",
879
"lesser-dark": "Lesser dark",
880
liquibyte: "Liquibyte",
881
lucario: "Lucario",
882
material: "Material",
883
"material-darker": "Material darker",
884
"material-ocean": "Material ocean",
885
"material-palenight": "Material palenight",
886
mbo: "mbo",
887
"mdn-like": "MDN like",
888
midnight: "Midnight",
889
monokai: "Monokai",
890
neat: "Neat",
891
neo: "Neo",
892
night: "Night",
893
"oceanic-next": "Oceanic next",
894
"panda-syntax": "Panda syntax",
895
"paraiso-dark": "Paraiso dark",
896
"paraiso-light": "Paraiso light",
897
"pastel-on-dark": "Pastel on dark",
898
railscasts: "Railscasts",
899
rubyblue: "Rubyblue",
900
seti: "Seti",
901
shadowfox: "Shadowfox",
902
"solarized dark": "Solarized dark",
903
"solarized light": "Solarized light",
904
ssms: "ssms",
905
"the-matrix": "The Matrix",
906
"tomorrow-night-bright": "Tomorrow Night - Bright",
907
"tomorrow-night-eighties": "Tomorrow Night - Eighties",
908
ttcn: "ttcn",
909
twilight: "Twilight",
910
"vibrant-ink": "Vibrant ink",
911
"xq-dark": "Xq dark",
912
"xq-light": "Xq light",
913
yeti: "Yeti",
914
yonce: "Yonce",
915
zenburn: "Zenburn",
916
};
917
918
Table({
919
name: "crm_accounts",
920
rules: {
921
virtual: "accounts",
922
primary_key: "account_id",
923
user_query: {
924
get: {
925
pg_where: [],
926
admin: true, // only admins can do get queries on this table
927
fields: {
928
...schema.accounts.user_query?.get?.fields,
929
banned: null,
930
groups: null,
931
notes: null,
932
salesloft_id: null,
933
sign_up_usage_intent: null,
934
owner_id: null,
935
deleted: null,
936
},
937
},
938
set: {
939
admin: true, // only admins can do get queries on this table
940
fields: {
941
account_id: true,
942
name: true,
943
first_name: true,
944
last_name: true,
945
autosave: true,
946
font_size: true,
947
banned: true,
948
unlisted: true,
949
notes: true,
950
tags: true,
951
salesloft_id: true,
952
purchase_closing_day: true,
953
min_balance: true, // admins can set this
954
},
955
},
956
},
957
},
958
fields: schema.accounts.fields,
959
});
960
961
Table({
962
name: "crm_agents",
963
rules: {
964
virtual: "accounts",
965
primary_key: "account_id",
966
user_query: {
967
get: {
968
// There where condition restricts to only admin accounts for now.
969
// TODO: Later this will change to 'crm'=any(groups) or something like that.
970
pg_where: ["'admin'=any(groups)"],
971
admin: true, // only admins can do get queries on this table
972
fields: schema.accounts.user_query?.get?.fields ?? {},
973
},
974
},
975
},
976
fields: schema.accounts.fields,
977
});
978
979
interface Tag {
980
label: string;
981
tag: string;
982
language?: string; // language of jupyter kernel
983
icon?: any; // I'm not going to import the IconName type from @cocalc/frontend
984
welcome?: string; // a simple "welcome" of this type
985
jupyterExtra?: string;
986
torun?: string; // how to run this in a terminal (e.g., for a .py file).
987
color?: string;
988
description?: string;
989
}
990
991
// They were used up until 2024-01-05
992
export const TAGS_FEATURES: Tag[] = [
993
{ label: "Jupyter", tag: "ipynb", color: "magenta" },
994
{
995
label: "Python",
996
tag: "py",
997
language: "python",
998
welcome: 'print("Welcome to CoCalc from Python!")',
999
torun: "# Click Terminal, then type 'python3 welcome.py'",
1000
color: "red",
1001
},
1002
{
1003
label: "AI / GPUs",
1004
tag: "gpu",
1005
color: "volcano",
1006
icon: "gpu",
1007
},
1008
{
1009
label: "R Stats",
1010
tag: "R",
1011
language: "r",
1012
welcome: 'print("Welcome to CoCalc from R!")',
1013
torun: "# Click Terminal, then type 'Rscript welcome.R'",
1014
color: "orange",
1015
},
1016
{
1017
label: "SageMath",
1018
tag: "sage",
1019
language: "sagemath",
1020
welcome: "print('Welcome to CoCalc from Sage!', factor(2024))",
1021
torun: "# Click Terminal, then type 'sage welcome.sage'",
1022
color: "gold",
1023
},
1024
{
1025
label: "Octave",
1026
icon: "octave",
1027
tag: "m",
1028
language: "octave",
1029
welcome: `disp("Welcome to CoCalc from Octave!")`,
1030
torun: "% Click Terminal, then type 'octave --no-window-system welcome.m'",
1031
color: "geekblue",
1032
},
1033
{
1034
label: "Linux",
1035
icon: "linux",
1036
tag: "term",
1037
language: "bash",
1038
welcome: "echo 'Welcome to CoCalc from Linux/BASH!'",
1039
color: "green",
1040
},
1041
{
1042
label: "LaTeX",
1043
tag: "tex",
1044
welcome: `\\documentclass{article}
1045
\\title{Welcome to CoCalc from \\LaTeX{}!}
1046
\\begin{document}
1047
\\maketitle
1048
\\end{document}`,
1049
color: "cyan",
1050
},
1051
{
1052
label: "C/C++",
1053
tag: "c",
1054
language: "C++17",
1055
icon: "cube",
1056
welcome: `
1057
#include <stdio.h>
1058
int main() {
1059
printf("Welcome to CoCalc from C!\\n");
1060
return 0;
1061
}`,
1062
jupyterExtra: "\nmain();\n",
1063
torun: "/* Click Terminal, then type 'gcc welcome.c && ./a.out' */",
1064
color: "blue",
1065
},
1066
{
1067
label: "Julia",
1068
language: "julia",
1069
icon: "julia",
1070
tag: "jl",
1071
welcome: 'println("Welcome to CoCalc from Julia!")',
1072
torun: "# Click Terminal, then type 'julia welcome.jl' */",
1073
color: "geekblue",
1074
},
1075
{
1076
label: "Markdown",
1077
tag: "md",
1078
welcome:
1079
"# Welcome to CoCalc from Markdown!\n\nYou can directly edit the rendered markdown -- try it!\n\nAnd run code:\n\n```py\n2+3\n```\n",
1080
color: "purple",
1081
},
1082
// {
1083
// label: "Whiteboard",
1084
// tag: "board",
1085
// welcome: `{"data":{"color":"#252937"},"h":96,"id":"1244fb1f","page":"b7cda7e9","str":"# Welcome to CoCalc from a Whiteboard!\\n\\n","type":"text","w":779,"x":-305,"y":-291,"z":1}
1086
// {"data":{"pos":0},"id":"b7cda7e9","type":"page","z":0}`,
1087
// },
1088
{ label: "Teaching", tag: "course", color: "green" },
1089
];
1090
1091
export const TAG_TO_FEATURE: { [key: string]: Readonly<Tag> } = {};
1092
for (const t of TAGS_FEATURES) {
1093
TAG_TO_FEATURE[t.tag] = t;
1094
}
1095
1096
const professional = "professional";
1097
1098
// Tags specific to user roles or if they want to be contacted
1099
export const TAGS_USERS: Readonly<Tag[]> = [
1100
{
1101
label: "Personal",
1102
tag: "personal",
1103
icon: "user",
1104
description: "You are interesting in using CoCalc for personal use.",
1105
},
1106
{
1107
label: "Professional",
1108
tag: professional,
1109
icon: "coffee",
1110
description: "You are using CoCalc as an employee or freelancer.",
1111
},
1112
{
1113
label: "Instructor",
1114
tag: "instructor",
1115
icon: "graduation-cap",
1116
description: "You are teaching a course.",
1117
},
1118
{
1119
label: "Student",
1120
tag: "student",
1121
icon: "smile",
1122
description: "You are a student in a course.",
1123
},
1124
] as const;
1125
1126
export const TAGS = TAGS_USERS;
1127
1128
export const TAGS_MAP: { [key: string]: Readonly<Tag> } = {};
1129
for (const x of TAGS) {
1130
TAGS_MAP[x.tag] = x;
1131
}
1132
1133
export const CONTACT_TAG = "contact";
1134
export const CONTACT_THESE_TAGS = [professional];
1135
1136
export interface UserSearchResult {
1137
account_id: string;
1138
first_name?: string;
1139
last_name?: string;
1140
name?: string; // "vanity" username
1141
last_active?: number; // ms since epoch -- when account was last active
1142
created?: number; // ms since epoch -- when account created
1143
banned?: boolean; // true if this user has been banned (only set for admin searches, obviously)
1144
email_address_verified?: boolean; // true if their email has been verified (a sign they are more trustworthy).
1145
// For security reasons, the email_address *only* occurs in search queries that
1146
// are by email_address (or for admins); we must not reveal email addresses
1147
// of users queried by substring searches, obviously.
1148
email_address?: string;
1149
}
1150
1151
export const ACCOUNT_ID_COOKIE_NAME = "account_id";
1152
1153