@saasicat/cli 0.26.0 → 0.27.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/LICENSE +90 -201
- package/bin/saasicat.js +416 -16
- package/dist/index.cjs +561 -15
- package/dist/index.d.cts +377 -1
- package/dist/index.d.ts +377 -1
- package/dist/index.js +532 -14
- package/package.json +7 -6
- package/templates/init/config/saas.yaml.tpl +30 -0
- package/templates/init/src/auth/password.hasher.ts.tpl +33 -0
- package/templates/init/src/saas/admin-manifest.contribution.ts.tpl +33 -0
- package/templates/init/src/saas/admin.module.ts.tpl +24 -0
- package/templates/init/src/saas/feature-ui-registry.ts.tpl +16 -0
- package/templates/init/src/saas/persistence-without-hasher.ts.tpl +24 -0
- package/templates/init/src/saas/persistence.ts.tpl +22 -0
- package/templates/init/src/saas/quota.provider.ts.tpl +31 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schemaVersion: 1
|
|
2
|
+
projectKey: __PROJECT_KEY__
|
|
3
|
+
|
|
4
|
+
app:
|
|
5
|
+
name: __APP_LABEL__
|
|
6
|
+
label: __APP_LABEL__ Cockpit
|
|
7
|
+
|
|
8
|
+
currency: EUR
|
|
9
|
+
vatRate: 19.0
|
|
10
|
+
|
|
11
|
+
marketing:
|
|
12
|
+
availableLocales: [en]
|
|
13
|
+
|
|
14
|
+
# The quickstart path: plans live here, and `loadPlanCatalogFromFile` reads
|
|
15
|
+
# them at boot. Apps that let operators manage plans in the SuperAdmin UI drop
|
|
16
|
+
# this block and pass `dbCatalog` instead — see docs/quickstart.md.
|
|
17
|
+
plans:
|
|
18
|
+
- id: STARTER
|
|
19
|
+
name: Starter
|
|
20
|
+
monthlyNet: 9
|
|
21
|
+
yearlyNet: 90
|
|
22
|
+
features: [__FEATURE_KEY__]
|
|
23
|
+
quotas:__STARTER_QUOTAS__
|
|
24
|
+
- id: PRO
|
|
25
|
+
name: Pro
|
|
26
|
+
popular: true
|
|
27
|
+
monthlyNet: 29
|
|
28
|
+
yearlyNet: 290
|
|
29
|
+
features: [__FEATURE_KEY__]
|
|
30
|
+
quotas:__PRO_QUOTAS__
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
import type { PasswordHasher } from '@saasicat/types';
|
|
4
|
+
|
|
5
|
+
const KEY_LENGTH = 64;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* scrypt, so the SuperAdmin setup wizard works before you have decided.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not a default the platform ships: hashing is a decision with
|
|
11
|
+
* consequences, and a framework that picks one for you picks it for everyone.
|
|
12
|
+
* For production, argon2id with tuned parameters — this class is the seam.
|
|
13
|
+
*/
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class __HASHER_CLASS__ implements PasswordHasher {
|
|
16
|
+
async hash(plain: string): Promise<string> {
|
|
17
|
+
const salt = randomBytes(16).toString('hex');
|
|
18
|
+
const derived = scryptSync(plain, salt, KEY_LENGTH).toString('hex');
|
|
19
|
+
return `scrypt:${salt}:${derived}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async verify(hash: string, plain: string): Promise<boolean> {
|
|
23
|
+
const [scheme, salt, derived] = hash.split(':');
|
|
24
|
+
if (scheme !== 'scrypt' || !salt || !derived) return false;
|
|
25
|
+
const candidate = scryptSync(plain, salt, KEY_LENGTH);
|
|
26
|
+
const stored = Buffer.from(derived, 'hex');
|
|
27
|
+
// `timingSafeEqual` THROWS on unequal lengths, so a truncated or
|
|
28
|
+
// corrupted stored hash would be a 500 rather than a failed login.
|
|
29
|
+
// The length is not a secret; comparing it first leaks nothing.
|
|
30
|
+
if (stored.length !== candidate.length) return false;
|
|
31
|
+
return timingSafeEqual(candidate, stored);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ManifestContribution } from '@saasicat/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What this app adds to the SuperAdmin UI.
|
|
5
|
+
*
|
|
6
|
+
* The platform derives capabilities for every standard module it mounts —
|
|
7
|
+
* discovery, catalogue, tenants, users, audit, subscriptions, promo codes.
|
|
8
|
+
* What it cannot derive is anything of yours, so this file is where an
|
|
9
|
+
* app-specific dashboard card or project page is declared.
|
|
10
|
+
*
|
|
11
|
+
* Without a registered contribution the sidebar is empty: the nav builder
|
|
12
|
+
* drops every page whose `requiredCapability` is not `true`.
|
|
13
|
+
*/
|
|
14
|
+
export const __MANIFEST_CONST__: ManifestContribution = {
|
|
15
|
+
capabilities: {
|
|
16
|
+
'dashboard.read': true,
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
dashboard: {
|
|
20
|
+
kpiCards: [
|
|
21
|
+
{
|
|
22
|
+
id: '__PROJECT_KEY__.tenants',
|
|
23
|
+
label: 'Tenants',
|
|
24
|
+
// Served by your own controller — the platform does not know
|
|
25
|
+
// what your KPIs count.
|
|
26
|
+
endpoint: '__API_BASE__/stats/tenants',
|
|
27
|
+
displayHint: { type: 'value', icon: 'business' },
|
|
28
|
+
slotPriority: 90,
|
|
29
|
+
requiredCapability: 'dashboard.read',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Module, type OnModuleInit } from '@nestjs/common';
|
|
2
|
+
// From `/platform`, not `/admin`: the CJS bundles do not share code between
|
|
3
|
+
// entries, so only this entry hands out the same class object that
|
|
4
|
+
// `SaaSiCatModule` registered.
|
|
5
|
+
import { AdminManifestService } from '@saasicat/nest/platform';
|
|
6
|
+
|
|
7
|
+
import { __MANIFEST_CONST__ } from './admin-manifest.contribution';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers this app's manifest contribution.
|
|
11
|
+
*
|
|
12
|
+
* Add the controllers that serve your dashboard KPIs here — the contribution
|
|
13
|
+
* declares the cards, this module is what mounts the endpoints they point at.
|
|
14
|
+
*/
|
|
15
|
+
@Module({
|
|
16
|
+
controllers: [],
|
|
17
|
+
})
|
|
18
|
+
export class __ADMIN_MODULE_CLASS__ implements OnModuleInit {
|
|
19
|
+
constructor(private readonly manifest: AdminManifestService) {}
|
|
20
|
+
|
|
21
|
+
onModuleInit(): void {
|
|
22
|
+
this.manifest.register(__MANIFEST_CONST__);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FeatureUiRegistry } from '@saasicat/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Labels and icons for the feature and quota keys discovery finds in your code.
|
|
5
|
+
*
|
|
6
|
+
* It does not decide what exists — the `@RequireFeature` and `@DefinesQuota`
|
|
7
|
+
* decorators do, and the scanner reads them at boot. This only stops the
|
|
8
|
+
* SuperAdmin catalogue from showing raw keys. Icons are Material Symbols names.
|
|
9
|
+
*/
|
|
10
|
+
export const __REGISTRY_CONST__: FeatureUiRegistry = {
|
|
11
|
+
__FEATURE_KEY__: {
|
|
12
|
+
label: '__APP_LABEL__',
|
|
13
|
+
description: 'What this feature lets a tenant do.',
|
|
14
|
+
icon: 'check_circle',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { prismaPersistence } from '@saasicat/adapter-prisma';
|
|
2
|
+
|
|
3
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every repository the platform needs, from one Prisma client.
|
|
7
|
+
*
|
|
8
|
+
* The `--skip-hasher` variant. The bundle is still generated — an app without
|
|
9
|
+
* one fails `core.adapters-bound` on its first boot — but it names no hasher,
|
|
10
|
+
* because you said you have your own.
|
|
11
|
+
*
|
|
12
|
+
* Bind it before the SuperAdmin setup wizard or self-registration runs: both
|
|
13
|
+
* write a password, and neither can do it without one.
|
|
14
|
+
*/
|
|
15
|
+
export const persistence = prismaPersistence({
|
|
16
|
+
client: PrismaService,
|
|
17
|
+
// passwordHasher: YourHasher,
|
|
18
|
+
// If your models are called something else, map them here:
|
|
19
|
+
// `adminResources: { delegates: { tenant: 'organization' },
|
|
20
|
+
// fields: { tenant: { users: 'members' } } }`
|
|
21
|
+
// — see docs/migrating-an-existing-app.md. Left out on purpose otherwise:
|
|
22
|
+
// the tenant row counter defaults to whatever the mapping says the users
|
|
23
|
+
// relation is called, and naming it here would hardcode `users` past that.
|
|
24
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { prismaPersistence } from '@saasicat/adapter-prisma';
|
|
2
|
+
|
|
3
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
4
|
+
import { __HASHER_CLASS__ } from '../auth/__HASHER_FILE__';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every repository the platform needs, from one Prisma client.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `app.module.ts` so the wiring is one import there and the
|
|
10
|
+
* decisions are visible here: which client, which hasher, and what the
|
|
11
|
+
* SuperAdmin tenant list counts.
|
|
12
|
+
*/
|
|
13
|
+
export const persistence = prismaPersistence({
|
|
14
|
+
client: PrismaService,
|
|
15
|
+
passwordHasher: __HASHER_CLASS__,
|
|
16
|
+
// If your models are called something else, map them here:
|
|
17
|
+
// `adminResources: { delegates: { tenant: 'organization' },
|
|
18
|
+
// fields: { tenant: { users: 'members' } } }`
|
|
19
|
+
// — see docs/migrating-an-existing-app.md. Left out on purpose otherwise:
|
|
20
|
+
// the tenant row counter defaults to whatever the mapping says the users
|
|
21
|
+
// relation is called, and naming it here would hardcode `users` past that.
|
|
22
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { DefinesQuota } from '@saasicat/nest/discovery';
|
|
3
|
+
import type { QuotaProvider } from '@saasicat/types';
|
|
4
|
+
|
|
5
|
+
import { PrismaService } from '../prisma/prisma.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What is countable, and how to count it.
|
|
9
|
+
*
|
|
10
|
+
* The decorator is the single source of truth — discovery reads it at boot and
|
|
11
|
+
* the quota becomes reviewable in the SuperAdmin UI. `count()` is what
|
|
12
|
+
* `@EnforceQuota('__QUOTA_KEY__')` calls at request time, so it has to be cheap.
|
|
13
|
+
*/
|
|
14
|
+
@Injectable()
|
|
15
|
+
@DefinesQuota({
|
|
16
|
+
key: '__QUOTA_KEY__',
|
|
17
|
+
label: '__QUOTA_LABEL__',
|
|
18
|
+
unit: 'count',
|
|
19
|
+
// continuous | monthlyReset | hardCap — hardCap refuses at the limit.
|
|
20
|
+
policy: 'hardCap',
|
|
21
|
+
feature: '__FEATURE_KEY__',
|
|
22
|
+
})
|
|
23
|
+
export class __QUOTA_CLASS__ implements QuotaProvider {
|
|
24
|
+
readonly key = '__QUOTA_KEY__';
|
|
25
|
+
|
|
26
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
27
|
+
|
|
28
|
+
async count(tenantId: string): Promise<number> {
|
|
29
|
+
return this.prisma.__QUOTA_MODEL__.count({ where: { tenantId } });
|
|
30
|
+
}
|
|
31
|
+
}
|