weifuwu 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1466,6 +1466,8 @@ function redirect(url, status = 302) {
1466
1466
  // src/user/index.ts
1467
1467
  var USERS_TABLE = "_weifuwu_users";
1468
1468
  var SESSIONS_TABLE = "_weifuwu_sessions";
1469
+ var APP_TABLE = "_weifuwu_apps";
1470
+ var MEMBER_TABLE = "_weifuwu_app_members";
1469
1471
  function normalizeEmail(email2) {
1470
1472
  return email2.trim().toLowerCase();
1471
1473
  }
@@ -1484,24 +1486,54 @@ function userSystem(options) {
1484
1486
  const accessTtlSeconds = options.accessTtlSeconds ?? 3600;
1485
1487
  const refreshTtlDays = options.refreshTtlDays ?? 30;
1486
1488
  const prefix = options.prefix ?? "/api/auth";
1489
+ const INVITE_TTL_SECONDS = 7 * 24 * 3600;
1487
1490
  let currentUser = null;
1488
1491
  async function findUserById(id) {
1489
1492
  const rows = await sql.query.from(USERS_TABLE).select("id", "email", "name", "role", "tenant").where({ id }).run();
1490
1493
  return rows.length ? rows[0] : null;
1491
1494
  }
1492
- async function issueSession(user) {
1495
+ async function findMemberRole(appId, userId) {
1496
+ const rows = await sql.query.from(MEMBER_TABLE).select("role").where({ app_id: appId, user_id: userId }).run();
1497
+ return rows.length ? String(rows[0].role) : null;
1498
+ }
1499
+ async function findAppIdBySlug(slug) {
1500
+ const rows = await sql.query.from(APP_TABLE).select("id").where({ slug }).run();
1501
+ return rows.length ? String(rows[0].id) : null;
1502
+ }
1503
+ async function listAppsFor(userId) {
1504
+ const rows = await sql.query.from(MEMBER_TABLE).select("app_id", "role").where({ user_id: userId }).run();
1505
+ if (!rows.length) return [];
1506
+ const out = [];
1507
+ for (const r of rows) {
1508
+ const apps = await sql.query.from(APP_TABLE).select("id", "slug", "name").where({ id: String(r.app_id) }).run();
1509
+ if (apps.length) {
1510
+ out.push({ id: String(apps[0].id), slug: String(apps[0].slug), name: String(apps[0].name), role: String(r.role) });
1511
+ }
1512
+ }
1513
+ return out;
1514
+ }
1515
+ async function issueSession(user, session) {
1493
1516
  const token = signToken(
1494
- { sub: user.id, ...user.tenant ? { tenantId: user.tenant } : {} },
1517
+ {
1518
+ sub: user.id,
1519
+ ...session?.appId ? { appId: session.appId, role: session.role } : {},
1520
+ ...!session?.appId && user.tenant ? { tenantId: user.tenant } : {}
1521
+ },
1495
1522
  secret,
1496
1523
  accessTtlSeconds
1497
1524
  );
1498
1525
  const refreshToken = generateRefreshToken();
1499
1526
  const expiresAt = new Date(Date.now() + refreshTtlDays * 24 * 3600 * 1e3);
1500
- await sql.query.insert(SESSIONS_TABLE).values({ token_hash: hashRefreshToken(refreshToken), user_id: user.id, expires_at: expiresAt }).run();
1527
+ await sql.query.insert(SESSIONS_TABLE).values({
1528
+ token_hash: hashRefreshToken(refreshToken),
1529
+ user_id: user.id,
1530
+ expires_at: expiresAt,
1531
+ ...session?.appId ? { app_id: session.appId } : {}
1532
+ }).run();
1501
1533
  return { token, refreshToken };
1502
1534
  }
1503
1535
  async function consumeRefreshToken(refreshToken) {
1504
- const rows = await sql.query.from(`${SESSIONS_TABLE} s`).select("s.user_id", "s.expires_at").where({ "s.token_hash": hashRefreshToken(refreshToken), "s.revoked_at": { isNull: true } }).run();
1536
+ const rows = await sql.query.from(`${SESSIONS_TABLE} s`).select("s.user_id", "s.expires_at", "s.app_id").where({ "s.token_hash": hashRefreshToken(refreshToken), "s.revoked_at": { isNull: true } }).run();
1505
1537
  if (!rows.length) throw new HttpError("Invalid refresh token", 401);
1506
1538
  const row = rows[0];
1507
1539
  if (new Date(row.expires_at) < /* @__PURE__ */ new Date()) {
@@ -1509,12 +1541,13 @@ function userSystem(options) {
1509
1541
  }
1510
1542
  const user = await findUserById(row.user_id);
1511
1543
  if (!user) throw new HttpError("User not found", 401);
1512
- return user;
1544
+ return { user, appId: row.app_id ? String(row.app_id) : void 0 };
1513
1545
  }
1514
1546
  const mw = (async (req, ctx, next) => {
1515
1547
  currentUser = null;
1516
1548
  const sessionPayload = {};
1517
1549
  let payloadTenantId;
1550
+ let payloadAppId;
1518
1551
  const authHeader = req.headers.get("authorization");
1519
1552
  if (authHeader?.startsWith("Bearer ")) {
1520
1553
  const payload = verifyToken(authHeader.slice(7), secret);
@@ -1526,9 +1559,11 @@ function userSystem(options) {
1526
1559
  }
1527
1560
  sessionPayload.userId = String(payload.sub);
1528
1561
  payloadTenantId = payload.tenantId;
1562
+ payloadAppId = payload.appId;
1529
1563
  }
1530
1564
  }
1531
1565
  ctx.user = currentUser;
1566
+ if (payloadAppId != null) ctx.appId = String(payloadAppId);
1532
1567
  if (payloadTenantId != null) ctx.tenantId = String(payloadTenantId);
1533
1568
  ctx.auth = {
1534
1569
  // ── 会话 payload 字段(来自 token,应用层签发时决定带什么) ──
@@ -1547,6 +1582,7 @@ function userSystem(options) {
1547
1582
  }).returning("id", "email", "name", "role", "tenant").run();
1548
1583
  const user = rows[0];
1549
1584
  const session = await issueSession(user);
1585
+ currentUser = user;
1550
1586
  return { ...session, user };
1551
1587
  },
1552
1588
  async login(email2, password) {
@@ -1563,7 +1599,9 @@ function userSystem(options) {
1563
1599
  tenant: row.tenant
1564
1600
  };
1565
1601
  const session = await issueSession(user);
1566
- return { ...session, user };
1602
+ const apps = await listAppsFor(user.id);
1603
+ currentUser = user;
1604
+ return { ...session, user, apps };
1567
1605
  },
1568
1606
  async logout(refreshToken) {
1569
1607
  await sql.query.update(SESSIONS_TABLE).set({ revoked_at: sql.raw`now()` }).where({ token_hash: hashRefreshToken(refreshToken) }).run();
@@ -1579,6 +1617,115 @@ function userSystem(options) {
1579
1617
  },
1580
1618
  createToken(type, payload, opts) {
1581
1619
  return signToken({ type, ...payload }, secret, opts.ttlSeconds);
1620
+ },
1621
+ // ── 应用三层模型 ──
1622
+ async createApp(input) {
1623
+ if (!currentUser) throw new HttpError("Unauthorized", 401);
1624
+ if (!input.slug || !input.name) throw new HttpError("slug and name are required", 400);
1625
+ const slug = input.slug.trim().toLowerCase();
1626
+ const rows = await sql.query.insert(APP_TABLE).values({
1627
+ slug,
1628
+ name: input.name,
1629
+ owner_user_id: currentUser.id,
1630
+ open_registration: input.openRegistration ?? false
1631
+ }).returning("id", "slug", "name", "owner_user_id", "open_registration").run();
1632
+ const appInfo = rows[0];
1633
+ await sql.query.insert(MEMBER_TABLE).values({ app_id: appInfo.id, user_id: currentUser.id, role: "owner", invited_by: currentUser.id }).run();
1634
+ return appInfo;
1635
+ },
1636
+ async registerInApp(input) {
1637
+ if (!input.email || !input.password) throw new HttpError("email and password are required", 400);
1638
+ if (input.password.length < 8) throw new HttpError("password must be at least 8 characters", 400);
1639
+ const appRows = await sql.query.from(APP_TABLE).select("id", "open_registration").where({ slug: input.appSlug }).run();
1640
+ if (!appRows.length) throw new HttpError("Application not found", 404);
1641
+ const appRow = appRows[0];
1642
+ const appId = String(appRow.id);
1643
+ const email2 = normalizeEmail(input.email);
1644
+ let role = "member";
1645
+ if (!appRow.open_registration) {
1646
+ if (!input.inviteToken) throw new HttpError("Registration closed for this application", 403);
1647
+ const inv = verifyToken(input.inviteToken, secret);
1648
+ if (!inv || inv.type !== "app-invite" || inv.appId !== appId) {
1649
+ throw new HttpError("Invalid invite", 403);
1650
+ }
1651
+ if (inv.email && String(inv.email) !== email2) throw new HttpError("Invite email mismatch", 403);
1652
+ if (typeof inv.role === "string" && inv.role !== "owner") role = inv.role;
1653
+ }
1654
+ let user;
1655
+ const existing = await sql.query.from(USERS_TABLE).select("id", "email", "name", "role", "tenant").where({ email: email2 }).run();
1656
+ if (existing.length) {
1657
+ user = existing[0];
1658
+ } else {
1659
+ const passwordHash = await hashPassword(input.password);
1660
+ const rows = await sql.query.insert(USERS_TABLE).values({
1661
+ email: email2,
1662
+ password_hash: passwordHash,
1663
+ name: input.name ?? null,
1664
+ role: null,
1665
+ tenant: null
1666
+ }).returning("id", "email", "name", "role", "tenant").run();
1667
+ user = rows[0];
1668
+ }
1669
+ const member = await findMemberRole(appId, user.id);
1670
+ if (!member) {
1671
+ await sql.query.insert(MEMBER_TABLE).values({ app_id: appId, user_id: user.id, role, invited_by: currentUser?.id ?? null }).run();
1672
+ }
1673
+ const session = await issueSession(user, { appId, role });
1674
+ currentUser = user;
1675
+ return { ...session, user };
1676
+ },
1677
+ async loginApp(appSlug, email2, password) {
1678
+ const appRows = await sql.query.from(APP_TABLE).select("id").where({ slug: appSlug }).run();
1679
+ if (!appRows.length) throw new HttpError("Application not found", 404);
1680
+ const appId = String(appRows[0].id);
1681
+ const rows = await sql.query.from(USERS_TABLE).select("id", "email", "password_hash", "name", "role", "tenant").where({ email: normalizeEmail(email2) }).run();
1682
+ const row = rows[0];
1683
+ if (!row) throw new HttpError("Invalid email or password", 401);
1684
+ const valid = await verifyPassword(password, String(row.password_hash));
1685
+ if (!valid) throw new HttpError("Invalid email or password", 401);
1686
+ const role = await findMemberRole(appId, String(row.id));
1687
+ if (!role) throw new HttpError("Not a member of this application", 401);
1688
+ const user = {
1689
+ id: String(row.id),
1690
+ email: String(row.email),
1691
+ name: row.name,
1692
+ role: row.role,
1693
+ tenant: row.tenant
1694
+ };
1695
+ const session = await issueSession(user, { appId, role });
1696
+ currentUser = user;
1697
+ return { ...session, user };
1698
+ },
1699
+ async createInvite(appId, opts) {
1700
+ if (!currentUser) throw new HttpError("Unauthorized", 401);
1701
+ const role = await findMemberRole(appId, currentUser.id);
1702
+ if (role !== "owner") throw new HttpError("Owner only", 403);
1703
+ const inviteRole = opts.role && opts.role !== "owner" ? opts.role : "member";
1704
+ const inviteToken = signToken(
1705
+ { type: "app-invite", appId, ...opts.email ? { email: normalizeEmail(opts.email) } : {}, role: inviteRole },
1706
+ secret,
1707
+ INVITE_TTL_SECONDS
1708
+ );
1709
+ return { inviteToken };
1710
+ },
1711
+ async addMember(appId, email2, role) {
1712
+ if (!currentUser) throw new HttpError("Unauthorized", 401);
1713
+ const callerRole = await findMemberRole(appId, currentUser.id);
1714
+ if (callerRole !== "owner") throw new HttpError("Owner only", 403);
1715
+ const target = await sql.query.from(USERS_TABLE).select("id").where({ email: normalizeEmail(email2) }).run();
1716
+ if (!target.length) throw new HttpError("User not found \u2014 invite them to register first", 400);
1717
+ const memberRole = role && role !== "owner" ? role : "member";
1718
+ await sql.query.insert(MEMBER_TABLE).values({ app_id: appId, user_id: String(target[0].id), role: memberRole, invited_by: currentUser.id }).run();
1719
+ },
1720
+ async listMyApps() {
1721
+ if (!currentUser) throw new HttpError("Unauthorized", 401);
1722
+ return listAppsFor(currentUser.id);
1723
+ },
1724
+ async requireApp(appId) {
1725
+ if (!currentUser) throw new HttpError("Unauthorized", 401);
1726
+ const role = await findMemberRole(appId, currentUser.id);
1727
+ if (!role) throw new HttpError("Not a member of this application", 403);
1728
+ return { userId: currentUser.id, appId, role };
1582
1729
  }
1583
1730
  };
1584
1731
  return next(req, ctx);
@@ -1601,11 +1748,33 @@ function userSystem(options) {
1601
1748
  CREATE TABLE IF NOT EXISTS ${SESSIONS_TABLE} (
1602
1749
  token_hash TEXT PRIMARY KEY,
1603
1750
  user_id UUID NOT NULL REFERENCES ${USERS_TABLE}(id) ON DELETE CASCADE,
1751
+ app_id TEXT,
1604
1752
  expires_at TIMESTAMPTZ NOT NULL,
1605
1753
  revoked_at TIMESTAMPTZ,
1606
1754
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
1607
1755
  )
1608
1756
  `);
1757
+ await sql.unsafe(`ALTER TABLE ${SESSIONS_TABLE} ADD COLUMN IF NOT EXISTS app_id TEXT`);
1758
+ await sql.unsafe(`
1759
+ CREATE TABLE IF NOT EXISTS ${APP_TABLE} (
1760
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1761
+ slug TEXT NOT NULL UNIQUE,
1762
+ name TEXT NOT NULL,
1763
+ owner_user_id UUID NOT NULL REFERENCES ${USERS_TABLE}(id) ON DELETE CASCADE,
1764
+ open_registration BOOLEAN NOT NULL DEFAULT false,
1765
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
1766
+ )
1767
+ `);
1768
+ await sql.unsafe(`
1769
+ CREATE TABLE IF NOT EXISTS ${MEMBER_TABLE} (
1770
+ app_id UUID NOT NULL REFERENCES ${APP_TABLE}(id) ON DELETE CASCADE,
1771
+ user_id UUID NOT NULL REFERENCES ${USERS_TABLE}(id) ON DELETE CASCADE,
1772
+ role TEXT NOT NULL DEFAULT 'member',
1773
+ invited_by UUID,
1774
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1775
+ PRIMARY KEY (app_id, user_id)
1776
+ )
1777
+ `);
1609
1778
  };
1610
1779
  mw.routes = (app, routeOpts) => {
1611
1780
  const p = routeOpts?.prefix ?? prefix;
@@ -1630,15 +1799,57 @@ function userSystem(options) {
1630
1799
  if (!excluded.has("refresh")) app.post(`${p}/refresh`, async (req, ctx) => {
1631
1800
  const body = await req.json().catch(() => ({}));
1632
1801
  if (!body.refreshToken) throw new HttpError("refreshToken is required", 400);
1633
- const user = await consumeRefreshToken(body.refreshToken);
1802
+ const { user, appId } = await consumeRefreshToken(body.refreshToken);
1634
1803
  await ctx.auth.logout(body.refreshToken);
1635
- const session = await issueSession(user);
1804
+ const session = await issueSession(user, appId ? { appId } : void 0);
1636
1805
  return ok({ ...session, user });
1637
1806
  });
1638
1807
  if (!excluded.has("me")) app.get(`${p}/me`, async (_req, ctx) => {
1639
1808
  if (!ctx.user) throw new HttpError("Unauthorized", 401);
1640
1809
  return ok(ctx.user);
1641
1810
  });
1811
+ if (!excluded.has("apps")) {
1812
+ app.post(`${p}/apps`, async (req, ctx) => {
1813
+ const body = await req.json().catch(() => ({}));
1814
+ const appInfo = await ctx.auth.createApp(body);
1815
+ return created({ app: appInfo });
1816
+ });
1817
+ app.get(`${p}/apps`, async (_req, ctx) => {
1818
+ const apps = await ctx.auth.listMyApps();
1819
+ return ok({ apps });
1820
+ });
1821
+ app.post(`${p}/apps/:appSlug/members`, async (req, ctx) => {
1822
+ const body = await req.json().catch(() => ({}));
1823
+ const appId = await findAppIdBySlug(ctx.params.appSlug);
1824
+ if (!appId) throw new HttpError("Application not found", 404);
1825
+ await ctx.auth.addMember(appId, body.email ?? "", body.role);
1826
+ return created({ ok: true });
1827
+ });
1828
+ app.post(`${p}/apps/:appSlug/invites`, async (req, ctx) => {
1829
+ const body = await req.json().catch(() => ({}));
1830
+ const appId = await findAppIdBySlug(ctx.params.appSlug);
1831
+ if (!appId) throw new HttpError("Application not found", 404);
1832
+ const result = await ctx.auth.createInvite(appId, { email: body.email, role: body.role });
1833
+ return created(result);
1834
+ });
1835
+ app.post(`${p}/apps/:appSlug/login`, async (req, ctx) => {
1836
+ const body = await req.json().catch(() => ({}));
1837
+ if (!body.email || !body.password) return badRequest("email and password are required");
1838
+ const result = await ctx.auth.loginApp(ctx.params.appSlug, body.email, body.password);
1839
+ return ok(result);
1840
+ });
1841
+ app.post(`${p}/apps/:appSlug/register`, async (req, ctx) => {
1842
+ const body = await req.json().catch(() => ({}));
1843
+ const result = await ctx.auth.registerInApp({
1844
+ appSlug: ctx.params.appSlug,
1845
+ email: body.email ?? "",
1846
+ password: body.password ?? "",
1847
+ name: body.name,
1848
+ inviteToken: body.inviteToken
1849
+ });
1850
+ return created(result);
1851
+ });
1852
+ }
1642
1853
  };
1643
1854
  return mw;
1644
1855
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * weifuwu/ui-dom — Confirm(命令式中间件)
3
3
  *
4
- * v1 退役(design/render-only-plan.md):实现已收敛到 components/Confirm(vdom 引擎,
4
+ * v1 退役(design 归档):实现已收敛到 components/Confirm(vdom 引擎,
5
5
  * mountCommand + render-only)。本文件保留为公开 API 兼容入口。
6
6
  */
7
7
  export { Confirm, confirm } from '../components/Confirm/Confirm.ts';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * weifuwu/ui-dom — Notification(命令式中间件)
3
3
  *
4
- * v1 退役(design/render-only-plan.md):实现已收敛到 components/Notification(vdom 引擎,
4
+ * v1 退役(design 归档):实现已收敛到 components/Notification(vdom 引擎,
5
5
  * mountCommand + render-only)。本文件保留为公开 API 兼容入口。
6
6
  */
7
7
  export { Notification, notification } from '../components/Notification/Notification.ts';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * weifuwu/ui-dom — Toast(命令式中间件)
3
3
  *
4
- * v1 退役(design/render-only-plan.md):实现已收敛到 components/Toast(vdom 引擎,
4
+ * v1 退役(design 归档):实现已收敛到 components/Toast(vdom 引擎,
5
5
  * mountCommand + render-only)。本文件保留为公开 API 兼容入口。
6
6
  */
7
7
  export { Toast, toast } from '../components/Toast/Toast.ts';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * hooks/chat — AI 对话会话(useChat)
3
3
  *
4
- * 会话语义 + 工具调用内嵌 + HITL 审批。render-only 方案(design/render-only-plan.md):
4
+ * 会话语义 + 工具调用内嵌 + HITL 审批。render-only 方案(design 归档):
5
5
  * state 是**普通对象**(不再挂组件 $),变化 → notify → 订阅者重渲染;
6
6
  * handle 带 subscribe——共享会话的子组件用 ctx.ui.useExternal(handle) 订阅。
7
7
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * useExternal — 订阅共享状态(render-only 共享原语,见 design/render-only-plan.md)
2
+ * useExternal — 订阅共享状态(render-only 共享原语,见 design 归档)
3
3
  *
4
4
  * 与 useInView/useScrollPosition 同构(mount 阶段注册,生命周期框架管):
5
5
  * - mount 注册:订阅 store(任何变化 → 自身重渲染)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * weifuwu/ui-dom — 前端路由 + 渲染运行时(v2 vdom 引擎,render-only)
3
3
  *
4
- * 渲染运行时 = vdom(第 2 代引擎,design/render-only-plan.md):
4
+ * 渲染运行时 = vdom(第 2 代引擎,design 归档):
5
5
  * - 渲染触发唯一原语 ctx.ui.render()(无 $ / dirty——无自动渲染)
6
6
  * - 共享状态:createStore + ctx.ui.useExternal
7
7
  * - 命令式挂载:mountCommand/unmountCommand(弹窗中间件在 components 各组件内部)