Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@ AUTH_GITHUB_SECRET=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Email (magic links)
# Outbound workspace invitations. EMAIL_SERVER is the compact SMTP URL form;
# alternatively set SMTP_HOST/SMTP_PORT/SMTP_SECURE/SMTP_USER/SMTP_PASSWORD,
# or use RESEND_API_KEY. Production invite attempts fail closed when no
# transport is configured.
EMAIL_SERVER=smtp://user:pass@smtp.example.com:587
EMAIL_FROM=noreply@forge.local
EMAIL_FROM="Forge <noreply@forge.local>"
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASSWORD=
RESEND_API_KEY=

# Plugin / agent authentication
PLUGIN_JWT_ISSUER=forge
Expand Down
12,914 changes: 1 addition & 12,913 deletions DEVLOG.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"next": "^15.1.3",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"nodemailer": "^6.10.1",
"openai": "^6.34.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
Expand All @@ -96,6 +97,7 @@
"@playwright/test": "^1.49.1",
"@types/katex": "^0.16.8",
"@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.24",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/web-push": "^3.6.4",
Expand Down
41 changes: 33 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions prisma/migrations/0104_workspace_invitations/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REVOKED', 'EXPIRED');

ALTER TYPE "EventKind" ADD VALUE 'INVITATION_CREATED';
ALTER TYPE "EventKind" ADD VALUE 'INVITATION_RESENT';
ALTER TYPE "EventKind" ADD VALUE 'INVITATION_REVOKED';
ALTER TYPE "EventKind" ADD VALUE 'INVITATION_ACCEPTED';

ALTER TABLE "Workspace"
ADD COLUMN "inviteExpiryHours" INTEGER NOT NULL DEFAULT 168;

CREATE TABLE "WorkspaceInvitation" (
"id" TEXT NOT NULL,
"workspaceId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"status" "InvitationStatus" NOT NULL DEFAULT 'PENDING',
"tokenHash" TEXT NOT NULL,
"note" TEXT,
"invitedById" TEXT NOT NULL,
"acceptedById" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSentAt" TIMESTAMP(3),
"sendCount" INTEGER NOT NULL DEFAULT 0,
"lastSendError" TEXT,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkspaceInvitation_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "WorkspaceInvitation_tokenHash_key" ON "WorkspaceInvitation"("tokenHash");
CREATE INDEX "WorkspaceInvitation_workspaceId_status_createdAt_idx" ON "WorkspaceInvitation"("workspaceId", "status", "createdAt");
CREATE INDEX "WorkspaceInvitation_workspaceId_email_status_idx" ON "WorkspaceInvitation"("workspaceId", "email", "status");
CREATE INDEX "WorkspaceInvitation_email_status_idx" ON "WorkspaceInvitation"("email", "status");
CREATE UNIQUE INDEX "WorkspaceInvitation_pending_email_key"
ON "WorkspaceInvitation"("workspaceId", lower("email"))
WHERE "status" = 'PENDING';

ALTER TABLE "WorkspaceInvitation"
ADD CONSTRAINT "WorkspaceInvitation_workspaceId_fkey"
FOREIGN KEY ("workspaceId") REFERENCES "Workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "WorkspaceInvitation"
ADD CONSTRAINT "WorkspaceInvitation_invitedById_fkey"
FOREIGN KEY ("invitedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "WorkspaceInvitation"
ADD CONSTRAINT "WorkspaceInvitation_acceptedById_fkey"
FOREIGN KEY ("acceptedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "WorkspaceInvitation" ADD COLUMN "deliveryLockAt" TIMESTAMP(3);
47 changes: 47 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ enum EventKind {
MEMBERSHIP_CREATED
MEMBERSHIP_ROLE_CHANGED
MEMBERSHIP_REMOVED
INVITATION_CREATED
INVITATION_RESENT
INVITATION_REVOKED
INVITATION_ACCEPTED
ISSUE_STALLED
AGENT_NOACK
ISSUE_SLA_BREACH
Expand Down Expand Up @@ -180,6 +184,13 @@ enum EventKind {
PLAN_STALLED
}

enum InvitationStatus {
PENDING
ACCEPTED
REVOKED
EXPIRED
}

/// Operator-requested control on an in-flight AgentRun. NONE is the
/// default steady state; PAUSE_REQUESTED / CANCEL_REQUESTED are
/// pending signals the agent's own runtime is expected to honor on
Expand Down Expand Up @@ -602,6 +613,8 @@ model User {
accounts Account[]
sessions Session[]
memberships Membership[]
invitationsSent WorkspaceInvitation[] @relation("InvitationSender")
invitationsAccepted WorkspaceInvitation[] @relation("InvitationAcceptor")
agentProfiles AgentProfile[]
requestedAgentProfiles AgentProfile[] @relation("AgentProfileRequester")
connections Connection[]
Expand Down Expand Up @@ -931,13 +944,17 @@ model Workspace {
/// Null until the admin generates one; rotating it invalidates any
/// outstanding inbound integrations.
emailIngestSecret String?
/// Lifetime of a newly-issued workspace invite link. Resends rotate the
/// token and restart this window. Kept tenant-configurable for self-hosters.
inviteExpiryHours Int @default(168)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?

defaultIssueAssigneeUser User? @relation("WorkspaceDefaultIssueAssignee", fields: [defaultIssueAssigneeUserId], references: [id], onDelete: SetNull)

memberships Membership[]
invitations WorkspaceInvitation[]
projects Project[]
issues Issue[]
statuses Status[]
Expand Down Expand Up @@ -1027,6 +1044,36 @@ model Membership {
@@index([workspaceId, role])
}

model WorkspaceInvitation {
id String @id @default(cuid())
workspaceId String
email String
role Role @default(MEMBER)
status InvitationStatus @default(PENDING)
/// SHA-256 digest only. The bearer token is sent once and never persisted.
tokenHash String @unique
note String? @db.Text
invitedById String
acceptedById String?
expiresAt DateTime
lastSentAt DateTime?
sendCount Int @default(0)
lastSendError String? @db.Text
deliveryLockAt DateTime?
acceptedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
invitedBy User @relation("InvitationSender", fields: [invitedById], references: [id], onDelete: Restrict)
acceptedBy User? @relation("InvitationAcceptor", fields: [acceptedById], references: [id], onDelete: SetNull)

@@index([workspaceId, status, createdAt])
@@index([workspaceId, email, status])
@@index([email, status])
}

// ----------------------------------------------------------------------------
// Work items
// ----------------------------------------------------------------------------
Expand Down
Loading