Skip to content

Commit d344047

Browse files
hotlongCopilot
andcommitted
fix(cli/serve): wire unknown-hostname guard via Plugin so http.server is available
The prior attempt called `kernel.getService('http.server')` immediately after `kernel.use(serverPlugin)`, but `kernel.use()` only registers the plugin — `init()` (which registers the http.server service) runs later during `kernel.bootstrap()`. So getService returned undefined, the guard install silently no-op'd, and unknown subdomains still fell through to the Console SPA. Wrap the middleware install in a tiny inline Plugin whose own `init()` runs during the kernel init phase, when sibling plugins (including HonoServerPlugin) have already registered their services. Env-registry is resolved lazily on each request because it's registered by ObjectOSProjectPlugin's start() phase, not init. Also resolves merge-conflict markers in CHANGELOG.md and package/publish.ts (kept both unreleased changelog entries; kept the DEFAULT_CLOUD_URL constant form of the --server flag default). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2567e29 commit d344047

7 files changed

Lines changed: 258 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ registry" workflows.
5454
- `packages/cli/src/utils/auth-flows.ts` — shared `loginWithBrowser` /
5555
`loginWithPassword` flows so runtime and cloud login paths can stay
5656
in sync without duplicating the RFC 8628 polling loop.
57+
### Added — `objectstack package publish` CLI (ADR-0006 v4 Phase B)
58+
59+
New CLI command for uploading a compiled artifact as a versioned package
60+
into the caller's organization on ObjectStack Cloud. Pairs with the
61+
`POST /cloud/packages` + `POST /cloud/packages/:id/versions` routes
62+
that landed in commit `9f87aa8f`.
63+
64+
- `packages/cli/src/commands/package/publish.ts` — new oclif command
65+
`os package publish [artifact]`. Flow:
66+
1. Reads `dist/objectstack.json` (or path arg).
67+
2. Derives `manifest_id` (default `local.<slug>` if artifact lacks
68+
a reverse-domain id) and version (default `artifact.manifest.version`
69+
or a timestamped `0.0.0-dev.<ts>`).
70+
3. POSTs to `/api/v1/cloud/packages` for idempotent package upsert.
71+
4. POSTs to `/api/v1/cloud/packages/:id/versions` to publish the bundle
72+
as a new `sys_package_version` snapshot.
73+
5. Optionally auto-installs into a target env via `--env <id> --install`.
74+
- Flags cover the full publish surface — `--manifest-id`, `--version`,
75+
`--display-name`, `--description`, `--category`, `--visibility`,
76+
`--org`, `--note`, `--pre-release`, `--seed-sample-data`, `--timeout`.
77+
- Re-exported from `packages/cli/src/index.ts` as `PackagePublishCommand`.
78+
79+
Closes the user-facing half of the ADR-0006 v4 unified Package path: a
80+
local code base can now land in the org Marketplace (or stay private)
81+
without manual SQL or Console clicks. The legacy `objectstack publish`
82+
command (writes `sys_environment_revision`) remains in place for
83+
backward compatibility until the loader fully migrates.
5784

5885
### Changed — ADR-0006 v4: drop dev-workspace Project, unify deploy on Package
5986

packages/cli/src/commands/package/publish.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export default class PackagePublish extends Command {
7878
'$ os package publish --manifest-id com.acme.crm --version 1.2.0',
7979
'$ os package publish --env env_abc123 --install',
8080
'$ os package publish dist/objectstack.json --visibility org --note "first cut"',
81+
'$ OS_CLOUD_URL=http://localhost:4000 os package publish # local dev (apps/cloud)',
8182
];
8283

8384
static override args = {

packages/cli/src/commands/serve.ts

Lines changed: 71 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -599,65 +599,81 @@ export default class Serve extends Command {
599599
// unchanged. Infra paths under /_admin or /.well-known are always
600600
// allowed so health checks / cert flows aren't broken.
601601
//
602-
// Registered BEFORE config plugins so it intercepts before any
603-
// route handler (including the Console static plugin) claims the
604-
// request.
602+
// Implemented as a Plugin so the middleware is wired during init
603+
// (when http.server is available) and BEFORE start() runs on the
604+
// Console static plugin / route-registering plugins. Hono's
605+
// `app.use('*')` is order-independent for matching, so as long as
606+
// the middleware is added before kernel:listening fires, it
607+
// intercepts every request regardless of which plugin registered
608+
// its handler.
605609
const __rootDomain = (process.env.OS_ROOT_DOMAIN || '').trim().toLowerCase();
606610
if (__rootDomain) {
607-
try {
608-
const httpServer: any = kernel.getService?.('http.server') ?? kernel.getService?.('http-server');
609-
const rawApp = httpServer?.getRawApp?.();
610-
if (rawApp && typeof rawApp.use === 'function') {
611-
const RESERVED = new Set(['', 'cloud', 'www', 'api', 'docs', 'admin', 'app']);
612-
let envRegistryRef: any;
613-
const getEnvRegistry = async () => {
614-
if (envRegistryRef !== undefined) return envRegistryRef;
615-
try {
616-
envRegistryRef = (await (kernel as any).getServiceAsync?.('env-registry'))
617-
?? (kernel as any).getService?.('env-registry')
618-
?? null;
619-
} catch {
620-
envRegistryRef = null;
621-
}
622-
return envRegistryRef;
623-
};
624-
rawApp.use('*', async (c: any, next: any) => {
625-
const rawHost = c.req.header('host') || '';
626-
const host = rawHost.split(':')[0].toLowerCase();
627-
if (!host) return next();
628-
const isPlatformHost = host === __rootDomain || host.endsWith('.' + __rootDomain);
629-
if (!isPlatformHost) return next();
630-
const sub = host === __rootDomain ? '' : host.slice(0, -(__rootDomain.length + 1));
631-
const head = sub.split('.').pop() || '';
632-
if (RESERVED.has(sub) || RESERVED.has(head)) return next();
633-
const p = c.req.path;
634-
if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) {
635-
return next();
636-
}
637-
const registry = await getEnvRegistry();
638-
if (!registry || typeof registry.resolveByHostname !== 'function') {
639-
return next();
640-
}
641-
try {
642-
const hit = await registry.resolveByHostname(host);
643-
if (hit) return next();
644-
} catch {
645-
return next();
611+
const RESERVED = new Set(['', 'cloud', 'www', 'api', 'docs', 'admin', 'app']);
612+
const guardPlugin: any = {
613+
name: 'com.objectstack.cli.unknown-hostname-guard',
614+
version: '1.0.0',
615+
init: async (ctx: any) => {
616+
try {
617+
const httpServer: any = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
618+
const rawApp = httpServer?.getRawApp?.();
619+
if (!rawApp || typeof rawApp.use !== 'function') {
620+
ctx.logger?.warn?.('[unknown-hostname-guard] http.server unavailable; guard not installed');
621+
return;
646622
}
647-
return c.json(
648-
{
649-
error: 'environment_not_found',
650-
message: `No environment is bound to hostname '${host}'.`,
651-
hostname: host,
652-
},
653-
404,
654-
);
655-
});
656-
trackPlugin('UnknownHostnameGuard');
657-
}
623+
const getEnvRegistry = () => {
624+
try {
625+
return ctx.getService?.('env-registry') ?? null;
626+
} catch {
627+
return null;
628+
}
629+
};
630+
rawApp.use('*', async (c: any, next: any) => {
631+
const rawHost = c.req.header('host') || '';
632+
const host = rawHost.split(':')[0].toLowerCase();
633+
if (!host) return next();
634+
const isPlatformHost = host === __rootDomain || host.endsWith('.' + __rootDomain);
635+
if (!isPlatformHost) return next();
636+
const sub = host === __rootDomain ? '' : host.slice(0, -(__rootDomain.length + 1));
637+
const head = sub.split('.').pop() || '';
638+
if (RESERVED.has(sub) || RESERVED.has(head)) return next();
639+
const p = c.req.path;
640+
if (p.startsWith('/_admin/') || p === '/_admin' || p.startsWith('/.well-known/')) {
641+
return next();
642+
}
643+
// Resolve env-registry lazily on each request — it may
644+
// not be registered yet at init() time (registered by
645+
// ObjectOSProjectPlugin's init which runs in plugin
646+
// dependency order; we don't want to rely on ordering).
647+
const registry: any = getEnvRegistry();
648+
if (!registry || typeof registry.resolveByHostname !== 'function') {
649+
return next();
650+
}
651+
try {
652+
const hit = await registry.resolveByHostname(host);
653+
if (hit) return next();
654+
} catch {
655+
return next();
656+
}
657+
return c.json(
658+
{
659+
error: 'environment_not_found',
660+
message: `No environment is bound to hostname '${host}'.`,
661+
hostname: host,
662+
},
663+
404,
664+
);
665+
});
666+
ctx.logger?.info?.('[unknown-hostname-guard] installed', { rootDomain: __rootDomain });
667+
} catch (err: any) {
668+
ctx.logger?.warn?.('[unknown-hostname-guard] install failed', { error: err?.message ?? err });
669+
}
670+
},
671+
};
672+
try {
673+
await kernel.use(guardPlugin);
674+
trackPlugin('UnknownHostnameGuard');
658675
} catch {
659-
// Best-effort: if http.server isn't registered or rawApp missing,
660-
// skip the guard. Falls back to legacy behaviour.
676+
// Best-effort.
661677
}
662678
}
663679

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,30 @@ export const SysMember = ObjectSchema.create({
2424
// Row-level actions: better-auth `organization/update-member-role` and
2525
// `organization/remove-member`. Generic CRUD is suppressed on better-auth
2626
// managed tables, so these are the canonical edit/delete entry points.
27+
// The `add_member` toolbar action covers the admin "attach an existing
28+
// user directly without sending an invitation" flow.
2729
actions: [
30+
{
31+
// Admin-only: directly attach an existing user to the active org,
32+
// bypassing the invite-accept flow. Better-auth:
33+
// `organization/add-member { userId, role, organizationId?, teamId? }`.
34+
// organizationId/teamId default to the caller's active org/team when
35+
// omitted, so we leave them as optional params.
36+
name: 'add_member',
37+
label: 'Add Member',
38+
icon: 'user-plus',
39+
variant: 'primary',
40+
locations: ['list_toolbar'],
41+
type: 'api',
42+
target: '/api/v1/auth/organization/add-member',
43+
successMessage: 'Member added',
44+
refreshAfter: true,
45+
params: [
46+
{ name: 'userId', field: 'user_id', required: true },
47+
{ field: 'role', required: true },
48+
{ name: 'organizationId', field: 'organization_id' },
49+
],
50+
},
2851
{
2952
name: 'update_member_role',
3053
label: 'Change Role',

packages/platform-objects/src/identity/sys-organization.object.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ export const SysOrganization = ObjectSchema.create({
7474
successMessage: 'Organization deleted',
7575
refreshAfter: true,
7676
},
77+
{
78+
// Switch the caller's active organization context. Standard
79+
// better-auth endpoint; no extra params needed (org id ships as
80+
// the row id). Used from the Setup list and the record header so
81+
// admins can context-switch without leaving the page.
82+
name: 'set_active_organization',
83+
label: 'Set Active',
84+
icon: 'check-circle-2',
85+
variant: 'secondary',
86+
mode: 'custom',
87+
locations: ['list_item', 'record_header'],
88+
type: 'api',
89+
target: '/api/v1/auth/organization/set-active',
90+
recordIdParam: 'organizationId',
91+
successMessage: 'Active organization switched',
92+
refreshAfter: true,
93+
},
94+
{
95+
// Current user leaves the org. Distinct from `delete_organization`
96+
// (admin-only, destroys the org) — `leave` only removes the caller's
97+
// own membership. Better-auth: `organization/leave { organizationId }`.
98+
name: 'leave_organization',
99+
label: 'Leave Organization',
100+
icon: 'log-out',
101+
variant: 'danger',
102+
mode: 'custom',
103+
locations: ['list_item', 'record_header'],
104+
type: 'api',
105+
target: '/api/v1/auth/organization/leave',
106+
recordIdParam: 'organizationId',
107+
confirmText: 'Leave this organization? You will lose access to all of its resources.',
108+
successMessage: 'You have left the organization',
109+
refreshAfter: true,
110+
},
77111
],
78112

79113
listViews: {

packages/platform-objects/src/identity/sys-team-member.object.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,50 @@ export const SysTeamMember = ObjectSchema.create({
2020
description: 'Team membership records linking users to teams',
2121
titleFormat: '{user_id} in {team_id}',
2222
compactLayout: ['user_id', 'team_id', 'created_at'],
23-
23+
24+
// Custom actions calling better-auth's team-member endpoints. Generic
25+
// CRUD is suppressed (managedBy: 'better-auth') so these are the
26+
// canonical add/remove entry points.
27+
actions: [
28+
{
29+
// Better-auth: `organization/add-team-member { teamId, userId }`.
30+
name: 'add_team_member',
31+
label: 'Add Member',
32+
icon: 'user-plus',
33+
variant: 'primary',
34+
locations: ['list_toolbar'],
35+
type: 'api',
36+
target: '/api/v1/auth/organization/add-team-member',
37+
successMessage: 'Team member added',
38+
refreshAfter: true,
39+
params: [
40+
{ name: 'teamId', field: 'team_id', required: true },
41+
{ name: 'userId', field: 'user_id', required: true },
42+
],
43+
},
44+
{
45+
// Better-auth: `organization/remove-team-member { teamId, userId }`.
46+
// The endpoint identifies the membership by the (teamId, userId)
47+
// pair rather than the join-row id, so we pull both from the row
48+
// via `defaultFromRow` instead of using `recordIdParam`.
49+
name: 'remove_team_member',
50+
label: 'Remove from Team',
51+
icon: 'user-minus',
52+
variant: 'danger',
53+
mode: 'delete',
54+
locations: ['list_item'],
55+
type: 'api',
56+
target: '/api/v1/auth/organization/remove-team-member',
57+
confirmText: 'Remove this user from the team? They will lose any team-scoped access.',
58+
successMessage: 'Team member removed',
59+
refreshAfter: true,
60+
params: [
61+
{ name: 'teamId', field: 'team_id', required: true, defaultFromRow: true },
62+
{ name: 'userId', field: 'user_id', required: true, defaultFromRow: true },
63+
],
64+
},
65+
],
66+
2467
fields: {
2568
id: Field.text({
2669
label: 'Team Member ID',

packages/platform-objects/src/identity/sys-team.object.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,64 @@ export const SysTeam = ObjectSchema.create({
2222
titleFormat: '{name}',
2323
compactLayout: ['name', 'organization_id'],
2424

25+
// Custom actions calling better-auth's team endpoints. Generic CRUD is
26+
// suppressed (managedBy: 'better-auth'), so these are the canonical
27+
// entry points for create/update/delete.
28+
actions: [
29+
{
30+
// Better-auth: `organization/create-team { name, organizationId? }`.
31+
// organizationId defaults to the caller's active org when omitted.
32+
name: 'create_team',
33+
label: 'Create Team',
34+
icon: 'plus',
35+
variant: 'primary',
36+
locations: ['list_toolbar'],
37+
type: 'api',
38+
target: '/api/v1/auth/organization/create-team',
39+
successMessage: 'Team created',
40+
refreshAfter: true,
41+
params: [
42+
{ field: 'name', required: true },
43+
{ name: 'organizationId', field: 'organization_id' },
44+
],
45+
},
46+
{
47+
// Better-auth: `organization/update-team { teamId, data: { name } }`.
48+
// teamId stays flat (top-level); the user-editable params nest under
49+
// `data` via bodyShape.
50+
name: 'update_team',
51+
label: 'Edit Team',
52+
icon: 'pencil',
53+
mode: 'edit',
54+
locations: ['list_item'],
55+
type: 'api',
56+
target: '/api/v1/auth/organization/update-team',
57+
recordIdParam: 'teamId',
58+
bodyShape: { wrap: 'data' },
59+
successMessage: 'Team updated',
60+
refreshAfter: true,
61+
params: [
62+
{ field: 'name', required: true, defaultFromRow: true },
63+
],
64+
},
65+
{
66+
// Better-auth: `organization/remove-team { teamId, organizationId? }`.
67+
// organizationId defaults to the caller's active org when omitted.
68+
name: 'remove_team',
69+
label: 'Delete Team',
70+
icon: 'trash-2',
71+
variant: 'danger',
72+
mode: 'delete',
73+
locations: ['list_item'],
74+
type: 'api',
75+
target: '/api/v1/auth/organization/remove-team',
76+
recordIdParam: 'teamId',
77+
confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.',
78+
successMessage: 'Team deleted',
79+
refreshAfter: true,
80+
},
81+
],
82+
2583
listViews: {
2684
by_org: {
2785
type: 'grid',

0 commit comments

Comments
 (0)