Skip to content

Commit 9f87aa8

Browse files
committed
Auto-mount default per-project plugins and add publish API
Introduce a default capability slate (queue, job, cache, settings, email, storage) that the CLI auto-injects for non-minimal presets and that hosted per-project kernels mount via a new mountDefaultProjectPlugins helper. Wire the helper into ArtifactKernelFactory and DefaultProjectKernelFactory (with an ops override hook basePluginsExtra), add local-storage fallback + prod warning behavior, and export types from service-cloud. Add cloud package publish endpoints (POST /cloud/packages and POST /cloud/packages/:id/versions) to register packages and publish versions (including optional auto-install), plus snapshot/checksum helpers. Update docs and examples to document the always-on slate and storage behavior, add unit tests for the CLI ALWAYS_ON_CAPABILITIES and the default-project plugin mounting, and bump service-cloud package deps / pnpm lockfile accordingly.
1 parent 6b160c9 commit 9f87aa8

15 files changed

Lines changed: 967 additions & 4 deletions

File tree

apps/objectos/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,54 @@ Required runtime env vars (set as Cloudflare secrets, not in `wrangler.toml`):
422422
| `OS_DATABASE_AUTH_TOKEN` | Turso auth token. |
423423
| `AUTH_SECRET` | Cookie/session signing secret. |
424424
| `OS_CLOUD_URL` *(optional)* | Point at an `apps/cloud` deployment for multi-project mode. Omit for single-project local mode. |
425+
426+
## Default per-project plugin slate
427+
428+
ObjectOS keeps the **host kernel intentionally bare** (stateless
429+
routing shell). All tenant-data plugins live on **per-project kernels**,
430+
mounted on first-hit and cached by `ArtifactKernelFactory` /
431+
`DefaultProjectKernelFactory`.
432+
433+
Every per-project kernel automatically gets the following default
434+
plugins (in this order) via `mountDefaultProjectPlugins()`:
435+
436+
1. `QueueServicePlugin` (in-memory)
437+
2. `JobServicePlugin`
438+
3. `CacheServicePlugin` (in-memory)
439+
4. `SettingsServicePlugin``sys_setting` / `sys_secret` /
440+
`sys_setting_audit` rows live in the **project's own driver**, so
441+
tenants are fully isolated
442+
5. `EmailServicePlugin` — each tenant configures its own provider /
443+
api_key via Settings; api_key is stored encrypted in `sys_secret`
444+
6. `StorageServicePlugin` — see below
445+
446+
### Storage adapter selection
447+
448+
- `OS_STORAGE_ADAPTER=s3` + `OS_S3_*` env → shared S3 bucket with
449+
`pathStylePrefix: projects/<projectId>` so tenant prefixes never
450+
collide
451+
- otherwise → local driver under
452+
`<dataRoot>/projects/<projectId>/uploads/`; a single boot warning
453+
fires in non-dev mode
454+
455+
### Ops overrides
456+
457+
The factory accepts `basePluginsExtra({ projectId, kernel })` which can
458+
return `{ caps?: {…: false}, extraPlugins?: [...] }` to:
459+
460+
- inject a **shared Redis-backed queue** so retries survive kernel
461+
eviction (set `caps.queue=false` and push your custom plugin in
462+
`extraPlugins`)
463+
- skip storage when the host worker mounts a shared S3 instance
464+
out-of-band
465+
- mount tenant-specific audit/automation/analytics plugins
466+
467+
### Caveats
468+
469+
- **In-memory queue eviction** — when a per-project kernel is LRU-
470+
evicted, queued retries are dropped. Use the `basePluginsExtra`
471+
hook above to inject a Redis-backed queue for SLA-bound workloads.
472+
- **InMemoryCryptoProvider is per-process** — the default AES key is
473+
regenerated on every restart, so encrypted `sys_secret` rows cannot
474+
be decrypted after a redeploy. Ship `KmsCryptoProvider` (AWS KMS
475+
envelope) before enabling encrypted settings on hosted objectos.

examples/app-crm/objectstack.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ export default defineStack({
4848
// `ui` serves the Studio shell and CRM apps under /_studio/.
4949
// Both are required for a clickable login flow when running `objectstack start`
5050
// off the compiled artifact.
51-
requires: ['ai', 'automation', 'analytics', 'auth', 'ui', 'storage', 'approvals', 'sharing', 'settings'],
51+
// Note: the foundational slate (queue, job, cache, settings, email,
52+
// storage) is auto-injected by the CLI for every non-`minimal`
53+
// preset — see `ALWAYS_CAPS` in packages/cli/src/commands/serve.ts.
54+
// Listed below only the *opt-in* capabilities this stack actually
55+
// wants on top of that slate.
56+
requires: ['ai', 'automation', 'analytics', 'auth', 'ui', 'approvals', 'sharing'],
5257

5358
objects: Object.values(objects),
5459
actions: Object.values(actions),

packages/cli/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,31 @@ os projects bind <id> --artifact dist/objectstack.json # 8. Bind to a Cloud Pro
254254
└── package.json # oclif config under "oclif" key
255255
```
256256

257+
258+
## Default capability slate (always-on)
259+
260+
`objectstack serve` (and `os dev`) auto-mount a baseline slate of
261+
service plugins for every preset **except** `--preset minimal`:
262+
263+
| Capability | Plugin | Why default |
264+
|-------------|------------------------------|--------------------------------------------------------------|
265+
| `queue` | `QueueServicePlugin` | Background retries (mail, audit batching) need a queue. |
266+
| `job` | `JobServicePlugin` | Scheduled jobs (cleanup, reports, cron). |
267+
| `cache` | `CacheServicePlugin` | In-memory adapter; performance baseline. |
268+
| `settings` | `SettingsServicePlugin` | Mail/branding/feature-flag UI + persistence. |
269+
| `email` | `EmailServicePlugin` | Auth callbacks (verify, reset, magic-link) hard-depend. |
270+
| `storage` | `StorageServicePlugin` | Avatars, attachments, exports. Local fallback in dev. |
271+
272+
Apps no longer need to list these in `requires: [...]`.
273+
274+
**Opt-out:** run `objectstack serve --preset minimal` to skip the slate
275+
and only load core + your explicit `requires`.
276+
277+
**Storage warning:** when no `storage:` block is configured the plugin
278+
falls back to the local driver under `.objectstack/data/uploads/`. In
279+
non-dev mode a single `console.warn` fires at boot — switch to S3/GCS/
280+
Azure for production by setting `config.storage` or `OS_STORAGE_*` env.
281+
257282
## License
258283

259284
Apache-2.0

packages/cli/src/commands/serve.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ export default class Serve extends Command {
8686
}),
8787
};
8888

89+
/**
90+
* Capabilities auto-added to every app's `requires` for every preset
91+
* EXCEPT `minimal`. These form the foundation that every server-side
92+
* runtime expects to exist (background work, settings persistence,
93+
* transactional mail, file uploads). Apps may still list these in
94+
* `requires:` explicitly — duplicates are de-duped.
95+
*
96+
* Opt out: `objectstack serve --preset minimal`.
97+
*
98+
* Mirrored on hosted objectos per-project kernels by
99+
* `mountDefaultProjectPlugins()` in `@objectstack/service-cloud`.
100+
*/
101+
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
102+
'queue', 'job', 'cache', 'settings', 'email', 'storage',
103+
]);
104+
89105
/**
90106
* Auto-registered plugin tiers. Plugins explicitly listed in
91107
* `config.plugins` are always loaded — tiers only gate the optional
@@ -306,7 +322,7 @@ export default class Serve extends Command {
306322
// storage). Opt out with `objectstack serve --preset minimal`.
307323
// Keeping `auth → email` above as a defensive rule for users who
308324
// explicitly opt into `minimal` but still enable auth.
309-
const ALWAYS_CAPS = ['queue', 'job', 'cache', 'settings', 'email', 'storage'];
325+
const ALWAYS_CAPS = Serve.ALWAYS_ON_CAPABILITIES;
310326
if (presetName !== 'minimal') {
311327
for (const cap of ALWAYS_CAPS) {
312328
if (!requires.includes(cap)) requires.push(cap);
@@ -1077,6 +1093,26 @@ export default class Serve extends Command {
10771093
));
10781094
arg.provider = 'log';
10791095
}
1096+
} else if (cap === 'storage') {
1097+
// Storage is now in the default capability slate. If the host
1098+
// hasn't configured a backend explicitly we fall back to the
1099+
// local-disk driver under `.objectstack/data/uploads/` so
1100+
// avatars / attachments / report files work out of the box.
1101+
// In production mode we emit a single loud warning so the
1102+
// operator knows to point storage at S3 / GCS / Azure before
1103+
// shipping (data on a single pod is volatile / non-replicated).
1104+
const cfgStorage = (config as any).storage;
1105+
if (cfgStorage && (cfgStorage.driver || cfgStorage.adapter)) {
1106+
arg = cfgStorage;
1107+
} else {
1108+
const root = process.env.OS_STORAGE_ROOT || '.objectstack/data/uploads';
1109+
arg = { driver: 'local', root };
1110+
if (!isDev) {
1111+
console.warn(chalk.yellow(
1112+
` ⚠ StorageServicePlugin using local driver (${root}) — switch to S3/GCS/Azure for production (set config.storage or OS_STORAGE_*).`,
1113+
));
1114+
}
1115+
}
10801116
}
10811117
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
10821118
trackPlugin(spec.export);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest';
2+
import Serve from '../src/commands/serve.js';
3+
4+
describe('serve: ALWAYS_ON_CAPABILITIES default slate', () => {
5+
it('exposes the six foundational capabilities in stable order', () => {
6+
expect(Serve.ALWAYS_ON_CAPABILITIES).toEqual([
7+
'queue', 'job', 'cache', 'settings', 'email', 'storage',
8+
]);
9+
});
10+
11+
it('is frozen so accidental mutation throws', () => {
12+
expect(Object.isFrozen(Serve.ALWAYS_ON_CAPABILITIES)).toBe(true);
13+
});
14+
15+
it('minimal preset has none of the always-on caps in its tier list', () => {
16+
const minimal = Serve.TIER_PRESETS.minimal;
17+
for (const cap of Serve.ALWAYS_ON_CAPABILITIES) {
18+
expect(minimal).not.toContain(cap);
19+
}
20+
expect(minimal).toEqual(['core']);
21+
});
22+
23+
it('default preset does not pre-include the always-on caps (they merge into `requires`, not tier)', () => {
24+
// ALWAYS_CAPS get injected into per-app `requires` at runtime; the
25+
// tier preset is the orthogonal "feature tier" axis.
26+
const def = Serve.TIER_PRESETS.default;
27+
for (const cap of Serve.ALWAYS_ON_CAPABILITIES) {
28+
expect(def).not.toContain(cap);
29+
}
30+
});
31+
});

packages/services/service-cloud/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,15 @@
3232
"@objectstack/objectql": "workspace:^",
3333
"@objectstack/plugin-audit": "workspace:^",
3434
"@objectstack/plugin-auth": "workspace:^",
35+
"@objectstack/plugin-email": "workspace:^",
3536
"@objectstack/plugin-security": "workspace:^",
3637
"@objectstack/runtime": "workspace:^",
38+
"@objectstack/service-cache": "workspace:^",
3739
"@objectstack/service-i18n": "workspace:^",
40+
"@objectstack/service-job": "workspace:^",
3841
"@objectstack/service-package": "workspace:^",
42+
"@objectstack/service-queue": "workspace:^",
43+
"@objectstack/service-settings": "workspace:^",
3944
"@objectstack/service-storage": "workspace:*",
4045
"@objectstack/service-tenant": "workspace:^",
4146
"@objectstack/spec": "workspace:^",

packages/services/service-cloud/src/artifact-kernel-factory.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { DriverPlugin, AppPlugin } from '@objectstack/runtime';
3333
import type { ProjectKernelFactory } from './kernel-manager.js';
3434
import type { EnvironmentDriverRegistry } from './environment-registry.js';
3535
import type { ArtifactApiClient } from './artifact-api-client.js';
36+
import { mountDefaultProjectPlugins } from './default-project-plugins.js';
3637

3738
type IDataDriver = Contracts.IDataDriver;
3839

@@ -183,6 +184,16 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
183184
});
184185
}
185186

187+
// Mount the default per-project plugin slate (queue, job, cache,
188+
// settings, email, storage). Mirrors `ALWAYS_CAPS` in
189+
// `packages/cli/src/commands/serve.ts` so hosted tenants get the
190+
// same foundational services a single-tenant `objectstack dev`
191+
// stack gets.
192+
await mountDefaultProjectPlugins(kernel, {
193+
projectId,
194+
logger: this.logger,
195+
});
196+
186197
const projectName = project.hostname ?? projectId;
187198
const bundle = artifact.metadata as any;
188199
const sys = bundle?.manifest ?? bundle;

packages/services/service-cloud/src/cloud-artifact-api-plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
* GET /cloud/projects/:id/revisions
1717
* POST /cloud/projects/:id/revisions/:commit/activate
1818
* POST /cloud/projects/:id/revisions/prune
19+
* POST /cloud/packages — register/upsert a package (ADR-0006 v4 Phase B)
20+
* POST /cloud/packages/:id/versions — publish a new package version (ADR-0006 v4 Phase B)
1921
* GET /pub/v1/projects/:id/manifest.json
2022
* GET /pub/v1/projects/:id/artifact[?commit=&redirect=]
2123
* GET /pub/v1/projects/:id/revisions
@@ -28,6 +30,7 @@ import { registerPublicRoutes } from './routes/public.js';
2830
import { registerBranchRoutes } from './routes/branches.js';
2931
import { registerProjectLifecycleRoutes } from './routes/project-lifecycle.js';
3032
import { registerPackageInstallRoutes } from './routes/package-install.js';
33+
import { registerPackagePublishRoutes } from './routes/package-publish.js';
3134
import type { ProjectTemplate } from './multi-project-plugin.js';
3235
import type { RouteDeps } from './routes/types.js';
3336

@@ -118,6 +121,7 @@ export function createCloudArtifactApiPlugin(options: CloudArtifactApiPluginOpti
118121
registerBranchRoutes(server, deps);
119122
registerProjectLifecycleRoutes(server, { ...deps, templates: options.templates });
120123
registerPackageInstallRoutes(server, { ...deps, templates: options.templates });
124+
registerPackagePublishRoutes(server, { ...deps, templates: options.templates });
121125
},
122126
stop: async (_ctx: AnyContext) => {},
123127
};

0 commit comments

Comments
 (0)