Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c07bc33
db change and new workspace slug
MichaelUnkey Aug 25, 2025
0560ccc
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 25, 2025
b255d98
rabbits
MichaelUnkey Aug 26, 2025
f4eb278
small changes
MichaelUnkey Aug 26, 2025
506e25a
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 26, 2025
c676bce
remove journal
MichaelUnkey Aug 26, 2025
bb7900d
rabbit
MichaelUnkey Aug 26, 2025
f6b2383
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 26, 2025
1b4f0c5
workspaceUrl changed to slug and regex changed.
MichaelUnkey Aug 28, 2025
2984797
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 28, 2025
60dc06e
rabbit comment missed name in descriptions and narrowed regex
MichaelUnkey Aug 28, 2025
c87e538
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 28, 2025
f1191b8
Update apps/dashboard/app/new/hooks/use-workspace-step.tsx
MichaelUnkey Aug 28, 2025
b87eba9
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 28, 2025
64122d8
safari rabbit fix
MichaelUnkey Aug 28, 2025
2170a12
rabbit nit
MichaelUnkey Aug 29, 2025
14a6e61
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
MichaelUnkey Aug 29, 2025
6cf2e6d
Merge branch 'main' of https://github.com/unkeyed/unkey into workspac…
chronark Aug 31, 2025
af74624
fix: shema
chronark Aug 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/pkg/testutil/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export abstract class Harness {
const unkeyWorkspace: Workspace = {
id: newId("test"),
name: "unkey",
slug: "unkey-workspace",
orgId: newId("test"),
plan: "enterprise",
tier: "Enterprise",
Expand All @@ -272,6 +273,7 @@ export abstract class Harness {
const userWorkspace: Workspace = {
id: newId("test"),
name: "user",
slug: "user-workspace",
orgId: newId("test"),
plan: "pro",
tier: "Pro Max",
Expand Down
86 changes: 48 additions & 38 deletions apps/dashboard/app/new/hooks/use-workspace-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,21 @@ const workspaceSchema = z.object({
.trim()
.min(3, "Workspace name is required")
.max(50, "Workspace name must be 50 characters or less"),
// workspaceUrl: z
// .string()
// .min(3, "Workspace URL is required")
// .regex(
// /^[a-zA-Z0-9-_]+$/,
// "URL handle can only contain letters, numbers, hyphens, and underscores"
// ),
slug: z
.string()
.trim()
.min(3, "Workspace slug must be at least 3 characters")
.max(64, "Workspace slug must be 64 characters or less")
.regex(
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
"Use lowercase letters, numbers, and single hyphens (no leading/trailing hyphens).",
),
});

type WorkspaceFormData = z.infer<typeof workspaceSchema>;

export const useWorkspaceStep = (): OnboardingStep => {
// const [isSlugGenerated, setIsSlugGenerated] = useState(false);
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
const [workspaceCreated, setWorkspaceCreated] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
Expand Down Expand Up @@ -101,7 +103,10 @@ export const useWorkspaceStep = (): OnboardingStep => {
// Workspace already created, just proceed
return;
}
createWorkspace.mutateAsync({ name: data.workspaceName });
createWorkspace.mutateAsync({
name: data.workspaceName,
slug: data.slug.toLowerCase(),
});
};

const validFieldCount = Object.keys(form.getValues()).filter((field) => {
Expand Down Expand Up @@ -143,43 +148,48 @@ export const useWorkspaceStep = (): OnboardingStep => {
{/* </div> */}
{/* </div> */}
{/* </div> */}

{/* Use this 'pt-7' version when implementing profile photo and slug based onboarding*/}
{/* <div className="space-y-4 pt-7"> */}
<div className="space-y-4 p-1">
<FormInput
{...form.register("workspaceName")}
placeholder="Enter workspace name"
label="Workspace name"
// onBlur={(evt) => {
// if (!isSlugGenerated) {
// form.setValue(
// "workspaceUrl",
// slugify(evt.currentTarget.value)
// );
// form.trigger("workspaceUrl");
// setIsSlugGenerated(true);
// }
// }}
onBlur={(evt) => {
const currentSlug = form.getValues("slug");
const isSlugDirty = form.formState.dirtyFields.slug;

// Only auto-generate if slug is empty, not dirty, and hasn't been manually edited
if (!currentSlug && !isSlugDirty && !slugManuallyEdited) {
form.setValue("slug", slugify(evt.currentTarget.value), {
shouldValidate: true,
});
}
}}
required
error={form.formState.errors.workspaceName?.message}
disabled={isLoading || workspaceCreated}
/>
{/* <FormInput */}
{/* {...form.register("workspaceUrl")} */}
{/* placeholder="enter-a-handle" */}
{/* label="Workspace URL handle" */}
{/* required */}
{/* error={form.formState.errors.workspaceUrl?.message} */}
{/* prefix="app.unkey.com/" */}
{/* /> */}
<FormInput
{...form.register("slug")}
placeholder="enter-a-handle"
label="Workspace URL handle"
required
error={form.formState.errors.slug?.message}
prefix="app.unkey.com/"
maxLength={64}
onChange={(evt) => {
const v = evt.currentTarget.value;
setSlugManuallyEdited(v.length > 0);
}}
/>
</div>
</div>
</form>
),
kind: "required" as const,
validFieldCount,
requiredFieldCount: 1,
requiredFieldCount: 2,
buttonText: workspaceCreated ? "Continue" : "Create workspace",
description: workspaceCreated
? "Workspace created successfully, continue to next step"
Expand All @@ -200,12 +210,12 @@ export const useWorkspaceStep = (): OnboardingStep => {
};
};

// const slugify = (text: string): string => {
// return text
// .toLowerCase()
// .trim()
// .replace(/[^\w\s-]/g, "") // Remove special chars except spaces and hyphens
// .replace(/\s+/g, "-") // Replace spaces with hyphens
// .replace(/-+/g, "-") // Replace multiple hyphens with single
// .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
// };
const slugify = (text: string): string => {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars except lowercase letters, numbers, spaces, and hyphens
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
};
6 changes: 5 additions & 1 deletion apps/dashboard/lib/trpc/routers/workspace/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export const createWorkspace = t.procedure
.use(requireUser)
.input(
z.object({
name: z.string().min(1).max(50),
name: z.string().min(3).max(50),
slug: z.string().regex(/^(?!-)[a-z0-9]+(?:-[a-z0-9]+)*(?<!-)$/, {
message: "Use lowercase letters, numbers, and hyphens (no leading/trailing hyphens).",
}),
}),
)
.mutation(async ({ ctx, input }) => {
Expand Down Expand Up @@ -50,6 +53,7 @@ export const createWorkspace = t.procedure
id: newId("workspace"),
orgId: orgId,
name: input.name,
slug: input.slug,
plan: "free",
tier: "Free",
stripeCustomerId: null,
Expand Down
4 changes: 3 additions & 1 deletion go/pkg/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ CREATE TABLE `workspaces` (
`id` varchar(256) NOT NULL,
`org_id` varchar(256) NOT NULL,
`name` varchar(256) NOT NULL,
`slug` varchar(64),
`partition_id` varchar(256),
Comment on lines +182 to 183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Plan a follow-up to make slug required and canonicalized.

Slug is nullable; if every workspace should have a URL slug, backfill existing rows, then add NOT NULL and enforce lowercase at the API boundary.

I can draft the migration plan if helpful.

🤖 Prompt for AI Agents
In go/pkg/db/schema.sql around lines 182-183, `slug` is currently nullable; plan
and implement a migration to make it required and canonicalized: first write a
backfill script that generates slugs for existing rows (e.g., derive from
workspace name or id), normalizes to lowercase and URL-safe format, and ensures
uniqueness (add suffixes if necessary); run and verify the backfill; then add a
schema migration to ALTER TABLE to set `slug` NOT NULL and create a unique index
on `slug`; finally enforce lowercase/canonicalization at the API boundary for
all new/updated workspaces (validate and transform input to lowercase and
URL-safe form before inserting/updating).

`plan` enum('free','pro','enterprise') DEFAULT 'free',
`tier` varchar(256) DEFAULT 'Free',
Expand All @@ -193,7 +194,8 @@ CREATE TABLE `workspaces` (
`updated_at_m` bigint,
`deleted_at_m` bigint,
CONSTRAINT `workspaces_id` PRIMARY KEY(`id`),
CONSTRAINT `workspaces_org_id_unique` UNIQUE(`org_id`)
CONSTRAINT `workspaces_org_id_unique` UNIQUE(`org_id`),
CONSTRAINT `workspaces_slug_unique` UNIQUE(`slug`)
);

CREATE TABLE `key_migration_errors` (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ CREATE TABLE `workspaces` (
`id` varchar(256) NOT NULL,
`org_id` varchar(256) NOT NULL,
`name` varchar(256) NOT NULL,
`slug` varchar(64),
`partition_id` varchar(256),
Comment on lines +182 to 183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Case handling and URL safety.

If slugs appear in URLs, enforce lowercase and character set in the API layer; DB collation might already be case-insensitive, but explicit normalization avoids surprises.

`plan` enum('free','pro','enterprise') DEFAULT 'free',
`tier` varchar(256) DEFAULT 'Free',
Expand All @@ -193,7 +194,8 @@ CREATE TABLE `workspaces` (
`updated_at_m` bigint,
`deleted_at_m` bigint,
CONSTRAINT `workspaces_id` PRIMARY KEY(`id`),
CONSTRAINT `workspaces_org_id_unique` UNIQUE(`org_id`)
CONSTRAINT `workspaces_org_id_unique` UNIQUE(`org_id`),
CONSTRAINT `workspaces_slug_unique` UNIQUE(`slug`)
);
--> statement-breakpoint
CREATE TABLE `key_migration_errors` (
Expand Down Expand Up @@ -408,4 +410,4 @@ CREATE INDEX `status_idx` ON `deployments` (`status`);--> statement-breakpoint
CREATE INDEX `domain_idx` ON `acme_users` (`workspace_id`);--> statement-breakpoint
CREATE INDEX `workspace_idx` ON `domains` (`workspace_id`);--> statement-breakpoint
CREATE INDEX `project_idx` ON `domains` (`project_id`);--> statement-breakpoint
CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`);
CREATE INDEX `workspace_idx` ON `acme_challenges` (`workspace_id`);
13 changes: 12 additions & 1 deletion internal/db/drizzle/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "mysql",
"id": "1c1b2291-b582-4d8f-9bd7-a76d34a8af62",
"id": "feb5640a-c169-40c0-a711-90db24af469e",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"apis": {
Expand Down Expand Up @@ -1127,6 +1127,13 @@
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"partition_id": {
"name": "partition_id",
"type": "varchar(256)",
Expand Down Expand Up @@ -1236,6 +1243,10 @@
"workspaces_org_id_unique": {
"name": "workspaces_org_id_unique",
"columns": ["org_id"]
},
"workspaces_slug_unique": {
"name": "workspaces_slug_unique",
"columns": ["slug"]
}
Comment on lines 1243 to 1250
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Unique constraint captured; consider eventual NOT NULL.

Keep as-is now; after backfill, flip slug to notNull in schema + follow-up migration.

🤖 Prompt for AI Agents
internal/db/drizzle/meta/0000_snapshot.json lines 1243-1250: the snapshot
records a unique index on "slug" but the schema still allows NULL; after
performing the planned backfill you should update the schema to mark the slug
column as notNull and add a follow-up migration that alters the column to NOT
NULL (or recreates it appropriately), ensure the backfill has completed for all
rows before running the migration, include a migration down/rollback step if
appropriate, and add/adjust tests to validate the new NOT NULL constraint.

}
},
Expand Down
4 changes: 2 additions & 2 deletions internal/db/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "5",
"when": 1756627798938,
"tag": "0000_useful_nextwave",
"when": 1756630515466,
"tag": "0000_dazzling_colonel_america",
"breakpoints": true
Comment on lines +8 to 10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Journal tag updated—ensure migration is wired into deploy process.

Confirm a PlanetScale preview branch is created and the migration is applied before deploying code that writes/reads slug.

🤖 Prompt for AI Agents
In internal/db/drizzle/meta/_journal.json around lines 8 to 10, the journal tag
was updated to "0000_dazzling_colonel_america" but the deployment pipeline may
not run the corresponding migration; ensure the migration is wired into the
deploy process by (1) creating a PlanetScale preview branch for this change and
applying the migration there, (2) updating CI/CD pipeline steps to create/use
that preview branch and run the migration step before any code that reads/writes
the slug is deployed, (3) adding a verification step in CI that checks the
migration ran successfully (e.g., using a migration status command or simple
schema check), and (4) updating the deploy docs/README with the new
preview-branch + migration steps so future deploys follow the same flow.

}
]
Expand Down
3 changes: 3 additions & 0 deletions internal/db/src/schema/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const workspaces = mysqlTable("workspaces", {
orgId: varchar("org_id", { length: 256 }).notNull().unique(),
name: varchar("name", { length: 256 }).notNull(),

// slug is used for the workspace URL
slug: varchar("slug", { length: 64 }).unique(),

Comment on lines +22 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify runtime validation at the API boundary.

Ensure createWorkspace Zod schema enforces: lowercase, [a-z0-9-], no leading/trailing hyphen, max 64.

Run to confirm:


🏁 Script executed:

#!/bin/bash
rg -nP -C2 --type tsx --type ts '\bslug\b' apps/dashboard/lib/trpc/routers/workspace/create.ts apps/dashboard/app/new/hooks/use-workspace-step.tsx
rg -nP -C2 --type ts 'z\.object\([^)]*slug[^)]*\)' apps/dashboard/lib/trpc/routers/workspace/create.ts
rg -nP -C2 --type ts '(toLowerCase|slugify)' apps/dashboard/lib/trpc/routers/workspace/create.ts apps/dashboard/app/new/hooks/use-workspace-step.tsx

Length of output: 2026


Add slug validation in createWorkspace schema
In apps/dashboard/lib/trpc/routers/workspace/create.ts the .input() Zod object doesn’t validate slug at runtime. Enforce lowercase alphanumerics and hyphens only (no leading/trailing hyphen) and max 64 chars, for example:

slug: z
  .string()
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "invalid slug")
  .max(64),
🤖 Prompt for AI Agents
internal/db/src/schema/workspaces.ts around lines 22 to 24: the workspace slug
must be validated at runtime in the createWorkspace input schema; update the Zod
input in apps/dashboard/lib/trpc/routers/workspace/create.ts to validate slug as
a string matching lowercase alphanumerics and single hyphen separators (no
leading/trailing hyphen) and max 64 chars by using
.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "invalid slug").max(64) so runtime
inputs cannot bypass the DB constraint.

// Deployment platform - which partition this workspace deploys to
partitionId: varchar("partition_id", { length: 256 }),

Expand Down
Loading