mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-27 21:01:03 +08:00
Source rename across the monorepo. Every publishable package now ships
under its @hexclave/* name natively, no rewrite-at-publish indirection.
Workflow + tooling:
- Delete scripts/rewrite-packages-to-hexclave.ts (one-shot mirror).
- Remove the mirror-publish block from .github/workflows/npm-publish.yaml.
The remaining `pnpm publish -r` step publishes @hexclave/* natively.
- Flip the auto-bump changeset target from @stackframe/stack to
@hexclave/next so 'Update package versions on dev' keeps working.
- Delete packages/template/src/internal/deprecation-warning.ts and its
imports — @hexclave/* never warns about itself, and after PR 3 no
@stackframe/* artifact is ever built from source again.
Package renames (publishable):
@stackframe/react → @hexclave/react
@stackframe/stack → @hexclave/next
@stackframe/js → @hexclave/js
@stackframe/stack-shared → @hexclave/shared
@stackframe/stack-ui → @hexclave/ui
@stackframe/stack-sc → @hexclave/sc
@stackframe/stack-cli → @hexclave/cli
@stackframe/tanstack-start → @hexclave/tanstack-start
@stackframe/dashboard-ui-components → @hexclave/dashboard-ui-components
Internal monorepo packages (private, never published) also renamed for
brand consistency: backend, dashboard, docs, mcp, skills, e2e-tests,
example apps, the swift-sdk, the monorepo root, etc. Cost is mechanical;
payoff is no stray @stackframe/* names left under apps/, examples/, sdks/.
Carve-outs intentionally kept under their legacy names:
- @stackframe/emails — virtual module imported by customer-stored email
templates; the renderer in apps/backend/src/lib/email-rendering.tsx
dual-aliases both names to the same backing module indefinitely.
- @stackframe/template — internal codegen source, never published; per
docs-mintlify/migration.mdx 'internal packages keep names'.
- @stackframe/init-stack — deprecated; now marked private: true so the
last published version on npm continues to serve old install commands
but the workspace stops publishing it.
Backward-compat detection (so projects still on the last @stackframe/*
release keep working):
- packages/stack-shared/src/config-rendering.ts — CONFIG_IMPORT_PACKAGES
table includes both @hexclave/* (canonical, first match wins) and
legacy @stackframe/* names. Function renamed
detectStackframeImportPackage → detectConfigImportPackage.
- apps/dashboard/src/lib/github-config-push.ts — import detection regex
now matches both @hexclave/<name> and @stackframe/<name>, hexclave
preferred.
Versions: every renamed package reset to 1.0.0 in source. The repo's
existing 'bump versions before merging to main' flow will move them to
1.0.1 on the first publish run, so the dual-publish 1.0.0 from PR 2 is
not overwritten.
Other touch-ups discovered during sweep:
- Root package.json: 'fern' script filter was @stackframe/docs (legacy
typo, never resolved) → @hexclave/docs.
- README.md contributor note: @stackframe/XYZ → @hexclave/XYZ.
- packages/stack-cli/package.json: register `hexclave` bin alongside
the legacy `stack` bin so `npx @hexclave/cli init` works on the
natively-published artifact (PR 1481's rewrite script did this at
publish time; now it's in source).
- packages/template/package-template.json: per-platform names + version
flipped to hexclave + 1.0.0 to stay in sync with generated package.json.
- docs/package.json (legacy fumadocs folder, otherwise carved out of the
brand sweep): workspace deps and name updated minimally so `pnpm
install` resolves — content (MDX) intentionally untouched per the
PR 2 scoping decision.
Carve-out files (skipped entirely by the sweep, intentional history):
- docs-mintlify/migration.mdx — teaches the rename, references both.
- RENAME-TO-HEXCLAVE.md — planning doc, references both indefinitely.
- legacy docs/ folder — content untouched per PR 2 carve-out.
generate-sdks regenerated packages/{react,stack,js} from template.
pnpm-lock.yaml regenerated. Typecheck green on stack-shared, stack, js,
react. Dashboard typecheck has pre-existing 'X is of type unknown'
errors that need to be investigated separately (likely a local
node_modules build state issue, not source).
289 lines
8.5 KiB
TypeScript
289 lines
8.5 KiB
TypeScript
import { KnownErrors } from "@hexclave/shared";
|
|
import { DEFAULT_EMAIL_THEME_ID, DEFAULT_TEMPLATE_IDS } from "@hexclave/shared/dist/helpers/emails";
|
|
import { wait } from "@hexclave/shared/dist/utils/promises";
|
|
import { it } from "../helpers";
|
|
import { withPortPrefix } from "../helpers/ports";
|
|
import { createApp } from "./js-helpers";
|
|
|
|
async function setupEmailServer(adminApp: any) {
|
|
const project = await adminApp.getProject();
|
|
await project.updateConfig({
|
|
emails: {
|
|
server: {
|
|
isShared: false,
|
|
host: "localhost",
|
|
port: Number(withPortPrefix("29")),
|
|
username: "test",
|
|
password: "test",
|
|
senderEmail: "test@example.com",
|
|
senderName: "Test User",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
it("should successfully send email with HTML content", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<h1>Test Email</h1><p>This is a test email with HTML content.</p>",
|
|
subject: "Test Subject",
|
|
})).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should successfully send email with template", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
|
|
variables: {
|
|
teamDisplayName: "Test Team",
|
|
signInInvitationLink: "https://example.com",
|
|
},
|
|
subject: "Welcome!",
|
|
})).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should successfully send email to multiple users", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user1 = await serverApp.createUser({
|
|
primaryEmail: "test1@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
const user2 = await serverApp.createUser({
|
|
primaryEmail: "test2@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user1.id, user2.id],
|
|
html: "<p>Bulk email test</p>",
|
|
subject: "Bulk Email Test",
|
|
})).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should send email with theme customization", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<p>Themed email test</p>",
|
|
subject: "Themed Email",
|
|
themeId: DEFAULT_EMAIL_THEME_ID,
|
|
})).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should send email with notification category", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<p>Notification email test</p>",
|
|
subject: "Notification Email",
|
|
notificationCategoryName: "Transactional",
|
|
})).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should throw RequiresCustomEmailServer error when email server is not configured", async ({ expect }) => {
|
|
const { serverApp } = await createApp();
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<p>This should fail</p>",
|
|
subject: "Test Email",
|
|
})).rejects.toThrow(KnownErrors.RequiresCustomEmailServer);
|
|
});
|
|
|
|
it("should handle non-existent user IDs", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
// Use a properly formatted UUID that doesn't exist
|
|
await expect(serverApp.sendEmail({
|
|
userIds: ["123e4567-e89b-12d3-a456-426614174000"],
|
|
html: "<p>Non-existent user test</p>",
|
|
subject: "Test Email",
|
|
})).rejects.toThrow(KnownErrors.UserIdDoesNotExist);
|
|
});
|
|
|
|
it("should handle missing required email content", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
subject: "Test Email",
|
|
} as any)).rejects.toThrow(KnownErrors.SchemaError);
|
|
});
|
|
|
|
it("should handle html and templateId at the same time", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "test@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
await expect(serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<p>Test Email</p>",
|
|
templateId: DEFAULT_TEMPLATE_IDS.sign_in_invitation,
|
|
subject: "Test Email",
|
|
} as any)).rejects.toThrow(KnownErrors.SchemaError);
|
|
});
|
|
|
|
it("should provide delivery statistics", async ({ expect }) => {
|
|
const { adminApp, serverApp } = await createApp();
|
|
await setupEmailServer(adminApp);
|
|
|
|
const user = await serverApp.createUser({
|
|
primaryEmail: "stats@example.com",
|
|
primaryEmailVerified: true,
|
|
});
|
|
|
|
// Give Bulldozer's pg_cron tick time to materialise the billing team's
|
|
// `emails_per_month` quota from its freshly-granted free plan. Without
|
|
// this wait the first email gets quota-blocked into a permanent
|
|
// server-error terminal and `stats.hour.sent` never reaches 1.
|
|
await wait(2000);
|
|
|
|
await serverApp.sendEmail({
|
|
userIds: [user.id],
|
|
html: "<p>Stats</p>",
|
|
subject: "Stats",
|
|
});
|
|
|
|
let info;
|
|
for (let i = 0; ; i++) {
|
|
info = await serverApp.getEmailDeliveryStats();
|
|
if (info.stats.hour.sent >= 1) break;
|
|
if (i >= 50) {
|
|
throw new Error(`Timed out waiting for email delivery stats to reflect sent email: ${JSON.stringify(info)}`);
|
|
}
|
|
await wait(500);
|
|
}
|
|
|
|
expect(info).toMatchInlineSnapshot(`
|
|
{
|
|
"capacity": {
|
|
"boost_expires_at": null,
|
|
"boost_multiplier": 1,
|
|
"is_boost_active": false,
|
|
"penalty_factor": 1,
|
|
"rate_per_second": 27.777779320987655,
|
|
},
|
|
"stats": {
|
|
"day": {
|
|
"bounced": 0,
|
|
"marked_as_spam": 0,
|
|
"sent": 1,
|
|
},
|
|
"hour": {
|
|
"bounced": 0,
|
|
"marked_as_spam": 0,
|
|
"sent": 1,
|
|
},
|
|
"month": {
|
|
"bounced": 0,
|
|
"marked_as_spam": 0,
|
|
"sent": 1,
|
|
},
|
|
"week": {
|
|
"bounced": 0,
|
|
"marked_as_spam": 0,
|
|
"sent": 1,
|
|
},
|
|
},
|
|
}
|
|
`);
|
|
});
|
|
|
|
it("should send test email with custom SMTP configuration", async ({ expect }) => {
|
|
const { adminApp } = await createApp();
|
|
|
|
// First configure the email server
|
|
await setupEmailServer(adminApp);
|
|
|
|
// Get the project to access the email config
|
|
const project = await adminApp.getProject();
|
|
const config = await project.getConfig();
|
|
|
|
// Verify config is not shared
|
|
expect(config.emails.server.isShared).toBe(false);
|
|
|
|
// Give Bulldozer's pg_cron tick time to materialise the billing team's
|
|
// `emails_per_month` quota from its freshly-granted free plan; otherwise
|
|
// the `tryDecreaseQuantity` inside the route rejects with
|
|
// ItemQuantityInsufficientAmount before SMTP is ever dialled.
|
|
await wait(2000);
|
|
|
|
// Send a test email
|
|
const result = await adminApp.sendTestEmail({
|
|
recipientEmail: "test-recipient@example.com",
|
|
emailConfig: {
|
|
host: config.emails.server.host!,
|
|
port: config.emails.server.port!,
|
|
username: config.emails.server.username!,
|
|
password: config.emails.server.password!,
|
|
senderEmail: config.emails.server.senderEmail!,
|
|
senderName: config.emails.server.senderName!,
|
|
}
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
});
|
|
|
|
it("should fail to send test email with shared server configuration", async ({ expect }) => {
|
|
const { adminApp } = await createApp();
|
|
|
|
// Don't configure custom email server, so it defaults to shared
|
|
const project = await adminApp.getProject();
|
|
const config = await project.getConfig();
|
|
|
|
// Verify config is shared
|
|
expect(config.emails.server.isShared).toBe(true);
|
|
|
|
// Attempting to send test email with shared config should fail in the UI
|
|
// (This test documents the expected behavior in the dashboard UI)
|
|
});
|