
Security News
GPT-6 Astra Attempts Supply Chain Attacks Against Open Source Maintainers in Testing
GPT-6 Astra hits 100% on ExploitBench and finds zero-days autonomously, while independent tests reveal scope violations and monitoring gaps.
@grantex/gemma
Advanced tools
Offline authorization adapter for Google Gemma on-device AI agents -- issue consent bundles online, verify grant tokens and enforce scopes entirely offline, and sync tamper-evident audit logs back to Grantex cloud when connectivity returns.
Homepage | Docs | API Reference | GitHub | Sign Up Free
Google Gemma 4 is a family of lightweight, open-weight models designed to run directly on edge devices -- phones, Raspberry Pis, Jetson boards, and embedded hardware. When an AI agent built on Gemma operates in the field, it often loses internet connectivity for hours or days at a time. Traditional cloud-based authorization (OAuth 2.0, API-key checks) simply stops working the moment the network drops.
@grantex/gemma solves this by bringing the Grantex delegated authorization protocol to offline environments. While the device is still online, it fetches a consent bundle -- a self-contained package containing a signed grant token, a JWKS snapshot for signature verification, and an Ed25519 key pair for audit signing. Once offline, the agent verifies tokens, enforces scopes, and logs every action to a tamper-evident, hash-chained audit log -- all without a single network call.
The lifecycle follows three phases: (1) Online bundle issue -- fetch the consent bundle and persist it with AES-256-GCM encryption, (2) Offline verify + audit -- verify JWTs, enforce scopes, and sign audit entries locally, and (3) Sync -- when connectivity returns, upload the audit log to the Grantex cloud with batched retry. This package also ships framework adapters for Google ADK and LangChain, so you can wrap any tool with offline authorization in a single function call.
npm install @grantex/gemma
yarn add @grantex/gemma
pnpm add @grantex/gemma
import { createConsentBundle, storeBundle } from '@grantex/gemma';
// While the device is online, request a consent bundle from Grantex
const bundle = await createConsentBundle({
apiKey: process.env.GRANTEX_API_KEY!,
agentId: 'agent_gemma_01',
userId: 'user_alice',
scopes: ['calendar:read', 'email:send'],
offlineTTL: '72h', // bundle is valid for 72 hours offline
});
// Persist the bundle to encrypted local storage (AES-256-GCM)
await storeBundle(bundle, '/data/grantex/bundle.enc', process.env.ENCRYPTION_KEY!);
import {
loadBundle,
createOfflineVerifier,
createOfflineAuditLog,
enforceScopes,
} from '@grantex/gemma';
// Load the bundle from encrypted storage
const bundle = await loadBundle('/data/grantex/bundle.enc', process.env.ENCRYPTION_KEY!);
// Create the offline verifier using the embedded JWKS snapshot
const verifier = createOfflineVerifier({
jwksSnapshot: bundle.jwksSnapshot,
requireScopes: ['calendar:read'],
maxDelegationDepth: 2,
});
// Verify the grant token entirely on-device -- no network call
const grant = await verifier.verify(bundle.grantToken);
console.log(grant.agentDID, grant.scopes, grant.expiresAt);
// Create a hash-chained, Ed25519-signed offline audit log
const auditLog = createOfflineAuditLog({
signingKey: bundle.offlineAuditKey,
logPath: '/data/grantex/audit.jsonl',
});
// Log every action the agent performs
await auditLog.append({
action: 'tool:read_calendar',
agentDID: grant.agentDID,
grantId: grant.grantId,
scopes: grant.scopes,
result: 'success',
metadata: { eventCount: 5 },
});
import { syncAuditLog } from '@grantex/gemma';
// When connectivity returns, sync all un-uploaded entries
const result = await syncAuditLog(auditLog, {
endpoint: bundle.syncEndpoint,
apiKey: process.env.GRANTEX_API_KEY!,
bundleId: bundle.bundleId,
batchSize: 100, // entries per HTTP request
});
console.log(`Synced ${result.syncedCount} entries`);
if (result.hasErrors) console.error(result.errors);
ONLINE (Phase 1) OFFLINE (Phase 2) ONLINE (Phase 3)
───────────────── ───────────────── ─────────────────
Grantex Cloud On-Device (Gemma 4) Grantex Cloud
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ POST /v1/ │ consent │ loadBundle() │ sync │ POST /v1/ │
│ consent- │ bundle │ ▼ JWKS Snapshot │ audit │ audit/ │
│ bundles │ ──────────► │ ▼ verify(token) │ ──────► │ offline-sync │
└──────────────┘ │ ▼ enforceScopes()│ └──────────────┘
AES-256-GCM ◄────────── │ ▼ auditLog │
encrypted on disk │ ▼ Hash-Chained │
│ (.jsonl) │
└──────────────────┘
createOfflineVerifier(options): OfflineVerifierCreate an offline JWT verifier that validates Grantex grant tokens using a pre-fetched JWKS snapshot. No network call is made during verification.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
jwksSnapshot | JWKSSnapshot | required | Pre-fetched JWKS keys from the consent bundle |
clockSkewSeconds | number | 30 | Clock skew tolerance in seconds |
requireScopes | string[] | -- | Scopes that must be present in every verified token |
maxDelegationDepth | number | -- | Maximum delegation chain depth (inclusive) |
onScopeViolation | 'throw' | 'log' | 'throw' | Behaviour when a scope check fails |
Returns an OfflineVerifier with a single method:
verify(token: string): Promise<VerifiedGrant> -- verify a JWT and return the decoded grant.VerifiedGrant fields:
| Field | Type | Description |
|---|---|---|
agentDID | string | Agent's DID (agt JWT claim) |
principalDID | string | User who authorized the grant (sub claim) |
scopes | string[] | Granted scopes (scp claim) |
expiresAt | Date | Token expiry |
jti | string | Unique token ID (jti claim) |
grantId | string | Grant record ID (grnt claim, falls back to jti) |
depth | number | Delegation depth (0 = root grant) |
Example:
const verifier = createOfflineVerifier({
jwksSnapshot: bundle.jwksSnapshot,
requireScopes: ['files:read'],
clockSkewSeconds: 60,
maxDelegationDepth: 3,
});
const grant = await verifier.verify(bundle.grantToken);
console.log(grant.agentDID); // 'did:web:agent.example'
console.log(grant.scopes); // ['files:read', 'files:write']
console.log(grant.grantId); // 'grnt_01J...'
enforceScopes(grantScopes, requiredScopes): voidThrows ScopeViolationError if any required scope is missing from the grant.
hasScope(grantScopes, scope): booleanCheck whether a single scope is present. Returns boolean.
createConsentBundle(options): Promise<ConsentBundle>Request a consent bundle from the Grantex API. This call requires network connectivity -- the returned bundle is then used for all offline operations.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | Grantex developer API key |
baseUrl | string | https://api.grantex.dev | API base URL |
agentId | string | required | Agent requesting the bundle |
userId | string | required | End-user / principal granting consent |
scopes | string[] | required | Requested scopes |
offlineTTL | string | '72h' | Offline validity period (e.g. '24h', '7d') |
offlineAuditKeyAlgorithm | string | 'Ed25519' | Signing algorithm for audit entries |
storage | string | -- | Storage backend: 'encrypted-file', 'keychain', 'secure-enclave' |
storagePath | string | -- | File path when storage is 'encrypted-file' |
ConsentBundle fields:
| Field | Type | Description |
|---|---|---|
bundleId | string | Unique bundle identifier |
grantToken | string | Grantex grant token (RS256 JWT) |
jwksSnapshot | JWKSSnapshot | JWKS keys for offline verification |
offlineAuditKey | { publicKey, privateKey, algorithm } | Ed25519 key pair for signing audit entries |
checkpointAt | number | Unix-ms timestamp of last successful cloud sync |
syncEndpoint | string | URL for syncing audit entries back to cloud |
offlineExpiresAt | string | ISO-8601 timestamp after which offline operation is disallowed |
Example:
const bundle = await createConsentBundle({
apiKey: 'gx_live_...',
agentId: 'agent_gemma_field',
userId: 'usr_01J...',
scopes: ['sensor:read', 'actuator:write'],
offlineTTL: '7d',
});
storeBundle(bundle, path, encryptionKey): Promise<void>Encrypt and write a bundle to disk using AES-256-GCM. File format: [12-byte IV][16-byte auth-tag][ciphertext]. The encryption key is SHA-256-hashed to derive a 32-byte AES key, so any passphrase length works.
loadBundle(path, encryptionKey): Promise<ConsentBundle>Read and decrypt a bundle from disk. Throws BundleTamperedError if decryption or integrity check fails.
shouldRefresh(bundle): booleanReturns true when less than 20% of the bundle's total offline TTL remains. Call this proactively when connectivity is available.
refreshBundle(bundle, apiKey, baseUrl?): Promise<ConsentBundle>Refresh an expiring bundle via the Grantex API. Returns a new bundle with extended offlineExpiresAt, fresh JWKS snapshot, and rotated audit keys.
if (shouldRefresh(bundle)) {
const refreshed = await refreshBundle(bundle, process.env.GRANTEX_API_KEY!);
await storeBundle(refreshed, '/data/grantex/bundle.enc', process.env.ENCRYPTION_KEY!);
}
createOfflineAuditLog(options): OfflineAuditLogCreate an append-only, Ed25519-signed, hash-chained audit log backed by a JSONL file. Every entry is cryptographically linked to the previous one, forming a tamper-evident chain.
Options:
| Option | Type | Default | Description |
|---|---|---|---|
signingKey | { publicKey, privateKey, algorithm } | required | Ed25519 key pair (from consent bundle) |
logPath | string | required | Path to the JSONL log file |
maxSizeMB | number | 50 | Max file size in MB before rotation |
rotateOnSize | boolean | true | Enable automatic size-based rotation |
Methods:
| Method | Signature | Description |
|---|---|---|
append | (entry: AuditEntry) => Promise<SignedAuditEntry> | Append a new signed, hash-chained entry |
entries | () => Promise<SignedAuditEntry[]> | Read all entries from the log file |
unsyncedCount | () => Promise<number> | Count entries not yet synced to cloud |
markSynced | (upToSeq: number) => Promise<void> | Mark entries as synced up to a sequence number |
SignedAuditEntry fields:
| Field | Type | Description |
|---|---|---|
seq | number | Monotonically increasing sequence number |
timestamp | string | ISO-8601 timestamp of the entry |
action | string | Action performed (e.g. 'tool:read_calendar') |
agentDID | string | Agent that performed the action |
grantId | string | Grant under which the action was authorized |
scopes | string[] | Scopes the grant carried at the time |
result | string | Outcome: 'success', 'auth_failure', 'scope_violation', 'execution_error' |
metadata | Record<string, unknown>? | Optional structured metadata |
prevHash | string | SHA-256 hash of the previous entry (genesis: '0000000000000000') |
hash | string | SHA-256 hash of this entry |
signature | string | Ed25519 signature of the hash (hex-encoded) |
syncAuditLog(auditLog, options): Promise<SyncResult>POST un-synced audit entries to the Grantex cloud in batches with automatic retry (3 attempts, exponential back-off).
| Option | Type | Default | Description |
|---|---|---|---|
endpoint | string | required | Sync endpoint URL (from bundle.syncEndpoint) |
apiKey | string | required | Developer API key |
bundleId | string | required | Consent bundle ID linking entries to an offline session |
batchSize | number | 100 | Entries per HTTP request |
Returns SyncResult with syncedCount, hasErrors, and errors[].
computeEntryHash(entry): stringCompute the SHA-256 hash of an audit entry. Input: seq|timestamp|action|agentDID|grantId|scopes|result|metadata|prevHash.
verifyChain(entries): { valid: boolean; brokenAt?: number }Verify the integrity of an ordered sequence of entries. Checks hash recomputation, prevHash linkage, and sequence continuity.
verifyEntrySignature(entry, publicKey): booleanVerify the Ed25519 signature of a single audit entry.
import { verifyChain, verifyEntrySignature } from '@grantex/gemma';
const { valid, brokenAt } = verifyChain(await auditLog.entries());
const sigValid = verifyEntrySignature(entry, bundle.offlineAuditKey.publicKey);
withGrantexAuthADK(tool, options) / withGrantexAuthLangChain(tool, options)Wrap a Google ADK FunctionTool or LangChain StructuredTool with Grantex offline authorization. Before the tool executes, the grant token is verified and scopes are enforced. After execution, an audit entry is appended automatically.
import { withGrantexAuthADK, withGrantexAuthLangChain } from '@grantex/gemma';
// Google ADK -- wraps tools with `func` or `run` methods
const protectedAdkTool = withGrantexAuthADK(myAdkTool, {
verifier, auditLog,
requiredScopes: ['calendar:read'],
grantToken: bundle.grantToken,
});
// LangChain -- wraps tools with `_call` or `invoke` methods
const protectedLcTool = withGrantexAuthLangChain(myLangChainTool, {
verifier, auditLog,
requiredScopes: ['email:read'],
grantToken: bundle.grantToken,
});
Both adapters accept the same options:
| Option | Type | Description |
|---|---|---|
verifier | OfflineVerifier | Offline verifier instance |
auditLog | OfflineAuditLog | Offline audit log instance |
requiredScopes | string[] | Scopes required to invoke this tool |
grantToken | string | Grantex grant token (JWT) |
interface JWKSSnapshot {
keys: JWK[]; // JWK key objects (must include kid and alg)
fetchedAt: string; // ISO-8601 when snapshot was fetched
validUntil: string; // ISO-8601 after which snapshot should be refreshed
}
interface ConsentBundle {
bundleId: string;
grantToken: string; // RS256 JWT
jwksSnapshot: JWKSSnapshot;
offlineAuditKey: { publicKey: string; privateKey: string; algorithm: string };
checkpointAt: number; // Unix-ms timestamp
syncEndpoint: string;
offlineExpiresAt: string; // ISO-8601
}
interface VerifiedGrant {
agentDID: string; principalDID: string;
scopes: string[]; expiresAt: Date;
jti: string; grantId: string; depth: number;
}
interface SignedAuditEntry {
seq: number; timestamp: string; action: string;
agentDID: string; grantId: string; scopes: string[];
result: string; metadata?: Record<string, unknown>;
prevHash: string; hash: string; signature: string;
}
interface SyncResult {
syncedCount: number; hasErrors: boolean; errors: string[];
}
// Errors -- all extend GrantexAuthError (which extends Error with a `code` property)
class OfflineVerificationError { code: string } // VERIFICATION_FAILED, MALFORMED_TOKEN,
// BLOCKED_ALGORITHM, MISSING_KID, KID_NOT_FOUND,
// FUTURE_IAT, DELEGATION_DEPTH_EXCEEDED
class ScopeViolationError { requiredScopes: string[]; grantScopes: string[] }
class TokenExpiredError { expiredAt: Date }
class BundleTamperedError { /* code: BUNDLE_TAMPERED */ }
class HashChainError { brokenAt: number }
What offline verification guarantees:
verifyChain() detects any modification.What it does NOT guarantee:
offlineExpiresAt. This is an inherent trade-off of offline operation.clockSkewSeconds to account for drift.Additional security details:
none and HS256 are explicitly blocked to prevent signature bypass and symmetric-key confusion attacks. Only RS256 tokens are accepted.seq|timestamp|action|agentDID|grantId|scopes|result|metadata|prevHash. Genesis hash: '0000000000000000'.verifyEntrySignature().@grantex/gemma runs anywhere Node.js 18+ is available:
| Platform | Requirement | Notes |
|---|---|---|
| Raspberry Pi 5 | Node.js 18+ (ARM64) | 3.2ms avg verification |
| Android | React Native / Termux | 1.8ms avg verification |
| iOS | JavaScriptCore / embedded V8 | 1.8ms avg verification |
| NVIDIA Jetson | Node.js 18+ (ARM64) | 1.1ms avg verification |
| Desktop | Node.js 18+ (x64/ARM64) | < 1ms avg verification |
| Edge servers | Cloudflare Workers, Deno Deploy | < 1ms avg verification |
The package is pure ESM ("type": "module" in your package.json, or use dynamic import()).
All errors extend GrantexAuthError and include a machine-readable code property:
import {
TokenExpiredError, ScopeViolationError,
OfflineVerificationError, BundleTamperedError, HashChainError,
} from '@grantex/gemma';
try {
const grant = await verifier.verify(token);
enforceScopes(grant.scopes, ['files:write']);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error(`Token expired at ${err.expiredAt.toISOString()}`);
} else if (err instanceof ScopeViolationError) {
console.error(`Missing: required ${err.requiredScopes}, got ${err.grantScopes}`);
} else if (err instanceof OfflineVerificationError) {
console.error(`[${err.code}]: ${err.message}`);
} else if (err instanceof BundleTamperedError) {
console.error('Bundle integrity check failed');
} else if (err instanceof HashChainError) {
console.error(`Chain broken at entry ${err.brokenAt}`);
}
}
49 tests across 6 test files (offline verifier, scope enforcer, consent bundles, hash chain, audit log, security):
npm test # run all tests
npm run test:watch # watch mode
npm run typecheck # type checking only
| Framework | Adapter | Import |
|---|---|---|
| Google ADK | withGrantexAuthADK | import { withGrantexAuthADK } from '@grantex/gemma' |
| LangChain | withGrantexAuthLangChain | import { withGrantexAuthLangChain } from '@grantex/gemma' |
Both adapters automatically handle token verification, scope enforcement, and audit logging. See the Adapters section above for full usage examples.
| Example | Platform | Description |
|---|---|---|
gemma-raspberry-pi | Raspberry Pi 5 | Python agent with bundle setup, offline operation, and audit sync |
gemma-android-kotlin | Android | Kotlin app with offline auth manager and audit logging |
gemma-ios-swift | iOS | Swift package with offline verifier and consent bundle manager |
The JWKS snapshot embedded in your consent bundle may have expired. Check the validUntil field:
import { isSnapshotExpired } from '@grantex/gemma';
if (isSnapshotExpired(bundle.jwksSnapshot)) {
// Snapshot expired -- refresh the bundle when connectivity is available
}
Also verify the offlineExpiresAt on the bundle itself has not passed.
This means decryption failed. Common causes:
storeBundle().verifyChain() returns { valid: false, brokenAt: N } when entries have been modified outside the audit log. This is expected tamper-detection behaviour. Do not manually edit the JSONL audit file.
If verification fails with FUTURE_IAT or tokens appear expired prematurely, increase the clock skew tolerance:
const verifier = createOfflineVerifier({
jwksSnapshot: bundle.jwksSnapshot,
clockSkewSeconds: 120, // allow up to 2 minutes of drift
});
| Package | Description |
|---|---|
@grantex/sdk | TypeScript SDK for the Grantex protocol |
grantex | Python SDK |
grantex-adk | Google ADK integration (Python) |
@grantex/langchain | LangChain integration |
@grantex/mcp | MCP server for Claude Desktop / Cursor / Windsurf |
@grantex/cli | Command-line tool |
@grantex/express | Express.js middleware |
@grantex/gateway | Reverse-proxy gateway |
See CONTRIBUTING.md for guidelines on setting up the development environment, running tests, and submitting pull requests.
FAQs
Offline authorization adapter for Google Gemma on-device AI agents
We found that @grantex/gemma demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
GPT-6 Astra hits 100% on ExploitBench and finds zero-days autonomously, while independent tests reveal scope violations and monitoring gaps.

Product
Socket can now send alerts and supply chain attack notifications to Microsoft Teams, with filters that route the right updates to each channel.

Security News
pnpm 12 rewrites the package manager in Rust, cutting install times by up to 90% while preserving pnpm 11 workflows and lockfiles.