History

Org slugs: mint at insert, drop lazy minting, make the column NOT NULL

The org-slug column was nullable and the runtime self-healed it: every resolveOrganization (and the SSR gate) lazily minted a slug for any row that lacked one. That left a permanent nullable state and a heal-on-read cost. Move slug generation to the single moment a row is inserted — upsertOrganization mints the slug at INSERT (ON CONFLICT DO NOTHING absorbs the id/slug races, retrying with a fresh candidate) and keeps the existing slug on conflict. The three insert sites (createOrganization, acceptInvitation, the WorkOS-fallback upsert in resolveOrganization) all get a slug for free; resolveOrganization and the SSR gate's organizationDisplay no longer heal. The schema column is now NOT NULL (migration 0004), backfilled once for legacy rows. Deploy order matters and is documented in the backfill script: backfill → deploy this code → apply 0004. The old code inserts a NULL slug then heals, so NOT NULL cannot land while it still serves. Also adds e2e/cloud/auth-routing-flow.test.ts: one browser session walking the whole arc — signed out → /login → hosted-AuthKit sign-in → org-less onboarding → create org → canonical /<slug> dashboard — through the real dev-server SSR gate. The hops are covered piecewise elsewhere; this is the narrative regression guard and the only test pinning the bare-/ → /<slug>/ dashboard canonicalization a fresh login lands on.

Rhys Sullivan committed 5 months ago

Commit da0a4cc1 parent 1f9bfe013 files · +1429 −88Browse files

Unified · Split

13 files
  1. apps/cloud/drizzle/0004_nervous_quasar.sql +1 −0
  2. apps/cloud/drizzle/meta/0004_snapshot.json +1242 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/scripts/backfill-org-slugs.ts +11 −8
  5. apps/cloud/src/api/protected-api-key-auth.node.test.ts +1 −6
  6. apps/cloud/src/auth/handlers.ts +5 −5
  7. apps/cloud/src/auth/org-selector-auth.node.test.ts +2 −6
  8. apps/cloud/src/auth/organization.ts +6 −8
  9. apps/cloud/src/auth/ssr-gate.ts +7 −11
  10. apps/cloud/src/auth/user-store.ts +33 −41
  11. apps/cloud/src/db/db.test.ts +35 −0
  12. apps/cloud/src/db/schema.ts +5 −3
  13. e2e/cloud/auth-routing-flow.test.ts +74 −0
apps/cloud/drizzle/0004_nervous_quasar.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "organizations" ALTER COLUMN "slug" SET NOT NULL;
apps/cloud/drizzle/meta/0004_snapshot.json+1242−0
@@ -0,0 +1,1242 @@
1{
2 "id": "1187b7aa-fdd6-412a-bcfb-20d6ea8f8644",
3 "prevId": "80591016-4fb7-47d8-ab60-9a5c8eaea9c8",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": ["account_id"],
64 "columnsTo": ["id"],
65 "onDelete": "cascade",
66 "onUpdate": "no action"
67 },
68 "memberships_organization_id_organizations_id_fk": {
69 "name": "memberships_organization_id_organizations_id_fk",
70 "tableFrom": "memberships",
71 "tableTo": "organizations",
72 "columnsFrom": ["organization_id"],
73 "columnsTo": ["id"],
74 "onDelete": "cascade",
75 "onUpdate": "no action"
76 }
77 },
78 "compositePrimaryKeys": {
79 "memberships_account_id_organization_id_pk": {
80 "name": "memberships_account_id_organization_id_pk",
81 "columns": ["account_id", "organization_id"]
82 }
83 },
84 "uniqueConstraints": {},
85 "policies": {},
86 "checkConstraints": {},
87 "isRLSEnabled": false
88 },
89 "public.organizations": {
90 "name": "organizations",
91 "schema": "",
92 "columns": {
93 "id": {
94 "name": "id",
95 "type": "text",
96 "primaryKey": true,
97 "notNull": true
98 },
99 "name": {
100 "name": "name",
101 "type": "text",
102 "primaryKey": false,
103 "notNull": true
104 },
105 "slug": {
106 "name": "slug",
107 "type": "text",
108 "primaryKey": false,
109 "notNull": true
110 },
111 "created_at": {
112 "name": "created_at",
113 "type": "timestamp with time zone",
114 "primaryKey": false,
115 "notNull": true,
116 "default": "now()"
117 }
118 },
119 "indexes": {
120 "organizations_slug_unique": {
121 "name": "organizations_slug_unique",
122 "columns": [
123 {
124 "expression": "slug",
125 "isExpression": false,
126 "asc": true,
127 "nulls": "last"
128 }
129 ],
130 "isUnique": true,
131 "concurrently": false,
132 "method": "btree",
133 "with": {}
134 }
135 },
136 "foreignKeys": {},
137 "compositePrimaryKeys": {},
138 "uniqueConstraints": {},
139 "policies": {},
140 "checkConstraints": {},
141 "isRLSEnabled": false
142 },
143 "public.blob": {
144 "name": "blob",
145 "schema": "",
146 "columns": {
147 "namespace": {
148 "name": "namespace",
149 "type": "varchar(255)",
150 "primaryKey": false,
151 "notNull": true
152 },
153 "key": {
154 "name": "key",
155 "type": "varchar(255)",
156 "primaryKey": false,
157 "notNull": true
158 },
159 "value": {
160 "name": "value",
161 "type": "text",
162 "primaryKey": false,
163 "notNull": true
164 },
165 "row_id": {
166 "name": "row_id",
167 "type": "varchar(255)",
168 "primaryKey": true,
169 "notNull": true
170 },
171 "id": {
172 "name": "id",
173 "type": "varchar(255)",
174 "primaryKey": false,
175 "notNull": true
176 }
177 },
178 "indexes": {
179 "blob_id_uidx": {
180 "name": "blob_id_uidx",
181 "columns": [
182 {
183 "expression": "id",
184 "isExpression": false,
185 "asc": true,
186 "nulls": "last"
187 }
188 ],
189 "isUnique": true,
190 "concurrently": false,
191 "method": "btree",
192 "with": {}
193 }
194 },
195 "foreignKeys": {},
196 "compositePrimaryKeys": {},
197 "uniqueConstraints": {},
198 "policies": {},
199 "checkConstraints": {},
200 "isRLSEnabled": false
201 },
202 "public.connection": {
203 "name": "connection",
204 "schema": "",
205 "columns": {
206 "integration": {
207 "name": "integration",
208 "type": "varchar(255)",
209 "primaryKey": false,
210 "notNull": true
211 },
212 "name": {
213 "name": "name",
214 "type": "varchar(255)",
215 "primaryKey": false,
216 "notNull": true
217 },
218 "template": {
219 "name": "template",
220 "type": "text",
221 "primaryKey": false,
222 "notNull": true
223 },
224 "provider": {
225 "name": "provider",
226 "type": "text",
227 "primaryKey": false,
228 "notNull": true
229 },
230 "item_ids": {
231 "name": "item_ids",
232 "type": "json",
233 "primaryKey": false,
234 "notNull": true
235 },
236 "identity_label": {
237 "name": "identity_label",
238 "type": "text",
239 "primaryKey": false,
240 "notNull": false
241 },
242 "oauth_client": {
243 "name": "oauth_client",
244 "type": "text",
245 "primaryKey": false,
246 "notNull": false
247 },
248 "oauth_client_owner": {
249 "name": "oauth_client_owner",
250 "type": "text",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "refresh_item_id": {
255 "name": "refresh_item_id",
256 "type": "text",
257 "primaryKey": false,
258 "notNull": false
259 },
260 "expires_at": {
261 "name": "expires_at",
262 "type": "bigint",
263 "primaryKey": false,
264 "notNull": false
265 },
266 "oauth_scope": {
267 "name": "oauth_scope",
268 "type": "text",
269 "primaryKey": false,
270 "notNull": false
271 },
272 "provider_state": {
273 "name": "provider_state",
274 "type": "json",
275 "primaryKey": false,
276 "notNull": false
277 },
278 "created_at": {
279 "name": "created_at",
280 "type": "timestamp",
281 "primaryKey": false,
282 "notNull": true
283 },
284 "updated_at": {
285 "name": "updated_at",
286 "type": "timestamp",
287 "primaryKey": false,
288 "notNull": true
289 },
290 "row_id": {
291 "name": "row_id",
292 "type": "varchar(255)",
293 "primaryKey": true,
294 "notNull": true
295 },
296 "tenant": {
297 "name": "tenant",
298 "type": "varchar(255)",
299 "primaryKey": false,
300 "notNull": true
301 },
302 "owner": {
303 "name": "owner",
304 "type": "varchar(255)",
305 "primaryKey": false,
306 "notNull": true
307 },
308 "subject": {
309 "name": "subject",
310 "type": "varchar(255)",
311 "primaryKey": false,
312 "notNull": true
313 }
314 },
315 "indexes": {
316 "connection_uidx": {
317 "name": "connection_uidx",
318 "columns": [
319 {
320 "expression": "tenant",
321 "isExpression": false,
322 "asc": true,
323 "nulls": "last"
324 },
325 {
326 "expression": "owner",
327 "isExpression": false,
328 "asc": true,
329 "nulls": "last"
330 },
331 {
332 "expression": "subject",
333 "isExpression": false,
334 "asc": true,
335 "nulls": "last"
336 },
337 {
338 "expression": "integration",
339 "isExpression": false,
340 "asc": true,
341 "nulls": "last"
342 },
343 {
344 "expression": "name",
345 "isExpression": false,
346 "asc": true,
347 "nulls": "last"
348 }
349 ],
350 "isUnique": true,
351 "concurrently": false,
352 "method": "btree",
353 "with": {}
354 }
355 },
356 "foreignKeys": {},
357 "compositePrimaryKeys": {},
358 "uniqueConstraints": {},
359 "policies": {},
360 "checkConstraints": {},
361 "isRLSEnabled": false
362 },
363 "public.definition": {
364 "name": "definition",
365 "schema": "",
366 "columns": {
367 "integration": {
368 "name": "integration",
369 "type": "varchar(255)",
370 "primaryKey": false,
371 "notNull": true
372 },
373 "connection": {
374 "name": "connection",
375 "type": "varchar(255)",
376 "primaryKey": false,
377 "notNull": true
378 },
379 "plugin_id": {
380 "name": "plugin_id",
381 "type": "text",
382 "primaryKey": false,
383 "notNull": true
384 },
385 "name": {
386 "name": "name",
387 "type": "text",
388 "primaryKey": false,
389 "notNull": true
390 },
391 "schema": {
392 "name": "schema",
393 "type": "json",
394 "primaryKey": false,
395 "notNull": true
396 },
397 "created_at": {
398 "name": "created_at",
399 "type": "timestamp",
400 "primaryKey": false,
401 "notNull": true
402 },
403 "row_id": {
404 "name": "row_id",
405 "type": "varchar(255)",
406 "primaryKey": true,
407 "notNull": true
408 },
409 "tenant": {
410 "name": "tenant",
411 "type": "varchar(255)",
412 "primaryKey": false,
413 "notNull": true
414 },
415 "owner": {
416 "name": "owner",
417 "type": "varchar(255)",
418 "primaryKey": false,
419 "notNull": true
420 },
421 "subject": {
422 "name": "subject",
423 "type": "varchar(255)",
424 "primaryKey": false,
425 "notNull": true
426 }
427 },
428 "indexes": {
429 "definition_uidx": {
430 "name": "definition_uidx",
431 "columns": [
432 {
433 "expression": "tenant",
434 "isExpression": false,
435 "asc": true,
436 "nulls": "last"
437 },
438 {
439 "expression": "owner",
440 "isExpression": false,
441 "asc": true,
442 "nulls": "last"
443 },
444 {
445 "expression": "subject",
446 "isExpression": false,
447 "asc": true,
448 "nulls": "last"
449 },
450 {
451 "expression": "integration",
452 "isExpression": false,
453 "asc": true,
454 "nulls": "last"
455 },
456 {
457 "expression": "connection",
458 "isExpression": false,
459 "asc": true,
460 "nulls": "last"
461 },
462 {
463 "expression": "name",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 }
468 ],
469 "isUnique": true,
470 "concurrently": false,
471 "method": "btree",
472 "with": {}
473 }
474 },
475 "foreignKeys": {},
476 "compositePrimaryKeys": {},
477 "uniqueConstraints": {},
478 "policies": {},
479 "checkConstraints": {},
480 "isRLSEnabled": false
481 },
482 "public.integration": {
483 "name": "integration",
484 "schema": "",
485 "columns": {
486 "slug": {
487 "name": "slug",
488 "type": "varchar(255)",
489 "primaryKey": false,
490 "notNull": true
491 },
492 "plugin_id": {
493 "name": "plugin_id",
494 "type": "text",
495 "primaryKey": false,
496 "notNull": true
497 },
498 "description": {
499 "name": "description",
500 "type": "text",
501 "primaryKey": false,
502 "notNull": true
503 },
504 "config": {
505 "name": "config",
506 "type": "json",
507 "primaryKey": false,
508 "notNull": false
509 },
510 "can_remove": {
511 "name": "can_remove",
512 "type": "boolean",
513 "primaryKey": false,
514 "notNull": true,
515 "default": true
516 },
517 "can_refresh": {
518 "name": "can_refresh",
519 "type": "boolean",
520 "primaryKey": false,
521 "notNull": true,
522 "default": false
523 },
524 "created_at": {
525 "name": "created_at",
526 "type": "timestamp",
527 "primaryKey": false,
528 "notNull": true
529 },
530 "updated_at": {
531 "name": "updated_at",
532 "type": "timestamp",
533 "primaryKey": false,
534 "notNull": true
535 },
536 "row_id": {
537 "name": "row_id",
538 "type": "varchar(255)",
539 "primaryKey": true,
540 "notNull": true
541 },
542 "tenant": {
543 "name": "tenant",
544 "type": "varchar(255)",
545 "primaryKey": false,
546 "notNull": true
547 }
548 },
549 "indexes": {
550 "integration_uidx": {
551 "name": "integration_uidx",
552 "columns": [
553 {
554 "expression": "tenant",
555 "isExpression": false,
556 "asc": true,
557 "nulls": "last"
558 },
559 {
560 "expression": "slug",
561 "isExpression": false,
562 "asc": true,
563 "nulls": "last"
564 }
565 ],
566 "isUnique": true,
567 "concurrently": false,
568 "method": "btree",
569 "with": {}
570 }
571 },
572 "foreignKeys": {},
573 "compositePrimaryKeys": {},
574 "uniqueConstraints": {},
575 "policies": {},
576 "checkConstraints": {},
577 "isRLSEnabled": false
578 },
579 "public.oauth_client": {
580 "name": "oauth_client",
581 "schema": "",
582 "columns": {
583 "slug": {
584 "name": "slug",
585 "type": "varchar(255)",
586 "primaryKey": false,
587 "notNull": true
588 },
589 "authorization_url": {
590 "name": "authorization_url",
591 "type": "text",
592 "primaryKey": false,
593 "notNull": true
594 },
595 "token_url": {
596 "name": "token_url",
597 "type": "text",
598 "primaryKey": false,
599 "notNull": true
600 },
601 "grant": {
602 "name": "grant",
603 "type": "text",
604 "primaryKey": false,
605 "notNull": true
606 },
607 "client_id": {
608 "name": "client_id",
609 "type": "text",
610 "primaryKey": false,
611 "notNull": true
612 },
613 "client_secret_item_id": {
614 "name": "client_secret_item_id",
615 "type": "text",
616 "primaryKey": false,
617 "notNull": false
618 },
619 "resource": {
620 "name": "resource",
621 "type": "text",
622 "primaryKey": false,
623 "notNull": false
624 },
625 "origin_kind": {
626 "name": "origin_kind",
627 "type": "text",
628 "primaryKey": false,
629 "notNull": false
630 },
631 "origin_integration": {
632 "name": "origin_integration",
633 "type": "text",
634 "primaryKey": false,
635 "notNull": false
636 },
637 "created_at": {
638 "name": "created_at",
639 "type": "timestamp",
640 "primaryKey": false,
641 "notNull": true
642 },
643 "row_id": {
644 "name": "row_id",
645 "type": "varchar(255)",
646 "primaryKey": true,
647 "notNull": true
648 },
649 "tenant": {
650 "name": "tenant",
651 "type": "varchar(255)",
652 "primaryKey": false,
653 "notNull": true
654 },
655 "owner": {
656 "name": "owner",
657 "type": "varchar(255)",
658 "primaryKey": false,
659 "notNull": true
660 },
661 "subject": {
662 "name": "subject",
663 "type": "varchar(255)",
664 "primaryKey": false,
665 "notNull": true
666 }
667 },
668 "indexes": {
669 "oauth_client_uidx": {
670 "name": "oauth_client_uidx",
671 "columns": [
672 {
673 "expression": "tenant",
674 "isExpression": false,
675 "asc": true,
676 "nulls": "last"
677 },
678 {
679 "expression": "owner",
680 "isExpression": false,
681 "asc": true,
682 "nulls": "last"
683 },
684 {
685 "expression": "subject",
686 "isExpression": false,
687 "asc": true,
688 "nulls": "last"
689 },
690 {
691 "expression": "slug",
692 "isExpression": false,
693 "asc": true,
694 "nulls": "last"
695 }
696 ],
697 "isUnique": true,
698 "concurrently": false,
699 "method": "btree",
700 "with": {}
701 }
702 },
703 "foreignKeys": {},
704 "compositePrimaryKeys": {},
705 "uniqueConstraints": {},
706 "policies": {},
707 "checkConstraints": {},
708 "isRLSEnabled": false
709 },
710 "public.oauth_session": {
711 "name": "oauth_session",
712 "schema": "",
713 "columns": {
714 "state": {
715 "name": "state",
716 "type": "varchar(255)",
717 "primaryKey": false,
718 "notNull": true
719 },
720 "client_slug": {
721 "name": "client_slug",
722 "type": "text",
723 "primaryKey": false,
724 "notNull": true
725 },
726 "integration": {
727 "name": "integration",
728 "type": "text",
729 "primaryKey": false,
730 "notNull": true
731 },
732 "name": {
733 "name": "name",
734 "type": "text",
735 "primaryKey": false,
736 "notNull": true
737 },
738 "template": {
739 "name": "template",
740 "type": "text",
741 "primaryKey": false,
742 "notNull": true
743 },
744 "redirect_url": {
745 "name": "redirect_url",
746 "type": "text",
747 "primaryKey": false,
748 "notNull": true
749 },
750 "pkce_verifier": {
751 "name": "pkce_verifier",
752 "type": "text",
753 "primaryKey": false,
754 "notNull": false
755 },
756 "identity_label": {
757 "name": "identity_label",
758 "type": "text",
759 "primaryKey": false,
760 "notNull": false
761 },
762 "payload": {
763 "name": "payload",
764 "type": "json",
765 "primaryKey": false,
766 "notNull": true
767 },
768 "expires_at": {
769 "name": "expires_at",
770 "type": "bigint",
771 "primaryKey": false,
772 "notNull": true
773 },
774 "created_at": {
775 "name": "created_at",
776 "type": "timestamp",
777 "primaryKey": false,
778 "notNull": true
779 },
780 "row_id": {
781 "name": "row_id",
782 "type": "varchar(255)",
783 "primaryKey": true,
784 "notNull": true
785 },
786 "tenant": {
787 "name": "tenant",
788 "type": "varchar(255)",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "owner": {
793 "name": "owner",
794 "type": "varchar(255)",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "subject": {
799 "name": "subject",
800 "type": "varchar(255)",
801 "primaryKey": false,
802 "notNull": true
803 }
804 },
805 "indexes": {
806 "oauth_session_uidx": {
807 "name": "oauth_session_uidx",
808 "columns": [
809 {
810 "expression": "tenant",
811 "isExpression": false,
812 "asc": true,
813 "nulls": "last"
814 },
815 {
816 "expression": "state",
817 "isExpression": false,
818 "asc": true,
819 "nulls": "last"
820 }
821 ],
822 "isUnique": true,
823 "concurrently": false,
824 "method": "btree",
825 "with": {}
826 }
827 },
828 "foreignKeys": {},
829 "compositePrimaryKeys": {},
830 "uniqueConstraints": {},
831 "policies": {},
832 "checkConstraints": {},
833 "isRLSEnabled": false
834 },
835 "public.plugin_storage": {
836 "name": "plugin_storage",
837 "schema": "",
838 "columns": {
839 "plugin_id": {
840 "name": "plugin_id",
841 "type": "varchar(255)",
842 "primaryKey": false,
843 "notNull": true
844 },
845 "collection": {
846 "name": "collection",
847 "type": "varchar(255)",
848 "primaryKey": false,
849 "notNull": true
850 },
851 "key": {
852 "name": "key",
853 "type": "varchar(255)",
854 "primaryKey": false,
855 "notNull": true
856 },
857 "data": {
858 "name": "data",
859 "type": "json",
860 "primaryKey": false,
861 "notNull": true
862 },
863 "created_at": {
864 "name": "created_at",
865 "type": "timestamp",
866 "primaryKey": false,
867 "notNull": true
868 },
869 "updated_at": {
870 "name": "updated_at",
871 "type": "timestamp",
872 "primaryKey": false,
873 "notNull": true
874 },
875 "row_id": {
876 "name": "row_id",
877 "type": "varchar(255)",
878 "primaryKey": true,
879 "notNull": true
880 },
881 "tenant": {
882 "name": "tenant",
883 "type": "varchar(255)",
884 "primaryKey": false,
885 "notNull": true
886 },
887 "owner": {
888 "name": "owner",
889 "type": "varchar(255)",
890 "primaryKey": false,
891 "notNull": true
892 },
893 "subject": {
894 "name": "subject",
895 "type": "varchar(255)",
896 "primaryKey": false,
897 "notNull": true
898 }
899 },
900 "indexes": {
901 "plugin_storage_uidx": {
902 "name": "plugin_storage_uidx",
903 "columns": [
904 {
905 "expression": "tenant",
906 "isExpression": false,
907 "asc": true,
908 "nulls": "last"
909 },
910 {
911 "expression": "owner",
912 "isExpression": false,
913 "asc": true,
914 "nulls": "last"
915 },
916 {
917 "expression": "subject",
918 "isExpression": false,
919 "asc": true,
920 "nulls": "last"
921 },
922 {
923 "expression": "plugin_id",
924 "isExpression": false,
925 "asc": true,
926 "nulls": "last"
927 },
928 {
929 "expression": "collection",
930 "isExpression": false,
931 "asc": true,
932 "nulls": "last"
933 },
934 {
935 "expression": "key",
936 "isExpression": false,
937 "asc": true,
938 "nulls": "last"
939 }
940 ],
941 "isUnique": true,
942 "concurrently": false,
943 "method": "btree",
944 "with": {}
945 }
946 },
947 "foreignKeys": {},
948 "compositePrimaryKeys": {},
949 "uniqueConstraints": {},
950 "policies": {},
951 "checkConstraints": {},
952 "isRLSEnabled": false
953 },
954 "public.private_executor_cloud_settings": {
955 "name": "private_executor_cloud_settings",
956 "schema": "",
957 "columns": {
958 "id": {
959 "name": "id",
960 "type": "varchar(255)",
961 "primaryKey": true,
962 "notNull": true
963 },
964 "version": {
965 "name": "version",
966 "type": "varchar(255)",
967 "primaryKey": false,
968 "notNull": true,
969 "default": "'1.0.0'"
970 }
971 },
972 "indexes": {},
973 "foreignKeys": {},
974 "compositePrimaryKeys": {},
975 "uniqueConstraints": {},
976 "policies": {},
977 "checkConstraints": {},
978 "isRLSEnabled": false
979 },
980 "public.tool": {
981 "name": "tool",
982 "schema": "",
983 "columns": {
984 "integration": {
985 "name": "integration",
986 "type": "varchar(255)",
987 "primaryKey": false,
988 "notNull": true
989 },
990 "connection": {
991 "name": "connection",
992 "type": "varchar(255)",
993 "primaryKey": false,
994 "notNull": true
995 },
996 "plugin_id": {
997 "name": "plugin_id",
998 "type": "text",
999 "primaryKey": false,
1000 "notNull": true
1001 },
1002 "name": {
1003 "name": "name",
1004 "type": "varchar(255)",
1005 "primaryKey": false,
1006 "notNull": true
1007 },
1008 "description": {
1009 "name": "description",
1010 "type": "text",
1011 "primaryKey": false,
1012 "notNull": true
1013 },
1014 "input_schema": {
1015 "name": "input_schema",
1016 "type": "json",
1017 "primaryKey": false,
1018 "notNull": false
1019 },
1020 "output_schema": {
1021 "name": "output_schema",
1022 "type": "json",
1023 "primaryKey": false,
1024 "notNull": false
1025 },
1026 "annotations": {
1027 "name": "annotations",
1028 "type": "json",
1029 "primaryKey": false,
1030 "notNull": false
1031 },
1032 "created_at": {
1033 "name": "created_at",
1034 "type": "timestamp",
1035 "primaryKey": false,
1036 "notNull": true
1037 },
1038 "updated_at": {
1039 "name": "updated_at",
1040 "type": "timestamp",
1041 "primaryKey": false,
1042 "notNull": true
1043 },
1044 "row_id": {
1045 "name": "row_id",
1046 "type": "varchar(255)",
1047 "primaryKey": true,
1048 "notNull": true
1049 },
1050 "tenant": {
1051 "name": "tenant",
1052 "type": "varchar(255)",
1053 "primaryKey": false,
1054 "notNull": true
1055 },
1056 "owner": {
1057 "name": "owner",
1058 "type": "varchar(255)",
1059 "primaryKey": false,
1060 "notNull": true
1061 },
1062 "subject": {
1063 "name": "subject",
1064 "type": "varchar(255)",
1065 "primaryKey": false,
1066 "notNull": true
1067 }
1068 },
1069 "indexes": {
1070 "tool_uidx": {
1071 "name": "tool_uidx",
1072 "columns": [
1073 {
1074 "expression": "tenant",
1075 "isExpression": false,
1076 "asc": true,
1077 "nulls": "last"
1078 },
1079 {
1080 "expression": "owner",
1081 "isExpression": false,
1082 "asc": true,
1083 "nulls": "last"
1084 },
1085 {
1086 "expression": "subject",
1087 "isExpression": false,
1088 "asc": true,
1089 "nulls": "last"
1090 },
1091 {
1092 "expression": "integration",
1093 "isExpression": false,
1094 "asc": true,
1095 "nulls": "last"
1096 },
1097 {
1098 "expression": "connection",
1099 "isExpression": false,
1100 "asc": true,
1101 "nulls": "last"
1102 },
1103 {
1104 "expression": "name",
1105 "isExpression": false,
1106 "asc": true,
1107 "nulls": "last"
1108 }
1109 ],
1110 "isUnique": true,
1111 "concurrently": false,
1112 "method": "btree",
1113 "with": {}
1114 }
1115 },
1116 "foreignKeys": {},
1117 "compositePrimaryKeys": {},
1118 "uniqueConstraints": {},
1119 "policies": {},
1120 "checkConstraints": {},
1121 "isRLSEnabled": false
1122 },
1123 "public.tool_policy": {
1124 "name": "tool_policy",
1125 "schema": "",
1126 "columns": {
1127 "id": {
1128 "name": "id",
1129 "type": "varchar(255)",
1130 "primaryKey": false,
1131 "notNull": true
1132 },
1133 "pattern": {
1134 "name": "pattern",
1135 "type": "text",
1136 "primaryKey": false,
1137 "notNull": true
1138 },
1139 "action": {
1140 "name": "action",
1141 "type": "text",
1142 "primaryKey": false,
1143 "notNull": true
1144 },
1145 "position": {
1146 "name": "position",
1147 "type": "text",
1148 "primaryKey": false,
1149 "notNull": true
1150 },
1151 "created_at": {
1152 "name": "created_at",
1153 "type": "timestamp",
1154 "primaryKey": false,
1155 "notNull": true
1156 },
1157 "updated_at": {
1158 "name": "updated_at",
1159 "type": "timestamp",
1160 "primaryKey": false,
1161 "notNull": true
1162 },
1163 "row_id": {
1164 "name": "row_id",
1165 "type": "varchar(255)",
1166 "primaryKey": true,
1167 "notNull": true
1168 },
1169 "tenant": {
1170 "name": "tenant",
1171 "type": "varchar(255)",
1172 "primaryKey": false,
1173 "notNull": true
1174 },
1175 "owner": {
1176 "name": "owner",
1177 "type": "varchar(255)",
1178 "primaryKey": false,
1179 "notNull": true
1180 },
1181 "subject": {
1182 "name": "subject",
1183 "type": "varchar(255)",
1184 "primaryKey": false,
1185 "notNull": true
1186 }
1187 },
1188 "indexes": {
1189 "tool_policy_uidx": {
1190 "name": "tool_policy_uidx",
1191 "columns": [
1192 {
1193 "expression": "tenant",
1194 "isExpression": false,
1195 "asc": true,
1196 "nulls": "last"
1197 },
1198 {
1199 "expression": "owner",
1200 "isExpression": false,
1201 "asc": true,
1202 "nulls": "last"
1203 },
1204 {
1205 "expression": "subject",
1206 "isExpression": false,
1207 "asc": true,
1208 "nulls": "last"
1209 },
1210 {
1211 "expression": "id",
1212 "isExpression": false,
1213 "asc": true,
1214 "nulls": "last"
1215 }
1216 ],
1217 "isUnique": true,
1218 "concurrently": false,
1219 "method": "btree",
1220 "with": {}
1221 }
1222 },
1223 "foreignKeys": {},
1224 "compositePrimaryKeys": {},
1225 "uniqueConstraints": {},
1226 "policies": {},
1227 "checkConstraints": {},
1228 "isRLSEnabled": false
1229 }
1230 },
1231 "enums": {},
1232 "schemas": {},
1233 "sequences": {},
1234 "roles": {},
1235 "policies": {},
1236 "views": {},
1237 "_meta": {
1238 "columns": {},
1239 "schemas": {},
1240 "tables": {}
1241 }
1242}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -29,6 +29,13 @@ · 28 unmodified lines
29 "when": 1781283644753,29 "when": 1781283644753,
30 "tag": "0003_friendly_monster_badoon",30 "tag": "0003_friendly_monster_badoon",
31 "breakpoints": true31 "breakpoints": true
32 },
33 {
34 "idx": 4,
35 "version": "7",
36 "when": 1781375837041,
37 "tag": "0004_nervous_quasar",
38 "breakpoints": true
32 }39 }
33 ]40 ]
34}41}
apps/cloud/scripts/backfill-org-slugs.ts+11−8
@@ -1,16 +1,19 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */
2// ---------------------------------------------------------------------------2// ---------------------------------------------------------------------------
3// One-off data backfill: mint URL slugs for organizations that predate the3// One-off data backfill: mint URL slugs for organizations that predate the
4// slug column. Run OUT-OF-BAND against the database after applying the4// slug column (added by migration 0003, nullable).
5// schema migration that adds `organizations.slug` (0002):
6//5//
7// bun run db:backfill-org-slugs:prod # op run --env-file=.env.production6// bun run db:backfill-org-slugs:prod # op run --env-file=.env.production
8// bun run db:backfill-org-slugs:dev # against the local PGlite dev db7// bun run db:backfill-org-slugs:dev # against the local PGlite dev db
9//8//
10// Idempotent — already-slugged rows are skipped, so re-running is safe. The9// Idempotent — already-slugged rows are skipped, so re-running is safe.
11// runtime also lazily mints slugs on first resolution (see10//
12// auth/organization.ts), so this backfill is belt-and-braces for bulk11// Ordering matters: the runtime now mints a slug at the moment a row is
13// pre-warming rather than a deploy gate.12// inserted and NO LONGER self-heals null slugs, and migration 0004 makes the
13// column NOT NULL. So this backfill is the SOLE way pre-existing null-slug rows
14// get a slug, and it MUST run BEFORE 0004 is applied (a null row would fail the
15// NOT NULL alter). The correct sequence is: backfill (this script) → deploy the
16// mint-at-insert code → apply 0004.
14// Pass --dry-run to print the plan without writing.17// Pass --dry-run to print the plan without writing.
15// ---------------------------------------------------------------------------18// ---------------------------------------------------------------------------
16 19
@@ -47,8 +50,8 @@ for (const row of rows) { · 30 unmodified lines
47 planned.add(slug);50 planned.add(slug);
48 console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`);51 console.log(`${row.id} ${JSON.stringify(row.name)} -> ${slug}`);
49 if (dryRun) continue;52 if (dryRun) continue;
50 // Guard WHERE slug IS NULL: a concurrently-deployed runtime may have lazily53 // Guard WHERE slug IS NULL keeps re-runs idempotent (and tolerates a row that
51 // minted this org's slug between our read and this write.54 // gained a slug between our read and this write).
52 await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`;55 await sql`UPDATE organizations SET slug = ${slug} WHERE id = ${row.id} AND slug IS NULL`;
53 updated += 1;56 updated += 1;
54}57}
apps/cloud/src/api/protected-api-key-auth.node.test.ts+1−6
@@ -50,7 +50,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 49 unmodified lines
50 getAccount: async (id: string) => ({ id, createdAt }),50 getAccount: async (id: string) => ({ id, createdAt }),
51 upsertOrganization: async (org: { id: string; name: string }) => ({51 upsertOrganization: async (org: { id: string; name: string }) => ({
52 ...org,52 ...org,
53 slug: null,53 slug: `org-slug-${org.id}`,
54 createdAt,54 createdAt,
55 }),55 }),
56 getOrganization: async (id: string) => ({56 getOrganization: async (id: string) => ({
@@ -65,11 +65,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 8 unmodified lines
65 slug,65 slug,
66 createdAt,66 createdAt,
67 }),67 }),
68 ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({
69 ...org,
70 slug: org.slug ?? `org-slug-${org.id}`,
71 createdAt,
72 }),
73 }),68 }),
74 ),69 ),
75});70});
apps/cloud/src/auth/handlers.ts+5−5
@@ -342,10 +342,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 341 unmodified lines
342 342
343 const org = yield* workos.createOrganization(name);343 const org = yield* workos.createOrganization(name);
344 yield* workos.createMembership(org.id, session.accountId, "admin");344 yield* workos.createMembership(org.id, session.accountId, "admin");
345 // `upsertOrganization` mints the slug at insert — no separate heal step.
345 const mirrored = yield* users.use((s) =>346 const mirrored = yield* users.use((s) =>
346 s.upsertOrganization({ id: org.id, name: org.name }),347 s.upsertOrganization({ id: org.id, name: org.name }),
347 );348 );
348 const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored));
349 349
350 // Try to attach the new org to the current session. This can fail350 // Try to attach the new org to the current session. This can fail
351 // (or silently return a session still scoped to the old org) when351 // (or silently return a session still scoped to the old org) when
@@ -372,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 20 unmodified lines
372 }372 }
373 373
374 (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);374 (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
375 return { id: org.id, name: org.name, slug: slugged.slug };375 return { id: org.id, name: org.name, slug: mirrored.slug };
376 }),376 }),
377 )377 )
378 .handle("pendingInvitations", () =>378 .handle("pendingInvitations", () =>
@@ -441,12 +441,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 62 unmodified lines
441 return yield* new WorkOSError();441 return yield* new WorkOSError();
442 }442 }
443 443
444 // Mirror the org locally so domain tables can FK against it.444 // Mirror the org locally so domain tables can FK against it; the
445 // upsert mints the slug at insert — no separate heal step.
445 const org = yield* workos.getOrganization(invitation.organizationId);446 const org = yield* workos.getOrganization(invitation.organizationId);
446 const mirrored = yield* users.use((s) =>447 const mirrored = yield* users.use((s) =>
447 s.upsertOrganization({ id: org.id, name: org.name }),448 s.upsertOrganization({ id: org.id, name: org.name }),
448 );449 );
449 const slugged = yield* users.use((s) => s.ensureOrganizationSlug(mirrored));
450 450
451 // Attach the just-accepted org to the current session. Same shape451 // Attach the just-accepted org to the current session. Same shape
452 // as createOrganization: refresh + verify; if we can't pin the452 // as createOrganization: refresh + verify; if we can't pin the
@@ -468,7 +468,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
468 }468 }
469 469
470 (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);470 (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS);
471 return { id: org.id, name: org.name, slug: slugged.slug };471 return { id: org.id, name: org.name, slug: mirrored.slug };
472 }),472 }),
473 )473 )
474 .handle("getMcpPaused", ({ params }) =>474 .handle("getMcpPaused", ({ params }) =>
apps/cloud/src/auth/org-selector-auth.node.test.ts+2−6
@@ -60,9 +60,10 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 59 unmodified lines
60 fn({60 fn({
61 ensureAccount: async (id: string) => ({ id, createdAt }),61 ensureAccount: async (id: string) => ({ id, createdAt }),
62 getAccount: async (id: string) => ({ id, createdAt }),62 getAccount: async (id: string) => ({ id, createdAt }),
63 // Slug is minted at insert now — the stub returns a slugged row.
63 upsertOrganization: async (org: { id: string; name: string }) => ({64 upsertOrganization: async (org: { id: string; name: string }) => ({
64 ...org,65 ...org,
65 slug: null,66 slug: org.id,
66 createdAt,67 createdAt,
67 }),68 }),
68 getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),69 getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }),
@@ -74,11 +75,6 @@ const stubUsers = Layer.succeed(UserStoreService)({ · 5 unmodified lines
74 slug,75 slug,
75 createdAt,76 createdAt,
76 }),77 }),
77 ensureOrganizationSlug: async (org: { id: string; name: string; slug: string | null }) => ({
78 ...org,
79 slug: org.slug ?? org.id,
80 createdAt,
81 }),
82 }),78 }),
83 ),79 ),
84});80});
apps/cloud/src/auth/organization.ts+6−8
@@ -29,22 +29,20 @@ import { WorkOSClient } from "./workos"; · 28 unmodified lines
29// unknown org is read. All other callers just do `getOrganization` and get29// unknown org is read. All other callers just do `getOrganization` and get
30// a self-healing lookup for free.30// a self-healing lookup for free.
31//31//
32// URL slugs are OURS (WorkOS orgs have none): resolution also lazily mints a32// URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a
33// slug for any org that predates them, so every resolved org is routable at33// row is inserted — `upsertOrganization` is the single mint point, so the
34// `/<slug>/…` without a separate backfill dependency.34// mirror-on-first-read below produces a slugged, routable org without any
35// read-path healing.
35 36
36export const resolveOrganization = (organizationId: string) =>37export const resolveOrganization = (organizationId: string) =>
37 Effect.gen(function* () {38 Effect.gen(function* () {
38 const users = yield* UserStoreService;39 const users = yield* UserStoreService;
39 const existing = yield* users.use((s) => s.getOrganization(organizationId));40 const existing = yield* users.use((s) => s.getOrganization(organizationId));
40 if (existing) return yield* users.use((s) => s.ensureOrganizationSlug(existing));41 if (existing) return existing;
41 42
42 const workos = yield* WorkOSClient;43 const workos = yield* WorkOSClient;
43 const fresh = yield* workos.getOrganization(organizationId);44 const fresh = yield* workos.getOrganization(organizationId);
44 const mirrored = yield* users.use((s) =>45 return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name }));
45 s.upsertOrganization({ id: fresh.id, name: fresh.name }),
46 );
47 return yield* users.use((s) => s.ensureOrganizationSlug(mirrored));
48 });46 });
49 47
50// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
apps/cloud/src/auth/ssr-gate.ts+7−11
@@ -139,22 +139,18 @@ const resolveAuthHint = async ( · 138 unmodified lines
139};139};
140 140
141// The sealed session carries the org ID but not its name/slug; the local141// The sealed session carries the org ID but not its name/slug; the local
142// mirror has both (the slug minted lazily for orgs that predate slugs). Only142// mirror has both (every org row is born with a slug — see
143// consulted when minting (absent/mismatched hint) — never on the steady-state143// `upsertOrganization`). Only consulted when minting the hint (absent/mismatched
144// path — and over per-request layers, because a connection cached in the144// cookie) — never on the steady-state path — and over per-request layers,
145// shared runtime would be reused across requests, which Cloudflare forbids. A145// because a connection cached in the shared runtime would be reused across
146// miss or failure reads as empty strings — display-only, corrected by the146// requests, which Cloudflare forbids. A miss or failure reads as empty strings
147// client's /account/me write.147// — display-only, corrected by the client's /account/me write.
148const organizationDisplay = async (148const organizationDisplay = async (
149 organizationId: string,149 organizationId: string,
150): Promise<{ name: string; slug: string }> => {150): Promise<{ name: string; slug: string }> => {
151 const exit = await getRuntime().runPromiseExit(151 const exit = await getRuntime().runPromiseExit(
152 Effect.flatMap(UserStoreService.asEffect(), (users) =>152 Effect.flatMap(UserStoreService.asEffect(), (users) =>
153 users.use(async (store) => {153 users.use((store) => store.getOrganization(organizationId)),
154 const org = await store.getOrganization(organizationId);
155 if (!org) return null;
156 return store.ensureOrganizationSlug(org);
157 }),
158 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),154 ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))),
159 );155 );
160 return Exit.isSuccess(exit)156 return Exit.isSuccess(exit)
apps/cloud/src/auth/user-store.ts+33−41
@@ -17,9 +17,6 @@ import type { DrizzleDb } from "../db/db"; · 16 unmodified lines
17export type Account = typeof accounts.$inferSelect;17export type Account = typeof accounts.$inferSelect;
18export type Organization = typeof organizations.$inferSelect;18export type Organization = typeof organizations.$inferSelect;
19 19
20/** An organization row whose slug has been minted (see `ensureOrganizationSlug`). */
21export type SluggedOrganization = Organization & { readonly slug: string };
22
23export const makeUserStore = (db: DrizzleDb) => {20export const makeUserStore = (db: DrizzleDb) => {
24 const getOrganization = async (id: string) => {21 const getOrganization = async (id: string) => {
25 const rows = await db.select().from(organizations).where(eq(organizations.id, id));22 const rows = await db.select().from(organizations).where(eq(organizations.id, id));
@@ -34,38 +31,45 @@ export const makeUserStore = (db: DrizzleDb) => { · 8 unmodified lines
34 return rows.length > 0;31 return rows.length > 0;
35 };32 };
36 33
37 // The unique index can reject a candidate when two requests race to mint34 // Insert a brand-new org row carrying a freshly-minted slug. `ON CONFLICT DO
38 // slugs (for the same org or colliding candidates); surface that as null35 // NOTHING` (no target) absorbs BOTH unique violations without throwing: an
39 // and let the caller re-read or retry.36 // id collision (the org was mirrored concurrently) and a slug collision (the
40 const trySetSlug = async (organizationId: string, slug: string) => {37 // candidate was claimed by a different org). Returns the inserted row, or
41 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a unique-index violation from a concurrent mint is an expected race, not a failure38 // null when either conflict swallowed the insert — the caller decides whether
42 try {39 // to re-read (id race) or retry with a new candidate (slug race).
40 const tryInsertOrg = async (id: string, name: string, slug: string) => {
41 const [row] = await db
42 .insert(organizations)
43 .values({ id, name, slug })
44 .onConflictDoNothing()
45 .returning();
46 return row ?? null;
47 };
48
49 // Every new org row is born with a slug — there is no nullable window and no
50 // self-healing. Existing rows keep their slug (stable across renames, so org
51 // URLs survive) and only refresh their name.
52 const upsertOrganization = async (org: { id: string; name: string }) => {
53 const existing = await getOrganization(org.id);
54 if (existing) {
43 const [updated] = await db55 const [updated] = await db
44 .update(organizations)56 .update(organizations)
45 .set({ slug })57 .set({ name: org.name })
46 .where(eq(organizations.id, organizationId))58 .where(eq(organizations.id, org.id))
47 .returning();59 .returning();
48 return updated ?? null;60 return updated ?? existing;
49 } catch {
50 return null;
51 }61 }
52 };62 for (let attempt = 0; attempt < 4; attempt++) {
53
54 // Mint and persist a slug for an org that predates slugs (or was mirrored
55 // before its name was known). Slugs are stable once set — renames do NOT
56 // regenerate them, so org URLs survive.
57 const ensureOrganizationSlug = async (org: Organization): Promise<SluggedOrganization> => {
58 if (org.slug) return org as SluggedOrganization;
59 for (let attempt = 0; attempt < 3; attempt++) {
60 const slug = await generateOrgSlug(org.name, slugTaken);63 const slug = await generateOrgSlug(org.name, slugTaken);
61 const updated = await trySetSlug(org.id, slug);64 const inserted = await tryInsertOrg(org.id, org.name, slug);
62 if (updated?.slug) return updated as SluggedOrganization;65 if (inserted) return inserted;
63 // Lost a race: either another request minted this org's slug (re-read66 // The insert was swallowed by a conflict. If the id now exists, a
64 // and return), or the candidate got claimed by a different org (retry).67 // concurrent request mirrored it — return that row. Otherwise the slug
68 // candidate collided; loop and mint a fresh one.
65 const fresh = await getOrganization(org.id);69 const fresh = await getOrganization(org.id);
66 if (fresh?.slug) return fresh as SluggedOrganization;70 if (fresh) return fresh;
67 }71 }
68 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats silently unslugged orgs72 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats a silently unslugged org
69 throw new Error(`unable to mint a slug for organization ${org.id}`);73 throw new Error(`unable to mint a slug for organization ${org.id}`);
70 };74 };
71 75
@@ -84,17 +88,7 @@ export const makeUserStore = (db: DrizzleDb) => { · 12 unmodified lines
84 88
85 // --- Organizations ---89 // --- Organizations ---
86 90
87 upsertOrganization: async (org: { id: string; name: string }) => {91 upsertOrganization,
88 const [result] = await db
89 .insert(organizations)
90 .values(org)
91 .onConflictDoUpdate({
92 target: organizations.id,
93 set: { name: org.name },
94 })
95 .returning();
96 return result!;
97 },
98 92
99 getOrganization,93 getOrganization,
100 94
@@ -102,7 +96,5 @@ export const makeUserStore = (db: DrizzleDb) => { · 1 unmodified line
102 const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));96 const rows = await db.select().from(organizations).where(eq(organizations.slug, slug));
103 return rows[0] ?? null;97 return rows[0] ?? null;
104 },98 },
105
106 ensureOrganizationSlug,
107 };99 };
108};100};
apps/cloud/src/db/db.test.ts+35−0
@@ -22,6 +22,7 @@ · 21 unmodified lines
22 22
23import { describe, it, expect } from "@effect/vitest";23import { describe, it, expect } from "@effect/vitest";
24import { Effect, Layer } from "effect";24import { Effect, Layer } from "effect";
25import { isValidOrgSlug } from "@executor-js/api";
25 26
26import { DbService } from "./db";27import { DbService } from "./db";
27import { makeUserStore } from "../auth/user-store";28import { makeUserStore } from "../auth/user-store";
@@ -118,3 +119,37 @@ describe("DbService", () => { · 90 unmodified lines
118 expect(result?.name).toBe("Acme");119 expect(result?.name).toBe("Acme");
119 }, 15_000);120 }, 15_000);
120});121});
122
123describe("upsertOrganization · slug is minted at insert", () => {
124 const upsert = (org: { id: string; name: string }) =>
125 program(
126 Effect.gen(function* () {
127 const { db } = yield* DbService;
128 return yield* Effect.promise(() => makeUserStore(db).upsertOrganization(org));
129 }),
130 );
131
132 it("mints a valid slug on insert", async () => {
133 const org = await upsert({ id: `org_${crypto.randomUUID()}`, name: "Slug Mint Co" });
134 expect(org.slug, "a new org row is born with a slug").toBeTruthy();
135 expect(isValidOrgSlug(org.slug), "the minted slug fits the URL grammar").toBe(true);
136 });
137
138 it("keeps the slug stable across renames (conflict updates name only)", async () => {
139 const id = `org_${crypto.randomUUID()}`;
140 const created = await upsert({ id, name: "Original Name" });
141 const renamed = await upsert({ id, name: "Renamed Org" });
142 expect(renamed.slug, "the slug survives a rename").toBe(created.slug);
143 expect(renamed.name, "the name is refreshed on conflict").toBe("Renamed Org");
144 });
145
146 it("discriminates same-name collisions into distinct slugs", async () => {
147 // Same name → same slug base; the second insert collides on the unique
148 // index and gets a discriminated slug.
149 const name = `Collide ${crypto.randomUUID().slice(0, 8)}`;
150 const a = await upsert({ id: `org_${crypto.randomUUID()}`, name });
151 const b = await upsert({ id: `org_${crypto.randomUUID()}`, name });
152 expect(a.slug).not.toBe(b.slug);
153 expect(isValidOrgSlug(a.slug) && isValidOrgSlug(b.slug), "both slugs are valid").toBe(true);
154 });
155});
apps/cloud/src/db/schema.ts+5−3
@@ -22,15 +22,17 @@ export const accounts = pgTable("accounts", { · 21 unmodified lines
22/**22/**
23 * Organization (billing entity, scoping root). The `id` is the WorkOS23 * Organization (billing entity, scoping root). The `id` is the WorkOS
24 * organization ID. The `slug` is OURS, not WorkOS's (their org object has no24 * organization ID. The `slug` is OURS, not WorkOS's (their org object has no
25 * slug): minted at create time, lazily backfilled for orgs first seen via the25 * slug): minted at the moment a row is inserted (the single mint point is
26 * mirror's self-heal path, and stable across renames so org URLs don't break.26 * `upsertOrganization`) and stable across renames so org URLs don't break.
27 * NOT NULL — there is no nullable window: legacy rows were backfilled once and
28 * every insert since carries a slug.
27 */29 */
28export const organizations = pgTable(30export const organizations = pgTable(
29 "organizations",31 "organizations",
30 {32 {
31 id: text("id").primaryKey(),33 id: text("id").primaryKey(),
32 name: text("name").notNull(),34 name: text("name").notNull(),
33 slug: text("slug"),35 slug: text("slug").notNull(),
34 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),36 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
35 },37 },
36 (t) => ({38 (t) => ({
e2e/cloud/auth-routing-flow.test.ts+74−0
@@ -0,0 +1,74 @@
1// Cloud-only (browser): the whole auth-routing arc in ONE session, end to end
2// through the real dev server (the e2e cloud target boots `vite dev` with the
3// SSR gate active, so this is the same routing that runs in prod):
4//
5// signed out → /login
6// click Sign in → WorkOS (emulator) callback → org-less → /create-org
7// create the first org → canonical dashboard at /<slug>
8//
9// The individual hops are also covered piecewise (cloud/unauthenticated-skeleton,
10// cloud/session-gate, scenarios/org-slug-routing) — those pin edge cases this
11// narrative doesn't (invalid-cookie clearing, token refresh, returnTo
12// open-redirect, unknown-slug 404). This one is the regression guard for the
13// happy path as a single continuous flow, and is the only test that pins the
14// bare-`/` → `/<slug>/` dashboard canonicalization a fresh login lands on.
15//
16// The headless helpers sign in via a `login_hint` query param, but the
17// emulator's hosted AuthKit also serves a real form ("continue as a new user"),
18// so the browser flow just fills it — genuinely end-to-end through the UI.
19import { randomUUID } from "node:crypto";
20
21import { expect } from "@effect/vitest";
22import { Effect } from "effect";
23
24import { scenario } from "../src/scenario";
25import { Browser, Target } from "../src/services";
26
27scenario(
28 "Auth routing · signed out → login → onboarding → dashboard, one browser session",
29 {},
30 Effect.gen(function* () {
31 const browser = yield* Browser;
32 yield* Target;
33
34 // A brand-new user the emulator provisions on first login_hint — no org, so
35 // the post-callback document is gated to onboarding.
36 const email = `flow-${randomUUID().slice(0, 8)}@e2e.test`;
37
38 // No cookies → a clean, signed-out browser.
39 const anonymous = { label: "anonymous" };
40
41 yield* browser.session(anonymous, async ({ page, step }) => {
42 await step("Signed out, the root redirects to /login", async () => {
43 await page.goto("/", { waitUntil: "commit" });
44 await page.getByText("Sign in to manage your tools and sources").waitFor();
45 });
46 expect(new URL(page.url()).pathname, "the signed-out root lands on /login").toBe("/login");
47
48 await step("Sign in through the UI → org-less session lands on onboarding", async () => {
49 await page.getByRole("link", { name: "Sign in" }).click();
50 // The emulator's hosted AuthKit form: provision a brand-new user.
51 await page.getByPlaceholder("new-user@example.com").fill(email);
52 await page.getByRole("button", { name: /Continue/ }).click();
53 // callback mints the session → SSR gate sees no org → /create-org.
54 await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 });
55 await page.getByPlaceholder("Northwind Labs").waitFor({ timeout: 30_000 });
56 });
57
58 await step("Create the first org → canonical dashboard at /<slug>", async () => {
59 await page.getByPlaceholder("Northwind Labs").fill("Flow Test Org");
60 await page.getByRole("button", { name: "Create organization" }).click();
61 await page.getByText("Connect your MCP client").waitFor({ timeout: 30_000 });
62 await page.getByRole("button", { name: "Continue to app" }).click();
63 // The bare landing canonicalizes onto the new org's slug.
64 await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { timeout: 30_000 });
65 await page.getByText("Integrations").first().waitFor({ timeout: 30_000 });
66 });
67
68 expect(
69 /^\/[a-z0-9-]+\/?$/.test(new URL(page.url()).pathname),
70 "the signed-in user ends on their org-slug dashboard",
71 ).toBe(true);
72 });
73 }),
74);