@sdxc/scim
v2026.10.16
Published
SCIM 2.0 resources, filters, PATCH operations and discovery documents
Downloads
1,058
Readme
@sdxc/scim
SCIM 2.0 resources, filters, PATCH operations and discovery documents.
SCIM 2.0 is how an enterprise directory (Okta, Microsoft Entra ID, Google Workspace) pushes people and groups into a service; RFC 7643 defines the resources and RFC 7644 the protocol. This package is the standard's half of a SCIM endpoint: typed Users and Groups, the filter grammar, PATCH, list queries, attribute projection, errors and discovery. Routing, authentication and storage stay yours.
Installation
npm add @sdxc/scimFallible calls return a Result from @sdxc/result,
and @sdxc/scim/data-table builds predicates for remix/data-table from
remix.
Usage
Read a User and answer
Every failure is a ScimError; errorResponse turns it into the RFC 7644 error document.
import { isFailure } from "@sdxc/result";
import {
errorResponse,
parseUser,
readBody,
ScimError,
scimResponse,
userResource,
} from "@sdxc/scim";
let body = await readBody(request);
if (isFailure(body)) return errorResponse(body.error);
let user = parseUser(body.data);
if (isFailure(user)) return errorResponse(user.error);
user.data.userName; // "[email protected]"
user.data.extensions.enterprise?.department; // the typed Enterprise User extension
if (await findUserByUserName(user.data.userName)) {
let taken = new ScimError(409, "userName is taken.", { scimType: "uniqueness" });
return errorResponse(taken);
}
let stored = await saveUser(user.data);
return scimResponse(userResource(stored), { status: 201 });Describe the attributes you serve
One Discovery.Definitions value drives filters, PATCH, projection and /Schemas, so what a
client is told and what the server does agree. Put the core schema first: unqualified names
resolve in insertion order.
import { ENTERPRISE_USER_SCHEMA, USER_SCHEMA } from "@sdxc/scim";
import type { Discovery } from "@sdxc/scim/discovery";
import { ENTERPRISE_USER_DEFINITION, pickAttributes, USER_DEFINITION } from "@sdxc/scim/discovery";
export const DEFINITIONS: Discovery.Definitions = {
[USER_SCHEMA]: pickAttributes(USER_DEFINITION, ["userName", "name", "emails", "active"]),
[ENTERPRISE_USER_SCHEMA]: ENTERPRISE_USER_DEFINITION,
};List Users with a filter
import { isFailure } from "@sdxc/result";
import { errorResponse, listResponse, parseListQuery, project, userResource } from "@sdxc/scim";
import { compileFilter } from "@sdxc/scim/filter";
let query = parseListQuery(new URL(request.url), { maxCount: 200, attributes: DEFINITIONS });
if (isFailure(query)) return errorResponse(query.error);
let resources = (await loadUsers()).map((user) => userResource(user));
if (query.data.filter) {
let matches = compileFilter(query.data.filter, {
definitions: DEFINITIONS,
allow: ["userName", "emails.value"],
});
if (isFailure(matches)) return errorResponse(matches.error);
resources = resources.filter(matches.data);
}
let start = query.data.startIndex - 1;
return listResponse(
{
resources: resources.slice(start, start + query.data.count),
totalResults: resources.length,
startIndex: query.data.startIndex,
},
(resource) => project(resource, query.data, DEFINITIONS),
);userName eq "[email protected]" matches [email protected], because userName is
caseExact: false in the definition; allow refuses filters on any other attribute. For
POST /Users/.search, read the same query from the body with parseSearchRequest(body.data).
Patch a User
Apply the operations to the current wire representation, then store the result the way a PUT
would.
import { isFailure } from "@sdxc/result";
import { errorResponse, parseUser, readBody, scimResponse, userResource } from "@sdxc/scim";
import { applyPatch, parsePatch } from "@sdxc/scim/patch";
let body = await readBody(request);
if (isFailure(body)) return errorResponse(body.error);
let operations = parsePatch(body.data);
if (isFailure(operations)) return errorResponse(operations.error);
let patched = applyPatch(userResource(await loadUser(id)), operations.data, {
definitions: DEFINITIONS,
});
if (isFailure(patched)) return errorResponse(patched.error); // noTarget, mutability, invalidPath
let next = parseUser(patched.data);
if (isFailure(next)) return errorResponse(next.error);
return scimResponse(userResource(await replaceUser(id, next.data)));Serve discovery
import { ENTERPRISE_USER_SCHEMA, GROUP_SCHEMA, scimResponse, USER_SCHEMA } from "@sdxc/scim";
import {
GROUP_DEFINITION,
resourceTypes,
schemas,
serviceProviderConfig,
} from "@sdxc/scim/discovery";
scimResponse(
serviceProviderConfig({
patch: true,
filter: { supported: true, maxResults: 200 },
sort: false,
etag: true,
authenticationSchemes: [
{ type: "oauthbearertoken", name: "Bearer Token", description: "A per-connection token." },
],
}),
);
scimResponse(
resourceTypes([
{
id: "User",
endpoint: "/Users",
schema: USER_SCHEMA,
extensions: [{ schema: ENTERPRISE_USER_SCHEMA, required: false }],
},
{ id: "Group", endpoint: "/Groups", schema: GROUP_SCHEMA },
]),
);
scimResponse(schemas([...Object.values(DEFINITIONS), GROUP_DEFINITION]));API
@sdxc/scim
Constants
MEDIA_TYPE (application/scim+json), and the schema URNs USER_SCHEMA, GROUP_SCHEMA,
ENTERPRISE_USER_SCHEMA, LIST_RESPONSE_SCHEMA, PATCH_OP_SCHEMA, SEARCH_REQUEST_SCHEMA and
ERROR_SCHEMA.
new ScimError(status: number, detail: string, options?: ScimErrorOptions)
The error every call fails with: status is the HTTP status, message the detail, and
scimType an RFC 7644 detail code (invalidFilter, uniqueness, mutability, …) or null.
parseUser(body: unknown, options?: ParseUserOptions): Result<Scim.User, ScimError>
Reads a User, matching attribute names in any case and reading null as unassigned. Each
extension is read from its URN member and validated with a Standard Schema; options.extensions
({ key: { urn, schema } }) replaces the default, which reads the Enterprise User extension under
enterprise.
parseGroup(body: unknown): Result<Scim.Group, ScimError>
Reads a Group; a member's $ref becomes ref.
userResource(user: Scim.User, options?: ResourceOptions): object
The wire object: schemas filled from the extensions present, extensions under their URNs,
ref written $ref, dates as RFC 3339, password and empty members dropped. Pass the same
extensions as parseUser to name each custom extension's URN.
groupResource(group: Scim.Group): object
The Group's wire object, with the same rules as userResource.
readBody(request: Request): Promise<Result<unknown, ScimError>>
Reads a JSON body sent as application/scim+json, application/json or with no Content-Type;
another type fails 415, and invalid JSON 400 invalidSyntax.
parseListQuery(url: URL, options?: ListQueryOptions): Result<Scim.ListQuery, ScimError>
Reads filter, startIndex, count, sortBy, sortOrder, attributes and
excludedAttributes from the query string. count defaults to options.defaultCount (100) and
is capped at options.maxCount; with options.attributes, every path must resolve against those
definitions.
parseSearchRequest(body: unknown, options?: ListQueryOptions): Result<Scim.ListQuery, ScimError>
The same query read from a POST /.search body.
scimResponse(body: object, init?: ResponseInit): Response
A JSON Response typed application/scim+json.
errorResponse(error: ScimError, init?: ResponseInit): Response
The RFC 7644 error document, answered with the error's status.
listResponse(page: Scim.Page, toResource: (resource) => object, init?: ResponseInit): Response
A ListResponse of the page's resources mapped through toResource, with itemsPerPage set to
the page length.
project(resource: object, query: Scim.ListQuery, definitions: Discovery.Definitions): object
Keeps the members a response carries, applying each attribute's returned and the query's
attributes or excludedAttributes, down to sub-attributes and whole extensions.
version(resource: object): Promise<string>
A weak entity tag (W/"…") over the resource's content, independent of key order and of meta,
so storing it in meta.version leaves it unchanged.
matchesVersion(request: Request, current: string): boolean
Whether the request's If-Match and If-None-Match let it proceed against the current tag,
compared weakly. Answer false with 412, or with 304 on a read that sent If-None-Match.
@sdxc/scim/filter
parseFilter(text: string): Result<Filter.Expression, ScimError>
Parses the full RFC 7644 filter grammar: every operator, and/or/not, grouping, value paths
(emails[type eq "work"]) and schema-qualified paths. Errors are 400 invalidFilter with the
position.
parsePath(text: string): Result<Filter.AttributePath, ScimError>
Parses an attribute path such as name.givenName; a bad one fails 400 invalidPath.
stringifyFilter(expression: Filter.Expression): string / stringifyPath(path: Filter.AttributePath): string
Write a tree back to text that parses to the same tree.
compileFilter(expression: Filter.Expression, options: Filter.CompileOptions): Result<(resource: object) => boolean, ScimError>
A predicate over wire resources, evaluated with the types and caseExact of
options.definitions. A multi-valued attribute matches when any value does. A type mismatch,
an unknown path, or a path outside options.allow fails invalidFilter at compile time.
@sdxc/scim/data-table
filterToWhere(expression: Filter.Expression, columns: ColumnMap): Result<Predicate, ScimError | UntranslatableFilterError>
A remix/data-table predicate selecting the rows the filter matches. columns maps each
filterable path to a column name, compared case-sensitively, or to
{ column, caseExact: false }, compared through ilike.
UntranslatableFilterError
The failure for a filter SQL cannot express exactly: value paths, unmapped paths, negated
substring matches, case-folded ordering, and values holding %, _ or \. Evaluate that filter
in memory with compileFilter.
@sdxc/scim/patch
parsePatch(body: unknown): Result<Patch.Operation[], ScimError>
Reads a PatchOp body, op in any case.
applyPatch(resource: object, operations: Patch.Operation[], options: Patch.ApplyOptions): Result<object, ScimError>
Applies the operations to a copy, all or none, following RFC 7644 §3.5.2. Writes to a read-only
attribute, or to an immutable one that has a value, fail mutability; a filter matching
nothing fails noTarget.
@sdxc/scim/discovery
USER_DEFINITION, GROUP_DEFINITION, ENTERPRISE_USER_DEFINITION
The RFC 7643 schema definitions.
pickAttributes(definition: Discovery.SchemaDefinition, names: string[]): Discovery.SchemaDefinition
The definition narrowed to the named attributes and sub-attributes (emails.value).
serviceProviderConfig(options: Discovery.ServiceProviderConfigOptions), resourceTypes(types: Discovery.ResourceType[]), schemas(definitions: Discovery.SchemaDefinition[])
The /ServiceProviderConfig, /ResourceTypes and /Schemas documents, ready for
scimResponse.
Types
Scim
The resource model: Scim.User<Extensions>, Scim.Group, Scim.Member, Scim.Name,
Scim.Address, Scim.EnterpriseUser, Scim.GroupMembership, Scim.MultiValued, Scim.Meta,
plus Scim.ListQuery, Scim.Page and Scim.ErrorType.
Filter
The tree parseFilter returns: Filter.Expression is a Compare, Present, Logical, Not or
ValuePath over Filter.AttributePath, Filter.Operator and Filter.Value;
Filter.CompileOptions types compileFilter's options.
Patch
Patch.Operation, Patch.Path and Patch.ApplyOptions.
Discovery
Discovery.Definitions, Discovery.SchemaDefinition, Discovery.Attribute,
Discovery.ServiceProviderConfigOptions, Discovery.AuthenticationScheme and
Discovery.ResourceType.
Options and extensions
ScimErrorOptions, ListQueryOptions, ParseUserOptions, ResourceOptions and ColumnMap type
the options above; ExtensionSchemas types an extensions map and InferExtensions the parsed
extensions it produces.
Pattern: Push A Filter Into SQL, Fall Back In Memory
Translate what SQL can answer exactly, and evaluate the rest over the loaded rows.
import { isFailure, isSuccess } from "@sdxc/result";
import { errorResponse, groupResource, parseListQuery } from "@sdxc/scim";
import { filterToWhere } from "@sdxc/scim/data-table";
import { compileFilter } from "@sdxc/scim/filter";
import { and, eq } from "remix/data-table";
let query = parseListQuery(new URL(request.url), { attributes: DEFINITIONS });
if (isFailure(query)) return errorResponse(query.error);
let filter = query.data.filter;
let where = filter
? filterToWhere(filter, {
displayName: { column: "display_name", caseExact: false },
externalId: "external_id",
})
: null;
let rows = await db.findMany(groups, {
where:
where && isSuccess(where)
? and(eq("tenant_id", tenantId), where.data)
: eq("tenant_id", tenantId),
});
let resources = rows.map((row) => groupResource(toGroup(row)));
if (filter && where && isFailure(where)) {
let matches = compileFilter(filter, { definitions: DEFINITIONS });
if (isFailure(matches)) return errorResponse(matches.error);
resources = resources.filter(matches.data);
}Pattern: Conditional Writes With Entity Tags
Stamp each response with its version, and refuse a write whose If-Match is stale.
import {
errorResponse,
matchesVersion,
ScimError,
scimResponse,
userResource,
version,
} from "@sdxc/scim";
let current = userResource(await loadUser(id));
if (!matchesVersion(request, await version(current))) {
return errorResponse(new ScimError(412, "The resource changed since it was read."));
}
let next = userResource(await replaceUser(id, user));
let tag = await version(next);
return scimResponse({ ...next, meta: { ...next.meta, version: tag } }, { headers: { ETag: tag } });On a GET that sent If-None-Match, the same false answers 304 Not Modified.
Pattern: A Custom Schema Extension
Give parseUser and userResource the same extensions map, and add the extension's definition so
filters, PATCH and /Schemas know its attributes. The map replaces the default, so list
enterprise in it too when you also accept the Enterprise User extension.
import { isFailure } from "@sdxc/result";
import { errorResponse, parseUser, scimResponse, USER_SCHEMA, userResource } from "@sdxc/scim";
import type { Discovery } from "@sdxc/scim/discovery";
import { USER_DEFINITION } from "@sdxc/scim/discovery";
import * as s from "remix/data-schema";
let BADGE_URN = "urn:example:params:scim:schemas:extension:badge:2.0:User";
let extensions = {
badge: { urn: BADGE_URN, schema: s.object({ number: s.string() }) },
};
let definitions: Discovery.Definitions = {
[USER_SCHEMA]: USER_DEFINITION,
[BADGE_URN]: {
id: BADGE_URN,
name: "Badge",
attributes: [
{
name: "number",
type: "string",
multiValued: false,
required: true,
caseExact: true,
mutability: "readWrite",
returned: "default",
uniqueness: "server",
},
],
},
};
let user = parseUser(body, { extensions });
if (isFailure(user)) return errorResponse(user.error); // 400 invalidValue names the bad member
user.data.extensions.badge?.number; // string | undefined
return scimResponse(userResource(user.data, { extensions }));definitions then feeds compileFilter, applyPatch, project and schemas, so a client can
filter on urn:example:params:scim:schemas:extension:badge:2.0:User:number.
Versioning
Releases are dated rather than semantic. A version is the UTC date it was published,
written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one
release goes out per day.
Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
Depend on one exact date, and move it when you are ready to take the change:
{
"dependencies": {
"@sdxc/scim": "2026.9.4"
}
}A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
License
MIT
