Skip to content

Commit ade6e93

Browse files
hotlongCopilot
andcommitted
fix(cloud): use resolveCallerUserId for project __session__ + ownerSeed
Root cause: the inline session probe at the project-create POST handler called `authService?.api?.getSession?.({headers: rawHeaders})` directly, but (a) better-auth's API may be at `authService.auth.api` (not `.api`), and (b) Hono hands us a plain Record<string,string> of headers which better-auth's getSession rejects (needs Headers instance). Result: session probe silently returned null, so: - req.created_by was never resolved from __session__ → stayed 'system' - ownerSeed metadata was never captured → project DBs spun up with no owner pre-seeded → owner became a JIT SSO user on first login Fix: route both flows through the existing resolveCallerUserId() helper which already handles Headers coercion + both API path variants. Look up the full sys_user row by id (need email/name/image for the seed). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d258167 commit ade6e93

2 files changed

Lines changed: 61 additions & 60 deletions

File tree

packages/objectql/src/protocol.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,25 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
632632
// Normalize legacy params → QueryAST standard (where/fields/orderBy/offset/expand)
633633
// ====================================================================
634634

635+
// OData-style `$`-prefixed params → bare aliases that the rest of
636+
// this function knows how to normalize. Without this step, params
637+
// like `?$top=2&$orderby=...` survive into the catch-all
638+
// implicit-filter pass below and get merged into `where` as
639+
// bogus field-equality predicates (e.g. `where.$top = "2"`),
640+
// which silently returns zero rows for every list endpoint.
641+
for (const [dollar, bare] of [
642+
['$top', 'top'],
643+
['$skip', 'skip'],
644+
['$orderby', 'orderBy'],
645+
['$select', 'select'],
646+
['$count', 'count'],
647+
] as const) {
648+
if (options[dollar] != null && options[bare] == null) {
649+
options[bare] = options[dollar];
650+
}
651+
delete options[dollar];
652+
}
653+
635654
// Numeric fields — normalize top → limit, skip → offset
636655
if (options.top != null) {
637656
options.limit = Number(options.top);

packages/runtime/src/http-dispatcher.ts

Lines changed: 42 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,18 +1759,34 @@ export class HttpDispatcher {
17591759
const req = body || {};
17601760
// Resolve `__session__` placeholders from the active session so clients
17611761
// can omit these fields and let the server infer them.
1762+
// Use `resolveCallerUserId` which properly converts plain header
1763+
// objects to `Headers` instances and probes both
1764+
// `authService.auth.api.getSession` and `authService.api.getSession`
1765+
// (better-auth's API shape differs across wrappings).
17621766
if (req.organization_id === '__session__' || req.created_by === '__session__') {
17631767
try {
1764-
const authService: any = await this.getService(CoreServiceName.enum.auth);
1765-
const sessionData = await authService?.api?.getSession?.({
1766-
headers: _context?.request?.headers,
1767-
});
1768+
const userId = await this.resolveCallerUserId(_context);
1769+
if (req.created_by === '__session__') {
1770+
req.created_by = userId ?? 'system';
1771+
}
17681772
if (req.organization_id === '__session__') {
1773+
// We still need the activeOrganizationId — fetch the
1774+
// session directly via the helper-built Headers.
1775+
const authService: any = await this.resolveService(CoreServiceName.enum.auth);
1776+
const rawHeaders = _context?.request?.headers;
1777+
let headers: any = rawHeaders;
1778+
if (rawHeaders && typeof rawHeaders === 'object' && typeof (rawHeaders as any).get !== 'function') {
1779+
const h = new Headers();
1780+
for (const [k, v] of Object.entries(rawHeaders as Record<string, any>)) {
1781+
if (v == null) continue;
1782+
h.set(k, Array.isArray(v) ? v.join(', ') : String(v));
1783+
}
1784+
headers = h;
1785+
}
1786+
const apiObj = authService?.auth?.api ?? authService?.api;
1787+
const sessionData = await apiObj?.getSession?.call(apiObj, { headers });
17691788
req.organization_id = sessionData?.session?.activeOrganizationId ?? undefined;
17701789
}
1771-
if (req.created_by === '__session__') {
1772-
req.created_by = sessionData?.user?.id ?? 'system';
1773-
}
17741790
} catch {
17751791
// Fall through — validation below will reject missing fields.
17761792
}
@@ -1869,67 +1885,33 @@ export class HttpDispatcher {
18691885
// pre-seed a `sys_user` row for this person on first boot.
18701886
// Without this, the owner lands on their own project as a
18711887
// brand-new SSO JIT user (no admin role, no membership) and
1872-
// has to manually promote themselves. We stash the cloud
1873-
// identity here — the project DB does not exist yet — and let
1874-
// ArtifactKernelFactory replay the seed once the project's
1875-
// sys_user table is provisioned. Best-effort: failure to
1876-
// resolve the user must not abort project creation.
1877-
//
1878-
// We prefer `req.created_by` (already resolved upstream from
1879-
// `__session__` or sent explicitly by the cloud frontend) and
1880-
// look the user row up directly via objectql. The previous
1881-
// approach — calling `authService.api.getSession({headers})`
1882-
// a SECOND time — was unreliable because the cloud's project-
1883-
// create route is often invoked via an internal RPC where the
1884-
// original browser cookies do not propagate.
1888+
// has to manually promote themselves. Best-effort: failure
1889+
// to resolve must not abort project creation.
18851890
try {
1886-
let ownerUserId: string | undefined = req.created_by && req.created_by !== 'system'
1887-
? String(req.created_by)
1888-
: undefined;
1889-
let ownerEmail: string | undefined;
1890-
let ownerName: string | undefined;
1891-
let ownerImage: string | undefined;
1892-
1891+
// Prefer the value already resolved by the upstream
1892+
// `__session__` block (now backed by `resolveCallerUserId`);
1893+
// fall back to `resolveCallerUserId` directly for callers
1894+
// that omitted `created_by` entirely.
1895+
let ownerUserId: string | undefined =
1896+
req.created_by && req.created_by !== 'system'
1897+
? String(req.created_by)
1898+
: undefined;
1899+
if (!ownerUserId) {
1900+
ownerUserId = await this.resolveCallerUserId(_context);
1901+
}
18931902
if (ownerUserId) {
18941903
const userRow = await ql.find('sys_user', { where: { id: ownerUserId } } as any);
18951904
const userRows = Array.isArray(userRow) ? userRow : (userRow?.value ?? []);
18961905
const u = Array.isArray(userRows) && userRows.length > 0 ? userRows[0] : null;
18971906
if (u?.email) {
1898-
ownerEmail = String(u.email);
1899-
ownerName = u.name ? String(u.name) : undefined;
1900-
ownerImage = u.image ? String(u.image) : undefined;
1907+
(baseMetadata as any).ownerSeed = {
1908+
userId: String(ownerUserId),
1909+
email: String(u.email),
1910+
name: u.name ? String(u.name) : null,
1911+
image: u.image ? String(u.image) : null,
1912+
};
19011913
}
19021914
}
1903-
1904-
// Fall back to the session probe if `created_by` was not
1905-
// set (e.g. CLI scripts) — preserves the prior behaviour
1906-
// when cookies are available.
1907-
if (!ownerEmail) {
1908-
try {
1909-
const authService: any = await this.getService(CoreServiceName.enum.auth);
1910-
const sessionData = await authService?.api?.getSession?.({
1911-
headers: _context?.request?.headers,
1912-
});
1913-
const u = sessionData?.user;
1914-
if (u?.id && u?.email) {
1915-
ownerUserId = String(u.id);
1916-
ownerEmail = String(u.email);
1917-
ownerName = u.name ? String(u.name) : undefined;
1918-
ownerImage = u.image ? String(u.image) : undefined;
1919-
}
1920-
} catch {
1921-
// session lookup is best-effort
1922-
}
1923-
}
1924-
1925-
if (ownerUserId && ownerEmail) {
1926-
(baseMetadata as any).ownerSeed = {
1927-
userId: ownerUserId,
1928-
email: ownerEmail,
1929-
name: ownerName ?? null,
1930-
image: ownerImage ?? null,
1931-
};
1932-
}
19331915
} catch {
19341916
// owner lookup failed entirely — skip seed; later access
19351917
// flows still work via the platform-SSO JIT path.

0 commit comments

Comments
 (0)