@shutter-network/concorde/users
The Users component holds the identities a Gateway knows and nothing a person presents. A User is an opaque Gateway-issued id, a set of Attributes the Operator writes, and the moment they were admitted. There is no email and no username anywhere, so the id is the only handle a User has. Attributes are arbitrary JSON that nothing in the framework interprets, and they are where a deployment's grouping and therefore its authorization live.
It authenticates nobody. A credential belongs to an Auth, which owns one scheme's secret and registers itself with the Public server, and that server composes every registered Auth into the one requireUser a protected route takes. @shutter-network/concorde/password-auth is the scheme a person logs into with a password.
createUsers makes one. Users is what comes back, carrying a programmatic API that admits a User, sets their Attributes and reads both. Neither write has a route anywhere: an agent that could mint a User and give it a credential has minted itself an account, so admitting one is the Operator's own code. UserRecord is what every surface here answers with.
The agent's routes are GET /users and GET /users/:id, and the one Public route is GET /users/me, which echoes the authenticated User whichever scheme named them.
The tables are not here. @shutter-network/concorde/users/schema is the subpath an Operator points their drizzle-kit at, and it is the only place the users table is reachable from. The Messenger, the Nostr Channel, Password Auth and Nostr Auth all point a foreign key at that table, so a configuration listing any of their schema subpaths without this one generates a constraint onto a table it never creates.
Importing this subpath declares request.concordeUser on every FastifyRequest in the program, whether or not the program constructs this component.
Example
A Gateway with Users and a password login, and a person admitted from the Operator's own code.
import { createGateway } from "@shutter-network/concorde/gateway";
import { createPasswordAuth } from "@shutter-network/concorde/password-auth";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { createUsers } from "@shutter-network/concorde/users";
const gateway = createGateway({
databaseUrl: process.env.DATABASE_URL ?? "",
runtime: createPiRuntime({ image: "my-agent:1" }),
// Not loopback: the agent reaches this server from a container of its own.
agentListen: { host: "0.0.0.0", port: 8081 },
publicListen: { host: "0.0.0.0", port: 8080 },
extend: ({ db, agentServer, publicServer }) => {
const users = createUsers({ db, agentServer, publicServer });
return {
users,
// Without an Auth on that server, GET /users/me refuses every request.
passwordAuth: createPasswordAuth({ db, users, publicServer, tokenTtl: 86_400_000 }),
};
},
handlers: () => ({}),
});
await gateway.start();
// One transaction, so a User nobody can log in as never reaches the table.
const { db, users, passwordAuth } = gateway.components;
const admitted = await db.tx(async (tx) => {
const user = await users.create(tx);
await passwordAuth.setPassword(tx, user.id, "correct horse battery staple");
return user;
});
console.log(`admitted ${admitted.id}, and that id is what they log in with`);Type Aliases
UserRecord
type UserRecord = {
readonly attributes: unknown;
readonly createdAt: string;
readonly id: string;
};A User as every surface answers with one: both agent reads, and the authenticated User.
attributes is arbitrary JSON that nothing in the Gateway interprets, and createdAt is ISO 8601, JSON having no date. How a User authenticates is answered nowhere, on this shape or on any other: that is an Auth's business and not this component's.
Properties
attributes
readonly attributes: unknown;createdAt
readonly createdAt: string;id
readonly id: string;Users
type Users = Component & {
readonly agentRoutes: FastifyPluginAsync;
create: <TSchema>(tx: Handle<TSchema>) => Promise<UserRecord>;
get: (id: string) => Promise<UserRecord | undefined>;
list: (options?: {
readonly limit?: number;
}) => Promise<UserRecord[]>;
setAttributes: <TSchema>(tx: Handle<TSchema>, user: string, attributes: unknown) => Promise<void>;
start: () => Promise<void>;
stop: () => Promise<void>;
};The Users component as a Component: one route plugin the agent may take, and a programmatic API.
It keeps a User's opaque id, their Attributes and when they were admitted, and nothing else. Nothing removes a User: there is no delete, no deactivation, and no column recording either.
Admitting a User and setting their Attributes are in the programmatic API and have no route anywhere. The Agent server is the surface an injected prompt reaches, so the two capabilities that escalate are not there to reach.
Every write takes the caller's transaction as its first argument and every read takes none, so a read cannot see the caller's own uncommitted write. That is why create answers with what it wrote.
Nothing is notified when a User is created. No Signal is emitted and no Handler wakes, so a deployment that wants one emits it itself inside the same transaction.
start and stop do nothing. A User is a committed row and outlives this process.
Type Declaration
agentRoutes
readonly agentRoutes: FastifyPluginAsync;The Agent server routes as a Fastify plugin: two reads, and nothing that writes.
For an Operator who wants them somewhere other than where agentServer puts them. The plugin carries no prefix of its own, so register it under a prefix of yours, inside your own encapsulated plugin, or behind your own hook.
Passing no Agent server and never registering this is how the agent's reads are switched off. There is no matching member for the Public route: that one takes the Public server's hook, so a plugin built without a server would have nothing to authenticate with.
create()
create<TSchema>(tx: Handle<TSchema>): Promise<UserRecord>;Creates a User with no Attributes, and answers with the record.
Takes the caller's transaction, so admitting a User and whatever gives them a credential cannot come apart: create a User and call an Auth's setPassword on the same tx, and a rollback loses both. The record comes back from here because a read cannot see that uncommitted write.
It accepts no id. A User has no natural key, so "create this User if absent" is not expressible and seeding the first one is the Operator's own job, out of band and once.
Type Parameters
TSchema
TSchema extends Record<string, unknown>
Parameters
tx
Handle<TSchema>
Returns
Promise<UserRecord>
get()
get(id: string): Promise<UserRecord | undefined>;One User by id, or undefined.
A read, so it takes no transaction and cannot see the caller's own uncommitted write. create answers with the User for that reason. It is also what an Auth calls to turn the identity it verified into the record an outcome carries.
Parameters
id
string
Returns
Promise<UserRecord | undefined>
list()
list(options?: {
readonly limit?: number;
}): Promise<UserRecord[]>;Users, newest first, limited.
A read, with the same consequence get carries. limit takes the routes' default when omitted and is not capped here: a cap is there to bound a response body the agent reads, and this is not that.
Parameters
options?
limit?
number
Returns
Promise<UserRecord[]>
setAttributes()
setAttributes<TSchema>(
tx: Handle<TSchema>,
user: string,
attributes: unknown
): Promise<void>;Replaces a User's Attributes, wholesale, and throws when no User has that id.
This is where authorization lives, and the agent cannot reach it: no route anywhere writes this column, so an injected prompt cannot mint a privileged User.
Wholesale rather than a merge, because a merge cannot express removal. A merge is one line on top of this: read, spread, set.
Type Parameters
TSchema
TSchema extends Record<string, unknown>
Parameters
tx
Handle<TSchema>
user
string
attributes
unknown
Returns
Promise<void>
start()
start(): Promise<void>;Returns
Promise<void>
stop()
stop(): Promise<void>;Returns
Promise<void>
UsersOptions
type UsersOptions = {
readonly agentServer?: {
readonly fastify: FastifyInstance;
};
readonly db: Db;
readonly publicServer?: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};
};Properties
agentServer?
readonly agentServer?: {
readonly fastify: FastifyInstance;
};The Agent server, if the agent is to read Users.
Given one, the constructor registers agentRoutes on it under /users: GET /users and GET /users/:id. Omit it and nothing is registered there, which is how the agent's ability to see who exists is denied. There is no flag and no route to guard.
Structural: anything carrying a Fastify instance satisfies it. A server built on http2 does not, and takes agentRoutes instead.
fastify
readonly fastify: FastifyInstance;db
readonly db: Db;publicServer?
readonly publicServer?: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};The Public server, if a person is to read back which User they are.
Given one, the constructor registers GET /users/me on it, behind that server's own requireUser. Omit it and this component serves nothing outside at all.
The hook is required rather than optional, because the route is unbuildable without one: this component authenticates nobody, and which schemes the deployment accepts is what the server holds. Construct an Auth with the same server, or the route throws NoAuthRegisteredError on every request.
fastify
readonly fastify: FastifyInstance;requireUser
readonly requireUser: preHandlerAsyncHookHandler;Functions
createUsers()
function createUsers(options: UsersOptions): Users;Builds the Users component and registers its route groups on whichever servers it is given.
Nothing here connects, listens or applies DDL.