@stacksjs/sites 0.71.1
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/LICENSE.md +21 -0
- package/README.md +36 -0
- package/dist/context.d.ts +34 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +2 -0
- package/dist/middleware.d.ts +12 -0
- package/dist/resolver.d.ts +35 -0
- package/dist/scoping.d.ts +20 -0
- package/dist/snapshot.d.ts +15 -0
- package/dist/types.d.ts +35 -0
- package/package.json +67 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Open Web Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @stacksjs/sites
|
|
2
|
+
|
|
3
|
+
Request-level multi-site tenancy: one Stacks app serving many public web
|
|
4
|
+
properties, each resolved from the request's Host header.
|
|
5
|
+
|
|
6
|
+
A host resolves in this order:
|
|
7
|
+
|
|
8
|
+
1. exact match on a **verified** `site_domains.domain` row (custom domains)
|
|
9
|
+
2. `{subdomain}.{config.sites.baseDomain}` matching `sites.subdomain`
|
|
10
|
+
3. a configured platform host (the app itself) - no site, never 404'd
|
|
11
|
+
4. nothing - null, or 404 when `config.sites.strict` is on
|
|
12
|
+
|
|
13
|
+
## The two channels
|
|
14
|
+
|
|
15
|
+
- **API / bun-router:** the `siteResolver` middleware stamps `request.site`
|
|
16
|
+
and the ambient AsyncLocalStorage context (`currentSite()`, `requireSite()`).
|
|
17
|
+
- **STX pages:** ALS does not survive into stx-serve's render. The serving
|
|
18
|
+
layer stashes `toSiteSnapshot(site)` on the request-context snapshot, and
|
|
19
|
+
`<script server>` blocks read `requestContext.site()`. Never call
|
|
20
|
+
`currentSite()` from an stx server script.
|
|
21
|
+
|
|
22
|
+
## Scoping is explicit
|
|
23
|
+
|
|
24
|
+
There is no automatic global scope, on purpose: generated `Model.where()`
|
|
25
|
+
statics offer no interception point, and an implicit ALS scope silently
|
|
26
|
+
vanishes in queue workers and CLI runs - exactly where a missing scope becomes
|
|
27
|
+
a cross-tenant leak. Instead:
|
|
28
|
+
|
|
29
|
+
- data-layer functions take `siteId` as a required argument; the route
|
|
30
|
+
boundary resolves it once via `requireSite()`
|
|
31
|
+
- `forSite(qb)` scopes a query builder to the ambient site
|
|
32
|
+
- `siteOwnership()` plugs into `model.ownership` so admin `useApi` routes are
|
|
33
|
+
restricted to sites the caller's active team owns
|
|
34
|
+
|
|
35
|
+
Deploy-level tenancy (`cloud.attachTo`) is a different axis: that is many apps
|
|
36
|
+
on one box. This is many sites in one app.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SiteContext } from './types';
|
|
2
|
+
/** Run `fn` with `site` as the ambient site context. */
|
|
3
|
+
export declare function runWithSite<T>(site: SiteContext | null, fn: () => T): T;
|
|
4
|
+
/**
|
|
5
|
+
* Set the ambient site for the remainder of the current async scope.
|
|
6
|
+
*
|
|
7
|
+
* For middleware pipelines that cannot wrap the downstream handler in a
|
|
8
|
+
* callback. `enterWith` binds to the current execution context, which the
|
|
9
|
+
* router's per-request scope already isolates.
|
|
10
|
+
*/
|
|
11
|
+
export declare function setCurrentSite(site: SiteContext | null): void;
|
|
12
|
+
/** The resolved site, or undefined outside any site scope (jobs, CLI, platform hosts). */
|
|
13
|
+
export declare function currentSite(): SiteContext | undefined;
|
|
14
|
+
/** The resolved site's id, or undefined. */
|
|
15
|
+
export declare function currentSiteId(): number | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* The resolved site, or throw 404.
|
|
18
|
+
*
|
|
19
|
+
* The route boundary calls this exactly once and passes `site.id` down
|
|
20
|
+
* explicitly - data-layer functions take `siteId` as a required argument
|
|
21
|
+
* rather than defaulting to ambient context, so a forgotten scope is a
|
|
22
|
+
* compile error, not a cross-tenant query.
|
|
23
|
+
*/
|
|
24
|
+
export declare function requireSite(): SiteContext;
|
|
25
|
+
/**
|
|
26
|
+
* Thrown when a handler that only makes sense on a tenant site runs without
|
|
27
|
+
* one - an unknown host, or a platform host hitting a site-only route.
|
|
28
|
+
* Carries `status: 404` so the router's error mapping answers Not Found
|
|
29
|
+
* rather than 500: an unknown host is the visitor's dead end, not our bug.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SiteNotResolvedError extends Error {
|
|
32
|
+
readonly status: number;
|
|
33
|
+
constructor(message?: string);
|
|
34
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type { SiteSnapshotShape } from './snapshot';
|
|
2
|
+
export type { HostKind, ResolvedSitesOptions, SiteContext, SiteStore } from './types';
|
|
3
|
+
export {
|
|
4
|
+
currentSite,
|
|
5
|
+
currentSiteId,
|
|
6
|
+
requireSite,
|
|
7
|
+
runWithSite,
|
|
8
|
+
setCurrentSite,
|
|
9
|
+
SiteNotResolvedError,
|
|
10
|
+
} from './context';
|
|
11
|
+
export { default as siteResolver } from './middleware';
|
|
12
|
+
export {
|
|
13
|
+
classifyHost,
|
|
14
|
+
clearSiteCache,
|
|
15
|
+
databaseSiteStore,
|
|
16
|
+
isPlatformHost,
|
|
17
|
+
normalizeHost,
|
|
18
|
+
requestHost,
|
|
19
|
+
resolveSiteByHost,
|
|
20
|
+
sitesOptions,
|
|
21
|
+
} from './resolver';
|
|
22
|
+
export { forSite, siteOwnership } from './scoping';
|
|
23
|
+
export { toSiteSnapshot } from './snapshot';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
export{R as toSiteSnapshot,c as sitesOptions,n as siteResolver,C as siteOwnership,S as setCurrentSite,i as runWithSite,H as resolveSiteByHost,s as requireSite,d as requestHost,u as normalizeHost,h as isPlatformHost,v as forSite,x as databaseSiteStore,r as currentSiteId,o as currentSite,m as clearSiteCache,l as classifyHost,p as SiteNotResolvedError};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Middleware } from '@stacksjs/router';
|
|
2
|
+
declare const __dtsx_default_export__: Middleware;
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the request's site from its Host header and publish it two ways:
|
|
5
|
+
* as `request.site` for handlers that hold the request, and as the ambient
|
|
6
|
+
* ALS context for model/query code downstream (`currentSite()`).
|
|
7
|
+
*
|
|
8
|
+
* Register under the `site` alias in `app/Middleware.ts` and attach to the
|
|
9
|
+
* route groups that serve tenant traffic. With `config.sites.strict`, an
|
|
10
|
+
* unknown host 404s here; platform hosts always pass with a null site.
|
|
11
|
+
*/
|
|
12
|
+
export default __dtsx_default_export__;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HostKind, ResolvedSitesOptions, SiteContext, SiteStore } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Lowercase, strip the port and any trailing dot. `Host: StMarks.CampusHQ.com:443.`
|
|
4
|
+
* and `stmarks.campushq.com` are the same site; DNS names are case-insensitive
|
|
5
|
+
* and the port is transport, not identity.
|
|
6
|
+
*
|
|
7
|
+
* IDN hosts arrive from the browser already punycoded (`xn--...`), so domains
|
|
8
|
+
* must be STORED punycoded; this function does not transcode.
|
|
9
|
+
*/
|
|
10
|
+
export declare function normalizeHost(raw: string | null | undefined): string;
|
|
11
|
+
/** `config.sites` with every field resolved to a concrete value. */
|
|
12
|
+
export declare function sitesOptions(): ResolvedSitesOptions;
|
|
13
|
+
/**
|
|
14
|
+
* Pure classification of a normalized host. Platform wins over subdomain so
|
|
15
|
+
* the app's own hosts (`www.<baseDomain>`, the apex itself) can never resolve
|
|
16
|
+
* as tenants even when they syntactically look like one.
|
|
17
|
+
*/
|
|
18
|
+
export declare function classifyHost(host: string, options: Pick<ResolvedSitesOptions, 'baseDomain' | 'platformHosts'>): HostKind;
|
|
19
|
+
/**
|
|
20
|
+
* The Host header this request should be judged by. Prefers the proxy's
|
|
21
|
+
* `X-Forwarded-Host` when configured to (both the stx views proxy and the rpx
|
|
22
|
+
* gateway forward it); otherwise the connection's own Host.
|
|
23
|
+
*/
|
|
24
|
+
export declare function requestHost(headers: Headers, options: Pick<ResolvedSitesOptions, 'trustProxyHost'>): string;
|
|
25
|
+
export declare function clearSiteCache(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Host -> site, cached. Null means "no such site": platform hosts, unknown
|
|
28
|
+
* hosts, and disabled multi-site all land there - `strict` decides what the
|
|
29
|
+
* caller does about unknown ones.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveSiteByHost(rawHost: string, store?: SiteStore, options?: ResolvedSitesOptions): Promise<SiteContext | null>;
|
|
32
|
+
/** Is this host the app itself rather than a tenant site? */
|
|
33
|
+
export declare function isPlatformHost(rawHost: string, options?: ResolvedSitesOptions): boolean;
|
|
34
|
+
/** The default store: `sites` + `site_domains` via the query builder. */
|
|
35
|
+
export declare const databaseSiteStore: SiteStore;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope a query to the ambient site. EXPLICIT by design: the generated
|
|
3
|
+
* `Model.where()` statics have no interception point for an automatic global
|
|
4
|
+
* scope, and an ALS-driven implicit one would silently vanish in queue
|
|
5
|
+
* workers, cron and the CLI - exactly where a missing scope becomes a
|
|
6
|
+
* cross-tenant leak. A visible `forSite(...)` is grep-able and auditable.
|
|
7
|
+
*/
|
|
8
|
+
// eslint-disable-next-line pickier/no-unused-vars -- `args` names the variadic in the structural type only
|
|
9
|
+
export declare function forSite<QB extends { where: (...args: any[]) => QB }>(qb: QB, column?: string, siteId?: number | undefined): QB;
|
|
10
|
+
/**
|
|
11
|
+
* `model.ownership` config for ADMIN surfaces (dashboard editing of
|
|
12
|
+
* site-scoped content through `useApi` routes): the authenticated user may
|
|
13
|
+
* touch rows belonging to any site their active team owns. Public site reads
|
|
14
|
+
* never ride this - they go through dedicated routes that call
|
|
15
|
+
* `requireSite()` and scope by the request's host instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function siteOwnership(): {
|
|
18
|
+
field: string
|
|
19
|
+
resolve: (user: unknown, req: unknown) => Promise<number[] | null>
|
|
20
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SiteContext } from './types';
|
|
2
|
+
export declare function toSiteSnapshot(site: SiteContext | null | undefined): SiteSnapshotShape | null;
|
|
3
|
+
/**
|
|
4
|
+
* The shape the stx serving layer stashes on the request-context snapshot
|
|
5
|
+
* (`RequestContextSnapshot.site` in `@stacksjs/config`), which is how
|
|
6
|
+
* `<script server>` blocks see the site - ALS does not survive into
|
|
7
|
+
* stx-serve's render, so this is the one channel that works there.
|
|
8
|
+
*/
|
|
9
|
+
export declare interface SiteSnapshotShape {
|
|
10
|
+
id: number
|
|
11
|
+
uuid?: string
|
|
12
|
+
name?: string
|
|
13
|
+
subdomain?: string
|
|
14
|
+
settings?: Record<string, unknown>
|
|
15
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the resolver hands the rest of the request: enough to scope queries,
|
|
3
|
+
* theme a render, and build absolute URLs - without another DB round trip.
|
|
4
|
+
*/
|
|
5
|
+
export declare interface SiteContext {
|
|
6
|
+
id: number
|
|
7
|
+
uuid: string
|
|
8
|
+
name: string
|
|
9
|
+
subdomain: string
|
|
10
|
+
host: string
|
|
11
|
+
teamId: number | null
|
|
12
|
+
status: string
|
|
13
|
+
settings: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
/** The pieces of `config.sites` the resolver actually reads, resolved to values. */
|
|
16
|
+
export declare interface ResolvedSitesOptions {
|
|
17
|
+
enabled: boolean
|
|
18
|
+
baseDomain: string
|
|
19
|
+
platformHosts: string[]
|
|
20
|
+
strict: boolean
|
|
21
|
+
trustProxyHost: boolean
|
|
22
|
+
cacheTtlSeconds: number
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The storage seam the resolver reads through, injectable so the pure
|
|
26
|
+
* resolution pipeline is testable without a database. The default store
|
|
27
|
+
* queries `sites` / `site_domains` via `@stacksjs/database`.
|
|
28
|
+
*/
|
|
29
|
+
export declare interface SiteStore {
|
|
30
|
+
byDomain: (domain: string) => Promise<SiteContext | null>
|
|
31
|
+
bySubdomain: (subdomain: string) => Promise<SiteContext | null>
|
|
32
|
+
}
|
|
33
|
+
export type HostKind = | { kind: 'platform' }
|
|
34
|
+
| { kind: 'subdomain', subdomain: string }
|
|
35
|
+
| { kind: 'custom', domain: string }
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stacksjs/sites",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.71.1",
|
|
5
|
+
"description": "The Stacks multi-site (request-level tenancy) functionality.",
|
|
6
|
+
"author": "Chris Breuer",
|
|
7
|
+
"contributors": [
|
|
8
|
+
"Chris Breuer <chris@stacksjs.com>"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"funding": "https://github.com/sponsors/chrisbbreuer",
|
|
12
|
+
"homepage": "https://github.com/stacksjs/stacks/tree/main/storage/framework/core/sites#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
16
|
+
"directory": "./storage/framework/core/sites"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/stacksjs/stacks/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"multi-site",
|
|
23
|
+
"tenancy",
|
|
24
|
+
"domains",
|
|
25
|
+
"stacks"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"bun": "./dist/index.js",
|
|
31
|
+
"import": "./dist/index.js",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./*": {
|
|
35
|
+
"types": "./dist/*.d.ts",
|
|
36
|
+
"bun": "./dist/*.js",
|
|
37
|
+
"import": "./dist/*.js",
|
|
38
|
+
"default": "./dist/*.js"
|
|
39
|
+
},
|
|
40
|
+
"./*.js": {
|
|
41
|
+
"types": "./dist/*.d.ts",
|
|
42
|
+
"bun": "./dist/*.js",
|
|
43
|
+
"import": "./dist/*.js",
|
|
44
|
+
"default": "./dist/*.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"module": "dist/index.js",
|
|
48
|
+
"types": "dist/index.d.ts",
|
|
49
|
+
"files": [
|
|
50
|
+
"README.md",
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "bun build.ts",
|
|
55
|
+
"typecheck": "bun tsc --noEmit",
|
|
56
|
+
"prepublishOnly": "bun run build"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@stacksjs/auth": "0.71.1",
|
|
60
|
+
"@stacksjs/config": "0.71.1",
|
|
61
|
+
"@stacksjs/database": "0.71.1",
|
|
62
|
+
"@stacksjs/error-handling": "0.71.1",
|
|
63
|
+
"@stacksjs/router": "0.71.1",
|
|
64
|
+
"better-dx": "^0.2.23"
|
|
65
|
+
},
|
|
66
|
+
"sideEffects": false
|
|
67
|
+
}
|