Compare commits

...

23 commits

Author SHA1 Message Date
Julien Oculi 28a517323b feat(model): add new static method load to create ressources from partial entry 2024-06-18 14:05:45 +02:00
Julien Oculi 873f4d23dc feat(chore): add avatar utility class 2024-06-18 14:04:38 +02:00
Julien Oculi 3e3fd59fe7 fix(model): 🐛 wrong definition for static method create 2024-06-18 13:52:24 +02:00
Julien Oculi 5ecebbec14 refactor(model): ♻️ move avatar attribute to Ressource base model 2024-06-18 13:35:49 +02:00
Julien Oculi 1fb1f1ce15 refactor(model): 🏷️ force type casting for toRef method 2024-06-18 11:49:28 +02:00
Julien Oculi aefdacbf59 refactor: ♻️ fix types and update import to import type 2024-06-18 10:29:21 +02:00
Julien Oculi 2028c0134a fix(db): 🏷️ update types to allow async filter for listing ressources 2024-06-18 10:14:22 +02:00
Julien Oculi 3b378b0968 chore(chore): 🙈 untrack sqlite artifacts and tmp test directories 2024-06-18 10:12:18 +02:00
Julien Oculi 8214dcd625 feat(db): allow async filter for ressource listing 2024-06-18 10:09:29 +02:00
Julien Oculi fa88ab2f68 refactor(db): ♻️ update types to fit recents commits 2024-06-18 10:07:45 +02:00
Julien Oculi 626bc3c0f6 feat(chore): add global module exports 2024-06-17 18:41:05 +02:00
Julien Oculi ba678692a5 refactor(chore): ♻️ update import to import type 2024-06-17 18:40:38 +02:00
Julien Oculi 9520d222ae refactor(model): ♻️ update references to credentials 2024-06-17 18:38:57 +02:00
Julien Oculi a5b31dd967 feat(model): refactor Credential model and add Passkey credential store type 2024-06-17 18:31:28 +02:00
Julien Oculi f0899797d8 refactor(model): ♻️ refactor Machine model to remove URL class attributes 2024-06-17 18:15:47 +02:00
Julien Oculi 6d08698d10 feat(model): refactor Group model and add groups attributes for inheritance 2024-06-17 18:10:07 +02:00
Julien Oculi fda7b03e59 feat(model): refactor and add tags to Service model 2024-06-17 18:00:28 +02:00
Julien Oculi 42006cc18e refactor(model): ♻️ update url string type to be more specific 2024-06-17 17:08:04 +02:00
Julien Oculi 33868a3995 chore(model): 🏷️ add meaningful string type aliases 2024-06-17 17:03:39 +02:00
Julien Oculi cbd14996d4 refactor(model): ♻️ use import type instead of import when possible 2024-06-17 13:49:42 +02:00
Julien Oculi 92a6ae01a3 chore(chore): 🔧 add package version and exports 2024-06-17 13:46:10 +02:00
Julien Oculi 98444ea685 chore: 🔧 update vscode commit scopes 2024-06-17 13:41:21 +02:00
Julien Oculi 3c773bca6b refactor(model): ♻️ rename type string to Base64String where is meaningful 2024-06-17 13:40:18 +02:00
15 changed files with 371 additions and 149 deletions

4
.gitignore vendored
View file

@ -1,6 +1,8 @@
# Directories # Directories
docs docs
tmp
temp
# Files # Files
.env .env
*.sqlite *.sqlite*

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"conventionalCommits.scopes": ["model", "chore", "db"]
}

View file

@ -1,5 +1,10 @@
{ {
"name": "@cohabit/ressources", "name": "@cohabit/ressources",
"version": "0.1.0",
"exports": {
".": "./mod.ts",
"./models": "./src/models/mod.ts"
},
"tasks": { "tasks": {
"start": "deno run --unstable-kv ./mod.ts" "start": "deno run --unstable-kv ./mod.ts"
}, },

2
mod.ts Normal file
View file

@ -0,0 +1,2 @@
export * from '@models'
export { Db } from '@db'

View file

@ -1,4 +1,12 @@
import { Credential, Group, Machine, Ressource, Service, User } from '@models' import {
Credential,
Group,
Machine,
type Ressource,
Service,
User,
} from '@models'
import type { CredentialCategory } from '@models/credential.ts'
//!TODO link ressources (get, list) //!TODO link ressources (get, list)
//!TODO Purge unused ressources (delete) //!TODO Purge unused ressources (delete)
@ -26,7 +34,10 @@ export class Db {
get ressource() { get ressource() {
return { return {
credential: this.#ressourceStorage<Credential>('credential', Credential), credential: this.#ressourceStorage<Credential<CredentialCategory>>(
'credential',
Credential,
),
group: this.#ressourceStorage<Group>('group', Group), group: this.#ressourceStorage<Group>('group', Group),
machine: this.#ressourceStorage<Machine>('machine', Machine), machine: this.#ressourceStorage<Machine>('machine', Machine),
service: this.#ressourceStorage<Service>('service', Service), service: this.#ressourceStorage<Service>('service', Service),
@ -44,8 +55,9 @@ export class Db {
set: (ressources: T[]) => this.#set<T>(type, ressources), set: (ressources: T[]) => this.#set<T>(type, ressources),
delete: (ressources: Pick<T, 'uuid'>[]) => delete: (ressources: Pick<T, 'uuid'>[]) =>
this.#delete<T>(type, ressources), this.#delete<T>(type, ressources),
list: (filter: (ressource: T) => boolean = () => true) => list: (
this.#list<T>(type, Builder, filter), filter: (ressource: T) => boolean | Promise<boolean> = () => true,
) => this.#list<T>(type, Builder, filter),
} }
} }
@ -94,7 +106,7 @@ export class Db {
async *#list<T extends Ressource>( async *#list<T extends Ressource>(
type: RessourceType<T>, type: RessourceType<T>,
Builder: RessourceBuilder<T>, Builder: RessourceBuilder<T>,
filter: (entry: T) => boolean, filter: (entry: T) => boolean | Promise<boolean>,
): AsyncGenerator<T, void, void> { ): AsyncGenerator<T, void, void> {
const list = this.#kv.list<RessourceJson<T>>({ const list = this.#kv.list<RessourceJson<T>>({
prefix: [this.prefix.ressource, type], prefix: [this.prefix.ressource, type],
@ -103,22 +115,23 @@ export class Db {
const value = entry.value const value = entry.value
//@ts-expect-error Type union of Ressource types for Builder //@ts-expect-error Type union of Ressource types for Builder
const ressource = Builder.fromJSON(value) as T const ressource = Builder.fromJSON(value) as T
if (filter(ressource)) { if (await filter(ressource)) {
yield ressource yield ressource
} }
} }
} }
} }
type RessourceType<T extends Ressource> = T extends Credential ? 'credential' type RessourceType<T extends Ressource> = T extends
Credential<CredentialCategory> ? 'credential'
: T extends Group ? 'group' : T extends Group ? 'group'
: T extends Machine ? 'machine' : T extends Machine ? 'machine'
: T extends Service ? 'service' : T extends Service ? 'service'
: T extends User ? 'user' : T extends User ? 'user'
: never : never
type RessourceBuilder<T extends Ressource> = T extends Credential type RessourceBuilder<T extends Ressource> = T extends
? typeof Credential Credential<CredentialCategory> ? typeof Credential
: T extends Group ? typeof Group : T extends Group ? typeof Group
: T extends Machine ? typeof Machine : T extends Machine ? typeof Machine
: T extends Service ? typeof Service : T extends Service ? typeof Service

View file

@ -1,27 +1,54 @@
import { ToJson } from '@/types.ts' import type { Base64String, ToJson, UUID } from '@/types.ts'
import { Ressource } from '@models/ressource.ts' import { Ressource } from '@models/ressource.ts'
import type { Ref } from '@models'
import { Avatar } from '@/src/models/utils/avatar.ts'
export class Credential extends Ressource { export class Credential<T extends CredentialCategory> extends Ressource {
static fromJSON( static fromJSON<T extends CredentialCategory>(
json: ToJson<Credential>, json: ToJson<Credential<T>>,
): Credential { ): Credential<T> {
return new Credential(json) return new Credential(json)
} }
static create( static create<T extends CredentialCategory>(
{ category, store, name }: Pick<Credential, 'category' | 'store' | 'name'>, { category, store, name, avatar }: Pick<
): Credential { Credential<T>,
const { uuid, createdAt, updatedAt } = super.create({ name }) 'category' | 'store' | 'name' | 'avatar'
return new Credential({ uuid, createdAt, updatedAt, name, category, store }) >,
): Credential<T> {
const { uuid, createdAt, updatedAt } = super.create({ name, avatar })
return new Credential({
uuid,
createdAt,
updatedAt,
name,
category,
store,
avatar,
})
} }
#category: 'password' | 'ssh' | 'passkey' static load<T extends CredentialCategory>({
#store: CredentialCategory<Credential['category']> category,
store,
name,
avatar = Avatar.fromEmoji('🔑'),
}: {
name: Credential<T>['name']
category: T
store: Credential<T>['store']
avatar?: Credential<T>['avatar']
}): Credential<T> {
return this.create({ category, store, name, avatar })
}
#category: T
#store: Readonly<CredentialStore<T>>
private constructor( private constructor(
{ category, store, ...props }: { category, store, ...props }:
& Pick<Credential, 'category' | 'store'> & Pick<Credential<T>, 'category' | 'store'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>, & Pick<Ressource, 'name' | 'uuid' | 'avatar' | 'createdAt' | 'updatedAt'>,
) { ) {
super(props) super(props)
@ -43,14 +70,14 @@ export class Credential extends Ressource {
} }
get store() { get store() {
return Object.freeze(this.#store) return this.#store
} }
update( update<T extends CredentialCategory>(
props: Partial<Omit<Credential, 'type' | 'uuid' | 'createdAt'>>, props: Partial<Omit<Credential<T>, 'type' | 'uuid' | 'createdAt'>>,
): Credential { ): Credential<T> {
const { updatedAt } = super.update(props) const { updatedAt } = super.update(props)
const credential = new Credential({ ...this, ...props, updatedAt }) const credential = new Credential<T>({ ...this, ...props, updatedAt })
return credential return credential
} }
@ -66,40 +93,55 @@ export class Credential extends Ressource {
toString(): string { toString(): string {
return `Credential (${JSON.stringify(this)})` return `Credential (${JSON.stringify(this)})`
} }
toRef(): Ref<Credential<T>> {
return super.toRef() as Ref<Credential<T>>
}
} }
export interface Credential extends Ressource { export interface Credential<T extends CredentialCategory> extends Ressource {
type: 'credential' type: 'credential'
category: 'password' | 'ssh' | 'passkey' category: T
store: Readonly<CredentialCategory<Credential['category']>> store: Readonly<CredentialStore<T>>
} }
type CredentialCategory<T extends 'password' | 'ssh' | 'passkey'> = T extends export type CredentialCategory = 'password' | 'ssh' | 'passkey'
'password' ? {
export type CredentialStore<T extends CredentialCategory> = T extends 'password'
? {
store: { store: {
hash: string //hex or b64 of Uint8Array hash: Base64String
alg: string alg: string
salt: string //hex or b64 of Uint8Array salt: Base64String
} }
} }
: T extends 'ssh' ? { : T extends 'ssh' ? {
store: { store: {
publicKey: string publicKey: Base64String
} }
} }
: T extends 'passkey' ? { : T extends 'passkey' ? {
store: Record<string, unknown> store: Passkey
} }
: never : never
/* /** Passkey store */
PassKey store: export type Passkey = {
{ /** User UUID */
publicKey: Uint8Array user: UUID
keyId: string /** WebAuthn registration key id */
transport: string webAuthnUserID: string
counter: number /** Passkey credential unique id */
id: string
/** Passkey user public key */
publicKey: Base64String
/** Number of times the authenticator has been used */
counter: number
/** Whether the passkey is single-device or multi-device */
deviceType: 'singleDevice' | 'multiDevice'
/** Whether the passkey has been backed up in some way */
backedUp: boolean
/** Passkey physical transport layer */
transports?:
('ble' | 'cable' | 'hybrid' | 'internal' | 'nfc' | 'smart-card' | 'usb')[]
} }
*/
//new Uint8Array(Object.values(JSON.parse(JSON.stringify(new Uint8Array([1, 2, 3])))))

View file

@ -1,33 +1,68 @@
import { Posix, ToJson, UUID } from '@/types.ts' import type { Posix, ToJson, UUID } from '@/types.ts'
import { Ressource } from '@models/ressource.ts' import { Ressource } from '@models/ressource.ts'
import { Ref } from '@models'
import { Avatar } from '@/src/models/utils/avatar.ts'
export class Group extends Ressource { export class Group extends Ressource {
static fromJSON( static fromJSON(
{ posix, ...json }: ToJson<Group>, { posix, groups, ...json }: ToJson<Group>,
): Group { ): Group {
return new Group({ ...json, posix: posix ?? undefined }) return new Group({
...json,
posix: posix ?? undefined,
groups: groups.map((group) => Ref.fromString<Group>(group)),
})
} }
static create( static create(
{ posix, permissions, name }: Pick<Group, 'posix' | 'permissions' | 'name'>, { posix, permissions, name, avatar, groups }: Pick<
Group,
'posix' | 'permissions' | 'name' | 'avatar' | 'groups'
>,
): Group { ): Group {
const { uuid, createdAt, updatedAt } = super.create({ name }) const { uuid, createdAt, updatedAt } = super.create({ name, avatar })
return new Group({ uuid, createdAt, updatedAt, name, posix, permissions }) return new Group({
uuid,
avatar,
createdAt,
updatedAt,
name,
posix,
permissions,
groups,
})
}
static load({
name,
groups = [],
permissions = {},
posix,
avatar = Avatar.fromEmoji('👥'),
}: {
name: Group['name']
permissions?: Group['permissions']
posix?: Group['posix']
avatar?: Group['avatar']
groups?: Group['groups']
}): Group {
return this.create({ name, groups, permissions, posix, avatar })
} }
#posix?: Posix #posix?: Posix
#permissions: { #permissions: Readonly<{
[serviceOrMachine: UUID]: { [serviceOrMachine: UUID]: {
read: boolean read: boolean
write: boolean write: boolean
execute: boolean execute: boolean
} }
} = {} }>
#groups: readonly Ref<Group>[]
private constructor( private constructor(
{ posix, permissions, ...props }: { posix, permissions, groups, ...props }:
& Pick<Group, 'posix' | 'permissions'> & Pick<Group, 'posix' | 'permissions' | 'groups'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>, & Pick<Ressource, 'name' | 'avatar' | 'uuid' | 'createdAt' | 'updatedAt'>,
) { ) {
super(props) super(props)
@ -50,7 +85,8 @@ export class Group extends Ressource {
this.#posix = posix this.#posix = posix
} }
this.permissions = Object.freeze(permissions) this.#permissions = Object.freeze(permissions)
this.#groups = Object.freeze(groups)
} }
get type(): 'group' { get type(): 'group' {
@ -62,7 +98,11 @@ export class Group extends Ressource {
} }
get permissions() { get permissions() {
return Object.freeze(this.#permissions) return this.#permissions
}
get groups() {
return this.#groups
} }
update(props: Partial<Omit<Group, 'type' | 'uuid' | 'createdAt'>>): Group { update(props: Partial<Omit<Group, 'type' | 'uuid' | 'createdAt'>>): Group {
@ -77,17 +117,23 @@ export class Group extends Ressource {
type: this.type, type: this.type,
posix: this.posix ?? null, posix: this.posix ?? null,
permissions: this.permissions, permissions: this.permissions,
groups: this.groups.map((group) => group.toJSON()),
} as const } as const
} }
toString(): string { toString(): string {
return `Group (${JSON.stringify(this)})` return `Group (${JSON.stringify(this)})`
} }
toRef(): Ref<Group> {
return super.toRef() as Ref<Group>
}
} }
export interface Group extends Ressource { export interface Group extends Ressource {
type: 'group' type: 'group'
posix: Posix | undefined posix: Posix | undefined
groups: readonly Ref<Group>[]
permissions: Readonly<{ permissions: Readonly<{
[serviceOrMachine: UUID]: { [serviceOrMachine: UUID]: {
read: boolean read: boolean

View file

@ -1,29 +1,58 @@
import { ToJson } from '@/types.ts'
import { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Ref } from '@/src/models/utils/ref.ts' import { Ref } from '@/src/models/utils/ref.ts'
import type { ToJson, UrlString } from '@/types.ts'
import type { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Avatar } from '@/src/models/utils/avatar.ts'
export class Machine extends Ressource { export class Machine extends Ressource {
static fromJSON( static fromJSON(
json: ToJson<Machine>, { url, ...json }: ToJson<Machine>,
): Machine { ): Machine {
const url = new URL(json.url)
const avatar = new URL(json.avatar)
const groups = json.groups.map((group) => Ref.fromString<Group>(group)) const groups = json.groups.map((group) => Ref.fromString<Group>(group))
return new Machine({ ...json, url, avatar, groups }) return new Machine({ ...json, url, groups })
} }
static create( static create(
{ tags, url, status, avatar, groups, ...props }: { tags, url, status, groups, avatar }: Pick<
& Pick<Machine, 'tags' | 'url' | 'status' | 'avatar' | 'groups'> Machine,
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>, 'name' | 'tags' | 'url' | 'status' | 'avatar' | 'groups'
>,
): Machine { ): Machine {
return new Machine({ ...props, tags, url, status, avatar, groups }) const { uuid, createdAt, updatedAt } = super.create({ name, avatar })
return new Machine({
uuid,
createdAt,
updatedAt,
avatar,
name,
tags,
url,
status,
groups,
})
}
static load({
name,
avatar = Avatar.fromEmoji('🖨️'),
tags = [],
url,
status = 'unknown',
groups = [],
}: {
name: Machine['name']
avatar?: Machine['avatar']
tags?: Machine['tags']
url: Machine['url']
status?: Machine['status']
groups?: Machine['groups']
}): Machine {
return this.create({ name, avatar, tags, url, status, groups })
} }
#tags: readonly string[] #tags: readonly string[]
#url: URL #url: UrlString
#status: #status:
| 'ready' | 'ready'
| 'busy' | 'busy'
@ -31,25 +60,23 @@ export class Machine extends Ressource {
| 'discontinued' | 'discontinued'
| 'error' | 'error'
| 'unknown' | 'unknown'
#avatar: URL
#groups: readonly Ref<Group>[] #groups: readonly Ref<Group>[]
private constructor( private constructor(
{ tags, url, status, avatar, groups, ...props }: { tags, url, status, groups, ...props }:
& Pick<Machine, 'tags' | 'url' | 'status' | 'avatar' | 'groups'> & Pick<Machine, 'tags' | 'url' | 'status' | 'avatar' | 'groups'>
& Pick<Ressource, 'name' | 'uuid' | 'createdAt' | 'updatedAt'>, & Pick<Ressource, 'name' | 'uuid' | 'avatar' | 'createdAt' | 'updatedAt'>,
) { ) {
super(props) super(props)
this.#tags = Object.freeze(tags) this.#tags = Object.freeze(tags)
this.#url = new URL(url) this.#url = new URL(url).href as UrlString
if (!['working', 'pending', 'ready', 'unavailable'].includes(status)) { if (!['working', 'pending', 'ready', 'unavailable'].includes(status)) {
throw new TypeError( throw new TypeError(
`status is "${status}" but ('ready' | 'busy' | 'unavailable' | 'discontinued' | 'error' | 'unknown') is required`, `status is "${status}" but ('ready' | 'busy' | 'unavailable' | 'discontinued' | 'error' | 'unknown') is required`,
) )
} }
this.#status = status this.#status = status
this.#avatar = new URL(avatar)
this.#groups = Object.freeze(groups) this.#groups = Object.freeze(groups)
} }
@ -57,17 +84,14 @@ export class Machine extends Ressource {
return 'machine' return 'machine'
} }
get tags() { get tags() {
return this.#tags.slice() return this.#tags
} }
get url() { get url() {
return new URL(this.#url) return this.#url
} }
get status() { get status() {
return this.#status return this.#status
} }
get avatar() {
return new URL(this.#avatar)
}
get groups() { get groups() {
return this.#groups return this.#groups
} }
@ -84,10 +108,9 @@ export class Machine extends Ressource {
return { return {
...super.toJSON(), ...super.toJSON(),
type: this.type, type: this.type,
tags: this.tags.slice(), tags: this.tags,
url: this.url.toJSON(), url: this.url,
status: this.status, status: this.status,
avatar: this.avatar.toJSON(),
groups: Object.freeze(this.groups.map((group) => group.toJSON())), groups: Object.freeze(this.groups.map((group) => group.toJSON())),
} as const } as const
} }
@ -95,12 +118,16 @@ export class Machine extends Ressource {
toString(): string { toString(): string {
return `Machine (${JSON.stringify(this)})` return `Machine (${JSON.stringify(this)})`
} }
toRef(): Ref<Machine> {
return super.toRef() as Ref<Machine>
}
} }
export interface Machine extends Ressource { export interface Machine extends Ressource {
type: 'machine' type: 'machine'
tags: string[] tags: readonly string[]
url: URL url: UrlString
status: status:
| 'ready' | 'ready'
| 'busy' | 'busy'
@ -108,6 +135,5 @@ export interface Machine extends Ressource {
| 'discontinued' | 'discontinued'
| 'error' | 'error'
| 'unknown' | 'unknown'
avatar: URL
groups: readonly Ref<Group>[] groups: readonly Ref<Group>[]
} }

View file

@ -1,5 +1,5 @@
import { Ref } from '@/src/models/utils/ref.ts' import { Ref } from '@/src/models/utils/ref.ts'
import { DateString, ToJson, UUID } from '@/types.ts' import type { DateString, ToJson, UrlString, UUID } from '@/types.ts'
import { regex } from '@/utils.ts' import { regex } from '@/utils.ts'
export class Ressource { export class Ressource {
@ -8,29 +8,37 @@ export class Ressource {
): Ressource { ): Ressource {
return new Ressource({ return new Ressource({
name: json.name, name: json.name,
avatar: json.avatar,
uuid: json.uuid, uuid: json.uuid,
createdAt: json.createdAt, createdAt: json.createdAt,
updatedAt: json.updatedAt, updatedAt: json.updatedAt,
}) })
} }
static create({ name }: Pick<Ressource, 'name'>): Ressource { static create(
{ name, avatar }: Pick<Ressource, 'name' | 'avatar'>,
): Ressource {
const uuid = crypto.randomUUID() as UUID const uuid = crypto.randomUUID() as UUID
const createdAt = new Date().toISOString() as DateString const createdAt = new Date().toISOString() as DateString
const updatedAt = createdAt const updatedAt = createdAt
return new Ressource({ name, uuid, createdAt, updatedAt }) return new Ressource({ name, avatar, uuid, createdAt, updatedAt })
}
static load(_options: never): Ressource {
throw new Error('not implemented')
} }
#name: string #name: string
#uuid: UUID #uuid: UUID
#createdAt: DateString #createdAt: DateString
#updatedAt: DateString #updatedAt: DateString
#avatar: UrlString
protected constructor( protected constructor(
{ name, uuid, createdAt, updatedAt }: Pick< { name, avatar, uuid, createdAt, updatedAt }: Pick<
Ressource, Ressource,
'name' | 'uuid' | 'createdAt' | 'updatedAt' 'name' | 'avatar' | 'uuid' | 'createdAt' | 'updatedAt'
>, >,
) { ) {
this.#name = name this.#name = name
@ -39,6 +47,7 @@ export class Ressource {
`the following uuid "${uuid}" is not a valid UUID V4`, `the following uuid "${uuid}" is not a valid UUID V4`,
) )
} }
this.#avatar = new URL(avatar).href as UrlString
this.#uuid = uuid this.#uuid = uuid
if (!regex.isoDateString.test(createdAt)) { if (!regex.isoDateString.test(createdAt)) {
throw new SyntaxError( throw new SyntaxError(
@ -63,6 +72,9 @@ export class Ressource {
get name(): string { get name(): string {
return this.#name return this.#name
} }
get avatar(): UrlString {
return this.#avatar
}
get createdAt(): DateString { get createdAt(): DateString {
return this.#createdAt return this.#createdAt
} }
@ -83,6 +95,7 @@ export class Ressource {
type: this.type, type: this.type,
uuid: this.uuid, uuid: this.uuid,
name: this.name, name: this.name,
avatar: this.avatar,
createdAt: this.createdAt, createdAt: this.createdAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
} as const } as const
@ -101,6 +114,7 @@ export interface Ressource {
type: 'user' | 'machine' | 'service' | 'group' | 'credential' type: 'user' | 'machine' | 'service' | 'group' | 'credential'
uuid: UUID uuid: UUID
name: string name: string
avatar: UrlString
createdAt: DateString createdAt: DateString
updatedAt: DateString updatedAt: DateString
} }

View file

@ -1,27 +1,26 @@
import { ToJson } from '@/types.ts'
import { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Ref } from '@/src/models/utils/ref.ts' import { Ref } from '@/src/models/utils/ref.ts'
import type { ToJson, UrlString } from '@/types.ts'
import type { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Avatar } from '@/src/models/utils/avatar.ts'
export class Service extends Ressource { export class Service extends Ressource {
static fromJSON( static fromJSON(
{ groups, url, avatar, ...json }: ToJson<Service>, { groups, ...json }: ToJson<Service>,
): Service { ): Service {
return new Service({ return new Service({
groups: groups.map((group) => Ref.fromString<Group>(group)), groups: groups.map((group) => Ref.fromString<Group>(group)),
url: new URL(url),
avatar: new URL(avatar),
...json, ...json,
}) })
} }
static create( static create(
{ category, url, groups, avatar, name }: Pick< { category, tags, url, groups, avatar, name }: Pick<
Service, Service,
'category' | 'url' | 'groups' | 'avatar' | 'name' 'category' | 'tags' | 'url' | 'groups' | 'avatar' | 'name'
>, >,
): Service { ): Service {
const { uuid, createdAt, updatedAt } = super.create({ name }) const { uuid, createdAt, updatedAt } = super.create({ name, avatar })
return new Service({ return new Service({
uuid, uuid,
createdAt, createdAt,
@ -29,21 +28,40 @@ export class Service extends Ressource {
name, name,
category, category,
url, url,
tags,
groups, groups,
avatar, avatar,
}) })
} }
#category: 'bin' | 'web' | 'fs' | 'git' static load({
#url: URL name,
avatar = Avatar.fromEmoji('⚙️'),
tags = [],
url,
category,
groups = [],
}: {
name: Service['name']
url: Service['url']
avatar?: Service['avatar']
tags?: Service['tags']
category: Service['category']
groups?: Service['groups']
}): Service {
return this.create({ name, avatar, tags, url, category, groups })
}
#category: 'web' | 'fs' | 'git'
#url: UrlString
#groups: readonly Ref<Group>[] #groups: readonly Ref<Group>[]
#avatar: URL #tags: readonly string[]
private constructor({ private constructor({
category, category,
tags,
url, url,
groups, groups,
avatar,
...ressource ...ressource
}: Pick< }: Pick<
Service, Service,
@ -53,20 +71,21 @@ export class Service extends Ressource {
| 'name' | 'name'
| 'category' | 'category'
| 'url' | 'url'
| 'tags'
| 'groups' | 'groups'
| 'avatar' | 'avatar'
>) { >) {
super(ressource) super(ressource)
if (!['bin', 'web', 'fs', 'git'].includes(category)) { if (!['web', 'fs', 'git'].includes(category)) {
throw new TypeError( throw new TypeError(
`category is "${category}" but ('bin' | 'web' | 'fs' | 'git') is required`, `category is "${category}" but ('web' | 'fs' | 'git') is required`,
) )
} }
this.#category = category this.#category = category
this.#url = url this.#url = new URL(url).href as UrlString
this.#groups = Object.freeze(groups) this.#groups = Object.freeze(groups)
this.#avatar = avatar this.#tags = Object.freeze(tags)
} }
get type(): 'service' { get type(): 'service' {
@ -77,16 +96,16 @@ export class Service extends Ressource {
return this.#category return this.#category
} }
get tags() {
return this.#tags
}
get url() { get url() {
return new URL(this.#url) return this.#url
} }
get groups() { get groups() {
return Object.freeze(this.#groups) return this.#groups
}
get avatar() {
return new URL(this.#avatar)
} }
update( update(
@ -102,21 +121,25 @@ export class Service extends Ressource {
...super.toJSON(), ...super.toJSON(),
type: this.type, type: this.type,
category: this.category, category: this.category,
url: this.url.toJSON(), tags: this.tags,
url: this.url,
groups: this.groups.map((group) => group.toJSON()), groups: this.groups.map((group) => group.toJSON()),
avatar: this.avatar.toJSON(),
} as const } as const
} }
toString(): string { toString(): string {
return `Service (${JSON.stringify(this)})` return `Service (${JSON.stringify(this)})`
} }
toRef(): Ref<Service> {
return super.toRef() as Ref<Service>
}
} }
export interface Service extends Ressource { export interface Service extends Ressource {
type: 'service' type: 'service'
category: 'bin' | 'web' | 'fs' | 'git' category: 'web' | 'fs' | 'git'
url: URL tags: readonly string[]
url: UrlString
groups: readonly Ref<Group>[] groups: readonly Ref<Group>[]
avatar: URL
} }

View file

@ -1,14 +1,13 @@
import { Login, MailAddress, Posix, ToJson } from '@/types.ts'
import { regex, toLogin } from '@/utils.ts'
import { Credential } from '@models/credential.ts'
import { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Ref } from '@/src/models/utils/ref.ts' import { Ref } from '@/src/models/utils/ref.ts'
import type { Login, MailAddress, Posix, ToJson } from '@/types.ts'
import { regex, toLogin } from '@/utils.ts'
import type { Credential, CredentialCategory } from '@models/credential.ts'
import type { Group } from '@models/group.ts'
import { Ressource } from '@models/ressource.ts'
import { Avatar } from '@/src/models/utils/avatar.ts'
export class User extends Ressource { export class User extends Ressource {
static fromJSON( static fromJSON(json: ToJson<User>): User {
json: ToJson<User>,
): User {
if (json.type !== 'user') { if (json.type !== 'user') {
throw new TypeError(`type is "${json.type}" but "user" is required`) throw new TypeError(`type is "${json.type}" but "user" is required`)
} }
@ -22,7 +21,10 @@ export class User extends Ressource {
`firstname type is "${typeof json.firstname}" but "string" is required`, `firstname type is "${typeof json.firstname}" but "string" is required`,
) )
} }
if (typeof json.login !== 'string' && /(\w|_)+\.(\w|_)+/.test(json.login)) { if (
typeof json.login !== 'string' &&
/(\w|_)+\.(\w|_)+/.test(json.login)
) {
throw new TypeError( throw new TypeError(
`login is "${json.login}" but "\${string}.\${string}" is required`, `login is "${json.login}" but "\${string}.\${string}" is required`,
) )
@ -35,20 +37,20 @@ export class User extends Ressource {
const credentials = Object.freeze( const credentials = Object.freeze(
json.credentials.map((credential) => json.credentials.map((credential) =>
Ref.fromString<Credential>(credential) Ref.fromString<Credential<CredentialCategory>>(credential)
), ),
) )
const groups = Object.freeze( const groups = Object.freeze(
json.groups.map((group) => Ref.fromString<Group>(group)), json.groups.map((group) => Ref.fromString<Group>(group)),
) )
const avatar = new URL(json.avatar)
const posix = json.posix ?? undefined const posix = json.posix ?? undefined
return new User({ ...json, posix, credentials, groups, avatar }) return new User({ ...json, posix, credentials, groups })
} }
static create({ static create({
name, name,
avatar,
...props ...props
}: Pick< }: Pick<
User, User,
@ -61,8 +63,38 @@ export class User extends Ressource {
| 'avatar' | 'avatar'
| 'credentials' | 'credentials'
>): User { >): User {
const { uuid, createdAt, updatedAt } = super.create({ name }) const { uuid, createdAt, updatedAt } = super.create({ name, avatar })
return new User({ name, uuid, createdAt, updatedAt, ...props }) return new User({ name, avatar, uuid, createdAt, updatedAt, ...props })
}
static load({
lastname,
firstname,
mail,
groups = [],
posix,
avatar = Avatar.fromEmoji('👤'),
credentials = [],
}: {
lastname: User['lastname']
firstname: User['firstname']
mail: User['mail']
groups?: User['groups']
posix?: User['posix']
avatar?: User['avatar']
credentials?: User['credentials']
}): User {
const name = `${firstname} ${lastname}`
return this.create({
name,
lastname,
firstname,
mail,
groups,
posix,
avatar,
credentials,
})
} }
#lastname: string #lastname: string
@ -71,8 +103,7 @@ export class User extends Ressource {
#mail: MailAddress #mail: MailAddress
#groups: readonly Ref<Group>[] #groups: readonly Ref<Group>[]
#posix?: Posix #posix?: Posix
#avatar: URL #credentials: readonly Ref<Credential<CredentialCategory>>[]
#credentials: readonly Ref<Credential>[]
private constructor({ private constructor({
lastname, lastname,
@ -80,7 +111,6 @@ export class User extends Ressource {
mail, mail,
groups, groups,
posix, posix,
avatar,
credentials, credentials,
...ressource ...ressource
}: Pick< }: Pick<
@ -113,7 +143,6 @@ export class User extends Ressource {
} }
} }
this.#posix = posix this.#posix = posix
this.#avatar = avatar
this.#login = toLogin({ firstname, lastname }) this.#login = toLogin({ firstname, lastname })
this.#credentials = Object.freeze(credentials) this.#credentials = Object.freeze(credentials)
} }
@ -139,9 +168,6 @@ export class User extends Ressource {
get posix() { get posix() {
return this.#posix return this.#posix
} }
get avatar() {
return this.#avatar
}
get credentials() { get credentials() {
return this.#credentials return this.#credentials
} }
@ -161,7 +187,7 @@ export class User extends Ressource {
mail: this.mail, mail: this.mail,
groups: this.groups.map((group) => group.toJSON()), groups: this.groups.map((group) => group.toJSON()),
posix: this.posix ?? null, posix: this.posix ?? null,
avatar: this.avatar.toJSON(), avatar: this.avatar,
credentials: this.credentials.map((credential) => credential.toJSON()), credentials: this.credentials.map((credential) => credential.toJSON()),
} as const } as const
} }
@ -169,6 +195,10 @@ export class User extends Ressource {
toString(): string { toString(): string {
return `User (${JSON.stringify(this)})` return `User (${JSON.stringify(this)})`
} }
toRef(): Ref<User> {
return super.toRef() as Ref<User>
}
} }
export interface User extends Ressource { export interface User extends Ressource {
@ -179,6 +209,5 @@ export interface User extends Ressource {
mail: MailAddress mail: MailAddress
groups: readonly Ref<Group>[] groups: readonly Ref<Group>[]
posix: Posix | undefined posix: Posix | undefined
avatar: URL credentials: readonly Ref<Credential<CredentialCategory>>[]
credentials: readonly Ref<Credential>[]
} }

View file

@ -0,0 +1,8 @@
import type { UrlString } from '@/types.ts'
export class Avatar {
static fromEmoji(emoji: string): UrlString {
return `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%2210 0 100 100%22><text y=%22.90em%22 font-size=%2290%22>${emoji}</text></svg>`
}
private constructor() {}
}

View file

@ -1,4 +1,4 @@
import { Db } from '@/mod.ts' import type { Db } from '@/mod.ts'
import type { UUID } from '@/types.ts' import type { UUID } from '@/types.ts'
import { import {
Credential, Credential,
@ -38,8 +38,9 @@ export class Ref<T extends Ressource> extends String {
} }
static dbResolver(db: Db) { static dbResolver(db: Db) {
return <T extends Ressource>(ref: RefString<T>) => { return <T extends Ressource>(ref: RefString<T>): Promise<T> => {
const { type, uuid } = Ref.parse(ref) const { type, uuid } = Ref.parse(ref)
//@ts-expect-error force type casting to fix
return db.ressource[type].get({ uuid }) return db.ressource[type].get({ uuid })
} }
} }

View file

@ -26,3 +26,11 @@ export type Posix = {
home: string home: string
shell: string shell: string
} }
export type Base64String = `${string}`
export type UrlString = `${
| 'data:'
| 'file://'
| 'http://'
| 'https://'
| 'git:'}${string}`

View file

@ -1,4 +1,4 @@
import { Login } from '@/types.ts' import type { Login } from '@/types.ts'
export function toLogin({ export function toLogin({
firstname, firstname,